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:
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)
|
||||
Reference in new issue
Block a user