Files
alktty/docs/architecture/tty-backend.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

status, last_updated
status last_updated
draft (ported from alknet 2026-08-17; alknet-tty → alktty, alknet/tty → alk/tty, alknet-core → alkcall::core, alknet-call → alkcall, ADRs renumbered 052..093 → 001..008) 2026-08-17

alktty — TtyBackend Trait and TtyHandle

The TtyBackend trait is the inversion point that keeps alktty decoupled from its backends. alktty defines the trait, the TtyParams allocation request, the TtyHandle a backend produces, and the TtyControl trait; the backend crates (alktty's own local feature module, future alknet-docker, alknet-ssh) implement TtyBackend. This document specifies what an implementer builds against. The trait shape is decided in ADR-002.

What

The TtyBackend trait is what the TtyAdapter calls to allocate a terminal/process session. The adapter holds a HashMap<String, Arc<dyn TtyBackend>> keyed by the negotiation frame's backend string ("local", "docker", "ssh"). On a new session, the adapter reads the negotiation frame, selects the backend by the backend field, calls allocate(), and pumps the resulting TtyHandle's fields bidirectionally using the chunk format (ADR-001). The backend does not write to the wire — it produces handles; the adapter pumps.

Three implementations are contemplated, each in its own crate (the no-handler-depends-on-another-handler rule from alknet ADR-003 is preserved — backends depend on alktty for the trait, alktty doesn't depend on them):

  • LocalTtyBackend (in alktty's local feature module, ADR-003) — wraps portable_pty for the PTY case and std::process::Command with Stdio::piped() for the pipe/runner case. See tty-local.md.
  • DockerTtyBackend (in alknet-docker or a sibling adapter crate — future, out of scope here) — wraps bollard::attach_container() for interactive attach or bollard::exec::start_exec with tty: true for exec-with-PTY. control.resize() calls bollard::exec::resize_exec or bollard::container::resize_container. stdout/stderr are merged when tty: true (bollard's LogOutput on a TTY exec returns StdOut only), so TtyHandle.stderr is None for the PTY case.
  • SshTtyBackend (in alknet-ssh — future, out of scope here) — wraps russh's pty_request + shell_request (or exec_request with a PTY) on a session channel. channel.into_stream() gives (AsyncRead, AsyncWrite) — the stream is the PTY; russh handles kernel PTY allocation on the server side. control.resize() sends a window_change channel request; control.signal() sends a signal channel request. stdout and stderr are merged (PTY property), so TtyHandle.stderr is None.

The docker and SSH backend crates are future work; this spec set commits the trait shape they will implement, so they can be built against it without re-spec'ing the seam.

Why

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 TtyBackend trait is what makes that insight load-bearing — alktty owns the wire format and session lifecycle; the backends own PTY allocation. The full rationale (the inversion point, why the trait is the seam) is in ADR-002 §Context.

The Phase 0 local-PTY POC was built before this spec specifically to discover constraints the trait sketch would have missed by reading docs alone. Two requirements fell out, recorded as REQ-TTY-01 and REQ-TTY-02 in the alknet research findings; this spec carries REQ-TTY-01 here (backends need not be natively async) and tty-local.md carries REQ-TTY-02 (signal forwarding to the process group).

Architecture

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`.
    fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> { None }
}

The adapter holds HashMap<String, Arc<dyn TtyBackend>> populated at construction. The assembly layer (the CLI binary) constructs backends with their dependencies and registers them. A backend is the thing that allocates a session; the wire-format pump is backend-agnostic.

TtyError

The error type for allocate() and exit_code. #[non_exhaustive] so new variants are additive (two-way-door extension within the one-way trait shape — ADR-002).

#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum TtyError {
    #[error("allocate failed: {message}")]
    AllocFailed { message: String },
    #[error("wait failed: {message}")]
    WaitFailed { message: String },
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("backend-specific: {message}")]
    Backend { message: String },
}
  • AllocFailed — the PTY couldn't be allocated, the docker exec failed to start, the SSH channel request was rejected. Returned by allocate(); the adapter sends {"error":"allocate_failed",...} and closes (tty-adapter.md §"Negotiation errors").
  • WaitFailed — the backend couldn't reap the child / determine the exit code. Returned by the exit_code future; the adapter sends {"type":"exit","code":-1} (ADR-004 §4).
  • Io — an I/O error from a backend's stream/handle.
  • Backend — backend-specific error not covered by the above (e.g., a bollard API error, a russh protocol error).

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). Non-empty.
    pub cmd: Vec<String>,
    /// Working directory (None = inherit/default).
    pub cwd: Option<PathBuf>,
    /// Environment variables (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. 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). The per-session choice is the backend's branch in allocate(), not a per-deployment choice — see ADR-003.

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 and deserializes from params.backend_params inside allocate():

// in alknet-docker
#[derive(Deserialize)]
struct DockerBackendParams { container: String }

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 ...
    }
    fn resource_id(&self, params: &TtyParams) -> Option<(&'static str, String)> {
        // extract for the ownership check — backend-driven, not adapter-hardcoded
        let p: DockerBackendParams = serde_json::from_value(
            serde_json::Value::Object(params.backend_params.clone())
        ).ok()?;
        Some(("container", 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. See ADR-002 §"Backend params are opaque" for the full rationale and why the typed-enum alternative (with SshChannelRef) was rejected.

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.
    /// `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. See OQ-43.
    pub control: Option<TtyControlHandle>,
}

TtyControl trait and 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.
    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>);

impl TtyControlHandle {
    pub fn new(control: Arc<dyn TtyControl + Send + Sync>) -> Self { Self(control) }
    pub fn resize(&self, c: u16, r: u16, pw: u16, ph: u16) { self.0.resize(c, r, pw, ph) }
    pub fn signal(&self, name: &str) { self.0.signal(name) }
}

The trait is kept object-safe by NOT putting Clone on it; the Clone newtype (TtyControlHandle) holds the trait object behind an Arc. The POC used a concrete PtyControl struct (inherently Clone — it held Arc<Mutex<...>> fields); this 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. See OQ-43 for the confirmation and the rationale for why Clone cannot live on the trait itself.

REQ-TTY-01: backends are not required to be natively async

portable_pty's API is blocking std::io::{Read, Write} and a blocking Child::wait() — there is no async variant. The local-PTY POC bridges this with three dedicated std threads (reader, writer, waiter) feeding tokio mpsc/oneshot channels; the async-facing LocalPty then exposes mpsc::Receiver<Bytes> for stdout, mpsc::Sender<StdinCmd> for stdin, and oneshot::Receiver<i32> for exit. This is the same pattern wezterm (portable_pty's primary consumer) uses.

The trait's adapter-facing types (AsyncWrite, Stream<Item = Bytes>, BoxFuture, TtyControl) 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.

This resolves the first half of OQ-TTY-01 (the research's open question on the trait shape): exit_code is a Future the adapter awaits; 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. See tty-local.md for the bridge details.

Backend registration and the assembly layer

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, "alk".into())) as Arc<dyn TtyBackend>);
backends.insert("ssh".into(),
    Arc::new(SshTtyBackend::new(ssh_session)) as Arc<dyn TtyBackend>);
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.

