Files
alktty/docs/architecture/decisions/002-ttybackend-trait-and-ttyhandle.md
T
glm-5.2 b3f50d1836 phase 4: architecture docs + BAST schema + renumbered ADRs
Port the alknet-tty architecture docs into alktty and add the BAST
document for the alk/tty wire format. Docs-only; no Rust source
changes.

Spec docs (docs/architecture/, flat layout — single-crate repo):
- overview.md — crate purpose, two-carriage model, deps, ALPN,
  backend location map, feature gates
- tty-wire.md — 5-byte chunk codec, control channel split
  (STREAM_CTRL_IN=3 / STREAM_CTRL_OUT=4), sentinels
- tty-backend.md — TtyBackend trait, TtyHandle, TtyControl,
  REQ-TTY-01 (backends need not be natively async)
- tty-adapter.md — TtyAdapter, three-pump driver, exit-chunk
  ordering (ADR-004), cancel cleanup (ADR-005), access control
- tty-local.md — LocalTtyBackend (local feature module), PTY +
  pipe modes, REQ-TTY-02 (signal forwarding to process group)
- README.md — architecture index

ADRs (docs/architecture/decisions/, renumbered 001..008 from
alknet 052,053,054,055,056,057,077,093 in order):
- 001 wire format + two-carriage model (incl. Phase 7 control-
  channel split amendment)
- 002 TtyBackend trait + TtyHandle
- 003 local backend placement (records both the alknet sibling-
  crate decision and the alktty single-crate consolidation behind
  a local feature)
- 004 exit code on a control chunk
- 005 backend cleanup on session cancel
- 006 self-contained negotiation framing
- 007 tty inside channels (reversed by 008; kept for historical
  context with reversal notice)
- 008 channels pure channel multiplexing (reverses 007; TTY
  always uses its 5-byte format)

BAST document (docs/architecture/tty-bast.md):
- Normative JSON spec for the alk/tty wire format, conforming to
  the BAST meta-schema at https://alk.dev/bast/v1/schema
- 5-byte chunk header (struct, big-endian: stream_type uint8,
  length uint32) + StreamType enum (Stdin=0..CtrlOut=4)
- ControlMessage union (field-name discriminator on type:
  resize/signal/eof/exit) with documented deviation that on-wire
  control payloads are UTF-8 JSON, not BAST's binary union
  encoding
- NegotiationFrame (4-byte BE length + UTF-8 JSON body) +
  NegotiateRequest / TerminalParams JSON shapes
- StreamType enum deviation noted: on-wire uint8, not BAST's
  standard u32 enum index (chunk header is 5 bytes, not 8)
- alktty does not depend on alktype; the hand-rolled wire.rs is
  the runtime codec, the BAST is the human-readable contract

AGENTS.md: fixed the ADR mapping table to match the plan's 8-to-8
mapping (the previous table substituted ADR-050 for 054, relabeled
056 as control-message split, dropped 077, and added a new
control-split ADR at 006 — inconsistent with both the plan and the
prose). ADR-050 (dynamic resource ownership) is an alkcall/alknet-
core ADR, not tty-specific, and is not ported; the Phase 7 control
split stays as an amendment inside ADR-001, mirroring alknet.

Verification (all pass, no Rust source changed):
- cargo test (80 passed)
- cargo test --all-features (103 passed)
- cargo clippy --all-targets -- -D warnings (clean)
- cargo fmt --check (clean)
- cargo check --target wasm32-unknown-unknown (clean)
- cargo clippy --target wasm32-unknown-unknown -- -D warnings
  (clean)
- cargo doc --no-deps: 9 pre-existing intra-doc-link warnings in
  src/session.rs and src/channels.rs (untouched by this commit;
  not introduced here)
- BAST JSON parses; StreamType indices match wire.rs constants
  (0=Stdin..4=CtrlOut)
- all markdown cross-reference links resolve
2026-08-17 10:52:26 +00:00

21 KiB

ADR-002: TtyBackend Trait and TtyHandle — the Backend Inversion Point

Status

Accepted (ported from alknet ADR-053 2026-08-17; cross-references renumbered to alktty's ADR range — ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. Alknet ADRs referenced by alknet number (003, 007, 017, 050) are not ported into alktty's ADR range because they are not tty-specific; the alknet originals at /workspace/@alkdev/alknet/docs/architecture/decisions/ remain authoritative.)

Context

