12 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| tty/adapter | Implement TtyAdapter (ProtocolHandler) and three-pump session driver | completed |
|
broad | high | phase | implementation |
Description
Implement the TtyAdapter (ProtocolHandler on alknet/tty) and the
drive_session three-pump bidirectional driver in src/adapter.rs. This is
where the wire format (ADR-052), the backend trait (ADR-053), and the
exit-chunk ordering (ADR-055) come together. The adapter is backend-agnostic;
the backends are wire-format-agnostic. The inversion is the TtyBackend trait.
This is a generalization of the POC's /workspace/alknet-tty-poc/src/session.rs,
which hardcoded the local PTY backend; the adapter dispatches to any
TtyBackend.
TtyAdapter struct
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 (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>>,
}
impl TtyAdapter {
pub fn new(backends: HashMap<String, Arc<dyn TtyBackend>>) -> Self;
pub fn with_ownership(backends: HashMap<String, Arc<dyn TtyBackend>>, ownership: Arc<dyn OwnershipProvider>) -> Self;
}
#[async_trait]
impl ProtocolHandler for TtyAdapter {
fn alpn(&self) -> &'static [u8] { b"alknet/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(())
}
}
Session lifecycle (drive_session)
A alknet/tty session on one bidi stream proceeds in three phases:
-
Negotiation. Read the single length-prefixed JSON negotiation frame (task
tty/negotiation), parse intoNegotiateRequest, extract thebackendstring, look up theTtyBackend, constructTtyParams. If the backend is not registered →unknown_backenderror. If the frame is malformed orcarriage != "raw"orcmdis empty →malformed_negotiationerror. Send the JSON error response in negotiation framing and close the stream (do NOT enter raw mode). -
Allocation. Call
backend.allocate(¶ms). If it fails →allocate_failederror response, close. Before allocating, run the access control checks (see below). -
Raw carriage — the bidirectional pump. Switch to the raw chunk format and pump three concurrent tasks:
- A. stdout → client: backend stdout (
TtyHandle.stdout) → stdout chunks (stream_type 1). 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). Control chunks (stream_type 3) →ControlMessagedispatch:Resize→TtyControl::resize,Signal→TtyControl::signal,Eof→ close stdin.Exitfrom the client is ignored (server→client only). On client read-half close or a zero-length stdin chunk, signal EOF to the backend's stdin. Unknown controltypevalues are ignored (logged at debug), not errors. - C. exit → exit chunk: await
TtyHandle.exit_code; on resolve, enqueue{"type":"exit","code":N}as a control chunk (stream_type 3). OnTtyError, send{"type":"exit","code":-1}(ADR-055 §4).
A drainer task writes chunks to the client in arrival order. After the exit chunk is written (task C resolves AND stdout/stderr pumps complete AND the exit chunk drains), the adapter closes the write half — the session ends.
- A. stdout → client: backend stdout (
Exit-chunk ordering (ADR-055)
The "exit chunk is last" invariant is enforced here, in the adapter's session driver, not in the backend:
- The stdout pump (task A) drains the backend's stdout to EOF.
- The exit task (task C) awaits
TtyHandle.exit_code. - The adapter waits for both the stdout pump to complete (EOF) and
exit_codeto resolve before enqueueing the exit chunk. IfstderrisSome, it also drains before the exit chunk. (ADR-055 assumption 2.) - After both resolve, the exit chunk is enqueued on the writer channel.
- The drainer writes the exit chunk to the client.
- The adapter closes the write half — the session ends.
Use a coordination primitive (e.g., tokio::join! on the stdout pump and the
exit future, or a barrier) to enforce the "both done before exit chunk" rule.
The POC's session.rs uses an mpsc writer channel where the exit task sends
the exit chunk and the drainer writes it last; the adapter must additionally
ensure the stdout pump has finished before the exit chunk is sent. A clean
pattern: join!(stdout_pump, exit_future) then send the exit chunk.
Access control
Terminal sessions are runtime-spawned resources per ADR-050:
- Scope-gate at negotiation. Check the caller's
identity.scopesfor thetty:openscope (or a deployment-configured scope) before allocating. A caller without the scope gets{"error":"forbidden"}and the stream closes. The scope name is a two-way-door choice (reversible, not a wire-format constant). - Resource ownership for backend-specific resources. Call
backend.resource_id(¶ms). IfSome((kind, id))and an ownership provider is wired, checkOwnershipProvider::owns(identity, kind, id, "tty"). If the caller doesn't own it →{"error":"forbidden"}. IfNone(the session creates its own resource — local process, SSH channel), no ownership check. forwarded_forfor proxied sessions (ADR-032) is the hub's concern, not the adapter's — the worker authorizes the hub (its direct caller).
Negotiation errors
Send the JSON error response in negotiation framing (task tty/negotiation),
then close the write half. The framing-disambiguation trick (first byte 0x00
= error frame, first byte 1/2/3 = raw chunk) is sound because error frames
are under 16 MiB.
Connection and stream lifecycle
- Connection drop: all in-flight sessions are cancelled. Pump tasks drop;
TtyHandledrops;exit_codefuture drops without completion → backend's cancel-cleanup kills the session target (ADR-056). - Stream reset:
ChunkReaderreturnsRawError(ConnectionClosed or Io). Pump tasks exit;TtyHandledrops; cancel-cleanup runs. No exit chunk sent. - Client cancel (write-half close / eof / zero-length stdin): signal EOF to backend stdin, keep pumping stdout until exit resolves. The session completes normally (exit chunk sent). This is NOT a cancel — ADR-056 is not triggered. Cancel-cleanup is triggered only when the adapter drops the handle (connection drop, stream reset, panic).
Tests
Use the MockBackend from task tty/backend-trait (in-memory tokio mpsc
channels for stdin/stdout, a oneshot for exit_code, a mock TtyControl).
Drive drive_session over a tokio::io::duplex:
- Happy path: send a negotiation frame, send stdin chunks, mock backend echoes stdout, resolve exit_code 0, assert the client receives stdout chunks then the exit chunk last, then stream close.
- Exit-chunk-is-last: assert no stdout chunk arrives after the exit chunk.
- Stdin EOF: send a zero-length stdin chunk (or
eofcontrol), assert the backend's stdin closes, stdout continues, exit chunk still sent. - Resize/signal control: send resize and signal control chunks, assert
the mock
TtyControlreceives them. - Unknown control type: send
{"type":"unknown"}, assert it's ignored (no error, session continues). - unknown_backend error: negotiation frame with unregistered backend → error response, stream closes, no raw mode.
- malformed_negotiation error: bad JSON or
carriage != "raw"or emptycmd→ error response, stream closes. - allocate_failed error: mock backend returns
TtyError::AllocFailed→ error response, stream closes. - Exit error: mock backend's
exit_coderesolves withTtyError→{"type":"exit","code":-1}. - Cancel cleanup: drop the connection mid-session, assert the mock
backend's
exit_codefuture is dropped (the kill-on-Drop guard fires). - Scope gate: identity without
tty:openscope →forbiddenerror. - Ownership check: backend returns
resource_id Some, ownership provider returns false →forbidden; returns true → session proceeds.
Acceptance Criteria
TtyAdapterstruct withbackendsandownershipfieldsTtyAdapter::newandwith_ownershipconstructorsimpl ProtocolHandler for TtyAdapterwithalpn()returningb"alknet/tty"handle()loopsaccept_bi, spawnsdrive_sessionper streamdrive_sessionreads negotiation frame, parses, selects backend, constructsTtyParams- Negotiation errors (
unknown_backend,malformed_negotiation,allocate_failed) sent as JSON in negotiation framing, stream closed - Three-pump driver: stdout→client, client→backend (stdin + control dispatch), exit→exit-chunk
- stderr pump concurrent with stdout when
TtyHandle.stderrisSome - Exit-chunk-is-last invariant: stdout/stderr pumps complete AND exit_code resolves before exit chunk enqueued
- Exit error →
{"type":"exit","code":-1} - Unknown control
typeignored (not an error) Exitcontrol from client ignored (server→client only)- Zero-length stdin chunk and
eofcontrol both close backend stdin - Scope-gate at negotiation (
tty:openscope, or deployment-configured) - Ownership check via
backend.resource_id()+OwnershipProvider::owns()when wired - Connection drop / stream reset drops
TtyHandle(triggers ADR-056 cancel-cleanup) - Client write-half close does NOT trigger cancel-cleanup (session runs to completion)
- Integration tests with
MockBackendovertokio::io::duplexfor all the scenarios above cargo test -p alknet-ttysucceedscargo clippy -p alknet-ttysucceeds with no warnings
References
- docs/architecture/crates/tty/tty-adapter.md — the authoritative session lifecycle spec
- docs/architecture/crates/tty/tty-wire.md — the wire format the adapter pumps
- docs/architecture/crates/tty/tty-backend.md — the backend trait the adapter dispatches to
- docs/architecture/decisions/052-alknet-tty-wire-format-and-two-carriage.md — ADR-052
- docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md — ADR-053
- docs/architecture/decisions/055-exit-code-on-control-chunk.md — ADR-055 (exit-chunk ordering)
- docs/architecture/decisions/056-backend-cleanup-on-session-cancel.md — ADR-056 (cancel-cleanup)
- docs/architecture/decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md — ADR-050 (access control)
- docs/architecture/decisions/032-forwarded-for-identity.md — ADR-032 (forwarded_for)
- /workspace/alknet-tty-poc/src/session.rs — the reference three-pump driver (hardcoded to local PTY; generalize to the trait)
Notes
This is the integration task where all the invariants come together. The exit-chunk-is-last ordering (ADR-055) is the subtle part: the adapter must wait for BOTH the stdout pump to complete AND exit_code to resolve before sending the exit chunk. The POC's
session.rsis the reference but it does not enforce this ordering strictly (the exit task sends the chunk independently); the crate's adapter must add the coordination. TheMockBackendfromtty/backend-traitis the test fixture. The scope nametty:openis a two-way-door choice — make it a constant or configurable, not a wire-format constant.
Summary
To be filled on completion