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).
This commit is contained in:
glm-5.2 committed 2026-07-06 15:05:36 +00:00
1 parent 14c8340380
commit e4e6ccfb14
12 files changed
+2892 -1

No files matched your search

@@ -0,0 +1,324 @@
# ADR-052: alknet-tty Wire Format and Two-Carriage Model
## Status
Accepted
## Context
The alknet-docker POC (`docs/research/alknet-docker/poc-summary.md`)
validated that interactive attach — bidirectional byte pumping over a framed
bidi stream with a 1-byte stream-type multiplexer — is the same problem
regardless of whether the backend is `bollard::attach_container()` or
russh's `pty_request`. The POC's raw chunk format
(`[stream_type: u8][length: u32 be][payload bytes]`, stream_type 0=stdin,
1=stdout, 2=stderr) is a deliberately impoverished version of SSH's channel
multiplexer: fixed set of channel types, no negotiation, no open/close
handshake, no windowing (QUIC provides flow control on the bidi stream).
That impoverishment is the feature — a terminal session needs exactly those
channels and no more.
The alknet-tty POC (`/workspace/alknet-tty-poc`, built 2026-07-05) extended
that format with a 4th stream_type (3 = control) carrying JSON control
messages (resize, signal, eof, exit) and validated the full round-trip
against a real `portable_pty` PTY: negotiate → PTY alloc → bidirectional
echo → mid-session resize → EOF → exit code, plus SIGINT forwarding to a
child process group. The wire format this ADR commits is the POC's
`src/raw.rs` + `src/control.rs`, generalized from one backend to the
backend-agnostic crate.
Three load-bearing questions are decided here:
1. **Separate ALPN with raw carriage, not call-protocol operations.** A
terminal session could be modeled as a call-protocol `Subscription`
operation (`tty/open`) streaming `call.responded` events. That is
rejected: JSON-encoding every byte chunk is wasteful (base64 for binary,
per-chunk `EventEnvelope` overhead) and lossy (a TTY streams partial
bytes with no message boundary that maps to a JSON object). The
two-carriage model — a single JSON negotiation frame, then raw chunks —
keeps the call protocol's JSON-RPC shape for the *request* and switches
to bytes for the *body*, which is the part that is actually bytes. This
is the pattern the docker POC validated and the SSH research
(`docs/research/alknet-ssh/phase-0-findings.md`) independently arrived
at for PTY.
2. **Fixed channel set, not extensible.** SSH multiplexes arbitrary
services (forwarding, SFTP, agent, X11) over `ChannelId(u32)` with
string-named types negotiated per channel. alknet-tty multiplexes one
service — a terminal session — with a fixed `u8` set and no negotiation.
Adding a 5th channel type is a wire-format change (one-way door). The
ALPN model handles extensibility at the protocol level: a genuinely new
sideband (e.g., file transfer alongside the terminal) is a different
ALPN, not a 5th tty channel type. A new ALPN is cheap; a wire-format
change is not.
3. **Control messages as JSON, not binary.** A binary control format
(`[control_type: u8][params...]`) would be faster but harder to extend
and inconsistent with the negotiation layer. Control messages are rare
(resize on window drag, signal on Ctrl-C, one eof, one exit per
session) — serialization cost is negligible against the data chunks. If
a hot control path appears, a binary `control_type` can be added without
breaking the chunk format (additive within the control channel).
## Decision
### 1. alknet-tty is a `ProtocolHandler` on ALPN `alknet/tty`
`alknet/tty` is a custom ALPN per the ADR-006 `alknet/<name>` convention.
The `TtyAdapter` implements `ProtocolHandler` (ADR-002, revised by ADR-007
to receive a `Connection`). The handler owns the entire connection
lifecycle and accepts one bidi stream per terminal session. This is a
separate ALPN, not a set of operations in the call protocol's
`OperationRegistry` — the raw-carriage byte pump is not a `StreamingHandler`
(ADR-049); it is its own wire format after a single JSON negotiation frame.
### 2. Two-carriage model: JSON negotiation, then raw chunks
The bidi stream has two phases:
- **Negotiation (JSON carriage).** The client opens a bidi stream and
writes a single length-prefixed JSON frame — the same 4-byte
big-endian length prefix + UTF-8 JSON body framing as alknet-call's
`FrameFramedReader`/`FrameFramedWriter`
(`crates/alknet-call/src/protocol/wire.rs`). The frame carries the
terminal parameters and backend selector the server needs to allocate
the session:
```json
{
"carriage": "raw",
"backend": "local",
"tty": {
"term": "xterm-256color",
"cols": 80,
"rows": 24,
"pixel_width": 0,
"pixel_height": 0,
"modes": {}
},
"cmd": ["/bin/bash"],
"cwd": null,
"env": {}
}
```
The `carriage` field is `"raw"` for terminal sessions (the only
carriage in v1). The `tty` block is `null` for the pipe/runner case
(no PTY — see ADR-054). The `backend` field selects the `TtyBackend`
(ADR-053); backend-specific fields (e.g., `container` for docker)
ride alongside.
- **Raw carriage.** After the negotiation frame, the stream switches to
the chunk format below for the life of the session. There is no
`call.responded`/`call.completed` — this is not the call protocol.
### 3. Chunk format
```text
[stream_type: u8][length: u32 be][payload bytes]
```
- `stream_type` — fixed set, no negotiation:
| stream_type | channel | direction | payload |
|---|---|---|---|
| 0 | data-in (stdin) | client→server | raw bytes |
| 1 | data-out (stdout) | server→client | raw bytes |
| 2 | data-err (stderr) | server→client | raw bytes |
| 3 | control | bidirectional | JSON control message |
`stream_type > 3` is a protocol error (`InvalidStreamType`). There is
no extension escape hatch in the byte — a 5th channel is a wire-format
change requiring a new ALPN (`alknet/tty/v2` per ADR-006), not a
negotiated addition to this format.
- `length` — payload length in bytes, u32 big-endian, max 16 MiB. A
chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). The
16 MiB bound accommodates large paste operations and large `env`
blocks while bounding memory per chunk (a reader can allocate a
bounded buffer up front); it mirrors the docker POC's limit.
- Zero-length data chunks are sentinels: a zero-length stdin chunk is
EOF from the client; a zero-length stdout chunk is "drained" from the
server (output stream ended). Control chunks are never zero-length
(the JSON payload is at least `{}`).
### 4. Control channel (stream_type 3) carries JSON control messages
Control chunks carry a small JSON payload, tagged by `type`:
| Direction | Message | Shape |
|---|---|---|
| client→server | resize | `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}` |
| client→server | signal | `{"type":"signal","name":"INT"}` |
| client→server | eof | `{"type":"eof"}` |
| server→client | exit | `{"type":"exit","code":0}` |
- **resize** — window-size change. Maps to SSH `window-change`, docker
exec resize, or `ioctl(TIOCSWINSZ)` on a local PTY.
- **signal** — signal forwarding. `name` is an uppercase string
(`"INT"`, `"TERM"`, `"HUP"`, `"QUIT"`, `"TSTP"`, `"CONT"`, `"KILL"`,
`"USR1"`, `"USR2"`). Unknown names fall back to the backend's default
kill (see ADR-053 / `tty-local.md` REQ-TTY-02).
- **eof** — client signals no more stdin. Maps to SSH channel EOF,
docker stdin close, or `ChildStdin::drop` / PTY writer close. This is
one of two canonical "stdin done" signals; the other is a zero-length
stdin chunk (the docker POC's sentinel). Both are accepted as
equivalent — `eof` is recommended for explicitness (it's a control
message, not a data-length hack); the zero-length stdin chunk is kept
for compatibility with the docker POC's pattern. See OQ-47.
- **exit** — server signals process exit with code. This is the last
control chunk before stream close (see ADR-055).
The `type` tag is the extensibility seam: new control message types are
added by extending the tagged enum. Unknown `type` values are ignored
(not a protocol error) so that a newer client sending a control message
an older server doesn't recognize degrades gracefully rather than
tearing down the session. This is a two-way-door extension point within
the one-way-door wire format — adding a control message type is
additive; changing the chunk header is not.
### 5. Negotiation errors use the JSON framing, not the raw chunk format
If the server cannot allocate the session (unknown backend, PTY
allocation failed, the command is invalid), it sends a JSON error
response in the same 4-byte length-prefixed framing as the negotiation
frame and closes the stream without entering raw mode. The error
response shape is `{"error":"<code>","message":"..."}` (see
[tty-adapter.md](../crates/tty/tty-adapter.md) §"Negotiation errors" for
the codes). This is not `call.error` — this is not the call protocol;
the error is a JSON response in the negotiation framing, and the stream
closes after it.
**Framing disambiguation (success vs error).** Both a successful
allocation (raw chunks) and a failed allocation (JSON error frame) begin
with bytes the client must read before knowing which framing applies.
The disambiguation is by the first byte: a JSON error frame's 4-byte
big-endian length prefix always starts with `0x00` (error frames are
small — under 16 MiB, so the high byte is zero), while a raw chunk's
first byte is a `stream_type` in `{0, 1, 2, 3}`. A stream_type of `0`
(stdin from server) is invalid — the server never sends stdin chunks —
so the client distinguishes: read the first byte; if it is `0x00`,
interpret the next 4 bytes as a big-endian length prefix and read that
many bytes as a JSON error frame; otherwise interpret it as a
`stream_type` byte and continue reading the raw chunk header. This is a
one-way-door wire-format invariant: error frames use the negotiation
framing (length prefix), success uses the raw chunk framing
(stream_type byte first); the `0x00`-as-length-prefix vs
`0x00`-as-invalid-stream_type disambiguation is what makes the two
distinguishable on the wire.
### 6. Negotiation frame reuses alknet-call's framing, not its types
The 4-byte length prefix + JSON body is byte-identical to
`alknet_call::protocol::wire::FrameFramedReader`/`FrameFramedWriter`.
alknet-tty reuses the *framing utility* (the length-prefix read/write),
not the `EventEnvelope` type — the negotiation payload is a
tty-specific struct (`NegotiateRequest`), not a `call.requested` event.
This keeps alknet-tty's dependency on alknet-call limited to the framing
codec, not the call protocol's type system. (See ADR-053 for the
dependency edge and ADR-003 Amendment 1 for the protocol-foundation
exception.)
## Consequences
**Positive:**
- The wire format is POC-validated twice (docker POC for stream_type 0/1/2
+ bidirectional pump; tty POC for stream_type 3 + control messages +
local PTY). No new wire-format invention in Phase 1.
- The fixed channel set is a `match`, not a hash lookup — fast on the hot
path where every chunk is data.
- The two-carriage model keeps the call protocol's JSON-RPC shape for the
structured request while letting the body be raw bytes, which is what a
terminal actually is. No base64, no per-chunk `EventEnvelope` overhead.
- Control messages as JSON are consistent with the negotiation layer and
trivially extensible (tagged enum), at negligible cost for rare messages.
- A separate ALPN composes with the ALPN dispatch model (ADR-001/006):
the endpoint routes `alknet/tty` to the `TtyAdapter`; the call protocol
is unaffected. Browser terminals (xterm.js over WebTransport, when
WebTransport revives) connect to `alknet/tty` directly without
implementing SSH or the call protocol.
**Negative:**
- A 5th channel type is a wire-format change (one-way door). The
justification is that the use cases are bounded — a terminal session
has stdin, stdout, stderr, and control. New sideband needs are
different ALPNs, not 5th channels. If this proves wrong, the reversal
is a new ALPN string (`alknet/tty/v2`), which coexists with the old one
rather than replacing it — but every client and server implementing
the old format would need updating to speak the new one.
- Control as JSON means a `serde_json` deserialize per control chunk.
Control chunks are rare (one per resize, one per signal, one eof, one
exit), so this is negligible. A hot control path would warrant a
binary format — the `type`-tagged enum leaves that door open without a
wire-format change.
- The negotiation frame is a custom JSON shape, not a `call.requested`
event, so a client library can't reuse its call-protocol client to open
a tty session — it speaks the tty wire format directly. This is
intentional (the tty session is not a call-protocol operation) but
means the tty client is a separate small client, not a `CallClient`
method.
## Door type
**One-way.** The chunk header (5 bytes: 1 type + 4 length), the fixed
stream_type set (0-3), and the two-carriage sequence (JSON frame → raw
chunks) are bytes clients and servers parse. Changing any of them breaks
every client and server implementing the format. The reversal path is a
new ALPN (`alknet/tty/v2`), which coexists rather than replaces — but
the cost of migrating every consumer is the one-way-door cost.
The control message `type` enum is a two-way-door extension point within
the one-way wire format: adding a control message type is additive
(unknown types are ignored), changing the meaning of an existing type is
not.
## Assumptions
1. **QUIC per-stream flow control is sufficient for terminal output.**
The chunk format has no windowing — QUIC's bidi-stream flow control
handles backpressure. High-throughput stdout (e.g., `cargo build`
output) is expected to work; a concrete high-volume use case that
surfaces a flow-control problem would require revisiting. See OQ-45.
2. **The negotiation frame fits in one chunk of the underlying stream's
initial flow-control window.** The frame is small (terminal params +
command + env, typically < 4 KiB). QUIC's default initial bidi-window
(quinn defaults are tens of KiB) accommodates it without a
flow-control round-trip. A pathological `env` block larger than the
window would stall until the window opens; the 16 MiB chunk limit is
the hard cap.
3. **Control messages are rare enough that JSON serialization cost is
negligible.** Validated by the tty POC: resize on window drag, one
signal per Ctrl-C, one eof, one exit per session. No measurable cost
observed.
## References
- `docs/research/alknet-tty/phase-0-findings.md` — Phase 0 research; the
wire format section is the seed of this ADR
- `docs/research/alknet-docker/poc-summary.md` — the POC that validated
the raw chunk format (stream_type 0/1/2) and the two-carriage model
- `/workspace/alknet-tty-poc/src/raw.rs` — the chunk codec
(`ChunkReader`/`ChunkWriter`, stream_type 0-3) this ADR commits
- `/workspace/alknet-tty-poc/src/control.rs` — the JSON control schema
(`ControlMessage` tagged enum) this ADR commits
- `/workspace/alknet-docker-poc/src/raw.rs` — the seed codec
(stream_type 0/1/2) the tty POC extended
- [ADR-001](001-alpn-protocol-dispatch.md) — ALPN-based dispatch
- [ADR-002](002-protocol-handler-trait.md) — ProtocolHandler trait
- [ADR-006](006-alpn-convention-and-connection-model.md) — `alknet/<name>`
ALPN convention; one ALPN per connection; new ALPN for incompatible
versions
- [ADR-007](007-bistream-type-definition.md) — handler receives a
`Connection`, accepts bidi streams
- [ADR-003](003-crate-decomposition.md) Amendment 1 — alknet-call as
protocol-foundation crate (framing utility reuse)
- [ADR-012](012-call-protocol-stream-model.md) — the call protocol's
stream model (which tty is *not* using for the body, by design)
- [ADR-049](049-streaming-handler-for-subscriptions.md) — the
`StreamingHandler` path tty explicitly does not use for the byte body
- Spec: [crates/tty/tty-wire.md](../crates/tty/tty-wire.md)
@@ -0,0 +1,319 @@
# 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
```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 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
```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).
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
```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 (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:
```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 _);
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](003-crate-decomposition.md) + Amendment 1 — no-handler-depends-
on-another-handler; alknet-tty depends on alknet-core; backends depend
on alknet-tty for the trait
- [ADR-007](007-bistream-type-definition.md) — `Connection`, `SendStream`,
`RecvStream` (the adapter receives a `Connection`, accepts bidi streams,
pumps per-session)
- [ADR-052](052-alknet-tty-wire-format-and-two-carriage.md) — the wire
format this trait's backends feed
- [ADR-054](054-local-tty-backend-sibling-crate.md) — the local backend's
crate placement (sibling crate, behind a feature re-export)
- [ADR-055](055-exit-code-on-control-chunk.md) — 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](../crates/tty/tty-backend.md)
@@ -0,0 +1,206 @@
# ADR-054: Local TTY Backend as a Sibling Crate (`alknet-tty-local`)
## Status
Accepted
## Context
The alknet-tty research (`docs/research/alknet-tty/phase-0-findings.md`
DP-1) posed the placement question for the local-process backend
(`std::process::Command` with piped stdio, or `portable_pty` for a real
PTY):
- **(a) In alknet-tty**: the crate ships with the local backend built-in.
Pro: zero-config runner, one crate gets a terminal/process-streaming
endpoint. Con: alknet-tty pulls in `portable_pty` even for deployments
that only use docker/ssh backends.
- **(b) In a sibling crate (`alknet-tty-local`)**: alknet-tty defines the
trait (ADR-053); the local backend is a separate crate. Pro:
alknet-tty stays dependency-light; consumers opt into the local
backend explicitly. Con: one extra crate for the common case.
The local backend is the simplest backend and the one that enables the
runner pattern (a process whose stdin/stdout/stderr/exit-code stream over
a framed bidi connection — the same shape as GitHub/Gitea Actions runners,
just over alknet's transport instead of HTTP polling). It has no heavy
dependencies in the pipe case — just `std` — but the PTY case pulls in
`portable_pty` (a non-trivial native dependency that builds on Unix
`openpty`/`ioctl` and Windows ConPTY).
The relevant constraint from ADR-053: alknet-tty itself depends only on
alknet-core and the wire-format codec (ADR-052). The `portable_pty`
dependency does not belong in the core tty crate — a docker-only
deployment or an SSH-PTY-only deployment should not pull in PTY allocation
code. This is the same inversion as `OperationAdapter` (ADR-017): the
trait lives where the types live; the implementations live where their
transport dependencies live.
### The feature-re-export compromise
The research recommended (b) sibling crate **behind a feature flag on
alknet-tty** for the common case (`features = ["local"]` → re-export from
`alknet-tty-local`). This keeps alknet-tty's default dependency surface
minimal while making the local backend a one-feature opt-in. The
`portable_pty` dependency lives in `alknet-tty-local`; alknet-tty itself
never depends on `portable_pty`.
## Decision
### 1. The local backend is a sibling crate, `alknet-tty-local`
`alknet-tty-local` implements `TtyBackend` for `LocalTtyBackend` (ADR-053),
backed by `portable_pty` for the PTY case and `std::process::Command` with
`Stdio::piped()` for the pipe/runner case. It depends on alknet-tty (for
the `TtyBackend`/`TtyHandle`/`TtyControl` traits) and on `portable_pty`
for the PTY case.
```
alknet-tty-local
├── alknet-tty (TtyBackend trait, TtyHandle, TtyControl, wire types)
├── alknet-core (Connection — via alknet-tty's re-export; not direct)
├── portable_pty (PTY allocation — the heavy dep, here not in alknet-tty)
└── libc (signal forwarding — REQ-TTY-02, Unix only)
```
### 2. `alknet-tty` re-exports the local backend behind a `local` feature
```toml
# alknet-tty Cargo.toml
[features]
default = []
local = ["dep:alknet-tty-local"] # re-export LocalTtyBackend
[dependencies]
alknet-tty-local = { path = "../alknet-tty-local", optional = true }
```
A consumer that wants the local backend enables `features = ["local"]`
on alknet-tty and gets `alknet_tty::local::LocalTtyBackend` re-exported
from `alknet-tty-local`. A consumer that only wants docker/ssh backends
uses the default features and depends on `alknet-tty-docker` /
`alknet-tty-ssh` (or their own backend crate) directly — no
`portable_pty` in the dependency tree.
This is the same feature-re-export pattern the Rust ecosystem uses for
optional heavy dependencies (e.g., `tokio`'s `full` feature pulling in
`tokio-util`, `h2`, etc.). The seam is the `TtyBackend` trait; extraction
is cheap because the trait is the contract.
### 3. PTY vs pipe is a per-session choice, not a per-deployment choice
`TtyParams.terminal: Option<TerminalParams>` (ADR-053) selects the mode:
- **`terminal: Some(TerminalParams { ... })`** — allocate a real PTY
via `portable_pty`. Terminal semantics: resize (via
`ioctl(TIOCSWINSZ)`), signal delivery to the foreground process group
(via `libc::kill(-pgid, sig)`, REQ-TTY-02), escape-sequence handling
(the kernel PTY's line discipline). stdout and stderr are merged
(kernel PTY property — one output stream from the slave), so
`TtyHandle.stderr` is `None`.
- **`terminal: None`** — pipe mode, no PTY. `std::process::Command` with
`Stdio::piped()` for stdin/stdout/stderr. No resize, no
escape-sequence handling, but `kill(pid, sig)` still works for signal
forwarding. stdout and stderr are separate streams, so
`TtyHandle.stderr` is `Some`. This is the **runner** case — a
command-streaming endpoint with no terminal semantics.
The same `LocalTtyBackend` serves both; the `allocate()` call branches on
`params.terminal`. A deployment that only does terminals always sends
`Some`; a deployment that only does runners always sends `None`; a
deployment that does both (a hub that runs agents in PTYs and runs
`cargo test` as a runner) sends the appropriate one per session.
### 4. The runner pattern is preserved, not specialized
The pipe mode (`terminal: None`) is the "runner" generalization the
research identified: a process whose stdin/stdout/stderr/exit-code stream
over a framed bidi connection. This is functionally identical to
GitHub/Gitea Actions runners, just over alknet's transport instead of
HTTP polling:
- A coordinator sends a negotiation frame with
`{ "backend": "local", "tty": null, "cmd": ["cargo", "test"] }`.
- The endpoint runs `cargo test` with piped stdio, streams stdout/stderr
chunks back, sends `{"type":"exit","code":N}` when it finishes (ADR-055).
- The coordinator gets reliable completion notification (the exit
control chunk + stream close) — no polling.
The runner-specific API surface (job management, log persistence, task
graph integration) is **out of scope for alknet-tty**. alknet-tty provides
the *mechanism* (a framed byte stream for a process); the runner *policy*
is a downstream crate's job. This ADR commits to preserving the option
(`terminal: None` → pipe mode) and not building runner policy into
alknet-tty. See OQ-46.
## Consequences
**Positive:**
- alknet-tty's default dependency surface is minimal (alknet-core + the
wire-format codec). A docker-only or ssh-only deployment never pulls
in `portable_pty`.
- The local backend is a one-feature opt-in (`features = ["local"]`) for
the common case — a consumer that wants a terminal/runner endpoint with
no docker or SSH gets it with one feature flag, not a separate
dependency.
- PTY vs pipe is per-session, so one `LocalTtyBackend` serves terminals
and runners. A hub that does both doesn't need two backends.
- The runner pattern is preserved without baking runner policy into
alknet-tty. The mechanism is the framed byte stream; the policy is
downstream.
- The sibling-crate placement composes with ADR-003's no-handler-depends-
on-another-handler rule: alknet-tty-local depends on alknet-tty (for
the trait), alknet-tty does not depend on alknet-tty-local (the
feature re-export is optional).
**Negative:**
- A consumer that wants the local backend must enable a feature flag
(`features = ["local"]`). Forgetting the flag results in the
`alknet_tty::local` module not existing — a compile error, not a silent
miss. This is the standard Rust feature-flag trade and is
self-documenting.
- One extra crate in the workspace (`alknet-tty-local` alongside
`alknet-tty`). The workspace `Cargo.toml` gains a member; version
coordination is per-crate. This is the established pattern (alknet-http
is one crate with colocated server + adapters; alknet-call is separate
from alknet-core).
- The runner-specific API surface (job management, log persistence) is
not in alknet-tty. A downstream crate that wants a full runner builds
on the pipe mode + the wire format. This is the right layering
(mechanism vs policy) but means a "runner crate" is a separate future
deliverable, not part of alknet-tty. See OQ-46.
## Door type
**Two-way.** The sibling-crate placement is reversible: if the local
backend turned out to be the only backend anyone used, merging
`alknet-tty-local` back into `alknet-tty` (behind the same `local`
feature, just in the same crate) is mechanical — the trait is the seam,
and the feature gate already exists. The cost of reversal is low
(re-exports become local modules), and no downstream consumer breaks (the
`alknet_tty::local::LocalTtyBackend` path stays valid).
This is a two-way door that is **decided** (sibling crate + feature
re-export), not deferred. The decision is made now; the reversal is cheap
if a future consolidation warrants it. See ADR-009 §"What this framework
is NOT" — door type classifies reversal cost, not urgency.
## References
- `docs/research/alknet-tty/phase-0-findings.md` DP-1 — the placement
question this ADR resolves
- [ADR-003](003-crate-decomposition.md) + Amendment 1 — crate
decomposition rule (the sibling-crate placement preserves it)
- [ADR-009](009-one-way-door-decision-framework.md) — door-type-as-deferral
anti-pattern (this ADR's two-way-door classification is reversal cost,
not a deferral)
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) — the
adapter-location-map pattern (trait where types live, implementation
where deps live) this ADR follows
- [ADR-053](053-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend`
trait this crate implements
- OQ-46 — runner API surface (deferred(scope): mechanism in alknet-tty,
policy is a downstream crate)
- Spec: [crates/tty/tty-local.md](../crates/tty/tty-local.md)
@@ -0,0 +1,204 @@
# ADR-055: Exit Code on a Control Chunk (the Last Chunk Before Stream Close)
## Status
Accepted
## Context
The alknet-docker POC validated exit-code propagation for the JSON
carriage path: exec with an exit code rides on a final `call.responded`
frame `{ "exitCode": N }` before `call.completed`. That works because
the JSON carriage path is the call protocol — `call.responded` and
`call.completed` exist and carry the result.
The raw-carriage path (ADR-052) has no `call.responded` and no
`call.completed` — after the negotiation frame, the stream is raw chunks.
The exit code must ride on the chunk format itself. Two options:
- **(a) Control chunk**: `{"type":"exit","code":N}` as the last control
chunk (stream_type 3) before stream close. Clean, explicit, carries the
code as structured data on the channel that already exists for control
metadata.
- **(b) Final data chunk with exit code**: a special stdout chunk with an
exit-code payload. Overloads the data channel for metadata — a client
parsing stdout chunks would have to special-case "this stdout chunk is
actually an exit code," conflating data and control.
The local-PTY POC (`/workspace/alknet-tty-poc`, built 2026-07-05)
validated option (a) end-to-end: the `{"type":"exit","code":N}` chunk
fires after the child is reaped (the waiter thread's
`oneshot::Receiver<i32>` resolves) and is the last control chunk before
the stream closes. The POC's `session.rs` `pump_exit` task awaits
`pty.exit_code`, serializes the result as `ControlMessage::Exit { code }`,
enqueues it as a control chunk, and the drainer writes it to the client
before the writer closes.
### Why this is a one-way door
Clients will depend on the **"exit chunk is last"** invariant: after the
exit control chunk, no more data chunks follow, and the stream closes.
This is the deterministic completion notification the docker POC
identified as the stopgap coordination property — a coordinator spawns a
process, streams its output, and gets a reliable "it exited with code N"
signal without polling or plugin state. Changing the ordering after
clients exist would break every consumer that reads stdout until the exit
chunk and then stops.
## Decision
### 1. The exit code rides on a control chunk, not a data chunk
The exit code is control metadata (the process's termination status), not
data (process output). It rides on the control channel (stream_type 3,
ADR-052) as:
```json
{"type":"exit","code":0}
```
The `code` is an `i32` (matches `std::process::ExitStatus::code()` and
Unix wait-status convention; negative values are signal-terminated, e.g.,
`-9` for SIGKILL, matching `ExitStatus::code()`'s behavior on Unix). The
chunk is the last control chunk before stream close.
### 2. The "exit chunk is last" invariant
After the `{"type":"exit","code":N}` control chunk:
- The server sends no more data chunks (stdout/stderr) and no more
control chunks.
- The server closes the write half of the bidi stream.
A client reads stdout/stderr/control chunks until it sees the exit chunk,
records the exit code, and treats subsequent stream close as the session
end. The exit chunk is the deterministic completion signal.
### 3. The adapter owns the exit-chunk ordering, not the backend
Per ADR-053 assumption 3, the backend resolves `exit_code` (a
`BoxFuture<'static, Result<i32, TtyError>>`); the adapter awaits it,
sends the exit control chunk, and closes the stream. The backend does not
write to the wire — it produces handles; the adapter pumps. This keeps
the wire-format logic (including the "exit is last" invariant) in one
place (the adapter's session driver) and the backend focused on its
allocation target.
The adapter's session driver (see `tty-adapter.md`) runs three concurrent
pumps:
1. **stdout → client**: backend stdout → stdout chunks (and stderr chunks
if `TtyHandle.stderr` is `Some`).
2. **client → backend**: stdin chunks → backend stdin; control chunks →
`TtyControl::resize`/`signal`/`eof`.
3. **exit → exit chunk**: await `TtyHandle.exit_code`; on resolve, enqueue
`{"type":"exit","code":N}` as a control chunk; after the drainer writes
it, close the write half.
The exit-chunk task coordinates with the stdout pump: the stdout pump
completes (backend stdout EOF) before or concurrently with the exit
resolve, and the exit chunk is enqueued only after the exit resolves. The
drainer writes chunks in arrival order; the exit chunk is last because
the exit is the last thing to resolve (the child must exit before its
stdout drains, but the exit chunk is sent only after `exit_code` resolves,
which is after `Child::wait()` returns — i.e., after the child is reaped).
### 4. Error exit codes
A backend `TtyError` during `allocate()` (the PTY couldn't be allocated,
the docker exec failed to start, the SSH channel request was rejected)
is handled before the raw-carriage phase begins — the adapter sends a
JSON error response to the negotiation frame and closes the stream
without entering raw mode. See `tty-adapter.md` §"Negotiation errors".
A `TtyError` from the `exit_code` future (the child couldn't be reaped,
or the backend's wait path failed) is serialized as an exit code of `-1`
(`ControlMessage::Exit { code: -1 }`) and the stream closes. The client
treats `-1` as "the backend reported an exit error, not a real exit
code." This is a best-effort signal; a backend that cannot determine the
exit code still sends the exit chunk so the client gets the completion
notification.
## Consequences
**Positive:**
- The exit code is structured data on the control channel, not a hacky
overload of the data channel. Clients parse it as a `ControlMessage::Exit`,
not as a special-cased stdout chunk.
- The "exit chunk is last" invariant gives coordinators deterministic
completion notification — the same stopgap property the docker POC
validated for logs subscriptions. No polling, no plugin state; the
process exiting is the signal.
- The adapter owns the ordering, so the invariant is enforced in one
place; backends don't have to know the wire format's completion
semantics.
- The error-exit `-1` fallback keeps the completion notification
reliable even when the backend can't determine the real code — the
client still knows the session ended.
**Negative:**
- The "exit chunk is last" invariant is a one-way door — clients depend on
it. Reversing it (allowing data chunks after the exit chunk, or moving
the exit code to a data chunk) would break every consumer. This is the
intended commitment: the invariant is the value.
- A client that doesn't read until the exit chunk (e.g., a runner that
cancels mid-stream by closing the write half) won't see the exit code.
That's correct — a cancelled stream doesn't have a deterministic exit;
the client that cancels already knows it cancelled. The exit chunk is
for the client that reads to completion.
- The `-1` error-exit code conflates "the backend couldn't determine the
exit" with "the process exited with code -1" (which doesn't happen on
Unix — `ExitStatus::code()` returns `None` for signal termination, not
-1; the POC's waiter thread sends -1 only on `wait()` failure, not on
signal termination — signal termination sends the negative signal
number, e.g., -9 for SIGKILL). A client that needs to distinguish
"real exit -1" from "backend error" can't from the code alone. This is
a documented edge case; if it becomes load-bearing, a future control
message type (`{"type":"exit_error","message":"..."}`) can carry the
distinction additively (the `type`-tagged enum is the extension seam
per ADR-052).
## Door type
**One-way.** The "exit chunk is last" invariant is what clients depend
on for deterministic completion. Changing it after clients exist breaks
every consumer. The `{"type":"exit","code":N}` shape is also one-way
(clients parse it as a `ControlMessage::Exit`), though the `type`-tagged
enum (ADR-052) makes adding *new* control message types additive.
## Assumptions
1. **The child exits before its stdout fully drains, and the exit chunk
is sent after `exit_code` resolves.** On Unix, `Child::wait()` blocks
until the child is reaped, which happens after the child exits and its
stdout pipe/PTY buffer drains. The POC validated this ordering: the
reader thread sees EOF (buffer drained), the waiter thread reaps
(exit code available), and the exit chunk is enqueued after the exit
resolves. There is no race where stdout chunks arrive after the exit
chunk.
2. **`exit_code` resolving implies the stdout pump is done or will be
soon.** The adapter's session driver waits for both the stdout pump
to complete (backend stdout EOF) and the exit to resolve before
sending the exit chunk and closing. If a backend's stdout outlives the
exit resolve (a hypothetical backend where the process exits but a
buffer flush is still in flight), the adapter waits for the stdout
pump before the exit chunk. The `TtyHandle.stderr` (if `Some`) is
pumped concurrently with stdout and also drains before the exit chunk.
## References
- `docs/research/alknet-tty/phase-0-findings.md` DP-5 — the decision
question this ADR resolves
- `docs/research/alknet-docker/poc-summary.md` — the JSON-carriage exit
code path (the analog this ADR's raw-carriage path mirrors)
- `/workspace/alknet-tty-poc/src/session.rs` `pump_exit` — the reference
implementation of the exit-chunk ordering this ADR commits
- [ADR-052](052-alknet-tty-wire-format-and-two-carriage.md) — the wire
format (control channel, stream_type 3) this ADR's exit chunk rides on
- [ADR-053](053-ttybackend-trait-and-ttyhandle.md) — the `TtyHandle.exit_code`
field (the `Future` the adapter awaits) this ADR's ordering consumes
- Spec: [crates/tty/tty-adapter.md](../crates/tty/tty-adapter.md) (the
session driver that enforces the ordering)