alktty's wire format (ADR-001) 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 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: alktty defines the trait and the wire-format adapter; the backend crates (alknet-docker, alknet-ssh, alktty's local feature module) implement the trait. This preserves alknet ADR-003's no-handler-depends-on-another-handler rule: alktty depends on alkcall; backend crates depend on alktty for the trait; alktty does not depend on any backend (and, per ADR-006, does not depend on alkcall's internal wire types either — the negotiation framing is self-contained).

What the local-PTY POC discovered about the trait shape

The Phase 0 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 alknet 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 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-001) 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 (alknet 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-003). `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.
    /// alktty 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-003). 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. alktty 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 alktty's local feature module
// 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, alktty doesn't depend on them) and the params are inverted (backends define their own typed shape, alktty doesn't carry it). A new backend crate requires zero changes to alktty — 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:

  1. Rust enums are closed. #[non_exhaustive] prevents consumers from matching exhaustively, but only the defining crate (alktty) can add variants. A backend crate cannot add a variant; every new backend requires modifying alktty. The inversion is only partial.
  2. SshChannelRef was an output, not an input. The SSH channel is what allocate() opens (session.channel_open_session()pty_requestshell_request). It doesn't exist until the backend creates it; the client doesn't send one.
  3. Dependency contradiction. SshChannelRef "wraps a russh ChannelId and session reference." If it lives in alknet-ssh, alktty depends on alknet-ssh (violates the inversion). If it lives in alktty, alktty pulls in russh types (same violation). Opaque params dissolve the contradiction — there is no SshChannelRef type in alktty 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 alktty 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-004) 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-003) 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-001 wire format + mock backend first, real backends last) follows directly.
  • alktty stays dependency-light: no bollard, no russh, no portable_pty in the default crate. The heavy deps live in the backend crates / the local feature. This is the same inversion as OperationAdapter (alknet 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-004).

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 per allocate() call (negligible — allocation is once per session, not on the hot path). The benefit is a complete inversion: alktty has zero knowledge of any backend's params shape, and a new backend crate requires zero changes to alktty (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 earlier SshChannelRef variant was an output modeled as an input and created a dependency contradiction).
  • TtyControl is not Clone; the TtyControlHandle newtype is. Clone is not object-safe (fn clone(&self) -> Self returns Self, which forbids dyn dispatch), so Box<dyn TtyControl + Clone> does not compile. The design splits the concerns: the TtyControl trait stays object-safe (Send + Sync, no Clone); the TtyControlHandle newtype (a concrete struct holding Arc<dyn TtyControl + Send + Sync>) implements Clone by cloning the Arc. 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 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 supply an empty cmd and 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 alktty 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

  1. The TtyControl trait is kept object-safe by NOT putting Clone on it; the TtyControlHandle newtype holds the trait object behind an Arc and implements Clone by cloning the Arc. The POC used a concrete PtyControl struct (inherently Clone — it held Arc<Mutex<...>> fields). The newtype generalizes the POC's shape so a backend produces its own control type via TtyControlHandle::new(Arc::new(MyControl)) without the adapter knowing the concrete shape. Clone cannot live on the trait itself (it is not object-safe); the newtype is the seam. OQ-43 confirms.

  2. Backends produce a single session per allocate() call. The adapter calls allocate() once per accepted bidi stream (one session per stream — ADR-001). 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-004). 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

  • alknet ADR-003 + Amendments 1 & 2 — no-handler-depends-on-another- handler; alktty depends on alkcall (no alkcall-internal-wire-types per Am. 2 / ADR-006); backends depend on alktty for the trait
  • ADR-006 — alktty does not depend on alkcall's internal wire types (self-contained negotiation framing)
  • alknet ADR-007 — Connection, SendStream, RecvStream (the adapter receives a Connection, accepts bidi streams, pumps per-session)
  • ADR-001 — the wire format this trait's backends feed
  • ADR-003 — the local backend's module placement (folded into alktty behind a local feature)
  • ADR-004 — the exit chunk ordering this trait's exit_code field feeds into
  • ADR-005 — the cancel- cleanup contract on this trait (exit_code future's Drop-on-cancel kills the session target)
  • alknet ADR-017 — the adapter-location-map pattern (trait where types live, implementation where deps live) this ADR follows
  • alknet ADR-050 — the ownership model the resource_id() default declares against
  • OQ-43 — TtyControl as Clone trait object (resolved: confirmed)
  • OQ-44 — terminal modes (deferred(scope): not needed for current scope)
  • Spec: tty-backend.md
  • Port origin: alknet ADR-053 at /workspace/@alkdev/alknet/docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md