Closes review #003 (prepublish review for v0.1.0). - P8: StdinSink::poll_shutdown parks an inflight reserve+send on a full channel (waker registered) — a stdin blast followed by EOF delivers the EOF instead of stranding it - P9: five poisoned-lock .expect() sites -> unwrap_or_else(into_inner) - P10: three thread-spawn .expect() sites -> TtyError::AllocFailed - P5: tty:open scope gate runs before carriage/cmd/backend-lookup checks (no backend-name enumeration differential for unscoped ids) - P11: recv_stdout terminates on the zero-length drained sentinel; the sentinel is no longer yielded as an item (doc was already the contract); stderr has no sentinel (doc noted) - P2: exclude AGENTS.md + docs/plans/, drop dead Cargo.lock and docs/research/ entries (package list: 42 files, 659.2KiB) - P3: AGENTS.md phase status (all five landed), ADR range 001..009 (+ alktty-native ADR-009 in the mapping), alkcall guidance corrected to v0.4.x / pin "0.4.0"; architecture README ADR-009 row + landed-phase status - P15: backend.rs doc typo; redundant tokio-stream dev-dep removed; NegotiationError::Io arm logs; set_identity failure logs; input_pump.abort() at session end; TtySessionError::Open carries the accept_bi StreamError (no io::Error flattening); borrowing deserialize in open_via_channels (no params.clone()); error_response_bytes guards an "error" key in fields; trivial inline comments promoted/removed; plan-doc test counts + doc front-matter refreshed; session tests that raced session teardown under the abort change use a GatedBackend (exit held until released) Verification: cargo test 104 lib / --all-features 147; clippy (all-targets + wasm32) -D warnings; fmt; wasm check; doc 0 warnings; publish dry-run OK.
33 KiB
Project Setup Plan
Status: draft (revised 2026-08-17 to reflect landed upstream changes; Phase 4 landed 2026-08-17 — architecture docs + BAST schema + renumbered ADRs; Phase 5 landed 2026-08-17 — tests) Last updated: 2026-08-17
Overview
Extract alknet-tty + alknet-tty-local from the alknet mono-repo into a
standalone alktty crate — a producer/consumer protocol crate on top of
alkcall channels. This is the first protocol crate built on alkcall and
establishes patterns that alktunnels and others will follow.
The local backend (alknet-tty-local) is folded into alktty as a
feature-gated local submodule rather than kept as a sibling crate.
Both the original alknet-tty (the wire/adapter/backend-trait half) and
alknet-tty-local (the portable_pty + tokio::process half) are
ported together, so the single crate carries the full stack: the
alk/tty protocol + the one backend implementation that ships with it.
A docker or SSH backend would be a separate crate; the local backend is
small and the cyclic-dependency workaround it required in the alknet
mono-repo (see ADR-054) doesn't apply to a single crate with a feature
flag.
Upstream changes that landed since the first draft
These were "open questions" or "upstream changes needed" in the 2026-08-14 draft and are now resolved. The plan reflects the as-built shape, not the original intent.
alkcall 0.1.1 — ALPN rename done
CHANNELS_ALPN:b"alknet/channels"→b"alk/channels"(inalkcall/src/channels/adapter.rs).- Test at
channels/client.rs:767uses"alk/tty"(was"alknet/tty"). - Semver showed no public API change → alkcall bumped 0.1.0 → 0.1.1.
alkcallCargo.tomldependency needs updating from0.1.0to0.1.1(the scaffold still pins0.1.0).
alkcall — Identity.resources shape
The alknet-core Identity had resources: HashMap<String, String> (a
flat resource-type → resource-id map). alkcall's Identity (in
alkcall::core::auth) has resources: HashMap<String, Vec<String>> —
one resource type can carry multiple resource ids. The ported adapter
tests that build an Identity by hand need this shape; the production
code only reads identity.scopes (the scope-gate at negotiation), so
the change is test-side.
alkcall — OwnershipProvider / OwnershipStore signature
OwnershipProvider::owns(&self, identity, resource_type, resource_id, action: &str)— added anactionparameter (was 3 args, now 4). The adapter's call site (provider.owns(id_ref, kind, &id, "tty")) already passes an"tty"action string, which is the new arity. No change to the production adapter; the mock and test fixtures need the 4-arg shape.OwnershipStore::record(&self, identity, resource_type, resource_id)— dropped the oldactionparameter that alknet-core's variant took. The adapter test'sstore.record(&owner, "container", "c1").awaitmatches the new arity. (The scaffold's plan previously listed anOwnershipStoreimport that no longer needs an action arg.)
alkcall — AuthContext::anonymous(alpn)
alkcall added a convenience constructor AuthContext::anonymous(alpn)
that builds an AuthContext with no identity, no fingerprint, no
remote address — only the ALPN. Useful in adapter tests; not used by
the production adapter (which receives the AuthContext from the
dispatch path).
alktype 0.2.0 — BAST format pivot
alktype was reworked: the v0.1.0 AlkType:* custom-keyword JSON Schema
backend was dropped in favor of a straightforward kind-based BAST
(Binary Abstract Syntax Tree) format inspired by unist ASTs. A BAST
document is a plain JSON file (conforming to a JSON Schema meta-schema)
that describes binary layouts using kind strings ("uint8",
"uint32", "struct", etc.) and $defs/$ref for composition.
alktty will not depend on alktype — the hand-rolled
ChunkReader/ChunkWriter in wire.rs already works and is
straightforward. The BAST format matters here only as a normative
schema document for the alk/tty wire format: a JSON file under
docs/architecture/ that downstream consumers (and alktype, if
desired) can consume to generate validators, layout maps, or readers
in any language. The schema is a documentation artifact, not a runtime
dependency.
Crate Structure
Single crate (alktty) with the local backend gated behind a local
feature. This is a "tty-specific monorepo" — one crate, one concern,
both halves of the original alknet split (alknet-tty +
alknet-tty-local) folded in.
alktty/
├── src/
│ ├── lib.rs # crate root, re-exports
│ ├── wire.rs # ChunkReader/ChunkWriter (5-byte format)
│ ├── negotiation.rs # NegotiateRequest, framing reader/writer
│ ├── control.rs # ControlMessage enum, signal_from_name
│ ├── backend.rs # TtyBackend trait, TtyHandle, TtyParams, TtyError
│ ├── adapter.rs # TtyAdapter (ProtocolHandler), drive_session
│ ├── session.rs # TtySession — typed consumer client
│ ├── channels.rs # OpenHandler factory, register_openable helper
│ └── local/ # (behind `local` feature — the folded alknet-tty-local)
│ ├── mod.rs # LocalTtyBackend re-export
│ ├── backend.rs # LocalTtyBackend (dispatches terminal: Some→pty, None→pipe)
│ ├── pty.rs # PTY mode (portable_pty, 3 std threads → tokio channels, #[cfg(unix)])
│ └── pipe.rs # Pipe/runner mode (tokio::process::Command, cross-platform)
├── docs/
│ ├── architecture/
│ │ ├── README.md # architecture index
│ │ ├── tty-wire.md # wire format spec (5-byte chunk + 4-byte neg frame)
│ │ ├── tty-bast.md # BAST JSON document for the alk/tty wire format
│ │ ├── tty-backend.md # TtyBackend trait + LocalTtyBackend
│ │ ├── tty-adapter.md # TtyAdapter + drive_session
│ │ ├── tty-local.md # PTY + pipe modes, REQ-TTY-01/02
│ │ └── decisions/ # renumbered ADRs
│ └── plans/
│ └── project-setup.md # this document
├── Cargo.toml
├── LICENSE-APACHE
└── LICENSE-MIT
Key Design Decisions
1. Single crate, feature-gated local backend
The old split (alknet-tty + alknet-tty-local) had a cyclic dependency
problem: alknet-tty-local depends on alknet-tty for the trait, and
ADR-054 wanted alknet-tty to re-export LocalTtyBackend behind a
local feature — which cargo rejects. The assembly-layer workaround
(consumer depends on both crates directly) works but is awkward.
Single crate with local feature solves this cleanly. The
portable-pty and tokio-util deps are optional. A docker-only
deployment doesn't pull in PTY code.
Folded-in scope. The single crate carries both halves of the
original alknet split: the wire/adapter/backend-trait half (from
alknet-tty) and the local backend (from alknet-tty-local). The
local backend is small (~1.4k lines), tightly coupled to the trait,
and the cyclic-dep workaround it required in the alknet mono-repo
doesn't exist in a single crate. A docker or SSH backend would still
be a separate crate — those have real external deps (bollard,
russh) and their own resource models.
WASM target. The default crate (no local feature) MUST compile
to wasm32-unknown-unknown. alkcall and alktype both target wasm;
keeping alktty wasm-clean is what makes the downstream TS/Python
adapter story work — a wasm-compiled alktty is the protocol layer for
a sandboxed adapter (browser terminal over WebTransport, a Python
wheel that shells out to a wasm module, etc.). The local feature is
inherently non-wasm (portable-pty needs a real OS, tokio::process
needs spawn), so enabling local on wasm is a build error by
design — the local-process backend runs on a real OS (Linux, macOS,
Windows), never in a sandbox.
Concretely, Cargo.toml uses tokio = { default-features = false, features = ["rt", "sync", "io-util", "macros"] } (the wasm-clean
subset alkcall uses) and local adds tokio/process +
tokio/rt-multi-thread. Do NOT use features = ["full"] — it pulls
in signal/fs/net which break wasm32-unknown-unknown. The
adapter's tokio::spawn for per-session pumps is fine on wasm (the
wasm tokio runtime supports spawn); the local module's std
threads and tokio::process::Command are the non-wasm parts, and
they're feature-gated. libc stays under cfg(unix) (it's already
there for signal_from_name and the pipe-mode kill path — neither
runs on wasm because the only callers are in local).
2. Dependency: alknet-core → alkcall::core
All types previously from alknet-core now come from alkcall::core:
ProtocolHandler,Connection,BiStream,BidiStreamSourceAuthContext,Identity,IdentityProviderAccessControl,OwnershipProvider,OwnershipStore,InMemoryOwnershipStoreHandlerError,StreamError
The TtyAdapter's impl ProtocolHandler changes which crate the trait
comes from. The trait shape is identical. The Identity struct's
resources field is HashMap<String, Vec<String>> in alkcall (was
HashMap<String, String> in alknet-core); the production adapter only
reads identity.scopes, so the change is test-side.
Cargo.toml pins alkcall = "0.1.1" (the scaffold's 0.1.0 pin is
stale — 0.1.1 is the ALPN-rename release; same public API per semver).
3. Channels integration (the new part)
This is where alktty becomes a proper producer/consumer on alkcall.
ALPN strings (settled). alk/tty (direct) and alk/channels
(multiplexed). The alknet/<name> → alk/<name> rename landed in
alkcall 0.1.1 — CHANNELS_ALPN is b"alk/channels", the channels
client test uses "alk/tty". No upstream work remains; we just use
the new strings.
Producer half — two paths to expose TTY:
-
Direct ALPN (
alk/tty):TtyAdapterimplementsProtocolHandler. Existing code, just import migration. Used for direct-connect scenarios (browser terminal over WebTransport, standalone TTY endpoint). -
Through channels (
alk/channels): Register anOpenHandlerviaChannelCore::register_openable(). The handler receives aConnection(the channel's data plane with ALPNalk/tty), callsaccept_bi(), and runs the samedrive_sessionon the resultingBiStream.Per ADR-093 (reversal of ADR-077): TTY always uses its 5-byte format. The channels layer strips its 8-byte header and hands TTY the payload transparently. The same
wire.rscode runs in both modes — only theBiStreamsource differs.The channels integration code is small: a
register_openablehelper function that takes aChannelCore,OperationRegistry, and backend map, and registers the per-ALPN open op (channels/tty/sub— consumer-opens). This lives insrc/channels.rs. The op spec carries thechannel_openmarker (ChannelOpenSpec::new("alk/tty")) and theAccessControlfor the scope-gate + ownership check; the channels wrapper doescheck_open→open_channel→ spawn theOpenHandler→ respond with{ channel_id }.
Consumer half — TtySession:
A typed client that wraps the wire protocol. Two constructors:
TtySession::connect_direct(connection)— for directalk/ttyTtySession::open_via_channels(client, params)— opens a channel viaChannelClient, negotiates, returns a session handle
The session handle exposes:
send_stdin(bytes)/close_stdin()recv_stdout()/recv_stderr()— streamsresize(cols, rows),signal(name)wait()— awaits exit code
4. Negotiation framing — keep self-contained
ADR-057 says the negotiation framing is self-contained (~30 lines, format coincides with alkcall's by convention). Even though alkcall is now a dependency, we keep the self-contained framing because:
- The payload is
NegotiateRequest, notEventEnvelope— alkcall'sFrameFramedReaderis hardcoded to deserializeEventEnvelope - The framing is trivial (4-byte BE length prefix + body)
- Avoids coupling to alkcall's internal wire types for a TTY-specific payload
5. Producer/consumer framing
The architecture README in alkcall describes protocol crates as having two halves:
-
Producer half —
register_*()functions that take an&mut OperationRegistryand register ops with handlers. For channels, anOpenHandlerfactory. -
Consumer half — a typed client wrapper around
CallConnectionorChannelClientthat exposes the crate's ops as async methods.
We make this explicit in the module structure:
src/adapter.rs+src/channels.rs= producer halfsrc/session.rs= consumer half
6. Access control and auth
The old code had minimal access control (a scope-gate at negotiation). With alkcall, we get:
- Operation-level ACL:
AccessControlonOperationSpec— scopes, resource ownership checks (required_scopes,required_scopes_any,resource_type+resource_id_path,resource_action) - Channel lifecycle policy:
ChannelLifecyclePolicy— per-identity channel caps, open/close hooks (consulted by theChannelCorewrapper, not by alktty directly) - Ownership:
OwnershipProvider— runtime-spawned resource tracking (terminal sessions are resources per ADR-050). alkcall'sOwnershipProvider::ownstakes anaction: &strarg (the adapter passes"tty");OwnershipStore::recordtakes no action arg.
7. BAST schema as a documentation artifact
The alk/tty wire format gets a BAST (Binary Abstract Syntax Tree)
document under docs/architecture/tty-bast.md. BAST is alktype's
JSON-based binary-layout vocabulary (alktype 0.2.0 dropped the old
AlkType:* custom-keyword backend in favor of this kind-based
format). The document is a normative spec: downstream consumers (and
alktype, if desired) can consume it to generate validators, layout
maps, or readers in any language with a JSON parser.
alktty does not depend on alktype. The hand-rolled
ChunkReader/ChunkWriter in wire.rs is the runtime codec; the BAST
document is the human-readable contract that describes what those
types round-trip. Keeping them as separate artifacts avoids pulling in
alktype as a runtime dep for a codec that's already ~200 lines and
works. If we later want runtime validation against the BAST (e.g., to
reject malformed chunks at the framing boundary with a generated
validator), alktype becomes an optional dep — the BAST document is
already there to feed it.
The TtyAdapter's direct-ALPN path keeps the existing ad-hoc scope
check (has_scope(identity, TTY_OPEN_SCOPE)) for the scope gate and
optionally consults OwnershipProvider for the resource-ownership
check. The channels path gets both for free via
ChannelCore::register_openable, which wires AccessControl into the
operation spec — the registry runs the ACL before the wrapper, so the
OpenHandler only needs to validate params and spawn the protocol.
Migration Strategy
Phase 0: Scaffold hygiene (small fixes before porting)
- Bump
alkcallinCargo.tomlfrom"0.1.0"→"0.1.1"(ALPN rename release). (Done in this revision.) - Add
futures = "0.3"to[dependencies](theBoxFuturealias usesfutures::future::BoxFuture; alkcall pulls it transitively but alktty should declare it directly to not rely on a transitive dep for a public type alias). (Done.) - Split tokio features for the WASM target. (Done.
Cargo.tomlnow usestokio = { default-features = false, features = ["rt", "sync", "io-util", "macros"] }for the default wasm-clean build, andlocaladdstokio/process+tokio/rt-multi-thread.) The scaffold'sfeatures = ["full"]pulled insignal/fs/netwhich breakwasm32-unknown-unknown. The dev-deps that needtokio/process(the pipe/pty integration tests) get it via thelocalfeature on the crate itself, not by re-declaringfull. - Confirm the
localfeature already wiresportable-pty+tokio-utilas optional deps (it does in the scaffold). Thelocalmodule'spty.rsadditionally needslibcundercfg(unix)— already in[target.'cfg(unix)'.dependencies].
Phase 1: Port core types from alknet-tty (no new logic)
- Port
wire.rs— noalknet-coreimports; juststd::io,tokio::io,bytes,thiserror. Rename crate references in doc comments (alknet/tty→alk/tty,alknet-tty→alktty). - Port
control.rs— noalknet-coreimports;serde,bytes,libc(undercfg(unix)). Renamealknet/tty→alk/ttyin doc comments. - Port
negotiation.rs— noalknet-coreimports;std::io,bytes,tokio::io,thiserror,serde_json. Rename doc references. - Port
backend.rs— noalknet-coreimports;std,async_trait,bytes,futures_core,tokio::sync,tokio_stream. Change theBoxFuturetype alias fromPin<Box<dyn Future + Send>>tofutures::future::BoxFuture<'static, T>(functionally identical; the alias matches the alkcall convention and lets us drop thePin/Futureimports). - Port
adapter.rs— change imports:alknet_core::auth::{AuthContext, Identity}→alkcall::core::{AuthContext, Identity}alknet_core::ownership::OwnershipProvider→alkcall::core::OwnershipProvideralknet_core::types::{Connection, HandlerError, ProtocolHandler, StreamError}→alkcall::core::{Connection, HandlerError, ProtocolHandler, StreamError}- Change
b"alknet/tty"→b"alk/tty"inTtyAdapter::alpn(). - The
handle()method signature is identical (alkcall'sProtocolHandlershape matches alknet-core's).
- Port the unit tests inside each module alongside the production
code. The adapter tests need:
Identity.resources: HashMap<String, Vec<String>>(wasHashMap<String, String>in alknet-core).OwnershipStore::record(&self, identity, resource_type, resource_id)— 3 args, noaction(alkcall dropped it).OwnershipProvider::owns(identity, rt, rid, action)— 4 args (alkcall addedaction).
Phase 2: Channels integration (new code)
- Create
src/channels.rs:register_openablehelper — takesChannelCore, backend map,OperationRegistry,AuthContext; builds theOperationSpecforchannels/tty/subwithChannelOpenSpec::new("alk/tty"),AccessControlcarryingTTY_OPEN_SCOPE+ resource-type/ownership if a backend declares aresource_id; callsChannelCore::register_openable(spec, open_handler, registry, auth).TtyOpenHandler— theOpenHandler(anArc<dyn Fn(Value, Connection, AuthContext) -> JoinHandle<()>) that receives a channelConnection, callsaccept_bi(), runsdrive_sessionon the resultingBiStream.
- Create
src/session.rs:TtySessionstruct — typed consumer clientTtySession::connect_direct(connection)— directalk/ttyTtySession::open_via_channels(client, params)— opens a channel viaChannelClient::open_channel("channels/tty/sub", params, "alk/tty"), negotiates, returns a session handle- Methods:
send_stdin,recv_stdout,recv_stderr,resize,signal,wait
Phase 3: Local backend (behind local feature) — folded alknet-tty-local
- Create
src/local/mod.rs(re-exportsLocalTtyBackend),src/local/backend.rs,src/local/pty.rs,src/local/pipe.rs. - Port from
alknet-tty-local/src/:backend.rs— changealknet_tty::backend::{...}→crate::backend::{...}.LocalTtyBackend::allocatedispatches onparams.terminal:Some→pty::allocate_pty,None→pipe::allocate_pipe.pty.rs—portable_pty+ 3 std threads (reader/writer/waiter) feeding tokio mpsc/oneshot.#[cfg(unix)]-only: PTY mode useslibc::kill(-pgid, sig)for process-group signal forwarding. Updatealknet_tty::backend::{BoxFuture, TtyControl, ...}→crate::backend::{...}andalknet_tty::control::signal_from_name→crate::control::signal_from_name.pipe.rs—tokio::process::Command+tokio_util::io::ReaderStreamfor stdout/stderr. Cross-platform: signal forwarding useslibc::kill(pid, sig)undercfg(unix), falls back toChild::kill()on non-Unix. Updatealknet_tty::...→crate::....
LocalTtyBackendimplementscrate::backend::TtyBackend. Wire thelocalfeature insrc/lib.rs:#[cfg(feature = "local")] pub mod local;
Phase 4: Architecture docs + BAST schema — landed 2026-08-17
- Ported the 5 spec docs from
alknet/docs/architecture/crates/tty/intodocs/architecture/(flat layout, not thecrates/tty/subpath — this is a single-crate repo now):overview.md(the crate overview; the alknetREADME.mdindex is ported asdocs/architecture/README.md),tty-wire.md,tty-backend.md,tty-adapter.md,tty-local.md. Each is renamedalknet-tty→alktty,alknet/tty→alk/tty,alknet-core→alkcall::core,alknet-call→alkcall,alknet-tty-local→ alktty'slocalfeature module, and cross-references to alknet ADRs are renumbered to alktty's ADR range (001..008). - Ported the 8 alknet ADRs (052, 053, 054, 055, 056, 057, 077, 093)
into
docs/architecture/decisions/renumbered 001..008 in order:- 001 ← alknet ADR-052 — wire format + two-carriage model (incl. the Phase 7 control-channel split amendment)
- 002
← alknet ADR-053 —
TtyBackendtrait +TtyHandle - 003
← alknet ADR-054 — local backend placement (records both the
alknet sibling-crate decision and the alktty single-crate
consolidation behind a
localfeature) - 004 ← alknet ADR-055 — exit code on a control chunk
- 005 ← alknet ADR-056 — backend cleanup on session cancel
- 006 ← alknet ADR-057 — self-contained negotiation framing
- 007 ← alknet ADR-077 — TTY inside channels (reversed by 008; kept for historical context with its reversal notice pointing to 008)
- 008 ← alknet ADR-093 — channels pure channel multiplexing (reverses 007; TTY always uses its 5-byte format)
- Wrote
docs/architecture/tty-bast.md— the BAST (Binary Abstract Syntax Tree) document for thealk/ttywire format. Conforms to the BAST meta-schema athttps://alk.dev/bast/v1/schema; validatable by any JSON Schema Draft 2020-12 validator. Covers:- The 5-byte chunk header (
structwithendian: "big":stream_type: uint8,length: uint32). - The five stream-type channels as an
enum(Stdin=0,Stdout=1,Stderr=2,CtrlIn=3,CtrlOut=4). - The control-message
union(field-name discriminator ontype:resize,signal,eof,exit) with the documented deviation that on-wire control payloads are UTF-8 JSON, not BAST's binary union encoding. - The negotiation frame as a separate
struct(4-byte BE length prefix + UTF-8 JSONNegotiateRequestbody) — annotated as out-of-band for the chunk codec but documented for completeness. - The
StreamTypeenum's documented deviation: on-wire encoding isuint8(1 byte), not BAST's standardu32enum index (4 bytes) — the chunk header is 5 bytes, not 8.
- The 5-byte chunk header (
- Wrote
docs/architecture/README.md— the architecture index: documents table, ADR table (with alknet origin numbers and port status), key design principles, open questions, and references.
The ADR mapping follows the plan's 8-to-8 list (ADR-052→001, 053→002,
054→003, 055→004, 056→005, 057→006, 077→007, 093→008). ADR-050
(dynamic resource ownership) is an alkcall/alknet-core ADR, not
tty-specific, and is not ported into alktty's ADR range; the
access-control work that declares against the ADR-050 model is
described in tty-adapter.md and the ADR-001/002 ported docs, which
reference ADR-050 by its alknet number. The Phase 7 control-channel
split (STREAM_CTRL_IN = 3, STREAM_CTRL_OUT = 4) is an amendment
inside ADR-001, mirroring alknet (it was not a standalone ADR there
either). AGENTS.md was updated to match this mapping.
Phase 5: Tests — landed 2026-08-17
- Ported existing unit tests from
alknet-tty(wire, negotiation, control, adapter) inline in eachsrc/*.rsmodule's#[cfg(test)] mod tests— done in Phases 1–2 alongside the production code (80 lib tests, all passing). - Ported integration tests from
alknet-tty-local/tests/intotests/at the crate root:tests/common/mod.rs— theClientSidewire-protocol harness +spawn_sessionhelper +negotiate_pty_json/negotiate_pipe_jsonbuilders. Imports renamedalknet_core::auth::Identity→alkcall::core::auth::Identity,alknet_tty::...→alktty::....tests/negotiation.rs— 4 negotiation-error scenarios (unknown_backend, malformed_negotiation ×3, allocate_failed).tests/pipe.rs— 6 pipe-mode scenarios (echo happy path, separate stderr, SIGTERM, cancel cleanup, resize no-op, stdout sentinel). The 2 cancel-cleanup / SIGTERM tests are#[cfg(unix)].tests/pty.rs— 8 PTY-mode scenarios (echo, interactive cat, resize, SIGINT, process-group signal, stdin-EOF sentinel, cancel cleanup, exit-chunk-is-last). The 4 signal / cancel-cleanup tests are#[cfg(unix)]. Each test file carries#![cfg(feature = "local")]so the default crate (no features) skips the integration binaries and stays wasm-buildable;cargo test --all-featuresruns all 19 integration tests (5 + 6 + 8).
- Added channels integration tests inline in
src/channels.rsmod tests(done in Phase 2 alongside the producer code):register_openableregistration, end-to-endChannelClient::call_open_opreturnschannel_id, scope-gate denies withouttty:open, and theMockBackendexit-code sanity check. Uses the in-memoryMockBackendfrombackend.rsfor the producer side so no real PTY is needed.
Total: 119 lib tests + 19 integration tests = 138 passing under
--all-features; 104 lib tests under default (wasm-clean) build
(counts as of the review #003 remediation; the suites have grown since
the 80/19 the plan originally recorded).
Open Questions
OQ-1: ALPN strings (RESOLVED — landed in alkcall 0.1.1)
Decision: alk/tty (direct) and alk/channels (multiplexed). The
alknet/<name> → alk/<name> rename landed in alkcall 0.1.1:
CHANNELS_ALPN is b"alk/channels", the channels client test uses
"alk/tty". Semver showed no public API change → 0.1.0 → 0.1.1.
OQ-2: Channel open operation name (RESOLVED)
The alkcall test uses channels/tty/sub as the operation name. The
convention from ADR-047 is channels/<alpn>/sub for subscribe (consumer
opens) and channels/<alpn>/pub for publish (producer opens). Since TTY
is consumer-opens (the client requests a shell), channels/tty/sub is
correct.
Decision: channels/tty/sub for consumer-opens, channels/tty/pub
reserved for future producer-opens use case.
OQ-3: TtySession API — stream vs callback (RESOLVED)
Decision: Stream-based (Stream<Item = Bytes>). Consistent with the
backend trait's TtyHandle shape.
OQ-4: drive_session — keep the three-pump pattern? (RESOLVED)
Decision: Keep it. It works, it's tested, and ADR-093 means the same code runs in both direct and channels modes.
OQ-5: BoxFuture type alias (RESOLVED)
Decision: Use futures::future::BoxFuture<'static, T>. alkcall
already pulls in futures, so no new transitive dep; alktty declares
futures = "0.3" directly so the public alias doesn't ride on a
transitive dep.
OQ-6: BAST schema scope (RESOLVED)
Decision: Documentation artifact only — docs/architecture/tty-bast.md.
alktty does not depend on alktype. The hand-rolled wire.rs codec is
the runtime; the BAST document is the normative contract for downstream
consumers. If runtime validation against the BAST becomes desirable
later, alktype becomes an optional dep and the BAST document is already
there to feed it.
Upstream Changes Needed in alkcall
All four landed in alkcall 0.1.1 (no public API change per semver). Nothing is blocking alktty; this section is kept as a historical record of what was pushed.
-
ALPN convention:
alknet/<name>→alk/<name>across docs, ADRs, and test code. The test atchannels/client.rs:767now usesChannelOpenSpec::new("alk/tty")(was"alknet/tty"). -
CHANNELS_ALPNconstant:b"alknet/channels"→b"alk/channels"inalkcall/src/channels/adapter.rs. -
ADR-004 (ALPN convention): Updated from
alknet/prefix toalk/. -
Architecture README: The dependency layering diagram and pattern section reference
alk/ttyandalk/channels.
Risks and Concerns
Risk: alkcall is v0.1.x — API stability
alkcall is published at 0.1.1. Breaking changes are expected at this
major-zero stage. The integration surface we use
(ChannelCore::register_openable, ChannelClient, ProtocolHandler,
Connection, Identity, OwnershipProvider) is the core API and
likely stable, but the channels module in particular may evolve.
Mitigation: We own alkcall and can push changes as needed. This is
the first real consumer, so we'll find and fix issues upstream rather
than working around them. Pin alkcall = "0.1.1" and bump deliberately.
Risk: drive_session complexity
The three-pump driver is ~1500 lines with edge cases around exit ordering, cancel cleanup, and bidirectional control. Porting it is mechanical but needs careful review.
Mitigation: The existing code has extensive tests. Port tests first or alongside.
Risk: Local backend PTY bridge
The PTY mode uses 3 std threads feeding tokio channels. This is a well-understood pattern (wezterm uses it) but is inherently platform- specific (Unix only for PTY, pipe mode works cross-platform).
Mitigation: Gate PTY behind #[cfg(unix)] as the existing code does.
Pipe mode works on all platforms. The whole local module is behind
the local feature, which is non-wasm by design.
Risk: WASM target regression
The default crate MUST stay wasm32-unknown-unknown-clean. A future
change that pulls in tokio::process, std::thread, libc, or any
OS-specific dep into a non-local module silently breaks the
downstream TS/Python adapter story (the wasm-compiled protocol layer
is what makes those adapters cheap to build).
Mitigation: Add a CI job that runs cargo check --target wasm32-unknown-unknown (no features) on every PR. Cheap, catches the
regression at the boundary. The tokio feature split in Cargo.toml
(the wasm-clean ["rt", "sync", "io-util", "macros"] subset, with
local adding process/rt-multi-thread) is the structural guard;
the CI job is the enforcement.
Risk: BAST schema drift
The BAST document in docs/architecture/tty-bast.md is a documentation
artifact; the runtime codec is the hand-rolled wire.rs. If the wire
format evolves (new stream_type, control message variant, negotiation
field) and the BAST doc isn't updated, downstream consumers generated
from the BAST will be wrong.
Mitigation: Add a test that parses the BAST document with
serde_json and asserts the stream-type enum values match
wire.rs's STREAM_STDIN/STREAM_STDOUT/STREAM_STDERR/
STREAM_CTRL_IN/STREAM_CTRL_OUT constants. Cheap; catches the
common drift case. (Doesn't require alktype as a dep — just
serde_json, which we already have.)
References
- alkcall architecture README
- alkcall channels operations —
ChannelCore::register_openable,OpenHandler - alkcall channels client —
ChannelClient::open_channel, reference integration pattern - alktype BAST format — normative format spec for the
tty-bast.mddocument - alknet-tty source — port origin (wire, control, negotiation, backend, adapter)
- alknet-tty-local source — port origin (local backend, pty, pipe)
- alknet-tty architecture docs — port origin for the spec docs
- ADR-093 — TTY always uses 5-byte format
- ADR-077 — reversed, historical context