- Bump alkcall 0.5.0 -> 0.6.0 (Establishment plan payload, ADR-049 amendment 2); alktty 0.2.0 -> 0.3.0 (breaking channels-path allocation-failure shape) - make_tty_establisher runs backend.allocate; failure maps to EstablishmentError::DialFailed (channel:open_failed reason dial_failed); the TtyHandle crosses to the pump handler via a private per-open AllocatedHandle one-shot slot (the handle is not Sync — it cannot be the ChannelPlan directly) - make_tty_open_handler gains the Option<ChannelPlan> parameter; a plan drives the new drive_session_pre_allocated (pumps only), a None plan falls back to drive_session_pre_negotiated (inline validate-and-allocate — defense-in-depth for no-establisher registrations) - tty_open_spec's channel:open_failed ErrorDefinition declares dial_failed (four reachable reasons) - Pinned in-band allocate test flipped: allocate_failure_fails_open_as_dial_failed (end-to-end, no channel survives); establisher unit gate for the plan slot + failure mapping - ADR-010 amended (§2A; §2 kept as historical record), tty-adapter.md, session/channels/adapter docs, CHANGELOG, AGENTS.md alkcall pin updated Verification: cargo test (113) + --all-features (137), clippy -D warnings (native + wasm32, default + all-features), fmt, doc, wasm32 check — all clean
23 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft (ported from alknet 2026-08-17; alknet/tty → alk/tty, alknet-core → alkcall::core, alknet-call → alkcall, ADRs renumbered 052..093 → 001..009) | 2026-09-05 |
alktty — TtyAdapter and Session Lifecycle
The TtyAdapter is the ProtocolHandler for alk/tty: it receives a
Connection, accepts bidi streams, reads the negotiation frame, selects
a TtyBackend (ADR-002), and pumps bytes bidirectionally for the life of
the session using the wire format (ADR-001). This document specifies the
session lifecycle, the three-pump driver, negotiation errors, the
exit-chunk ordering (ADR-004), session-cancel cleanup (ADR-005), and
access control.
What
TtyAdapter implements ProtocolHandler (alknet ADR-002, revised by
alknet ADR-007 to receive a Connection) on ALPN alk/tty (alknet
ADR-006). It holds a HashMap<String, Arc<dyn TtyBackend>> populated at
construction (ADR-002 §5). Its handle() method accepts the connection
and loops connection.accept_bi(), dispatching each bidi stream to a
session. One alk/tty connection hosts multiple terminal sessions — one
session per bidi stream (DP-6, decided in the alknet research; matches
the call protocol's one-operation-per-stream model).
pub struct TtyAdapter {
/// Backends keyed by the negotiation frame's `backend` string
/// ("local", "docker", "ssh"). Populated at construction.
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
/// Optional ownership provider (alknet ADR-050) for terminal sessions as
/// runtime-spawned resources. None = no resource-level ACL (scope-
/// gate only). Wired by the assembly layer.
ownership: Option<Arc<dyn OwnershipProvider>>,
}
#[async_trait]
impl ProtocolHandler for TtyAdapter {
fn alpn(&self) -> &'static [u8] { b"alk/tty" }
async fn handle(&self, connection: Connection, auth: &AuthContext)
-> Result<(), HandlerError>
{
// One connection → many sessions (one bidi stream each).
while let Ok((send, recv)) = connection.accept_bi().await {
let backends = self.backends.clone();
let ownership = self.ownership.clone();
let identity = auth.identity.clone();
tokio::spawn(async move {
let _ = drive_session(send, recv, backends, ownership, identity).await;
});
}
Ok(())
}
}
The drive_session function is the per-stream session driver — the
counterpart to the POC's session::drive_session, generalized from the
local PTY backend to the TtyBackend trait. It is also the function the
channels path reuses — per ADR-008,
TTY always uses its 5-byte format, so the same driver runs in
both direct alk/tty and channels alk/channels modes; only the
BiStream source differs. The two paths differ in where the
NegotiateRequest comes from and where semantic failures go: the
direct path reads the wire-frame negotiation (ADR-001 §"Negotiation
Frame") and answers failures with in-band error frames; the channels
path parses the open op's registry-validated input and runs
drive_session_pre_negotiated (ADR-009 — no second negotiation
frame), with the establisher rejecting the semantic-failure classes
as channel:open_failed before the handler spawns (ADR-010).
Why
The adapter is the place where the wire format (ADR-001), the backend
trait (ADR-002), and the exit-chunk ordering (ADR-004) come together.
Keeping these in one place — the adapter — is what makes the invariants
enforceable: the wire format's "exit chunk is last" invariant is enforced
here, not in the backends (which produce handles, not wire bytes); the
backend's exit_code future is awaited here, not in the backend; the
negotiation frame is parsed here, not in the backend. The adapter is
backend-agnostic; the backends are wire-format-agnostic. The inversion is
the TtyBackend trait.
Architecture
Session Lifecycle
A alk/tty session on one bidi stream proceeds in three phases:
-
Negotiation. The adapter reads the single length-prefixed JSON negotiation frame from the client (ADR-001 §"Negotiation Frame"), parses it into
NegotiateRequest, extracts thebackendstring, looks up theTtyBackend, and constructsTtyParams. If the backend is not registered, or the negotiation frame is malformed, the adapter sends a JSON error response and closes the stream (see §"Negotiation errors" below). -
Allocation. The adapter calls
backend.allocate(¶ms), which returns aTtyHandle(ADR-002). If allocation fails (PTY couldn't be allocated, docker exec failed, SSH channel request rejected), the adapter sends a JSON error response and closes the stream. -
Raw carriage — the bidirectional pump. The adapter switches to the raw chunk format and pumps three concurrent tasks:
- A. stdout → client: backend stdout (
TtyHandle.stdout) → stdout chunks (stream_type 1) to the client. IfTtyHandle.stderrisSome, a concurrent stderr pump emits stderr chunks (stream_type 2). On backend stdout EOF, emit a zero-length stdout sentinel. - B. client → backend: client chunks → backend. stdin chunks
(stream_type 0) →
TtyHandle.stdin(viaAsyncWrite). Client→server control chunks (STREAM_CTRL_IN, stream_type 3) →ControlMessagedispatch:Resize→TtyControl::resize,Signal→TtyControl::signal,Eof→ close stdin.ExitonSTREAM_CTRL_INis a protocol violation (it's server→client only) and is ignored.STREAM_CTRL_OUT(stream_type 4) from the client is a protocol violation (it's the server→client half) and is ignored. On client read-half close or a zero-length stdin chunk, signal EOF to the backend's stdin. - C. exit → exit chunk: await
TtyHandle.exit_code; on resolve, enqueue{"type":"exit","code":N}as a server→client control chunk (STREAM_CTRL_OUT, stream_type 4).
A drainer task writes chunks to the client in arrival order. After the exit chunk is written (task C resolves and the exit chunk drains), the adapter closes the write half — the session ends.
- A. stdout → client: backend stdout (
This is the POC's session::drive_session pattern, generalized: the POC
hardcoded the local PTY backend; the adapter dispatches to any
TtyBackend.
Bidirectional Control Channel (Phase 7)
The control channel is split into two halves so it is genuinely bidirectional on the wire:
STREAM_CTRL_IN = 3— client→server control (Resize,Signal,Eof).STREAM_CTRL_OUT = 4— server→client control (Exit).
The adapter enforces the direction:
- An
Exitarriving onSTREAM_CTRL_INis a protocol violation (server→client message on the client→server half) — the adapter ignores it (the previous singleSTREAM_CONTROL = 3could not distinguish the two directions, soExitfrom the client was always ignored; the split makes the rejection explicit). - A
Resize/Signal/Eofarriving onSTREAM_CTRL_OUTis a protocol violation (client→server message on the server→client half) — the adapter ignores it (the server never dispatches control messages it receives on the server→client half). STREAM_CTRL_OUT(stream_type 4) chunks written by the client are a protocol violation (the client should not write on the server→client half) — the adapter ignores them.
The exit chunk (Exit) is emitted on STREAM_CTRL_OUT (stream_type
4), not on the previous STREAM_CONTROL = 3. A client distinguishing
the two halves can route exit vs. control without parsing the JSON
type tag first. See ADR-001 §"Control channel split" and
tty-wire.md §"Control Channel".
Negotiation Errors
If the server cannot allocate the session, it sends a JSON error response in the same length-prefixed framing as the negotiation frame (the JSON carriage, not the raw chunk format) and closes the stream without entering raw mode. The error response shape:
{ "error": "unknown_backend", "backend": "kubernetes" }
On the direct-ALPN path this is the failure surface for every
negotiation/allocation failure class (below). On the channels path
the semantic classes (unknown backend, malformed negotiation,
ownership denial) and allocation are rejected by the establisher
before the open reply and surface as channel:open_failed call
errors (alkcall ADR-049 / alktty ADR-010, as amended for alkcall 0.6's
Establishment plan payload — ADR-010 §2A) — the in-band frames on
that path are defense-in-depth arms only (reachable from
no-establisher registrations).
| Error | When | Shape |
|---|---|---|
unknown_backend |
the backend string is not in the adapter's backend map — direct path, or the channels path's defense-in-depth arm (the establisher rejects it as channel:open_failed / unknown_resource first) |
{"error":"unknown_backend","backend":"..."} |
malformed_negotiation |
the negotiation frame failed to parse as JSON or failed NegotiateRequest validation — on the direct path the wire frame; on the channels path the open op's input (schema-valid values can still fail the typed parse, e.g. cwd typed as a number, because the schema is deliberately partial — the establisher rejects it as channel:open_failed / handler_error first; the handler-side frame is defense-in-depth, ADR-010 §2) |
{"error":"malformed_negotiation","message":"..."} |
allocate_failed |
backend.allocate() returned a TtyError — the failure surface of the direct path. On the channels path the establisher runs allocate (the handle crosses to the pump handler via alkcall 0.6's Establishment plan payload — ADR-010 §2A) and its failure is a channel:open_failed call error (details.reason == "dial_failed") |
{"error":"allocate_failed","message":"..."} |
After sending the error response, the adapter closes the write half of
the bidi stream. The client reads the error frame and treats stream close
as the failure signal. There is no call.error — this is not the call
protocol; the error is a JSON response in the negotiation framing.
Framing disambiguation (success vs error). Both a successful
allocation (raw chunks) and a failed allocation (JSON error frame) begin
with bytes the client must read before knowing which framing applies.
The disambiguation is by the first byte: a JSON error frame's 4-byte
big-endian length prefix always starts with 0x00 (error frames MUST
be under 16 MiB — MAX_CHUNK_LEN — so the high byte is zero; this is
a wire-format invariant, not an assumption), while a raw chunk's first
byte is a stream_type. The server never sends 0 (stdin —
client→server only) or 3 (STREAM_CTRL_IN — client→server only), so
the server-sent set is {1, 2, 4} (stdout, stderr, STREAM_CTRL_OUT);
0x00 is unambiguous. The client distinguishes: read the first byte; if
it is 0x00, interpret the next 4 bytes as a big-endian length prefix
and read that many bytes as a JSON error frame; otherwise interpret it
as a stream_type byte and continue reading the raw chunk header. This
is a one-way-door wire-format invariant (ADR-001): error frames use the
negotiation framing (length prefix) and MUST be under 16 MiB; success
uses the raw chunk framing (stream_type byte first); the
0x00-as-length-prefix vs 0x00-as-invalid-stream_type disambiguation
is what makes the two distinguishable on the wire.
Exit-Chunk Ordering (ADR-004)
The "exit chunk is last" invariant (ADR-004) is enforced here, in the adapter's session driver, not in the backend. The ordering:
- The stdout pump (task A) drains the backend's stdout to EOF. The
backend's stdout ends when the process exits and the PTY/pipe buffer
drains (Unix
Child::wait()blocks until the child is reaped, which happens after the child exits and its stdout drains — ADR-004 assumption 1). - The exit task (task C) awaits
TtyHandle.exit_code. The exit resolves after the child is reaped (the local backend's waiter thread callsChild::wait(); docker'sinspect_execafter the output stream ends; SSH's channel close after the process exits). - The adapter waits for both the stdout pump to complete (EOF)
and
exit_codeto resolve before enqueueing the exit chunk. If a backend's stdout outlives the exit resolve (a hypothetical backend where the process exits but a buffer flush is still in flight), the adapter waits for the stdout pump; theTtyHandle.stderr(ifSome) is pumped concurrently and also drains before the exit chunk. (ADR-004 assumption 2.) - After both resolve, the exit chunk (
{"type":"exit","code":N}) is enqueued on the writer channel. - The drainer writes the exit chunk to the client.
- The adapter closes the write half — the session ends.
A client reads stdout/stderr/control chunks until it sees the exit chunk, records the exit code, and treats subsequent stream close as the session end. The exit chunk is the deterministic completion signal — the same stopgap property the docker POC validated for logs subscriptions, now for any backend.
If exit_code resolves with a TtyError (the backend couldn't determine
the exit code), the adapter sends {"type":"exit","code":-1} (ADR-004
§4). The client treats -1 as "the backend reported an exit error, not
a real exit code."
Access Control
Terminal sessions are runtime-spawned resources per alknet ADR-050. A
alk/tty session is a resource the caller owns: the caller that
opened the session owns it; proxy to share; teardown (stream close)
revokes. The adapter's access control declares against the ADR-050 model:
- Scope-gate at negotiation. The adapter checks the caller's
identity.scopesfor thetty:openscope (or a deployment-configured scope) before allocating the session. A caller without the scope gets a negotiation error ({"error":"forbidden"}) and the stream closes. - Resource ownership for backend-specific resources. Some
backends target a pre-existing resource (a docker backend targets a
specific container); others create their own (a local backend's
process, an SSH backend's channel). The adapter delegates the
resource-id extraction to the backend via
TtyBackend::resource_id(¶ms)(ADR-002), which returnsNone(no pre-existing resource — the session creates its own) orSome((kind, id))(the caller must own this resource). The adapter checksOwnershipProvider::owns(identity, kind, id, "tty")if an ownership provider is wired and the backend returnsSome. The adapter does not parse backend-specific JSON itself — the extraction is backend-driven, so adding a backend with a new resource shape (e.g., a Kubernetes backend targeting a pod) requires no adapter change. forwarded_forfor proxied sessions. A hub that proxies a terminal session to a worker carries the end user's identity asforwarded_for(alknet ADR-032); the worker authorizes the hub (its direct caller), not the end user. The hub's end-user ACL is its own layer.
The tty adapter is a ProtocolHandler, not an OperationSpec-registered
operation — it doesn't go through the call protocol's
OperationRegistry::invoke(). The access-control shape is the adapter's
own (scope-gate + backend-driven ownership check at negotiation),
declaring against the ADR-050 model but not consuming
OperationSpec.resource_id_path (that field is for call-protocol
operations; the tty adapter is its own ALPN, and the resource-id
extraction is delegated to the backend via resource_id() rather than
a path expression). See alknet ADR-050 §"Specifics" for the model this
declares against.
The concrete choice — the scope name (tty:open) and the
check-at-negotiation timing — is a two-way-door choice within the
one-way TtyAdapter shape. The scope name can be renamed (a
deployment-configured scope, not a wire-format constant). The
resource-id extraction is backend-driven via resource_id(), so new
backends with new resource shapes require no adapter change — the
generalization is already in place (ADR-002). No ADR is warranted for
the scope name; it is a reversible implementation choice, not an
architectural commitment.
Connection and Stream Lifecycle
- Connection drop: when the QUIC connection closes, all in-flight
sessions on that connection are cancelled. Each session's pump tasks
are dropped (Rust
Drop); theTtyHandleis dropped; theexit_codefuture is dropped without being driven to completion, which triggers the backend's cancel-cleanup — the session target is killed (ADR-005). For the local backend, theexit_codefuture'sDropcallsChildKiller::kill(SIGHUP), the child exits, the waiter thread'swait()reaps it and exits, and the reader/writer threads exit on channel close. For docker/SSH backends (future), theDropissues the backend's kill (container kill / channel close). See ADR-005 for the contract and the mechanism. - Stream reset: when a bidi stream is reset mid-session, the
ChunkReaderreturns aRawError(ConnectionClosed or Io). The pump tasks exit; theTtyHandleis dropped; the cancel-cleanup runs (ADR-005). No exit chunk is sent — the stream is gone, the client that reset it already knows. - Client cancel: when the client closes the write half (or sends a
zero-length stdin chunk /
eofcontrol chunk), the adapter signals EOF to the backend's stdin and keeps pumping stdout until the backend's stdout ends and the exit resolves. The session completes normally — the exit chunk is sent — the client just stopped sending input. This is NOT a cancel from the adapter's perspective (the session runs to completion); the cancel-cleanup (ADR-005) is not triggered. The cancel-cleanup is triggered only when the adapter drops the handle (connection drop, stream reset, panic), not when the client closes the write half.
Constraints
- The adapter, not the backend, owns the wire format. Backends produce handles; the adapter pumps. A backend that wrote to the wire directly would break the "exit chunk is last" invariant (ADR-004) and the negotiation-error framing.
- One session per bidi stream, multiple streams per connection. A
connection hosts multiple sessions (one stream each); the adapter
spawns a
drive_sessiontask per accepted stream. Sessions are independent — one session's exit doesn't affect another. - Negotiation errors are JSON, not raw chunks. The error response uses the negotiation framing (length-prefixed JSON), not the raw chunk format. The stream enters raw mode only after a successful allocation.
- The exit chunk is the deterministic completion signal. A client reading to completion sees the exit chunk and knows the process exited with code N. A client that cancels mid-stream (closes the write half) won't see the exit chunk — that's correct; a cancelled stream doesn't have a deterministic exit.
- The adapter triggers backend cleanup by dropping the
TtyHandle(ADR-005). On connection drop, stream reset, or pump-task panic, the adapter's pump tasks are dropped, which drops theTtyHandle, which drops theexit_codefuture without driving it to completion. Theexit_codefuture'sDropis the backend's cancel-cleanup path (kill the session target). The adapter has no separate kill method to call; the cleanup is wired into theexit_codefuture'sDropby the backend. A backend that returns anexit_codefuture without a kill-on-Dropguard violates the contract and will orphan processes on cancel. See ADR-005.
Design Decisions
| Decision | ADR | Summary |
|---|---|---|
| Wire format and two-carriage model | ADR-001 | The chunk codec + control channel the adapter pumps |
TtyBackend trait and TtyHandle |
ADR-002 | The backend the adapter dispatches to; the handles the adapter pumps |
| Exit code on a control chunk | ADR-004 | The "exit chunk is last" invariant the adapter enforces |
| Backend cleanup on session cancel | ADR-005 | Dropping exit_code future kills the session target; the adapter triggers it by dropping the TtyHandle on cancel |
| Channels pure channel multiplexing | ADR-008 | The same session driver runs in both direct and channels modes; only the BiStream source differs |
| Negotiation carried in the open op | ADR-009 | The channels path carries no second negotiation frame; the open op's registry-validated input is the negotiation (drive_session_pre_negotiated) |
| Channels establishment failures are call errors | ADR-010 | The channels path's semantic failures and allocation are rejected/run in the establisher (channel:open_failed, alkcall ADR-049; alkcall 0.6 plan payload carries the TtyHandle); no in-band frame from a registered producer |
| Dynamic resource ownership | alknet ADR-050 | Terminal sessions as runtime-spawned resources; the adapter's access-control shape |
Open Questions
- OQ-45 (resolved): Flow control for high-throughput stdout — no application-level windowing; QUIC per-stream flow control is the backpressure mechanism.
References
- ADR-001 — the wire format the adapter pumps
- ADR-002 — the backend trait the adapter dispatches to
- ADR-004 — the exit-chunk ordering the adapter enforces
- ADR-005 — the
cancel-cleanup contract the adapter triggers by dropping the
TtyHandleon session cancel - ADR-008 — why the same session driver runs in channels mode
- ADR-009 — the
channels path carries no second negotiation frame (the open op's
registry-validated
inputis the negotiation) - ADR-010 — the
channels path's semantic failures are establisher call errors
(
channel:open_failed); since alkcall 0.6 the establisher also allocates (theTtyHandlecrosses via the plan payload), so no failure class arrives in-band from a registered producer - alknet ADR-050 — the ownership model the adapter's access control declares against
- alknet ADR-007 —
Connection,accept_bi, the handler-receives- Connection pattern src/adapter.rs— the Rust source this spec documents- tty-wire.md — the wire format details
- tty-backend.md — the backend trait details