The earlier specs claimed alknet-tty reuses alknet-call's FrameFramedReader/FrameFramedWriter 'framing utility' (ADR-052 §6, ADR-003 Am. 1). A pre-implementation sanity check found this was unsound: FrameFramedReader::read_frame() is hardcoded to deserialize EventEnvelope — the length-prefix read and the type-specific deserialize are one entangled call, not a separable utility. alknet-tty's negotiation payload is a NegotiateRequest, not an EventEnvelope, so the claimed reuse did not exist in a usable form. ADR-057: alknet-tty implements its own ~30-line length-prefixed framing directly on tokio AsyncRead/AsyncWrite. The format coincides with alknet-call's framing by convention (both are length-prefixed JSON); the implementations are independent. alknet-tty depends on alknet-core only — no alknet-call dependency. The ~30 lines of framing is an idiom, not a domain abstraction worth a cross-crate dependency. Updates: - ADR-003 Amendment 2: clarifies alknet-tty does not depend on alknet-call; the Am. 1 protocol-foundation exception remains for alknet-http/agent/napi (actual type reuse), not alknet-tty. - ADR-052 §6: framing is self-contained in alknet-tty; format coincides by convention, not by code reuse. - overview.md: dependency block drops alknet-call; new 'Why no alknet-call dependency' subsection explains the unsound-reuse finding. - tty-wire.md, tty-backend.md, tty README, crates/tty/README.md, top-level README: ADR tables, Design Decisions tables, and dependency edges updated to reflect alknet-core-only dependency. - ADR-053, ADR-054: References updated to ADR-003 Am. 2 + ADR-057.
21 KiB
ADR-053: TtyBackend Trait and TtyHandle — the Backend Inversion Point
Status
Accepted
Context
alknet-tty's wire format (ADR-052) is backend-agnostic — the chunk codec pumps bytes and JSON control messages without knowing whether the backend is a docker container, an SSH session channel, or a local process. The question this ADR answers: what is the seam between the wire-format adapter and the backends?
The alknet-tty research (docs/research/alknet-tty/phase-0-findings.md)
identified the TtyBackend trait as the inversion point. The guiding
insight:
A terminal session is not an SSH concern, or a Docker concern — it is a terminal concern. SSH and Docker are just two backends that can allocate a PTY.
The trait is what makes that insight load-bearing: alknet-tty defines the trait and the wire-format adapter; the backend crates (alknet-docker, alknet-ssh, alknet-tty-local) implement the trait. This preserves ADR-003's no-handler-depends-on-another-handler rule: alknet-tty depends on alknet-core; backend crates depend on alknet-tty for the trait; alknet-tty does not depend on any backend (and, per ADR-057, does not depend on alknet-call either — the negotiation framing is self-contained).
What the local-PTY POC discovered about the trait shape
The Phase 0 POC (/workspace/alknet-tty-poc) was built before this ADR
specifically to discover constraints the trait sketch would have missed by
reading docs alone. Two requirements fell out of it (recorded as
REQ-TTY-01 and REQ-TTY-02 in the findings doc):
-
REQ-TTY-01: backends are not required to be natively async.
portable_ptyis a blockingstd::io::{Read, Write}API with a blockingChild::wait(). The POC bridges it to async via three dedicated std threads (reader, writer, waiter) feeding tokio mpsc/oneshot channels — the same pattern wezterm (portable_pty's primary consumer) uses. The trait's adapter-facing types (AsyncWrite,Stream<Item = Bytes>,BoxFuture) are the adapter's contract; a backend may expose blocking handles internally and bridge them. The bridging pattern is a documented, supported implementation strategy, not a workaround. -
exit_codeis aFuturethe adapter awaits, not a method onTtyHandle. Aoneshot::Receiver<i32>(or anyBoxFuture<'static, i32>) lets the adapterselectbetween exit and stream-close without coupling to the handle's other fields. The local backend's waiter thread produces exactly this shape for free.
The POC's LocalPty struct (src/local_pty.rs) is the reference
implementation of what a backend produces: stdout: mpsc::Receiver<Bytes>,
stdin: mpsc::Sender<StdinCmd>, control: PtyControl (Clone), exit_code: oneshot::Receiver<i32>. The trait shape below generalizes this.
What the local-PTY POC did not resolve
The POC used a separate cloneable PtyControl struct for resize/signal,
not a trait object. The research noted this worked cleanly because the
control-chunk dispatcher needs to be Clone to hand off to the spawned
pump task. Phase 1 confirms the control field as a separate
TtyControlHandle newtype — a concrete #[derive(Clone)] struct
wrapping Arc<dyn TtyControl + Send + Sync> (the trait is NOT Clone;
Clone is not object-safe — see OQ-43). The newtype carries the
Clone-ability; the trait stays object-safe.
Decision
1. TtyBackend trait
#[async_trait]
pub trait TtyBackend: Send + Sync {
/// Allocate a terminal/process session and return the handles the
/// adapter pumps. The `backend` field of the negotiation frame
/// (ADR-052) selects which registered backend's `allocate` is called.
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError>;
/// The pre-existing resource this session targets, for ownership
/// checks (ADR-050). `None` = no pre-existing resource (the session
/// creates its own — local process, SSH channel). `Some((kind, id))`
/// = the session targets an existing resource the caller must own
/// (e.g., DockerTtyBackend returns `Some(("container", id))`). The
/// adapter calls this at negotiation to gate access; the backend
/// extracts the id from its own `backend_params`. Default `None`
/// (most backends create their own resource).
fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> { None }
}
The adapter holds a HashMap<String, Arc<dyn TtyBackend>> keyed by the
negotiation frame's backend string ("local", "docker", "ssh").
The assembly layer registers backends at startup; the adapter dispatches
by the backend field. A backend is the thing that allocates a session;
the wire-format pump is backend-agnostic.
2. TtyParams — the allocation request
pub struct TtyParams {
/// Terminal parameters. `None` = pipe mode (no PTY — the runner case,
/// ADR-054). `Some` = allocate a PTY with these dimensions.
pub terminal: Option<TerminalParams>,
/// Command vector (argv[0] + args).
pub cmd: Vec<String>,
/// Working directory (backend-specific; None = inherit/default).
pub cwd: Option<PathBuf>,
/// Environment variables (backend-specific; empty = inherit).
pub env: HashMap<String, String>,
/// Backend-specific selector fields from the negotiation frame,
/// unparsed. The adapter passes the JSON object through verbatim; the
/// backend deserializes its own strongly-typed params struct from it.
/// alknet-tty has zero knowledge of any backend's params shape. See
/// §"Backend params are opaque" below.
pub backend_params: serde_json::Map<String, serde_json::Value>,
}
pub struct TerminalParams {
pub term: Option<String>, // e.g., "xterm-256color"; None = backend default
pub cols: u16,
pub rows: u16,
pub pixel_width: u16,
pub pixel_height: u16,
pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1
}
terminal: None is the pipe/runner case — no PTY, separate stdout/stderr
(ADR-054). terminal: Some is the PTY case — stdout/stderr merged into
the single stdout stream (TtyHandle.stderr is None), real terminal
semantics (resize, signal delivery to process group).
Backend params are opaque
backend_params is a serde_json::Map<String, serde_json::Value>, not a
typed enum. The adapter passes the negotiation frame's backend-specific
fields through verbatim; the backend deserializes its own
strongly-typed params struct. alknet-tty has zero knowledge of any
backend's params shape — not docker's container, not an SSH host
selector, not anything.
Each backend defines its own params struct:
// in alknet-docker
#[derive(Deserialize)]
struct DockerBackendParams { container: String }
// in alknet-ssh
#[derive(Deserialize)]
struct SshBackendParams { /* host selector if multi-host; else empty */ }
// in alknet-tty-local
// no backend-specific params — backend_params is empty
And deserializes from params.backend_params inside allocate():
impl TtyBackend for DockerTtyBackend {
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError> {
let p: DockerBackendParams = serde_json::from_value(
serde_json::Value::Object(params.backend_params.clone())
).map_err(|e| TtyError::Backend { message: e.to_string() })?;
// use p.container ...
}
}
This is a complete inversion: the trait is inverted (backends implement, alknet-tty doesn't depend on them) and the params are inverted (backends define their own typed shape, alknet-tty doesn't carry it). A new backend crate requires zero changes to alknet-tty — no enum variant to add, no forward-reference type to place, no dependency edge.
Why not a typed enum. The earlier draft of this ADR defined
BackendParams as a #[non_exhaustive] enum with Local, Docker { container }, and Ssh { channel: SshChannelRef } variants. Three problems:
- Rust enums are closed.
#[non_exhaustive]prevents consumers from matching exhaustively, but only the defining crate (alknet-tty) can add variants. A backend crate cannot add a variant; every new backend requires modifying alknet-tty. The inversion is only partial. SshChannelRefwas an output, not an input. The SSH channel is whatallocate()opens (session.channel_open_session()→pty_request→shell_request). It doesn't exist until the backend creates it; the client doesn't send one.- Dependency contradiction.
SshChannelRef"wraps a russhChannelIdand session reference." If it lives in alknet-ssh, alknet-tty depends on alknet-ssh (violates the inversion). If it lives in alknet-tty, alknet-tty pulls in russh types (same violation). Opaque params dissolve the contradiction — there is noSshChannelReftype in alknet-tty at all.
The earlier draft rejected serde_json::Value because "it loses type
safety and forces the adapter to parse backend-specific JSON it
shouldn't interpret." The first concern doesn't apply (each backend has
its own strongly-typed struct via serde; type safety moves from
alknet-tty to the backend where it belongs). The second was already
inconsistent with the adapter, which hardcoded extraction of docker's
container field for the ownership check — the adapter was parsing
backend-specific JSON. The opaque approach removes that: the adapter
delegates the resource-id extraction to the backend via resource_id()
below.
3. TtyHandle — what a backend produces
pub struct TtyHandle {
/// Stdin writer — bytes the adapter pumps from client stdin chunks.
/// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io`
/// one — they are incompatible traits; the tokio stack is the
/// adapter's runtime).
pub stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
/// Stdout stream — bytes the adapter pumps to client stdout chunks.
/// Ends when the backend's stdout reaches EOF (process exited,
/// container output stream ended, SSH channel closed).
/// `futures_core::Stream<Item = bytes::Bytes>` (re-exported by
/// `tokio_stream::StreamExt` for extension methods).
pub stdout: Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>,
/// Stderr stream — `None` for PTY backends (stdout/stderr merged
/// into `stdout`). `Some` for pipe backends (separate streams).
pub stderr: Option<Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>>,
/// Exit code — a `Future` the adapter awaits. Resolves when the
/// process/container/SSH exec exits. The adapter sends the result
/// as the `{"type":"exit","code":N}` control chunk (ADR-055) and
/// closes the stream. This is `BoxFuture`, not a method on
/// `TtyHandle`, so the adapter can `select` between exit and
/// stream-close without coupling to the other fields. (REQ-TTY-01.)
pub exit_code: BoxFuture<'static, Result<i32, TtyError>>,
/// Control handle (resize, signal) — `Clone` so the adapter can
/// hand it to the spawned control-chunk dispatcher. `None` only
/// when the backend genuinely has no control path (e.g., a pipe
/// backend with no PTY — signal still works via `kill(pid, sig)`,
/// but resize is a no-op). See OQ-43 for the `TtyControlHandle`
/// newtype rationale.
pub control: Option<TtyControlHandle>,
}
pub trait TtyControl: Send + Sync {
/// Resize the terminal. Maps to SSH `window-change`, docker exec
/// resize, or `ioctl(TIOCSWINSZ)` on a local PTY. No-op for pipe
/// backends without a PTY (the adapter still calls it; the backend
/// ignores).
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16);
/// Forward a signal by name. Best-effort delivery to the foreground
/// process group (see tty-local.md REQ-TTY-02). Unknown names fall
/// back to the backend's default kill.
fn signal(&self, name: &str);
}
/// The `Clone`-able handle to a backend's control path. The `TtyControl`
/// trait is NOT `Clone` (`Clone` is not object-safe — `fn clone(&self) ->
/// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability
/// lives on this concrete newtype, which holds the trait object behind an
/// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned
/// control-chunk dispatcher. See OQ-43.
#[derive(Clone)]
pub struct TtyControlHandle(Arc<dyn TtyControl + Send + Sync>);
4. Backends are not required to be natively async (REQ-TTY-01)
The trait's adapter-facing types (AsyncWrite, Stream<Item = Bytes>,
BoxFuture, the TtyControl trait object) are the adapter's contract.
A backend may expose blocking handles internally and bridge them to these
async-facing types. The bridging pattern — blocking std::io on dedicated
std threads or tokio::task::spawn_blocking, feeding tokio mpsc/oneshot
channels — is a documented, supported implementation strategy, not a
workaround.
The local backend (ADR-054) uses this pattern: portable_pty is a blocking
API, and the backend's allocate() spawns reader/writer/waiter threads
that feed mpsc::Receiver<Bytes> (stdout), mpsc::Sender<StdinCmd> (stdin
wrapped as AsyncWrite), and oneshot::Receiver<i32> (exit). The
adapter consumes the bridged async-facing types and is unaware of the
threading. See tty-local.md for the bridge details.
5. Backend registration and the assembly layer
The TtyAdapter does not know the set of backends at compile time — it
holds a HashMap<String, Arc<dyn TtyBackend>> populated at construction.
The assembly layer (the CLI binary) constructs backends with their
dependencies (a DockerTtyBackend wraps a bollard::Docker client; an
SshTtyBackend wraps an SSH session; a LocalTtyBackend takes no
deps) and registers them:
let mut backends = HashMap::new();
backends.insert("local".into(), Arc::new(LocalTtyBackend::new()) as Arc<dyn TtyBackend>);
backends.insert("docker".into(), Arc::new(DockerTtyBackend::new(docker_client)) as _);
backends.insert("ssh".into(), Arc::new(SshTtyBackend::new(ssh_session)) as _);
let tty_adapter = TtyAdapter::new(Arc::new(backends));
A deployment that doesn't want docker registers only local. A browser
terminal endpoint that proxies to remote docker/ssh registers docker
and/or ssh backends. The adapter is backend-agnostic; the assembly
layer chooses what's available.
Consequences
Positive:
- The wire-format adapter is backend-agnostic and testable with a mock backend (in-memory pipes). The build order (ADR-052 wire format + mock backend first, real backends last) follows directly.
- alknet-tty stays dependency-light: no bollard, no russh, no
portable_ptyin the core crate. The heavy deps live in the backend crates. This is the same inversion asOperationAdapter(ADR-017): the trait lives where the types live; the implementations live where their transport dependencies live. - Blocking-API backends (portable_pty) are first-class — the trait accommodates them by making the adapter-facing types the contract and the bridging pattern a documented strategy. No re-spec required when a future backend is also blocking.
exit_codeas aFuture(not a method on the handle) lets the adapterselectbetween exit and stream-close — the load-bearing composition the session lifecycle needs (the exit chunk is sent after the child is reaped, then the stream closes — ADR-055).
Negative:
- Backend params are opaque (
serde_json::Map), not a typed enum. Each backend deserializes its own params struct; the adapter passes the JSON through verbatim. The cost is one serde deserialize perallocate()call (negligible — allocation is once per session, not on the hot path). The benefit is a complete inversion: alknet-tty has zero knowledge of any backend's params shape, and a new backend crate requires zero changes to alknet-tty (no enum variant, no forward-reference type). See §"Backend params are opaque" for the full rationale and why the typed-enum alternative was rejected (Rust enums are closed; the earlierSshChannelRefvariant was an output modeled as an input and created a dependency contradiction). TtyControlis notClone; theTtyControlHandlenewtype is.Cloneis not object-safe (fn clone(&self) -> SelfreturnsSelf, which forbidsdyndispatch), soBox<dyn TtyControl + Clone>does not compile. The design splits the concerns: theTtyControltrait stays object-safe (Send + Sync, noClone); theTtyControlHandlenewtype (a concrete struct holdingArc<dyn TtyControl + Send + Sync>) implementsCloneby cloning theArc. This is the cost of the POC-discovered constraint that the control-chunk dispatcher needs to beCloneto hand off to the spawned pump task. See OQ-43 for the confirmation and the concrete newtype approach.- A backend that produces neither a PTY nor a process (a hypothetical
"recorded session replay" backend) would have a no-op
TtyControland a syntheticexit_code. The trait accommodates it but theTtyParamsshape (cmdisVec<String>,terminalisOption) assumes command-spawning. A non-command backend would supply an emptycmdand synthesize one internally. Not a current use case; the trait shape doesn't preclude it but doesn't optimize for it.
Door type
One-way. The TtyBackend trait method allocate(), the
TtyHandle field set, and the TtyControl trait are the API surface
every backend crate implements and the adapter consumes. Changing them
after backends exist is a rewrite across crates. backend_params as an
opaque serde_json::Map is part of the one-way TtyParams shape — the
carrier is fixed (opaque JSON), but the contents are
backend-defined and require no alknet-tty change for new backends. The
resource_id() default method is additive (a new method with a default
impl doesn't break existing implementors); its return type
Option<(&'static str, String)> is one-way.
Assumptions
-
The
TtyControltrait is kept object-safe by NOT puttingCloneon it; theTtyControlHandlenewtype holds the trait object behind anArcand implementsCloneby cloning theArc. The POC used a concretePtyControlstruct (inherentlyClone— it heldArc<Mutex<...>>fields). The newtype generalizes the POC's shape so a backend produces its own control type viaTtyControlHandle::new(Arc::new(MyControl))without the adapter knowing the concrete shape.Clonecannot live on the trait itself (it is not object-safe); the newtype is the seam. OQ-43 confirms. -
Backends produce a single session per
allocate()call. The adapter callsallocate()once per accepted bidi stream (one session per stream — ADR-052). A backend that multiplexed multiple sessions over oneallocate()would not fit the trait; no such backend is contemplated. -
The adapter, not the backend, owns the exit-chunk ordering. The backend resolves
exit_code; the adapter awaits it, sends the exit control chunk, and closes the stream (ADR-055). The backend does not write to the wire — it produces handles; the adapter pumps. This keeps the wire-format logic in one place (the adapter) and the backend focused on its allocation target (docker, ssh, local process).
References
docs/research/alknet-tty/phase-0-findings.md— Phase 0 research; §"The Backend Trait" is the seed of this ADR; §"Requirements from the local-PTY POC" (REQ-TTY-01) is the load-bearing constraint/workspace/alknet-tty-poc/src/local_pty.rs— the reference implementation of what a backend produces (LocalPty: stdout mpsc, stdin mpsc, controlPtyControl(Clone), exit oneshot)/workspace/alknet-tty-poc/src/session.rs— the adapter-side pump that consumesTtyHandle-shaped fields (the reference for how the adapter uses the trait)- ADR-003 + Amendments 1 & 2 — no-handler-depends-on-another-handler; alknet-tty depends on alknet-core (no alknet-call per Am. 2 / ADR-057); backends depend on alknet-tty for the trait
- ADR-057 — alknet-tty does not depend on alknet-call (self-contained negotiation framing)
- ADR-007 —
Connection,SendStream,RecvStream(the adapter receives aConnection, accepts bidi streams, pumps per-session) - ADR-052 — the wire format this trait's backends feed
- ADR-054 — the local backend's crate placement (sibling crate, behind a feature re-export)
- ADR-055 — the exit chunk ordering
this trait's
exit_codefield feeds into - OQ-43 —
TtyControlasClonetrait object (resolved: confirmed) - OQ-44 — terminal modes (deferred(scope): not needed for current scope)
- Spec: crates/tty/tty-backend.md