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

17 KiB

status, last_updated
status last_updated
draft 2026-07-06

alknet-tty — TtyBackend Trait and TtyHandle

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

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-052). 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 ADR-003 is preserved — backends depend on alknet-tty for the trait, alknet-tty doesn't depend on them):

  • LocalTtyBackend (in alknet-tty-local, ADR-054) — 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 — alknet-tty 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-053 §Context.

The Phase 0 local-PTY POC (/workspace/alknet-tty-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 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-052) selects which registered backend's `allocate` is called.
    async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError>;
}

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

#[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-055 §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-054). `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.
    /// The adapter parses the negotiation frame's backend-specific fields
    /// and passes them here. The adapter does not interpret them.
    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 — OQ-44
}

#[non_exhaustive]
pub enum BackendParams {
    Local,
    Docker { container: String },
    Ssh { channel: SshChannelRef },
    // Extensible: a backend crate may add variants. The adapter's match
    // has a `_ => unsupported backend` arm.
}

SshChannelRef is a forward-reference type for the future SshTtyBackend (out of scope here; will be defined in the alknet-ssh crate — expected to wrap a russh ChannelId and session reference). It appears in the enum so the trait shape is committed; the concrete definition lives in the ssh backend crate when built.

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

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.
    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. See OQ-43.
    pub control: Option<Box<dyn TtyControl + Send + Unpin + Clone>>,
}

TtyControl trait

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.
    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 trait-object bound is satisfied via an Arc-backed Clone newtype (OQ-43): a small struct holding Arc<dyn TtyControlInner> where TtyControlInner: Send + Sync has the resize/signal methods, and the public TtyControl newtype implements Clone by cloning the Arc. The POC used a concrete PtyControl struct (inherently Clone); the trait-object form generalizes it so a backend can produce its own control type without the adapter knowing the concrete shape.

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)) 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 alknet-tty-local (sibling, behind local feature) in scope (tty-local.md) portable_pty (PTY) + std::process (pipe); the runner pattern
DockerTtyBackend alknet-docker or sibling adapter future, out of scope here wraps bollard::attach_container / exec with tty: true
SshTtyBackend alknet-ssh future, out of scope here wraps russh pty_request + shell_request/exec_request; dissolves alknet-ssh DP-5 PTY hedge

The docker and SSH backend crates are future work; this spec commits the trait shape they implement so they can be built against it without re-spec'ing the seam. The DockerTtyBackend is the natural extension of the alknet-docker POC's drive_attach_raw (/workspace/alknet-docker-poc/src/ops.rs) — 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 alknet-tty 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 (alknet/tty), not hedged inside alknet-ssh.

Constraints

  • The trait shape is one-way (ADR-053). The TtyBackend trait, TtyHandle field set, and TtyControl trait are the API surface every backend crate implements and the adapter consumes. Changing them after backends exist is a rewrite across crates.
  • BackendParams is #[non_exhaustive]. New backend crates add variants additively; the adapter's match has a _ => unsupported backend arm. This is a two-way-door extension point within the one-way trait shape.
  • 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-055). 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.

Design Decisions

Decision ADR Summary
TtyBackend trait and TtyHandle ADR-053 The backend inversion point; exit_code as Future; backends need not be natively async (REQ-TTY-01)
Local backend as a sibling crate ADR-054 alknet-tty-local behind a local feature re-export
Wire format ADR-052 The chunk codec + control channel the adapter pumps to/from these handles
Exit code on a control chunk ADR-055 The adapter awaits exit_code, sends the exit chunk, closes
Crate decomposition ADR-003 Am. 1 alknet-tty depends on alknet-core; backends depend on alknet-tty for the trait

Open Questions

See open-questions.md for full details.

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

References

  • ADR-053 — the trait shape decision (this spec is its elaboration)
  • ADR-052 — the wire format the adapter pumps to/from these handles
  • ADR-055 — the exit-chunk ordering the exit_code field feeds into
  • ADR-054 — the local backend's crate placement
  • docs/research/alknet-tty/phase-0-findings.md — §"The Backend Trait" (the seed of this spec) and §"Requirements from the local-PTY POC" (REQ-TTY-01, 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)
  • 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