Backend implementations (where they live)

Backend Crate Status Notes
LocalTtyBackend alktty's local feature module (ADR-003) in scope (tty-local.md) portable_pty (PTY) + std::process (pipe); the runner pattern
DockerTtyBackend alknet-docker (behind tty feature) future, out of scope here wraps bollard::attach_container / exec with tty: true; attach vs exec mode
SshTtyBackend alknet-ssh future, out of scope here wraps russh pty_request + shell_request/exec_request; dissolves alknet-ssh DP-5 PTY hedge

The SSH backend crate is future work; this spec commits the trait shape it implements so it can be built against it without re-spec'ing the seam. The DockerTtyBackend is future work in alknet-docker — the natural extension of the alknet-docker POC's drive_attach_raw — with the trait, it becomes impl TtyBackend for DockerTtyBackend. The SshTtyBackend dissolves the alknet-ssh research's PTY hedge (DP-5): alknet-ssh's session channel still does exec (structured, JSON carriage, exit code on completion) but delegates PTY to alktty via the SshTtyBackend. alknet-ssh's "default-reject" stance stays for the SSH channel policy (it rejects pty_request on its own session channels), but the PTY capability is provided by a separate crate via a separate ALPN (alk/tty), not hedged inside alknet-ssh.

Constraints

  • The trait shape is one-way (ADR-002). 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 are opaque (serde_json::Map), not a typed enum. The carrier type is one-way (part of TtyParams), but the contents are backend-defined: each backend deserializes its own strongly-typed params struct, and a new backend crate requires zero changes to alktty. See "Backend params are opaque" above and ADR-002 §"Backend params are opaque."
  • 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 wire-format invariants (the exit-chunk ordering, ADR-004). The backend's exit_code future resolves and the adapter sends the exit chunk — the backend does not serialize ControlMessage::Exit.
  • PTY backends merge stdout/stderr. TtyHandle.stderr is None for the PTY case (kernel PTY property — one output stream from the slave). The adapter pumps only stdout chunks (stream_type 1). Pipe backends set stderr: Some and the adapter pumps both stdout (stream_type 1) and stderr (stream_type 2) chunks.
  • TtyControl::signal is best-effort. The contract is "best-effort delivery to the foreground process group," not "the child pid receives the signal." See tty-local.md REQ-TTY-02 for the process-group targeting and the fallback to the backend's default kill.
  • Dropping the exit_code future MUST kill the session target (ADR-005). The exit_code field is a BoxFuture<'static, Result<i32, TtyError>> whose Drop-on-cancel (i.e., dropped without being driven to completion) MUST kill the child/container/SSH process. This is a behavioral contract on the TtyBackend trait — the adapter triggers it by dropping the TtyHandle on session cancel (connection drop, stream reset); the backend wires the kill into the exit_code future's Drop. A backend that returns a bare oneshot::Receiver<i32> (or any future without a kill-on-Drop guard) as exit_code violates the contract and will orphan processes on cancel. See ADR-005 and tty-local.md §"Cancel-Cleanup (ADR-005)" for the local backend's mechanism.

Design Decisions

Decision ADR Summary
TtyBackend trait and TtyHandle ADR-002 The backend inversion point; exit_code as Future; backends need not be natively async (REQ-TTY-01)
Local backend placement ADR-003 alktty folds the local backend in behind a local feature (resolves the alknet cyclic-dep workaround)
Wire format ADR-001 The chunk codec + control channel the adapter pumps to/from these handles
Exit code on a control chunk ADR-004 The adapter awaits exit_code, sends the exit chunk, closes
Backend cleanup on session cancel ADR-005 Dropping exit_code future (cancel) MUST kill the session target; contract on the TtyBackend trait

Open Questions

  • OQ-43 (resolved): TtyControl as a Clone trait object.
  • OQ-44 (deferred(scope)): Terminal modes.

References

  • ADR-002 — the trait shape decision (this spec is its elaboration)
  • ADR-001 — the wire format the adapter pumps to/from these handles
  • ADR-004 — the exit-chunk ordering the 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)
  • ADR-003 — the local backend's placement (folded into alktty behind a local feature)
  • src/backend.rs — the Rust source this spec documents
  • tty-local.md — the LocalTtyBackend spec (carries REQ-TTY-02: signal forwarding to the process group)
  • tty-adapter.md — the session driver that consumes these handles