Files
alknet/docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md
T
glm-5.2 e4e6ccfb14 docs(arch): alknet-tty Phase 1 specs — wire format, backend trait, local backend, exit-chunk ordering
Grounded in the alknet-docker POC (seed codec) and the alknet-tty POC
(2026-07-05, validated the control channel, the local-PTY blocking→async
bridge, and the signal-delivery contract).

Four ADRs:
- ADR-052: wire format — alknet/tty ALPN, two-carriage (JSON negotiation
  then raw chunks), fixed channel set 0-3, control as JSON, negotiation-
  error framing disambiguation
- ADR-053: TtyBackend trait + TtyHandle — the backend inversion point;
  exit_code as a Future; REQ-TTY-01 (backends need not be natively async)
- ADR-054: local backend as alknet-tty-local sibling crate behind a
  feature re-export; PTY vs pipe per-session; runner pattern preserved
- ADR-055: exit code on a stream_type 3 control chunk; "exit chunk is
  last" invariant; adapter owns the ordering

Five component specs (crates/tty/): README, overview, tty-wire, tty-
backend, tty-adapter, tty-local. The docker/SSH backends are future
crates (out of scope); the trait shape is committed so they can be
built against it.

Five OQs (OQ-43…47): two resolved (TtyControl Clone, stdin closure),
two deferred(scope) (terminal modes, runner API surface), one open
low-risk (flow control).

Zero critical issues from a general-subagent architecture review;
warnings addressed (inline rationale trimmed to ADR refs, TtyError and
StdinCmd defined, framing disambiguation documented, missing ADRs added
to index tables).
2026-07-06 15:05:36 +00:00

15 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 (amended by ADR-003 Amendment 1 for protocol-foundation crates): alknet-tty depends on alknet-core; backend crates depend on alknet-tty for the trait; alknet-tty does not depend on any backend.

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_pty is a blocking std::io::{Read, Write} API with a blocking Child::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_code is a Future the adapter awaits, not a method on TtyHandle. A oneshot::Receiver<i32> (or any BoxFuture<'static, i32>) lets the adapter select between 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 Box<dyn TtyControl + Send + Unpin> 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 Clone trait object — see OQ-43 for the confirmation and the Clone constraint rationale.

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 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
    /// (e.g., `container` for docker, `SshChannelRef` for ssh). The
    /// adapter parses the negotiation frame's backend-specific fields
    /// and passes them here. The exact shape is backend-defined; the
    /// adapter does not interpret it.
    pub backend_params: BackendParams,
}

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 — see OQ-44
}

pub enum BackendParams {
    Local,
    Docker { container: String },
    Ssh { channel: SshChannelRef },
    // Extensible: a backend crate may add variants. (See Consequences.)
}

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).

3. TtyHandle — what a backend produces

pub struct TtyHandle {
    /// Stdin writer — bytes the adapter pumps from client stdin chunks.
    pub stdin: Box<dyn 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).
    pub stdout: Pin<Box<dyn Stream<Item = 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 Stream<Item = 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 trait-object-vs-methods
    /// confirmation.
    pub control: Option<Box<dyn TtyControl + Send + Unpin + Clone>>,
}

pub trait TtyControl: Send {
    /// 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);
}

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_pty in the core crate. The heavy deps live in the backend crates. This is the same inversion as OperationAdapter (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_code as a Future (not a method on the handle) lets the adapter select between 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:

  • BackendParams is an enum, which means a new backend crate adding a variant modifies the enum. This is a two-way door (the enum is #[non_exhaustive]; a new variant is additive, and the adapter's match has a _ => unsupported backend arm), but it means BackendParams lives in alknet-tty and backend crates extend it rather than defining their own. An alternative — backend-specific params as serde_json::Value — was rejected: it loses type safety and forces the adapter to parse backend-specific JSON it shouldn't interpret. The enum-with-#[non_exhaustive] trade is the right one: the adapter dispatches by string key, the params enum carries the typed shape, and new backends add variants additively.
  • Box<dyn TtyControl + Send + Unpin + Clone> is an unusual trait-object bound (Clone on a trait object requires a workaround — typically a custom clone-via-Arc or a Box<dyn TtyControl> wrapped in a small Clone newtype). This is the cost of the POC-discovered constraint that the control-chunk dispatcher needs to be Clone to hand off to the spawned pump task. See OQ-43 for the confirmation and the concrete Clone newtype approach.
  • A backend that produces neither a PTY nor a process (a hypothetical "recorded session replay" backend) would have a no-op TtyControl and a synthetic exit_code. The trait accommodates it but the TtyParams shape (cmd is Vec<String>, terminal is Option) assumes command-spawning. A non-command backend would need a different BackendParams variant and a backend-internal cmd synthesis. 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, TtyHandle field set, and TtyControl trait are the API surface every backend crate implements and the adapter consumes. Changing the trait shape after backends exist is a rewrite across crates. The BackendParams enum is #[non_exhaustive] (additive — two-way for new variants), but the trait and handle shapes are one-way.

Assumptions

  1. The TtyControl trait object can be made Clone via a small newtype. The POC used a concrete PtyControl struct (inherently Clone). The trait-object form needs a Clone newtype wrapping the Box (e.g., a struct holding Arc<dyn TtyControl> so Clone is an Arc clone). This is a known Rust pattern; OQ-43 confirms the approach.

  2. Backends produce a single session per allocate() call. The adapter calls allocate() once per accepted bidi stream (one session per stream — ADR-052). A backend that multiplexed multiple sessions over one allocate() would not fit the trait; no such backend is contemplated.

  3. 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, control PtyControl (Clone), exit oneshot)
  • /workspace/alknet-tty-poc/src/session.rs — the adapter-side pump that consumes TtyHandle-shaped fields (the reference for how the adapter uses the trait)
  • ADR-003 + Amendment 1 — no-handler-depends- on-another-handler; alknet-tty depends on alknet-core; backends depend on alknet-tty for the trait
  • ADR-007 — Connection, SendStream, RecvStream (the adapter receives a Connection, 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_code field feeds into
  • OQ-43 — TtyControl as Clone trait object (resolved: confirmed)
  • OQ-44 — terminal modes (deferred(scope): not needed for current scope)
  • Spec: crates/tty/tty-backend.md