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

356 lines
17 KiB
Markdown

---
status: draft
last_updated: 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](../../decisions/053-ttybackend-trait-and-ttyhandle.md).
## 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](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](../../decisions/053-ttybackend-trait-and-ttyhandle.md) §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](tty-local.md) carries REQ-TTY-02 (signal forwarding to
the process group).
## Architecture
### `TtyBackend` trait
```rust
#[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).
```rust
#[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
```rust
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
```rust
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
```rust
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](tty-local.md) for the bridge
details.
### Backend registration and the assembly layer
```rust
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](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](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](../../decisions/053-ttybackend-trait-and-ttyhandle.md) | 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](../../decisions/054-local-tty-backend-sibling-crate.md) | `alknet-tty-local` behind a `local` feature re-export |
| Wire format | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | The chunk codec + control channel the adapter pumps to/from these handles |
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | The adapter awaits `exit_code`, sends the exit chunk, closes |
| Crate decomposition | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | alknet-tty depends on alknet-core; backends depend on alknet-tty for the trait |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-43** (resolved): `TtyControl` as a `Clone` trait object.
- **OQ-44** (deferred(scope)): Terminal modes.
## References
- [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) — the
trait shape decision (this spec is its elaboration)
- [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md)
— the wire format the adapter pumps to/from these handles
- [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) — the
exit-chunk ordering the `exit_code` field feeds into
- [ADR-054](../../decisions/054-local-tty-backend-sibling-crate.md) —
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](tty-local.md) — the `LocalTtyBackend` spec (carries
REQ-TTY-02: signal forwarding to the process group)
- [tty-adapter.md](tty-adapter.md) — the session driver that consumes
these handles