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

+21
View File
@@ -22,6 +22,8 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
**Next step**: The storage/repo-pattern ADRs (030–033) are accepted and amend the core and call specs. The next implementation phase is the ADR-029 migration (peer-keyed overlays, `PeerRef` routing, retire `remote_safe`/`trusted_peer`) with the ADR-030 `PeerEntry` change and the ADR-032 `forwarded_for` field folded in — the `OperationContext`, `from_call` handler, and `AuthPolicy` are all under edit, making this the cheapest window. After that: alknet-http implementation (specs drafted; `h3`/WebTransport deferred per ADR-044, browser bidirectional path uses WebSocket), which consumes the `CredentialStore` trait and the `OperationAdapter` contract. The alknet-ssh crate (the other post-core crate, specced in parallel) proceeds independently — it depends on `alknet-core`, not `alknet-call`.
**alknet-tty specs drafted.** The alknet-tty crate (terminal session protocol handler — `ProtocolHandler` on `alknet/tty`, two-carriage wire format with a raw chunk codec + JSON control channel, backend-agnostic via a `TtyBackend` trait) now has architecture specs: [crates/tty/](crates/tty/) (overview, tty-wire, tty-backend, tty-adapter, tty-local) and four ADRs — [ADR-052](decisions/052-alknet-tty-wire-format-and-two-carriage.md) (wire format: `alknet/tty` ALPN, JSON negotiation frame then raw chunks, fixed channel set 0-3, control as JSON), [ADR-053](decisions/053-ttybackend-trait-and-ttyhandle.md) (`TtyBackend` trait + `TtyHandle`; `exit_code` as a `Future`; backends need not be natively async — REQ-TTY-01 from the local-PTY POC), [ADR-054](decisions/054-local-tty-backend-sibling-crate.md) (`alknet-tty-local` sibling crate behind a `local` feature re-export; PTY vs pipe per-session; the runner pattern preserved), [ADR-055](decisions/055-exit-code-on-control-chunk.md) (exit code on a stream_type 3 control chunk; "exit chunk is last" invariant; adapter owns the ordering). The specs are grounded in the alknet-docker POC (`docs/research/alknet-docker/poc-summary.md`) and the alknet-tty POC (`/workspace/alknet-tty-poc/`, built 2026-07-05), which validated the wire format, the control channel, the local-PTY bridge, and the signal-delivery contract (REQ-TTY-02). The docker and SSH backends are future crates that implement the `TtyBackend` trait — out of scope for this spec set, but the trait shape is committed so they can be built against it.
## Architecture Documents
| Document | Status | Description |
@@ -44,6 +46,12 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [crates/http/http-adapters.md](crates/http/http-adapters.md) | draft | from_openapi (reqwest; JSON + YAML input per ADR-051) and to_openapi (projection); no-env-vars injection point |
| [crates/http/http-mcp.md](crates/http/http-mcp.md) | draft | from_mcp / to_mcp (feature-gated), streamable-HTTP-only, stdio exclusion |
| [crates/http/webtransport.md](crates/http/webtransport.md) | deferred | h3/WebTransport handler — deferred per ADR-044; browser bidirectional path uses WebSocket (see http-server.md). Spec kept intact for revival. |
| [crates/tty/README.md](crates/tty/README.md) | draft | alknet-tty crate index |
| [crates/tty/overview.md](crates/tty/overview.md) | draft | Crate purpose, two-carriage model, dependencies, ALPN, backend location map, feature gates |
| [crates/tty/tty-wire.md](crates/tty/tty-wire.md) | draft | Wire format: negotiation frame (JSON carriage), raw chunk codec (`[stream_type: u8][length: u32 be][payload]`), control channel (stream_type 3, JSON control messages), sentinels |
| [crates/tty/tty-backend.md](crates/tty/tty-backend.md) | draft | `TtyBackend` trait, `TtyParams`, `TtyHandle`, `TtyControl` — the backend inversion point. Carries REQ-TTY-01 (backends need not be natively async) |
| [crates/tty/tty-adapter.md](crates/tty/tty-adapter.md) | draft | `TtyAdapter` (`ProtocolHandler` on `alknet/tty`): session lifecycle, three-pump bidirectional driver, negotiation errors, exit-chunk ordering (ADR-055), access control |
| [crates/tty/tty-local.md](crates/tty/tty-local.md) | draft | `alknet-tty-local` sibling crate: `LocalTtyBackend` via `portable_pty` (PTY) and `std::process::Command` (pipe/runner). Carries REQ-TTY-02 (signal forwarding to the process group) |
| [crates/vault/README.md](crates/vault/README.md) | stable | alknet-vault crate index |
| [crates/vault/mnemonic-derivation.md](crates/vault/mnemonic-derivation.md) | stable | BIP39, SLIP-0010, BIP-0032, derivation paths, key types |
| [crates/vault/encryption.md](crates/vault/encryption.md) | stable | AES-256-GCM, EncryptedData, key versioning, salt (Phase B reserved) |
@@ -105,6 +113,10 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [049](decisions/049-streaming-handler-for-subscriptions.md) | Streaming Handler for Subscription Operations | Accepted |
| [050](decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership for Runtime-Spawned Resources | Accepted |
| [051](decisions/051-yaml-input-for-from-openapi.md) | YAML Input Format for from_openapi | Accepted |
| [052](decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format and Two-Carriage Model | Accepted |
| [053](decisions/053-ttybackend-trait-and-ttyhandle.md) | TtyBackend Trait and TtyHandle — the Backend Inversion Point | Accepted |
| [054](decisions/054-local-tty-backend-sibling-crate.md) | Local TTY Backend as a Sibling Crate (`alknet-tty-local`) | Accepted |
| [055](decisions/055-exit-code-on-control-chunk.md) | Exit Code on a Control Chunk (the Last Chunk Before Stream Close) | Accepted |
## Open Questions
@@ -160,9 +172,18 @@ See [open-questions.md](open-questions.md) for the full tracker.
**Resolved (blocks lifted, ADR drafting can proceed):**
- **OQ-42**: Dynamic resource ownership for runtime-spawned resources — **resolved**. Storage reuses the repo/adapter pattern (ADR-033, fourth instance); integration is Option 2 (`AccessControl::check` consults an ownership provider directly, `OperationSpec` gains `resource_id_path`); access pattern is proxy-only (spawner owns, proxy to share, teardown revokes; no grant mechanism in core — "poking holes" is a downstream-app concern, additive if ever needed). Four edge specifics pinned: `list` = scope-gate + result-filter; teardown = automatic, handler-driven; fleet = per-node ownership, downstream app tracks "who is this for"; composition = two orthogonal checks, ADR-015/022 unchanged. Ready for ADR drafting; dependent crate specs (docker, tty, runner, fleet) can declare their `AccessControl` shapes against this model.
**Resolved by the alknet-tty spec set (ADR-052–055):**
- **OQ-43**: `TtyControl` as a `Clone` trait object — **resolved**. `Arc`-backed `Clone` newtype; confirmed by the local-PTY POC's concrete `PtyControl`.
- **OQ-47**: Stdin closure canonical signal — **resolved**. Either a zero-length stdin chunk or a `{"type":"eof"}` control chunk; both accepted; `eof` recommended.
**Deferred (not active):**
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
- **OQ-10**: Git adapter scope — start with smart protocol, add ERC721 later
- **OQ-44**: Terminal modes (TTY modes) — `TerminalParams.modes` reserved; default terminal modes suffice for the current scope; blocked on a concrete mode-control use case.
- **OQ-46**: Runner API surface — the runner mechanism (pipe mode) is in alknet-tty; runner policy (job management, log persistence, task graph) is a downstream crate, not in scope; blocked on a concrete runner-policy use case.
**Open (low risk, not blocking):**
- **OQ-45**: Flow control for high-throughput stdout — QUIC per-stream flow control is expected to suffice; a high-volume POC would confirm.
## Document Lifecycle
+139
View File
@@ -0,0 +1,139 @@
---
status: draft
last_updated: 2026-07-06
---
# alknet-tty
Terminal session protocol handler for the ALPN-as-service architecture:
a `ProtocolHandler` on `alknet/tty` that pumps a bidirectional byte stream
(stdin/stdout/stderr) with a JSON control channel (resize, signal, eof,
exit) over a framed bidi stream, decoupled from the backend that allocates
the PTY (docker, SSH, local process) via a `TtyBackend` trait.
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [overview.md](overview.md) | draft | Crate purpose, the two-carriage model in brief, dependencies, ALPN, backend location map, feature gates |
| [tty-wire.md](tty-wire.md) | draft | The wire format: negotiation frame (JSON carriage), raw chunk codec (`[stream_type: u8][length: u32 be][payload]`), control channel (stream_type 3, JSON control messages), sentinels |
| [tty-backend.md](tty-backend.md) | draft | `TtyBackend` trait, `TtyParams`, `TtyHandle`, `TtyControl` — the inversion point between the wire-format adapter and the backends. Carries REQ-TTY-01 (backends need not be natively async) |
| [tty-adapter.md](tty-adapter.md) | draft | `TtyAdapter` (`ProtocolHandler` on `alknet/tty`): session lifecycle, three-pump bidirectional driver, negotiation errors, exit-chunk ordering (ADR-055), access control |
| [tty-local.md](tty-local.md) | draft | `alknet-tty-local` sibling crate: `LocalTtyBackend` via `portable_pty` (PTY) and `std::process::Command` (pipe/runner). Carries REQ-TTY-02 (signal forwarding to the process group) |
## Applicable ADRs
| ADR | Title | Relevance |
|-----|-------|-----------|
| [001](../../decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | `TtyAdapter` registers on `alknet/tty` |
| [002](../../decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | `TtyAdapter` implements `ProtocolHandler` |
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-tty depends on alknet-core; backends depend on alknet-tty (Amendment 1: alknet-call as protocol-foundation, framing utility reuse) |
| [006](../../decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention and Connection Model | `alknet/tty` is the custom ALPN; one ALPN per connection; new ALPN for incompatible versions |
| [007](../../decisions/007-bistream-type-definition.md) | BiStream Type Definition | `TtyAdapter` receives a `Connection`, accepts bidi streams, pumps per-session |
| [009](../../decisions/009-one-way-door-decision-framework.md) | One-Way Door Decision Framework | Wire format is one-way; local backend placement is two-way (decided, not deferred) |
| [012](../../decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | The call protocol's stream model — which tty's raw carriage is *not* using for the body, by design |
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | `forwarded_for` for proxied terminal sessions (hub→worker) |
| [040](../../decisions/040-webtransport-alpn-stream-proxy.md) | WebTransport ALPN-Stream-Proxy | **Parked** per ADR-044; the `alknet/tty` ALPN is reachable over WebTransport's stream proxy when WebTransport revives |
| [044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) | Defer h3/WebTransport; Browsers Use WebSocket | WebTransport deferred; the browser terminal case revives with WebTransport |
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | Streaming Handler for Subscriptions | The `StreamingHandler` path tty explicitly does *not* use for the byte body — raw carriage, not `call.responded` events |
| [050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | Terminal sessions are runtime-spawned resources; `AccessControl` shape declares against this model |
| [052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format and Two-Carriage Model | The chunk codec, control channel, negotiation frame, fixed channel set |
| [053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) | TtyBackend Trait and TtyHandle | The backend trait, handle shape, blocking-backend accommodation (REQ-TTY-01) |
| [054](../../decisions/054-local-tty-backend-sibling-crate.md) | Local TTY Backend as a Sibling Crate | `alknet-tty-local` behind a `local` feature re-export; PTY vs pipe per-session |
| [055](../../decisions/055-exit-code-on-control-chunk.md) | Exit Code on a Control Chunk | Exit code on stream_type 3; "exit chunk is last" invariant; adapter owns the ordering |
## Relevant Open Questions
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-43 | `TtyControl` trait object `Clone` constraint | resolved | `control: Option<Box<dyn TtyControl + Send + Unpin + Clone>>` via an `Arc`-backed `Clone` newtype; confirmed by the POC's concrete `PtyControl` |
| OQ-44 | Terminal modes (TTY modes) | deferred(scope) | `TerminalParams.modes` reserved; default terminal modes suffice for current scope; blocked on a concrete mode-control use case |
| OQ-45 | Flow control for high-throughput stdout | open (low risk) | QUIC per-stream flow control is expected to suffice; a high-volume POC would confirm |
| OQ-46 | Runner API surface | deferred(scope) | The runner mechanism (pipe mode) is in alknet-tty; runner policy (job management, log persistence, task graph) is a downstream crate, not in scope here |
| OQ-47 | Stdin closure canonical signal | resolved | Either a zero-length stdin chunk or a `{"type":"eof"}` control chunk; both are accepted; the spec recommends `eof` for explicitness |
## Key Design Principles
1. **A terminal session is a terminal concern, not an SSH or Docker
concern.** SSH and Docker are two backends that can allocate a PTY.
alknet-tty owns the terminal session lifecycle; the backends
(`DockerTtyBackend`, `SshTtyBackend`, `LocalTtyBackend`) implement a
`TtyBackend` trait. This dissolves the PTY hedge in the alknet-ssh
research (DP-5): PTY is not an SSH feature delegated to a separate
crate, it's a tty feature that SSH happens to be able to provide. See
[overview.md](overview.md) and [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md).
2. **Two-carriage model: JSON negotiation, then raw chunks.** The bidi
stream opens with a single length-prefixed JSON negotiation frame
(terminal params, backend selector, command), then switches to a raw
chunk format (`[stream_type: u8][length: u32 be][payload]`) for the
life of the session. The call protocol's JSON-RPC shape handles the
structured request; raw bytes handle the body, which is what a
terminal actually is. No per-chunk `EventEnvelope` overhead, no
base64. See [tty-wire.md](tty-wire.md) and
[ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md).
3. **Fixed channel set, not extensible.** Four stream types (0=stdin,
1=stdout, 2=stderr, 3=control), no negotiation. A 5th channel type
is a wire-format change (one-way door); the ALPN model handles
extensibility at the protocol level (a new ALPN is cheap, a
wire-format change is not). The impoverishment vs SSH channels is
the feature: alknet-tty multiplexes *one* service (a terminal
session) with a fixed channel structure, not *arbitrary* services.
See [tty-wire.md](tty-wire.md).
4. **The backend trait is the inversion point.** alknet-tty defines
`TtyBackend`; the backend crates implement it. alknet-tty depends on
alknet-core; backends depend on alknet-tty for the trait; alknet-tty
does not depend on any backend. This preserves ADR-003's
no-handler-depends-on-another-handler rule (Amendment 1 for the
alknet-call framing utility reuse). See
[tty-backend.md](tty-backend.md) and
[ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md).
5. **Backends need not be natively async (REQ-TTY-01).** The trait's
adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`,
`BoxFuture`, `TtyControl`) are the adapter's contract. A backend may
expose blocking handles internally and bridge them via std threads +
tokio mpsc/oneshot (the pattern `portable_pty` requires, and the
local-PTY POC validated). The bridging pattern is a documented,
supported implementation strategy. See
[tty-backend.md](tty-backend.md) and [tty-local.md](tty-local.md).
6. **Exit code on a control chunk, last before stream close (ADR-055).**
`{"type":"exit","code":N}` rides on the control channel (stream_type
3) and is the last chunk before the server closes the write half.
This gives coordinators deterministic completion notification — no
polling, no plugin state. The adapter owns the ordering; backends
resolve `exit_code` and the adapter awaits, sends the chunk, closes.
See [tty-adapter.md](tty-adapter.md) and
[ADR-055](../../decisions/055-exit-code-on-control-chunk.md).
7. **The runner pattern is preserved, not specialized.** The local
backend in pipe mode (`terminal: None`) is a process-streaming
endpoint — the same shape as GitHub/Gitea Actions runners, just over
alknet's transport instead of HTTP polling. alknet-tty provides the
*mechanism* (framed byte stream + exit code); runner *policy* (job
management, log persistence, task graph) is a downstream crate's
job. See [tty-local.md](tty-local.md) and
[ADR-054](../../decisions/054-local-tty-backend-sibling-crate.md).
## References
- `docs/research/alknet-tty/phase-0-findings.md` — Phase 0 research
(wire format, backend trait, REQ-TTY-01/02, decision points DP-1
through DP-6, open questions OQ-TTY-01 through OQ-TTY-05)
- `/workspace/alknet-tty-poc/` — Phase 0 local-PTY validation POC
(`src/raw.rs` chunk codec, `src/control.rs` JSON control schema,
`src/local_pty.rs` blocking→async bridge, `src/session.rs` session
pump, `tests/integration.rs` + `tests/signal.rs` round-trip tests)
- `/workspace/alknet-docker-poc/src/raw.rs` — the seed codec
(stream_type 0/1/2) the tty POC extended with stream_type 3
- `docs/research/alknet-docker/poc-summary.md` — the POC that seeded
this crate (two-carriage model, raw chunk format, validated targets)
- `docs/research/alknet-ssh/phase-0-findings.md` DP-5 — the PTY hedge
this crate dissolves (PTY is a tty feature, not an SSH feature)
- `/workspace/@alkdev/dispatch/` — the reverse-runner prior art
(currently requires SSH; `LocalTtyBackend` removes that requirement)
- `portable-pty` 0.9 source — the blocking-API constraint that drives
REQ-TTY-01 and the signal-delivery contract (REQ-TTY-02)
+284
View File
@@ -0,0 +1,284 @@
---
status: draft
last_updated: 2026-07-06
---
# alknet-tty — Overview
The terminal session protocol handler: a `ProtocolHandler` on `alknet/tty`
that pumps a bidirectional byte stream (stdin/stdout/stderr) with a JSON
control channel (resize, signal, eof, exit) over a framed bidi stream,
decoupled from the backend that allocates the PTY via a `TtyBackend`
trait. This document covers the crate's purpose, the two-carriage model
in brief, its dependency edges, the ALPN, and the backend location map.
Component details are in the sibling documents.
## What
`alknet-tty` is the terminal session protocol handler for the
ALPN-as-service architecture (ADR-001). It registers the `alknet/tty`
ALPN on the shared `AlknetEndpoint` and implements the `ProtocolHandler`
trait (ADR-002, ADR-007). The `TtyAdapter` receives a `Connection`,
accepts one bidi stream per terminal session, reads a single JSON
negotiation frame, switches to a raw chunk format, and pumps bytes
bidirectionally for the life of the session — backend-agnostic.
The guiding insight that shapes the crate:
> 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 alknet-docker POC (`docs/research/alknet-docker/poc-summary.md`)
proved that the hard part of interactive attach — bidirectional byte
pumping over a framed 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 is the seed of alknet-tty's wire format. alknet-tty
extracts that pattern into its own crate and ALPN; the backends (Docker,
SSH, local process) implement a `TtyBackend` trait; the `alknet/tty`
handler is backend-agnostic. This dissolves the PTY hedge in the
alknet-ssh research (DP-5): PTY is not an SSH feature delegated to a
separate crate, it's a tty feature that SSH happens to be able to
provide.
## Why
The crate's purpose is to be the terminal session library for downstream
consumers. A hub that runs agent workspaces in containers wires
`DockerTtyBackend` into the `TtyAdapter` and gets interactive terminal
sessions over `alknet/tty`. A coordinator that runs `cargo test`
remotely wires `LocalTtyBackend` (pipe mode) and gets 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. A browser terminal (xterm.js
over WebTransport, when WebTransport revives) connects to `alknet/tty`
directly and gets raw bytes without implementing SSH or the call
protocol.
The key architectural insight: **the wire format and the backends
invert at the `TtyBackend` trait.** alknet-tty owns the wire format, the
negotiation frame, the chunk codec, the control channel, and the
session lifecycle; the backends own the PTY allocation (docker exec
with `tty: true`, russh `pty_request` + `shell_request`,
`portable_pty::openpty`). The adapter is backend-agnostic and testable
with a mock backend (in-memory pipes). See
[ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md).
## The Two-Carriage Model in Brief
A `alknet/tty` bidi stream has two phases (full detail in
[tty-wire.md](tty-wire.md), decided in
[ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md)):
1. **Negotiation (JSON carriage).** The client writes a single
length-prefixed JSON frame carrying the terminal parameters, backend
selector, command, and environment. The framing is byte-identical to
alknet-call's `FrameFramedReader`/`FrameFramedWriter` (the utility is
reused, not the `EventEnvelope` type — see Dependencies below).
2. **Raw carriage.** After the negotiation frame, the stream switches to
the chunk format (`[stream_type: u8][length: u32 be][payload]`) for
the life of the session. Four stream types: 0=stdin (client→server),
1=stdout (server→client), 2=stderr (server→client), 3=control
(bidirectional, JSON control messages: resize, signal, eof, exit).
There is no `call.responded`/`call.completed` — this is not the call
protocol; the raw-carriage byte pump is its own wire format after the
single JSON negotiation frame.
This is the pattern the docker POC validated and the SSH research
independently arrived at: JSON for the structured request, raw bytes for
the body, which is the part that is actually bytes. The full rationale
(why not JSON for everything; the two-carriage decision) is in
[ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md)
§Context.
## Dependencies
```
alknet-tty
├── alknet-core (ProtocolHandler, Connection, AuthContext, Identity, AccessControl,
│ OwnershipProvider — ADR-050 for terminal sessions as resources)
├── alknet-call (FrameFramedReader/FrameFramedWriter — framing utility reuse only,
│ NOT EventEnvelope or the call protocol types; ADR-003 Amendment 1)
└── (no backend deps — portable_pty, bollard, russh are in the backend crates)
```
alknet-tty is dependency-light: alknet-core (the handler interface and
auth) and alknet-call's framing codec (the length-prefix utility for the
negotiation frame). The heavy backend dependencies (`portable_pty`,
`bollard`, `russh`) live in the backend crates, not here.
### The `alknet-call` dependency (ADR-003 Amendment 1)
alknet-tty depends on alknet-call for the `FrameFramedReader`/
`FrameFramedWriter` utility — the 4-byte length prefix + JSON body
framing the negotiation frame uses. This is a *framing utility* reuse,
not a dependency on the call protocol's type system: the negotiation
payload is a tty-specific struct (`NegotiateRequest`), not a
`call.requested` `EventEnvelope`. ADR-003's rule is "no handler crate
depends on another handler crate," but `alknet-call` is both a handler
(it implements `ProtocolHandler` on `alknet/call`) *and* the
protocol-foundation crate. alknet-tty depending on alknet-call is "tty
uses the call protocol's framing codec," not "tty depends on SSH." See
[ADR-003 Amendment 1](../../decisions/003-crate-decomposition.md).
alknet-call stays lean — it has no `portable_pty`, no `bollard`, no
backend deps. The `TtyBackend` implementations are opaque
`Arc<dyn TtyBackend>` from the adapter's perspective: constructed by
the assembly layer at startup, stored in the adapter's backend map,
dispatched by the `backend` field of the negotiation frame.
## ALPN
| ALPN | Handler | Transport | Browser? |
|------|---------|-----------|----------|
| `alknet/tty` | `TtyAdapter` | QUIC bidi stream | Yes (when WebTransport revives — ADR-040 parked) |
`alknet/tty` is a custom ALPN per the ADR-006 `alknet/<name>` convention.
The `TtyAdapter` registers for it; the endpoint's `HandlerRegistry` maps
`alknet/tty` to the adapter instance. One ALPN per connection (ADR-006);
within a connection, multiple bidi streams carry independent sessions (one
session per stream — see [tty-adapter.md](tty-adapter.md)).
The browser terminal case: a browser (xterm.js) connects via WebTransport
to `alknet/tty` and gets raw bytes. The browser doesn't need to implement
SSH or the call protocol for the terminal use case — only if it wants
SSH-specific features (port forwarding, SFTP). This is a cleaner browser
story than "run a WASM SSH client." WebTransport is deferred per
[ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md);
when it revives, the `alknet/tty` ALPN is reachable over WebTransport's
ALPN-stream-proxy (ADR-040, parked). See OQ-38 for the WebTransport
relay scope question (unrelated to tty's own scope).
## Backend Location Map
The decomposition principle (the same as `alknet-http`'s adapter location
map): the trait lives where the types live (`alknet-tty`); the
implementations live where their transport dependencies live.
```
alknet-tty (lean — no portable_pty, no bollard, no russh)
├── TtyBackend trait (the contract — ADR-053)
├── TtyHandle, TtyControl (the handle shape backends produce)
├── TtyParams, TerminalParams (the allocation request)
├── TtyAdapter (ProtocolHandler on alknet/tty — session lifecycle)
├── wire format (ChunkReader/ChunkWriter, ControlMessage — ADR-052)
└── negotiation framing (reuses alknet-call's FrameFramedReader/Writer)
alknet-tty-local (sibling crate — ADR-054; behind alknet-tty's `local` feature re-export)
├── LocalTtyBackend (impl TtyBackend — portable_pty for PTY, std::process for pipe)
├── portable_pty dependency (PTY allocation — the heavy dep, here not in alknet-tty)
└── libc (signal forwarding — REQ-TTY-02, Unix only)
alknet-docker (or alknet-tty-docker adapter — future crate, out of scope here)
└── DockerTtyBackend (impl TtyBackend — wraps bollard::attach_container / exec with tty:true)
alknet-ssh (future crate — out of scope here)
└── SshTtyBackend (impl TtyBackend — wraps russh pty_request + shell_request/exec_request)
```
alknet-tty never sees `portable_pty`, `bollard`, or `russh`. The backend
implementations are opaque `Arc<dyn TtyBackend>` from the adapter's
perspective. alknet-tty stays lean; the backend crates own their
transport dependencies. The local backend's crate placement (sibling
crate behind a feature re-export) is decided in
[ADR-054](../../decisions/054-local-tty-backend-sibling-crate.md); the
docker and SSH backends are future crates (out of scope for this spec
set — see [tty-backend.md](tty-backend.md) §"Backend implementations"
for where they live).
## Feature Gates
```toml
# alknet-tty Cargo.toml
[features]
default = []
local = ["dep:alknet-tty-local"] # re-export LocalTtyBackend from alknet-tty-local
```
- `default` — the wire format, `TtyAdapter`, and the `TtyBackend` trait.
No backend implementations; the assembly layer registers backends
from their own crates. A docker-only or ssh-only deployment uses the
default features and depends on `alknet-docker` / `alknet-ssh` (or
their own backend crate) directly.
- `local` — re-export `alknet_tty_local::LocalTtyBackend` as
`alknet_tty::local::LocalTtyBackend`. Pulls in `alknet-tty-local`
(which pulls in `portable_pty`). A consumer that wants the local
backend (terminal or runner) enables this feature.
The local backend's `portable_pty` dependency is the heavy dep that
motivates the feature gate — a docker-only deployment should not pull in
PTY allocation code. See [ADR-054](../../decisions/054-local-tty-backend-sibling-crate.md).
## Architecture (component pointers)
- **[tty-wire.md](tty-wire.md)** — the wire format: the negotiation
frame (JSON carriage, reusing alknet-call's framing), the raw chunk
codec (`[stream_type: u8][length: u32 be][payload]`), the four
stream types, the control channel (stream_type 3, JSON control
messages), sentinels, and the fixed-channel-set rationale.
- **[tty-backend.md](tty-backend.md)** — the `TtyBackend` trait,
`TtyParams`, `TtyHandle`, `TtyControl`. The inversion point between
the wire-format adapter and the backends. Carries REQ-TTY-01 (backends
need not be natively async; the bridging pattern is a documented
strategy). Notes where the docker/SSH backend crates live (future,
out of scope here).
- **[tty-adapter.md](tty-adapter.md)** — the `TtyAdapter`
(`ProtocolHandler` on `alknet/tty`): the session lifecycle, the
three-pump bidirectional driver (stdout→client, client→backend,
exit→exit-chunk), negotiation errors, the exit-chunk ordering
(ADR-055), access control (terminal sessions as runtime-spawned
resources per ADR-050).
- **[tty-local.md](tty-local.md)** — the `alknet-tty-local` sibling
crate: `LocalTtyBackend` via `portable_pty` (PTY mode) and
`std::process::Command` (pipe/runner mode). Carries REQ-TTY-02
(signal forwarding to the foreground process group). The
blocking→async bridge pattern (the three std threads feeding tokio
mpsc/oneshot) is the reference for any future blocking-API backend.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Wire format and two-carriage model | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | `alknet/tty` ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-3; control as JSON |
| `TtyBackend` trait and `TtyHandle` | [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) | The backend inversion point; `exit_code` as `Future`; backends need not be natively async (REQ-TTY-01) |
| Local backend as a sibling crate | [ADR-054](../../decisions/054-local-tty-backend-sibling-crate.md) | `alknet-tty-local` behind a `local` feature re-export; PTY vs pipe per-session |
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | `{"type":"exit","code":N}` on stream_type 3; "exit chunk is last" invariant; adapter owns the ordering |
| ALPN-based protocol dispatch | [ADR-001](../../decisions/001-alpn-protocol-dispatch.md) | `TtyAdapter` registers on `alknet/tty` |
| ProtocolHandler trait | [ADR-002](../../decisions/002-protocol-handler-trait.md) | `TtyAdapter` implements `ProtocolHandler` |
| Crate decomposition | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | alknet-tty depends on alknet-core + alknet-call (framing utility); backends depend on alknet-tty for the trait |
| ALPN string convention | [ADR-006](../../decisions/006-alpn-convention-and-connection-model.md) | `alknet/tty` is the custom ALPN; new ALPN for incompatible versions |
| BiStream type definition | [ADR-007](../../decisions/007-bistream-type-definition.md) | `TtyAdapter` receives a `Connection`, accepts bidi streams |
| Call protocol stream model (not used for body) | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | The raw carriage is *not* the call protocol's `EventEnvelope` streaming — by design |
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` for proxied terminal sessions (hub→worker) |
| WebTransport ALPN-stream-proxy (parked) | [ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md) | **Parked** per ADR-044; `alknet/tty` reachable over WebTransport's stream proxy when WebTransport revives |
| Defer h3/WebTransport | [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) | WebTransport deferred; the browser terminal case revives with WebTransport |
| Streaming handler (not used for body) | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | The `StreamingHandler` path tty explicitly does *not* use for the byte body |
| Dynamic resource ownership | [ADR-050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Terminal sessions are runtime-spawned resources; `AccessControl` shape declares against this model |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-43** (resolved): `TtyControl` as a `Clone` trait object.
- **OQ-44** (deferred(scope)): Terminal modes (TTY modes).
- **OQ-45** (open, low risk): Flow control for high-throughput stdout.
- **OQ-46** (deferred(scope)): Runner API surface.
- **OQ-47** (resolved): Stdin closure canonical signal.
## References
- `docs/research/alknet-tty/phase-0-findings.md` — Phase 0 research
- `/workspace/alknet-tty-poc/` — Phase 0 local-PTY validation POC
(the reference implementation for REQ-TTY-01 and REQ-TTY-02)
- `/workspace/alknet-docker-poc/src/raw.rs` — the seed codec
(stream_type 0/1/2) the tty POC extended with stream_type 3
- `docs/research/alknet-docker/poc-summary.md` — the POC that seeded
this crate
- `docs/research/alknet-ssh/phase-0-findings.md` DP-5 — the PTY hedge
this crate dissolves
- `/workspace/@alkdev/dispatch/` — the reverse-runner prior art
(currently requires SSH; `LocalTtyBackend` removes that requirement)
- `portable-pty` 0.9 source — the blocking-API constraint that drives
REQ-TTY-01 and the signal-delivery contract (REQ-TTY-02)
+312
View File
@@ -0,0 +1,312 @@
---
status: draft
last_updated: 2026-07-06
---
# alknet-tty — TtyAdapter and Session Lifecycle
The `TtyAdapter` is the `ProtocolHandler` for `alknet/tty`: it receives a
`Connection`, accepts bidi streams, reads the negotiation frame, selects
a `TtyBackend` (ADR-053), and pumps bytes bidirectionally for the life of
the session using the wire format (ADR-052). This document specifies the
session lifecycle, the three-pump driver, negotiation errors, the
exit-chunk ordering (ADR-055), and access control.
## What
`TtyAdapter` implements `ProtocolHandler` (ADR-002, revised by ADR-007 to
receive a `Connection`) on ALPN `alknet/tty` (ADR-006). It holds a
`HashMap<String, Arc<dyn TtyBackend>>` populated at construction (ADR-053
§5). Its `handle()` method accepts the connection and loops
`connection.accept_bi()`, dispatching each bidi stream to a session. One
`alknet/tty` connection hosts multiple terminal sessions — one session
per bidi stream (DP-6, decided in the research; matches the call
protocol's one-operation-per-stream model).
```rust
pub struct TtyAdapter {
/// Backends keyed by the negotiation frame's `backend` string
/// ("local", "docker", "ssh"). Populated at construction.
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
/// Optional ownership provider (ADR-050) for terminal sessions as
/// runtime-spawned resources. None = no resource-level ACL (scope-
/// gate only). Wired by the assembly layer.
ownership: Option<Arc<dyn OwnershipProvider>>,
}
#[async_trait]
impl ProtocolHandler for TtyAdapter {
fn alpn(&self) -> &'static [u8] { b"alknet/tty" }
async fn handle(&self, connection: Connection, auth: &AuthContext)
-> Result<(), HandlerError>
{
// One connection → many sessions (one bidi stream each).
while let Ok((send, recv)) = connection.accept_bi().await {
let backends = self.backends.clone();
let ownership = self.ownership.clone();
let identity = auth.identity.clone();
tokio::spawn(async move {
let _ = drive_session(send, recv, backends, ownership, identity).await;
});
}
Ok(())
}
}
```
The `drive_session` function is the per-stream session driver — the
counterpart to the POC's `session::drive_session`
(`/workspace/alknet-tty-poc/src/session.rs`), generalized from the local
PTY backend to the `TtyBackend` trait.
## Why
The adapter is the place where the wire format (ADR-052), the backend
trait (ADR-053), and the exit-chunk ordering (ADR-055) come together.
Keeping these in one place — the adapter — is what makes the invariants
enforceable: the wire format's "exit chunk is last" invariant is enforced
here, not in the backends (which produce handles, not wire bytes); the
backend's `exit_code` future is awaited here, not in the backend; the
negotiation frame is parsed here, not in the backend. The adapter is
backend-agnostic; the backends are wire-format-agnostic. The inversion is
the `TtyBackend` trait.
## Architecture
### Session Lifecycle
A `alknet/tty` session on one bidi stream proceeds in three phases:
1. **Negotiation.** The adapter reads the single length-prefixed JSON
negotiation frame from the client (ADR-052 §"Negotiation Frame"),
parses it into `NegotiateRequest`, extracts the `backend` string,
looks up the `TtyBackend`, and constructs `TtyParams`. If the backend
is not registered, or the negotiation frame is malformed, the adapter
sends a JSON error response and closes the stream (see §"Negotiation
errors" below).
2. **Allocation.** The adapter calls `backend.allocate(&params)`, which
returns a `TtyHandle` (ADR-053). If allocation fails (PTY couldn't be
allocated, docker exec failed, SSH channel request rejected), the
adapter sends a JSON error response and closes the stream.
3. **Raw carriage — the bidirectional pump.** The adapter switches to
the raw chunk format and pumps three concurrent tasks:
- **A. stdout → client**: backend stdout (`TtyHandle.stdout`) → stdout
chunks (stream_type 1) to the client. If `TtyHandle.stderr` is
`Some`, a concurrent stderr pump emits stderr chunks (stream_type 2).
On backend stdout EOF, emit a zero-length stdout sentinel.
- **B. client → backend**: client chunks → backend. stdin chunks
(stream_type 0) → `TtyHandle.stdin` (via `AsyncWrite`). Control
chunks (stream_type 3) → `ControlMessage` dispatch: `Resize` →
`TtyControl::resize`, `Signal` → `TtyControl::signal`, `Eof` →
close stdin. `Exit` from the client is ignored (server→client only).
On client read-half close or a zero-length stdin chunk, signal EOF
to the backend's stdin.
- **C. exit → exit chunk**: await `TtyHandle.exit_code`; on resolve,
enqueue `{"type":"exit","code":N}` as a control chunk (stream_type 3).
A drainer task writes chunks to the client in arrival order. After the
exit chunk is written (task C resolves and the exit chunk drains),
the adapter closes the write half — the session ends.
This is the POC's `session::drive_session` pattern, generalized: the POC
hardcoded the local PTY backend; the adapter dispatches to any
`TtyBackend`. See `/workspace/alknet-tty-poc/src/session.rs` for the
reference implementation of the three-pump driver.
### Negotiation Errors
If the server cannot allocate the session, it sends a JSON error response
in the same length-prefixed framing as the negotiation frame (the JSON
carriage, not the raw chunk format) and closes the stream without
entering raw mode. The error response shape:
```json
{ "error": "unknown_backend", "backend": "kubernetes" }
```
| Error | When | Shape |
|-------|------|------|
| `unknown_backend` | the `backend` string is not in the adapter's backend map | `{"error":"unknown_backend","backend":"..."}` |
| `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation | `{"error":"malformed_negotiation","message":"..."}` |
| `allocate_failed` | `backend.allocate()` returned a `TtyError` | `{"error":"allocate_failed","message":"..."}` |
After sending the error response, the adapter closes the write half of
the bidi stream. The client reads the error frame and treats stream close
as the failure signal. There is no `call.error` — this is not the call
protocol; the error is a JSON response in the negotiation framing.
**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 (ADR-052): 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.
### Exit-Chunk Ordering (ADR-055)
The "exit chunk is last" invariant (ADR-055) is enforced here, in the
adapter's session driver, not in the backend. The ordering:
1. The stdout pump (task A) drains the backend's stdout to EOF. The
backend's stdout ends when the process exits and the PTY/pipe buffer
drains (Unix `Child::wait()` blocks until the child is reaped, which
happens after the child exits and its stdout drains — ADR-055
assumption 1).
2. The exit task (task C) awaits `TtyHandle.exit_code`. The exit resolves
after the child is reaped (the local backend's waiter thread calls
`Child::wait()`; docker's `inspect_exec` after the output stream ends;
SSH's channel close after the process exits).
3. **The adapter waits for *both* the stdout pump to complete (EOF)
*and* `exit_code` to resolve** before enqueueing the exit chunk. 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; the `TtyHandle.stderr` (if `Some`)
is pumped concurrently and also drains before the exit chunk. (ADR-055
assumption 2.)
4. After both resolve, the exit chunk (`{"type":"exit","code":N}`) is
enqueued on the writer channel.
5. The drainer writes the exit chunk to the client.
6. The adapter closes the write half — the session ends.
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 —
the same stopgap property the docker POC validated for logs subscriptions,
now for any backend.
If `exit_code` resolves with a `TtyError` (the backend couldn't determine
the exit code), the adapter sends `{"type":"exit","code":-1}` (ADR-055
§4). The client treats `-1` as "the backend reported an exit error, not
a real exit code."
### Access Control
Terminal sessions are runtime-spawned resources per ADR-050. A
`alknet/tty` session is a resource the caller owns: the caller that
opened the session owns it; proxy to share; teardown (stream close)
revokes. The adapter's access control declares against the ADR-050 model:
- **Scope-gate at negotiation.** The adapter checks the caller's
`identity.scopes` for the `tty:open` scope (or a deployment-configured
scope) before allocating the session. A caller without the scope gets
a negotiation error (`{"error":"forbidden"}`) and the stream closes.
- **Resource ownership for backend-specific resources.** A docker
backend's session targets a specific container (`BackendParams::Docker
{ container }`); the adapter extracts the container ID via the
`OperationSpec.resource_id_path` analog (here, a hardcoded field — the
tty adapter is not an `OperationSpec`, so the extraction is direct
from the negotiation frame's `container` field) and checks
`OwnershipProvider::owns(identity, "container", id, "tty")` if an
ownership provider is wired. A local backend's session has no
pre-existing resource — the process is the resource, and the caller
that opened the session owns it by construction.
- **`forwarded_for` for proxied sessions.** A hub that proxies a
terminal session to a worker carries the end user's identity as
`forwarded_for` (ADR-032); the worker authorizes the hub (its direct
caller), not the end user. The hub's end-user ACL is its own layer.
The tty adapter is a `ProtocolHandler`, not an `OperationSpec`-registered
operation — it doesn't go through the call protocol's
`OperationRegistry::invoke()`. The access-control shape is the adapter's
own (scope-gate + ownership check at negotiation), declaring against the
ADR-050 model but not consuming `OperationSpec.resource_id_path` (that
field is for call-protocol operations; the tty adapter is its own
ALPN). See ADR-050 §"Specifics" for the model this declares against.
The concrete choices — the scope name (`tty:open`), the check-at-
negotiation timing, and the hardcoded `container` field extraction for
docker backend-specific resources (rather than a generic path like
`OperationSpec.resource_id_path`) — are **two-way-door** choices within
the one-way `TtyAdapter` shape. The scope name can be renamed (a
deployment-configured scope, not a wire-format constant); the hardcoded
field extraction can be generalized if a second backend with a different
resource-id location appears (the adapter would branch by backend). No
ADR is warranted for these until a second backend forces the
generalization — they are reversible implementation choices, not
architectural commitments.
### Connection and Stream Lifecycle
- **Connection drop**: when the QUIC connection closes, all in-flight
sessions on that connection are cancelled. Each session's pump tasks
are dropped (Rust `Drop`); the backend's handles are dropped (the
local backend's reader/writer/waiter threads exit on channel close;
docker's bollard streams are dropped; SSH's channel closes). No
explicit cleanup is needed — `Drop` is the cleanup.
- **Stream reset**: when a bidi stream is reset mid-session, the
`ChunkReader` returns a `RawError` (ConnectionClosed or Io). The pump
tasks exit; the backend's handles are dropped. No exit chunk is sent —
the stream is gone, the client that reset it already knows.
- **Client cancel**: when the client closes the write half (or sends a
zero-length stdin chunk / `eof` control chunk), the adapter signals
EOF to the backend's stdin and keeps pumping stdout until the backend's
stdout ends and the exit resolves. The session completes normally —
the exit chunk is sent — the client just stopped sending input.
## Constraints
- **The adapter, not the backend, owns the wire format.** Backends
produce handles; the adapter pumps. A backend that wrote to the wire
directly would break the "exit chunk is last" invariant (ADR-055) and
the negotiation-error framing.
- **One session per bidi stream, multiple streams per connection.** A
connection hosts multiple sessions (one stream each); the adapter
spawns a `drive_session` task per accepted stream. Sessions are
independent — one session's exit doesn't affect another.
- **Negotiation errors are JSON, not raw chunks.** The error response
uses the negotiation framing (length-prefixed JSON), not the raw chunk
format. The stream enters raw mode only after a successful allocation.
- **The exit chunk is the deterministic completion signal.** A client
reading to completion sees the exit chunk and knows the process exited
with code N. A client that cancels mid-stream (closes the write half)
won't see the exit chunk — that's correct; a cancelled stream doesn't
have a deterministic exit.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Wire format and two-carriage model | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | The chunk codec + control channel the adapter pumps |
| `TtyBackend` trait and `TtyHandle` | [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) | The backend the adapter dispatches to; the handles the adapter pumps |
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | The "exit chunk is last" invariant the adapter enforces |
| ALPN-based protocol dispatch | [ADR-001](../../decisions/001-alpn-protocol-dispatch.md) | `TtyAdapter` registers on `alknet/tty` |
| ProtocolHandler receives `Connection` | [ADR-007](../../decisions/007-bistream-type-definition.md) | `TtyAdapter` accepts the connection, loops `accept_bi` |
| Dynamic resource ownership | [ADR-050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Terminal sessions as runtime-spawned resources; the adapter's access-control shape |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-45** (open, low risk): Flow control for high-throughput stdout.
## References
- [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md)
— the wire format the adapter pumps
- [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) — the
backend trait the adapter dispatches to
- [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) — the
exit-chunk ordering the adapter enforces
- [ADR-050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md)
— the ownership model the adapter's access control declares against
- [ADR-007](../../decisions/007-bistream-type-definition.md) — `Connection`,
`accept_bi`, the handler-receives-Connection pattern
- `/workspace/alknet-tty-poc/src/session.rs` — the reference
implementation of the three-pump session driver (hardcoded to the
local PTY backend; the adapter generalizes to the trait)
- [tty-wire.md](tty-wire.md) — the wire format details
- [tty-backend.md](tty-backend.md) — the backend trait details
+356
View File
@@ -0,0 +1,356 @@
---
status: draft
last_updated: 2026-07-06
---
# alknet-tty — TtyBackend Trait and TtyHandle
The `TtyBackend` trait is the inversion point that keeps alknet-tty
decoupled from its backends. alknet-tty defines the trait, the
`TtyParams` allocation request, the `TtyHandle` a backend produces, and
the `TtyControl` trait; the backend crates (alknet-tty-local,
alknet-docker, alknet-ssh) implement `TtyBackend`. This document
specifies what an implementer builds against. The trait shape is decided
in [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md).
## What
The `TtyBackend` trait is what the `TtyAdapter` calls to allocate a
terminal/process session. The adapter holds a
`HashMap<String, Arc<dyn TtyBackend>>` keyed by the negotiation frame's
`backend` string (`"local"`, `"docker"`, `"ssh"`). On a new session, the
adapter reads the negotiation frame, selects the backend by the
`backend` field, calls `allocate()`, and pumps the resulting
`TtyHandle`'s fields bidirectionally using the chunk format
(ADR-052). The backend does not write to the wire — it produces handles;
the adapter pumps.
Three implementations are contemplated, each in its own crate (the
no-handler-depends-on-another-handler rule from ADR-003 is preserved —
backends depend on alknet-tty for the trait, alknet-tty doesn't depend on
them):
- **`LocalTtyBackend`** (in `alknet-tty-local`, ADR-054) — wraps
`portable_pty` for the PTY case and `std::process::Command` with
`Stdio::piped()` for the pipe/runner case. See
[tty-local.md](tty-local.md).
- **`DockerTtyBackend`** (in `alknet-docker` or a sibling adapter crate —
future, out of scope here) — wraps `bollard::attach_container()` for
interactive attach or `bollard::exec::start_exec` with `tty: true` for
exec-with-PTY. `control.resize()` calls `bollard::exec::resize_exec`
or `bollard::container::resize_container`. stdout/stderr are merged
when `tty: true` (bollard's `LogOutput` on a TTY exec returns
`StdOut` only), so `TtyHandle.stderr` is `None` for the PTY case.
- **`SshTtyBackend`** (in `alknet-ssh` — future, out of scope here) —
wraps russh's `pty_request` + `shell_request` (or `exec_request` with
a PTY) on a session channel. `channel.into_stream()` gives
`(AsyncRead, AsyncWrite)` — the stream *is* the PTY; russh handles
kernel PTY allocation on the server side. `control.resize()` sends a
`window_change` channel request; `control.signal()` sends a `signal`
channel request. stdout and stderr are merged (PTY property), so
`TtyHandle.stderr` is `None`.
The docker and SSH backend crates are future work; this spec set commits
the trait shape they will implement, so they can be built against it
without re-spec'ing the seam.
## Why
The guiding insight: **a terminal session is not an SSH concern, or a
Docker concern — it is a terminal concern. SSH and Docker are just two
backends that can allocate a PTY.** The `TtyBackend` trait is what makes
that insight load-bearing — alknet-tty owns the wire format and session
lifecycle; the backends own PTY allocation. The full rationale (the
inversion point, why the trait is the seam) is in
[ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) §Context.
The Phase 0 local-PTY POC (`/workspace/alknet-tty-poc`) was built
*before* this spec specifically to discover constraints the trait sketch
would have missed by reading docs alone. Two requirements fell out,
recorded as REQ-TTY-01 and REQ-TTY-02 in the research findings; this
spec carries REQ-TTY-01 here (backends need not be natively async) and
[tty-local.md](tty-local.md) carries REQ-TTY-02 (signal forwarding to
the process group).
## Architecture
### `TtyBackend` trait
```rust
#[async_trait]
pub trait TtyBackend: Send + Sync {
/// Allocate a terminal/process session and return the handles the
/// adapter pumps. The `backend` field of the negotiation frame
/// (ADR-052) selects which registered backend's `allocate` is called.
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError>;
}
```
The adapter holds `HashMap<String, Arc<dyn TtyBackend>>` populated at
construction. The assembly layer (the CLI binary) constructs backends
with their dependencies and registers them. A backend is the *thing
that allocates a session*; the wire-format pump is backend-agnostic.
### `TtyError`
The error type for `allocate()` and `exit_code`. `#[non_exhaustive]` so
new variants are additive (two-way-door extension within the one-way
trait shape — ADR-053).
```rust
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum TtyError {
#[error("allocate failed: {message}")]
AllocFailed { message: String },
#[error("wait failed: {message}")]
WaitFailed { message: String },
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("backend-specific: {message}")]
Backend { message: String },
}
```
- `AllocFailed` — the PTY couldn't be allocated, the docker exec failed
to start, the SSH channel request was rejected. Returned by
`allocate()`; the adapter sends `{"error":"allocate_failed",...}` and
closes (tty-adapter.md §"Negotiation errors").
- `WaitFailed` — the backend couldn't reap the child / determine the
exit code. Returned by the `exit_code` future; the adapter sends
`{"type":"exit","code":-1}` (ADR-055 §4).
- `Io` — an I/O error from a backend's stream/handle.
- `Backend` — backend-specific error not covered by the above (e.g., a
bollard API error, a russh protocol error).
### `TtyParams` — the allocation request
```rust
pub struct TtyParams {
/// Terminal parameters. `None` = pipe mode (no PTY — the runner case,
/// ADR-054). `Some` = allocate a PTY with these dimensions.
pub terminal: Option<TerminalParams>,
/// Command vector (argv[0] + args). Non-empty.
pub cmd: Vec<String>,
/// Working directory (None = inherit/default).
pub cwd: Option<PathBuf>,
/// Environment variables (empty = inherit).
pub env: HashMap<String, String>,
/// Backend-specific selector fields from the negotiation frame.
/// The adapter parses the negotiation frame's backend-specific fields
/// and passes them here. The adapter does not interpret them.
pub backend_params: BackendParams,
}
pub struct TerminalParams {
pub term: Option<String>, // e.g., "xterm-256color"; None = backend default
pub cols: u16,
pub rows: u16,
pub pixel_width: u16,
pub pixel_height: u16,
pub modes: serde_json::Value, // reserved — OQ-44
}
#[non_exhaustive]
pub enum BackendParams {
Local,
Docker { container: String },
Ssh { channel: SshChannelRef },
// Extensible: a backend crate may add variants. The adapter's match
// has a `_ => unsupported backend` arm.
}
```
`SshChannelRef` is a forward-reference type for the future
`SshTtyBackend` (out of scope here; will be defined in the alknet-ssh
crate — expected to wrap a russh `ChannelId` and session reference). It
appears in the enum so the trait shape is committed; the concrete
definition lives in the ssh backend crate when built.
`terminal: None` is the pipe/runner case — no PTY, separate
stdout/stderr. `terminal: Some` is the PTY case — stdout/stderr merged
into the single stdout stream (`TtyHandle.stderr` is `None`), real
terminal semantics (resize, signal delivery to process group). The
per-session choice is the backend's branch in `allocate()`, not a
per-deployment choice — see ADR-054.
### `TtyHandle` — what a backend produces
```rust
pub struct TtyHandle {
/// Stdin writer — bytes the adapter pumps from client stdin chunks.
pub stdin: Box<dyn AsyncWrite + Send + Unpin>,
/// Stdout stream — bytes the adapter pumps to client stdout chunks.
/// Ends when the backend's stdout reaches EOF.
pub stdout: Pin<Box<dyn Stream<Item = Bytes> + Send>>,
/// Stderr stream — `None` for PTY backends (stdout/stderr merged
/// into `stdout`). `Some` for pipe backends (separate streams).
pub stderr: Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>>,
/// Exit code — a `Future` the adapter awaits. Resolves when the
/// process/container/SSH exec exits. The adapter sends the result
/// as the `{"type":"exit","code":N}` control chunk (ADR-055) and
/// closes the stream. This is `BoxFuture`, not a method on
/// `TtyHandle`, so the adapter can `select` between exit and
/// stream-close without coupling to the other fields. (REQ-TTY-01.)
pub exit_code: BoxFuture<'static, Result<i32, TtyError>>,
/// Control handle (resize, signal) — `Clone` so the adapter can
/// hand it to the spawned control-chunk dispatcher. `None` only
/// when the backend genuinely has no control path. See OQ-43.
pub control: Option<Box<dyn TtyControl + Send + Unpin + Clone>>,
}
```
### `TtyControl` trait
```rust
pub trait TtyControl: Send {
/// Resize the terminal. Maps to SSH `window-change`, docker exec
/// resize, or `ioctl(TIOCSWINSZ)` on a local PTY. No-op for pipe
/// backends without a PTY.
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16);
/// Forward a signal by name. Best-effort delivery to the foreground
/// process group (see tty-local.md REQ-TTY-02). Unknown names fall
/// back to the backend's default kill.
fn signal(&self, name: &str);
}
```
The `Clone` trait-object bound is satisfied via an `Arc`-backed `Clone`
newtype (OQ-43): a small struct holding `Arc<dyn TtyControlInner>` where
`TtyControlInner: Send + Sync` has the `resize`/`signal` methods, and
the public `TtyControl` newtype implements `Clone` by cloning the
`Arc`. The POC used a concrete `PtyControl` struct (inherently
`Clone`); the trait-object form generalizes it so a backend can produce
its own control type without the adapter knowing the concrete shape.
### REQ-TTY-01: backends are not required to be natively async
`portable_pty`'s API is blocking `std::io::{Read, Write}` and a blocking
`Child::wait()` — there is no async variant. The local-PTY POC bridges
this with three dedicated std threads (reader, writer, waiter) feeding
tokio mpsc/oneshot channels; the async-facing `LocalPty` then exposes
`mpsc::Receiver<Bytes>` for stdout, `mpsc::Sender<StdinCmd>` for stdin,
and `oneshot::Receiver<i32>` for exit. This is the same pattern wezterm
(portable_pty's primary consumer) uses.
The trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`,
`BoxFuture`, `TtyControl`) are the **adapter's contract**. A backend may
expose blocking handles internally and bridge them to these async-facing
types. The bridging pattern — blocking `std::io` on dedicated std threads
or `tokio::task::spawn_blocking`, feeding tokio mpsc/oneshot channels —
is a **documented, supported implementation strategy**, not a workaround.
This resolves the first half of OQ-TTY-01 (the research's open question
on the trait shape): `exit_code` is a `Future` the adapter awaits; a
`oneshot::Receiver<i32>` (or any `BoxFuture<'static, i32>`) lets the
adapter `select` between exit and stream-close without coupling to the
handle's other fields. The local backend's waiter thread produces exactly
this shape for free. See [tty-local.md](tty-local.md) for the bridge
details.
### Backend registration and the assembly layer
```rust
let mut backends = HashMap::new();
backends.insert("local".into(),
Arc::new(LocalTtyBackend::new()) as Arc<dyn TtyBackend>);
backends.insert("docker".into(),
Arc::new(DockerTtyBackend::new(docker_client)) as Arc<dyn TtyBackend>);
backends.insert("ssh".into(),
Arc::new(SshTtyBackend::new(ssh_session)) as Arc<dyn TtyBackend>);
let tty_adapter = TtyAdapter::new(Arc::new(backends));
```
A deployment that doesn't want docker registers only `local`. A browser
terminal endpoint that proxies to remote docker/ssh registers `docker`
and/or `ssh` backends. The adapter is backend-agnostic; the assembly
layer chooses what's available.
### Backend implementations (where they live)
| Backend | Crate | Status | Notes |
|---------|-------|--------|-------|
| `LocalTtyBackend` | `alknet-tty-local` (sibling, behind `local` feature) | in scope ([tty-local.md](tty-local.md)) | `portable_pty` (PTY) + `std::process` (pipe); the runner pattern |
| `DockerTtyBackend` | `alknet-docker` or sibling adapter | future, out of scope here | wraps `bollard::attach_container` / `exec` with `tty: true` |
| `SshTtyBackend` | `alknet-ssh` | future, out of scope here | wraps russh `pty_request` + `shell_request`/`exec_request`; dissolves alknet-ssh DP-5 PTY hedge |
The docker and SSH backend crates are future work; this spec commits the
trait shape they implement so they can be built against it without
re-spec'ing the seam. The `DockerTtyBackend` is the natural extension of
the alknet-docker POC's `drive_attach_raw` (`/workspace/alknet-docker-poc/src/ops.rs`)
— with the trait, it becomes `impl TtyBackend for DockerTtyBackend`. The
`SshTtyBackend` dissolves the alknet-ssh research's PTY hedge (DP-5):
alknet-ssh's session channel still does `exec` (structured, JSON carriage,
exit code on completion) but *delegates* PTY to alknet-tty via the
`SshTtyBackend`. alknet-ssh's "default-reject" stance stays for the SSH
channel policy (it rejects `pty_request` on its own session channels),
but the PTY capability is provided by a separate crate via a separate
ALPN (`alknet/tty`), not hedged inside alknet-ssh.
## Constraints
- **The trait shape is one-way (ADR-053).** The `TtyBackend` trait,
`TtyHandle` field set, and `TtyControl` trait are the API surface every
backend crate implements and the adapter consumes. Changing them after
backends exist is a rewrite across crates.
- **`BackendParams` is `#[non_exhaustive]`.** New backend crates add
variants additively; the adapter's `match` has a `_ => unsupported
backend` arm. This is a two-way-door extension point within the
one-way trait shape.
- **The adapter, not the backend, owns the wire format.** Backends
produce handles; the adapter pumps. A backend that wrote to the wire
directly would break the wire-format invariants (the exit-chunk
ordering, ADR-055). The backend's `exit_code` future resolves and the
adapter sends the exit chunk — the backend does not serialize
`ControlMessage::Exit`.
- **PTY backends merge stdout/stderr.** `TtyHandle.stderr` is `None` for
the PTY case (kernel PTY property — one output stream from the slave).
The adapter pumps only stdout chunks (stream_type 1). Pipe backends
set `stderr: Some` and the adapter pumps both stdout (stream_type 1)
and stderr (stream_type 2) chunks.
- **`TtyControl::signal` is best-effort.** The contract is "best-effort
delivery to the foreground process group," not "the child pid receives
the signal." See [tty-local.md](tty-local.md) REQ-TTY-02 for the
process-group targeting and the fallback to the backend's default kill.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| `TtyBackend` trait and `TtyHandle` | [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) | The backend inversion point; `exit_code` as `Future`; backends need not be natively async (REQ-TTY-01) |
| Local backend as a sibling crate | [ADR-054](../../decisions/054-local-tty-backend-sibling-crate.md) | `alknet-tty-local` behind a `local` feature re-export |
| Wire format | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | The chunk codec + control channel the adapter pumps to/from these handles |
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | The adapter awaits `exit_code`, sends the exit chunk, closes |
| Crate decomposition | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | alknet-tty depends on alknet-core; backends depend on alknet-tty for the trait |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-43** (resolved): `TtyControl` as a `Clone` trait object.
- **OQ-44** (deferred(scope)): Terminal modes.
## References
- [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) — the
trait shape decision (this spec is its elaboration)
- [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md)
— the wire format the adapter pumps to/from these handles
- [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) — the
exit-chunk ordering the `exit_code` field feeds into
- [ADR-054](../../decisions/054-local-tty-backend-sibling-crate.md) —
the local backend's crate placement
- `docs/research/alknet-tty/phase-0-findings.md` — §"The Backend Trait"
(the seed of this spec) and §"Requirements from the local-PTY POC"
(REQ-TTY-01, the load-bearing constraint)
- `/workspace/alknet-tty-poc/src/local_pty.rs` — the reference
implementation of what a backend produces (`LocalPty`: stdout mpsc,
stdin mpsc, control `PtyControl` (Clone), exit oneshot)
- `/workspace/alknet-tty-poc/src/session.rs` — the adapter-side pump that
consumes `TtyHandle`-shaped fields (the reference for how the adapter
uses the trait)
- [tty-local.md](tty-local.md) — the `LocalTtyBackend` spec (carries
REQ-TTY-02: signal forwarding to the process group)
- [tty-adapter.md](tty-adapter.md) — the session driver that consumes
these handles
+314
View File
@@ -0,0 +1,314 @@
---
status: draft
last_updated: 2026-07-06
---
# alknet-tty — Local TTY Backend (`alknet-tty-local`)
The local backend: a `TtyBackend` implementation that wraps
`portable_pty` for the PTY case (terminal semantics — resize, signal
delivery, escape-sequence handling) and `std::process::Command` with
`Stdio::piped()` for the pipe/runner case (process-streaming without
terminal semantics). This document specifies the `LocalTtyBackend`, the
blocking→async bridge pattern (REQ-TTY-01's reference implementation), and
the signal-delivery contract (REQ-TTY-02). The crate placement is decided
in [ADR-054](../../decisions/054-local-tty-backend-sibling-crate.md);
the trait it implements is in [tty-backend.md](tty-backend.md).
## What
`alknet-tty-local` is a sibling crate (ADR-054) that implements
`TtyBackend` for `LocalTtyBackend`. The backend's `allocate()` branches on
`TtyParams.terminal`:
- **`terminal: Some(TerminalParams { ... })`** — allocate a real PTY via
`portable_pty::native_pty_system().openpty()`, spawn the command into
the slave side, return a `TtyHandle` with merged stdout (stderr is
`None` — kernel PTY property) and a real `TtyControl` (resize via
`MasterPty::resize`, signal via `libc::kill(-pgid, sig)`).
- **`terminal: None`** — pipe mode, the runner case. Spawn the command
with `Stdio::piped()` for stdin/stdout/stderr, return a `TtyHandle`
with separate stdout and stderr (stderr is `Some`) and a `TtyControl`
whose `resize` is a no-op (no PTY) and `signal` calls
`libc::kill(pid, sig)` (still works for signal forwarding without a
PTY).
The backend is the reference implementation of REQ-TTY-01 (backends need
not be natively async) and carries REQ-TTY-02 (signal forwarding to the
process group).
## Why
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. With
`LocalTtyBackend`, the dispatch project (`/workspace/@alkdev/dispatch/`, a
reverse runner that currently requires SSH on the remote end) works
without SSH — the endpoint runs the process directly and streams its I/O
back. SSH becomes one transport option (for reaching hosts that don't run
alknet), not a requirement.
The PTY case is what makes a terminal a terminal: real resize (via
`ioctl(TIOCSWINSZ)`), signal delivery to the foreground process group
(via `libc::kill(-pgid, sig)`, REQ-TTY-02), and escape-sequence handling
(the kernel PTY's line discipline). Without a PTY, it's a runner (piped
process); with a PTY, it's a terminal. The per-session choice
(`TtyParams.terminal`) lets one `LocalTtyBackend` serve both — see
ADR-054.
The wrinkle that drove the Phase 0 POC: `portable_pty` is a **blocking
`std::io` API**, not async. `MasterPty::try_clone_reader()` returns
`Box<dyn std::io::Read + Send>`; `take_writer()` returns
`Box<dyn std::io::Write + Send>`; `Child::wait()` blocks. The POC was
built to discover how that constraint shapes the `TtyBackend` trait
(REQ-TTY-01) and the signal-delivery contract (REQ-TTY-02). This spec
records both as requirements, not open questions — the POC turned them
into grounded requirements.
## Architecture
### PTY Mode (`terminal: Some`)
`allocate()` calls `portable_pty::native_pty_system().openpty(PtySize)`
with the terminal dimensions, spawns the command into the slave side
via `SlavePty::spawn_command(CommandBuilder)`, drops the slave (so the
child sees EOF on its stdin when the master writer closes), and returns
a `TtyHandle`.
The blocking→async bridge (REQ-TTY-01's reference implementation):
**three dedicated std threads** feed tokio mpsc/oneshot channels. The
writer thread consumes an mpsc of `StdinCmd`:
```rust
pub enum StdinCmd {
Bytes(Vec<u8>), // write these bytes to the master writer
Eof, // close the master writer (EOF to the slave's stdin)
}
```
1. **Reader thread** — blocking reads from `MasterPty::try_clone_reader()`
→ `mpsc::Sender<Bytes>`. The reader loop reads into an 8 KiB buffer,
copies each chunk to `Bytes`, and `blocking_send`s to the mpsc. On EOF
(the master reader returns EOF when the slave closes — the child has
exited and the OS has drained the PTY buffer), the thread sends a
zero-length `Bytes` sentinel (the "drained" signal) and exits. The
async-facing `TtyHandle.stdout` is the `mpsc::Receiver<Bytes>`,
wrapped as `Pin<Box<dyn Stream<Item = Bytes> + Send>>`.
2. **Writer thread** — drains an `mpsc::Receiver<StdinCmd>` → blocking
writes to `MasterPty::take_writer()`. `StdinCmd::Bytes(bytes)` writes
and flushes; `StdinCmd::Eof` drops the writer (sends EOF to the
slave's stdin) and exits. The async-facing `TtyHandle.stdin` is the
`mpsc::Sender<StdinCmd>`, wrapped as `Box<dyn AsyncWrite + Send +
Unpin>` (an `AsyncWrite` impl that wraps each `write` as a
`StdinCmd::Bytes` and `flush` as a no-op; the `mpsc::Sender` is the
sink).
3. **Waiter thread** — blocking `Child::wait()` → `oneshot::Sender<i32>`
with the exit code. The async-facing `TtyHandle.exit_code` is the
`oneshot::Receiver<i32>`, wrapped as `BoxFuture<'static, Result<i32,
TtyError>>`. This is the `Future` the adapter awaits (ADR-053
REQ-TTY-01; ADR-055).
`TtyHandle.stderr` is `None` (PTY backends merge stdout/stderr — kernel
PTY property, one output stream from the slave).
`TtyHandle.control` is a `PtyControl` struct (the POC's concrete type;
the trait-object form per ADR-053 is the `Arc`-backed `Clone` newtype,
OQ-43):
```rust
#[derive(Clone)]
pub struct PtyControl {
master: Arc<Mutex<Box<dyn MasterPty + Send>>>,
killer: Arc<Mutex<Box<dyn portable_pty::ChildKiller + Send + Sync>>>,
pid: Option<u32>,
}
```
`resize()` locks the master and calls `MasterPty::resize(PtySize)` —
non-blocking (it issues an `ioctl`). `signal()` — see REQ-TTY-02 below.
### REQ-TTY-02: Signal Forwarding Must Target the Process Group
`libc::kill(pid, sig)` on the spawned child's pid alone is **insufficient**
for terminal semantics: a shell running under a PTY will have spawned
children (a `find | grep` pipeline, a `make` with sub-makes), and those
children will not receive the signal. A real terminal forwards Ctrl-C to
the **foreground process group**, which (under job-control shells) is the
process group the shell most recently spawned for the foreground job.
`portable_pty` makes the child a session leader (when
`controlling_tty = true`, the default — `CommandBuilder::set_controlling_tty(true)`),
so the child's pid *is* its process-group id, and `libc::kill(-pid, sig)`
(the negative pid) reaches the whole group. The POC's `PtyControl::signal`
uses exactly this — `kill(-pgid, sig)` with a fallback to `kill(pid, sig)`
if the group signal fails (e.g., the child already exited).
The spec records:
1. **The local backend MUST forward signals to the child's process
group, not just the child pid.** Using `kill(-pgid, sig)` when the
child is a session leader (the `portable_pty` default).
2. **The local backend MUST spawn the child as a session leader with a
controlling tty.** This is `portable_pty`'s default
(`CommandBuilder::set_controlling_tty(true)`); disabling it (e.g.,
for container-boundary workarounds) breaks signal forwarding and is
therefore not supported for the terminal use case.
3. **The `TtyControl::signal` contract is "best-effort delivery to the
foreground process group,"** not "the child pid receives the signal."
Unknown signal names fall back to the backend's default kill
(`portable_pty`'s `ChildKiller::kill` sends SIGHUP); known names map
to `libc` signal numbers (`HUP`, `INT`, `QUIT`, `TERM`, `KILL`,
`USR1`, `USR2`, `TSTP`, `CONT`) and are sent to the group.
This pre-empts a class of "Ctrl-C doesn't kill my `cargo build`" bugs
that would otherwise surface in Phase 2/3.
### Pipe Mode (`terminal: None`)
`allocate()` spawns the command with `std::process::Command` and
`Stdio::piped()` for stdin, stdout, and stderr. The async bridge is
simpler than the PTY case — tokio's `Child` provides `AsyncRead` for
stdout/stderr and `AsyncWrite` for stdin directly (no std-thread
bridge needed). `TtyHandle.stderr` is `Some` (separate streams). The
`exit_code` future is `Child::wait()` (async on tokio's `Child`).
`TtyHandle.control` is a `PipeControl` whose `resize()` is a no-op
(no PTY — resize doesn't apply) and `signal()` calls `libc::kill(pid, sig)`
on the child's pid. Signal forwarding to the process group is not
applicable in pipe mode (there's no session leader / controlling tty);
`kill(pid, sig)` reaches the direct child only. If the child has
spawned its own children, they won't receive the signal — this is a
known limitation of the runner case (a runner that needs
process-group signal delivery uses the PTY case, not the pipe case).
### The Threading/Deadlock Caveat (DP-4, Acknowledged Constraint)
`std::process::Command` with piped stdio can deadlock if stdin writes
block while stdout/stderr buffers fill — the classic pipe-buffer deadlock.
The fix is concurrent reads on stdout/stderr alongside stdin writes,
which is exactly what the bidirectional pump does (the POC's
`drive_attach_raw` runs the two directions as concurrent
`tokio::spawn` tasks). The same pattern works for `LocalTtyBackend`:
spawn one task pumping stdin→process, one task pumping process→stdout-chunks,
one for stderr if piped. This is a known constraint with a known solution
(POC-validated); no design decision needed.
### Crate Placement (ADR-054)
`alknet-tty-local` is a sibling crate. `alknet-tty` re-exports
`LocalTtyBackend` behind a `local` feature:
```toml
# alknet-tty Cargo.toml
[features]
default = []
local = ["dep:alknet-tty-local"]
```
A consumer that wants the local backend enables `features = ["local"]`
and gets `alknet_tty::local::LocalTtyBackend`. A consumer that only wants
docker/ssh uses the default features and depends on the backend crate
directly — no `portable_pty` in the dependency tree. See ADR-054.
### Dependencies
```
alknet-tty-local
├── alknet-tty (TtyBackend trait, TtyHandle, TtyControl, wire types)
├── alknet-core (via alknet-tty's re-export; not direct)
├── portable_pty (PTY allocation — the heavy dep, Unix openpty + Windows ConPTY)
├── libc (signal forwarding — REQ-TTY-02, Unix only)
└── tokio (mpsc, oneshot, AsyncRead/AsyncWrite for the pipe case)
```
## The Runner Pattern
The pipe mode (`terminal: None`) is the "runner" generalization the
research identified. 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, no plugin state.
This is functionally identical to GitHub/Gitea Actions runners, just over
alknet's transport instead of HTTP polling. The dispatch project
(`/workspace/@alkdev/dispatch/`) is a reverse runner that currently
requires SSH on the remote end; with `LocalTtyBackend`, the same pattern
works without SSH — the endpoint runs the process directly. SSH becomes
one transport option (for reaching hosts that don't run alknet), not a
requirement.
The runner-specific API surface (job management, log persistence, task
graph integration) is **out of scope for alknet-tty** (OQ-46). alknet-tty
provides the *mechanism* (a framed byte stream for a process + exit
code); the runner *policy* is a downstream crate's job. This spec
commits to preserving the option (`terminal: None` → pipe mode) and not
building runner policy into alknet-tty.
## Constraints
- **PTY mode requires `portable_pty`'s native PTY (Unix `openpty` /
Windows ConPTY).** The blocking→async bridge (three std threads) is
the documented pattern for any blocking-API backend (REQ-TTY-01).
- **Signal forwarding in PTY mode targets the process group (REQ-TTY-02).**
`kill(-pgid, sig)` when the child is a session leader
(`controlling_tty = true`, the default). Disabling the controlling tty
breaks signal forwarding and is not supported for the terminal use
case.
- **Pipe mode does not forward signals to the process group.** `kill(pid,
sig)` reaches the direct child only; grandchildren don't receive it.
A runner that needs process-group signal delivery uses the PTY case.
- **The pipe-buffer deadlock is handled by the concurrent pump.** The
adapter's three-pump driver (`tty-adapter.md`) reads stdout/stderr
concurrently with writing stdin — the POC-validated pattern. No design
decision needed; the spec notes it as a known constraint with a known
solution.
- **`LocalTtyBackend` takes no constructor dependencies.** Unlike
`DockerTtyBackend` (wraps a `bollard::Docker` client) or
`SshTtyBackend` (wraps an SSH session), the local backend is
dependency-free at construction — the `portable_pty` system is
process-global. The assembly layer constructs one `LocalTtyBackend`
and registers it as `"local"`.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Local backend as a sibling crate | [ADR-054](../../decisions/054-local-tty-backend-sibling-crate.md) | `alknet-tty-local` behind a `local` feature re-export; PTY vs pipe per-session |
| `TtyBackend` trait and `TtyHandle` | [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) | The trait this backend implements; REQ-TTY-01 (backends need not be natively async) |
| Wire format | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | The chunk codec + control channel the adapter pumps to/from this backend |
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | The waiter thread's `oneshot::Receiver<i32>` feeds the exit chunk |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-46** (deferred(scope)): Runner API surface.
## References
- [ADR-054](../../decisions/054-local-tty-backend-sibling-crate.md) —
the crate placement decision
- [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) — the
trait this backend implements; REQ-TTY-01 (the blocking-backend
accommodation)
- `docs/research/alknet-tty/phase-0-findings.md` — §"Requirements from
the local-PTY POC" (REQ-TTY-01 and REQ-TTY-02, the load-bearing
constraints this spec records)
- `/workspace/alknet-tty-poc/src/local_pty.rs` — the reference
implementation of the PTY backend (the three-thread bridge, the
`PtyControl` struct, the `kill(-pgid, sig)` signal forwarding)
- `/workspace/alknet-tty-poc/src/session.rs` — the session driver that
consumes this backend's handles (the reference for the adapter's
three-pump driver)
- `/workspace/alknet-tty-poc/tests/signal.rs` — the SIGINT-forwarding
integration test (validates REQ-TTY-02)
- `/workspace/@alkdev/dispatch/` — the reverse-runner prior art
(currently requires SSH; this backend removes that requirement)
- `portable-pty` 0.9 source — the blocking-API constraint and the
`CommandBuilder::set_controlling_tty` default REQ-TTY-02 depends on
- [tty-backend.md](tty-backend.md) — the trait this backend implements
- [tty-adapter.md](tty-adapter.md) — the session driver that consumes
this backend's handles
+291
View File
@@ -0,0 +1,291 @@
---
status: draft
last_updated: 2026-07-06
---
# alknet-tty — Wire Format
The wire protocol for `alknet/tty`: the negotiation frame (JSON
carriage), the raw chunk codec, the control channel, and the sentinels.
The two-carriage model is decided in
[ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md);
this document specifies what an implementer builds.
## What
A `alknet/tty` bidi stream carries one terminal session. The stream has
two phases:
1. **Negotiation (JSON carriage).** A single length-prefixed JSON frame
from the client carrying the terminal parameters, backend selector,
command, and environment.
2. **Raw carriage.** After the negotiation frame, the stream switches to
a chunk format for the life of the session: bidirectional byte pumping
with a 1-byte stream-type multiplexer and a JSON control channel.
The format is the alknet-docker POC's raw chunk format
(`/workspace/alknet-docker-poc/src/raw.rs`, stream_type 0/1/2) extended
with a 4th stream_type (3 = control) and a JSON control message schema,
both validated by the alknet-tty POC (`/workspace/alknet-tty-poc/src/raw.rs`
+ `src/control.rs`). See ADR-052.
## Why
A terminal session is a byte stream with a small control sideband. The
two-carriage model (JSON negotiation, then raw chunks) keeps the call
protocol's JSON-RPC shape for the structured request and switches to
bytes for the body, which is what a terminal actually is. The fixed
channel set (four stream types, no negotiation) is an impoverishment of
SSH's channel multiplexer that is the feature: alknet-tty multiplexes
*one* service (a terminal session) with a fixed channel structure, not
*arbitrary* services, so the demux is a `match`, not a hash lookup. The
full rationale — why not JSON for everything, why fixed channel set
rather than extensible — is in
[ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md)
§Context.
## Architecture
### Phase 1: Negotiation Frame (JSON Carriage)
The client opens a bidi stream (or the server accepts one) and writes a
single length-prefixed JSON frame. The framing is byte-identical to
alknet-call's `FrameFramedReader`/`FrameFramedWriter`
(`crates/alknet-call/src/protocol/wire.rs`): a 4-byte big-endian length
prefix + UTF-8 JSON body. alknet-tty reuses the framing *utility*
(`FrameFramedReader`/`FrameFramedWriter`), not the `EventEnvelope` type —
the negotiation payload is a tty-specific struct, not a `call.requested`
event. See ADR-052 §5 and ADR-003 Amendment 1.
The payload shape:
```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": {}
}
```
Fields:
- `carriage` — `"raw"` for terminal sessions (the only carriage in v1).
Selects the post-negotiation byte format.
- `backend` — the backend selector string (`"local"`, `"docker"`,
`"ssh"`). The adapter dispatches to the registered `TtyBackend` by this
key (ADR-053 §5).
- `tty` — terminal parameters. `null` for the pipe/runner case (no PTY —
ADR-054). `Some` for the PTY case. The `tty` block maps directly to
SSH's `pty_request` parameters (term, cols, rows, pixel_width,
pixel_height, modes) and to docker's `CreateExecOptions { tty: true }`;
a local backend passes it to `portable_pty::PtySystem::openpty`. The
`modes` field is reserved (OQ-44 — default terminal modes suffice for
the current scope).
- `cmd` — command vector (argv[0] + args). Non-empty.
- `cwd` — working directory (`null` = inherit/default).
- `env` — environment variables (empty = inherit).
Backend-specific selector fields ride alongside (e.g., `"container":
"abc123"` for docker). The adapter parses the negotiation frame, extracts
the `backend` string, and passes the backend-specific fields to the
selected backend's `allocate()` as `BackendParams` (ADR-053).
After the negotiation frame, the stream switches to raw chunks. There is
no `call.responded`/`call.completed` — this is not the call protocol.
### Phase 2: Raw Chunk Format
```text
[stream_type: u8][length: u32 be][payload bytes]
```
- **`stream_type`** (1 byte) — the channel:
| 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. See ADR-052 §"Fixed channel set,
not extensible."
- **`length`** (4 bytes, big-endian) — payload length in bytes. Max
16 MiB (`MAX_CHUNK_LEN = 16 * 1024 * 1024`). A chunk larger than 16 MiB
is a protocol error (`ChunkTooLarge`).
- **`payload`** (`length` bytes) — the raw bytes (for data channels) or
UTF-8 JSON (for the control channel).
The codec is `ChunkReader`/`ChunkWriter` (the POC's
`/workspace/alknet-tty-poc/src/raw.rs` generalized):
`ChunkReader::read_chunk()` reads the 5-byte header, validates the
stream_type and length, reads the payload; `ChunkWriter::write_chunk()`
writes the header and payload. See ADR-052.
### Sentinels
Zero-length data chunks are sentinels:
- **Zero-length stdin chunk (stream_type 0, length 0)** — EOF from the
client. The server closes the backend's stdin (`ChildStdin::drop` /
PTY writer close). This is one of two canonical "stdin done" signals;
the other is a `{"type":"eof"}` control chunk — see OQ-47.
- **Zero-length stdout chunk (stream_type 1, length 0)** — "drained"
from the server. The backend's stdout stream ended (process exited,
container output stream ended, SSH channel closed). This is an
implementation sentinel; the deterministic completion signal is the
exit control chunk (ADR-055), not this sentinel — but the drained
sentinel is emitted for symmetry with the docker POC's pattern.
Control chunks are never zero-length (the JSON payload is at least
`{}`).
### Control Channel (stream_type 3)
Control chunks carry a JSON payload tagged by `type`. The schema is the
POC's `ControlMessage` (`/workspace/alknet-tty-poc/src/control.rs`):
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ControlMessage {
Resize {
cols: u16,
rows: u16,
#[serde(default)]
pixel_width: u16,
#[serde(default)]
pixel_height: u16,
},
Signal { name: String },
Eof,
Exit { code: i32 },
}
```
| Direction | Message | Shape | Maps to |
|---|---|---|---|
| client→server | resize | `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}` | SSH `window-change`, docker exec resize, `ioctl(TIOCSWINSZ)` |
| client→server | signal | `{"type":"signal","name":"INT"}` | SSH `signal`, docker exec signal, `kill(-pgid, sig)` (REQ-TTY-02) |
| client→server | eof | `{"type":"eof"}` | SSH channel EOF, docker stdin close, `ChildStdin::drop` |
| server→client | exit | `{"type":"exit","code":0}` | the terminal/completion signal (ADR-055) |
**Signal names.** `name` is an uppercase string. The supported set (per
the POC's `signal_from_name`): `HUP`, `INT`, `QUIT`, `TERM`, `KILL`,
`USR1`, `USR2`, `TSTP`, `CONT`. Unknown names fall back to the backend's
default kill (see [tty-local.md](tty-local.md) REQ-TTY-02 —
`portable_pty`'s `ChildKiller::kill` sends SIGHUP).
**Exit code.** `code` is `i32` (matches `std::process::ExitStatus::code()`;
negative values are signal-terminated, e.g., -9 for SIGKILL on Unix). The
exit chunk is the last control chunk before stream close (ADR-055).
**Extensibility.** The `type` tag is the extension 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 (ADR-052) — adding a
control message type is additive; changing the meaning of an existing
type is not.
### Stdin Closure
Two signals both close the client's stdin:
1. **`{"type":"eof"}` control chunk** (stream_type 3) — explicit,
recommended. Tells the server to close the backend's stdin
(`ChildStdin::drop` / PTY writer close). The client may still want to
receive remaining stdout + the exit code, so the server does not tear
down the session on eof — it just closes stdin and keeps pumping
output.
2. **Zero-length stdin chunk** (stream_type 0, length 0) — the docker
POC's sentinel. Accepted for compatibility with that pattern.
The spec recommends `eof` for explicitness (it's a control message, not
a data-length hack), but both are accepted. See OQ-47.
### Connection vs Stream
A `Connection` (ADR-007) can open/accept multiple bidi streams. One
`alknet/tty` connection hosts multiple terminal sessions — one session
per bidi stream (DP-6, decided in the research). This matches the call
protocol's model (one operation per stream, multiple operations per
connection) and is the natural fit for QUIC's stream multiplexing. A
coordinator opens one connection to an endpoint and launches multiple
sessions (one stream each) for parallel tasks. The `TtyAdapter::handle`
accepts the connection and loops `accept_bi`, dispatching each stream to
a session — see [tty-adapter.md](tty-adapter.md).
## Constraints
- **The wire format is one-way (ADR-052).** The 5-byte header, the fixed
stream_type set (0-3), and the two-carriage sequence are bytes clients
and servers parse. A 5th channel type requires a new ALPN
(`alknet/tty/v2` per ADR-006), not a negotiated addition.
- **No windowing.** The chunk format has no flow-control window; QUIC's
per-stream flow control handles backpressure. See OQ-45 (high-throughput
stdout; expected to suffice; a POC would confirm).
- **No negotiation round-trip.** The client writes the negotiation frame
and starts sending chunks; the server reads the frame and starts
pumping. There is no "the server acknowledges the negotiation before
the client sends data" step — QUIC's stream reliability handles
in-order delivery, and the negotiation frame is small (fits in the
initial flow-control window — ADR-052 assumption 2).
- **Negotiation errors are JSON, not chunks.** 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
length-prefixed framing as the negotiation frame and closes the stream
without entering raw mode. See [tty-adapter.md](tty-adapter.md)
§"Negotiation errors".
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Wire format and two-carriage model | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | `alknet/tty` ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-3; control as JSON |
| `alknet-call` framing utility reuse | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | Negotiation frame uses `FrameFramedReader`/`FrameFramedWriter`, not `EventEnvelope` |
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | `{"type":"exit","code":N}` on stream_type 3; "exit chunk is last" invariant |
| Stdin closure canonical signal | OQ-47 | Either `eof` control chunk or zero-length stdin chunk; `eof` recommended |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-44** (deferred(scope)): Terminal modes.
- **OQ-45** (open, low risk): Flow control for high-throughput stdout.
- **OQ-47** (resolved): Stdin closure canonical signal.
## References
- [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md)
— the wire format decision
- [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) — the
exit-chunk ordering the control channel carries
- [ADR-003](../../decisions/003-crate-decomposition.md) Amendment 1 —
the `alknet-call` framing utility reuse
- `/workspace/alknet-tty-poc/src/raw.rs` — the chunk codec
(`ChunkReader`/`ChunkWriter`, stream_type 0-3) this spec commits
- `/workspace/alknet-tty-poc/src/control.rs` — the JSON control schema
(`ControlMessage` tagged enum) this spec commits
- `/workspace/alknet-docker-poc/src/raw.rs` — the seed codec
(stream_type 0/1/2) the tty POC extended
- `docs/research/alknet-docker/poc-summary.md` — the POC that validated
the raw chunk format and the two-carriage model
- [tty-adapter.md](tty-adapter.md) — the session lifecycle that consumes
this wire format
@@ -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)
+122 -1
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-05
last_updated: 2026-07-06
---
# Open Questions
@@ -1187,3 +1187,124 @@ is a feature extension, not an unmade architecture decision.
(`AccessControl`, `OperationSpec` — `resource_id_path` addition),
[alknet-docker POC summary](../../research/alknet-docker/poc-summary.md)
§"Open Unknowns" #3
## Theme: alknet-tty
### OQ-43: `TtyControl` as a `Clone` trait object
- **Origin**: [crates/tty/tty-backend.md](crates/tty/tty-backend.md)
(the `TtyHandle.control` field shape);
`docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-01 (the trait-
shape open question the local-PTY POC resolved).
- **Status**: resolved
- **Door type**: One-way (the `TtyControl` trait shape is part of the
`TtyBackend` API surface — ADR-053), two-way (the concrete `Clone`
newtype mechanism)
- **Priority**: medium
- **Resolution**: `TtyHandle.control` is
`Option<Box<dyn TtyControl + Send + Unpin + Clone>>`. The `Clone`
trait-object bound is satisfied via an `Arc`-backed `Clone` newtype: a
small struct holding `Arc<dyn TtyControlInner>` where `TtyControlInner:
Send + Sync` has the `resize`/`signal` methods, and the public
`TtyControl` newtype implements `Clone` by cloning the `Arc`. The
local-PTY POC used a concrete `PtyControl` struct (inherently `Clone` —
it held `Arc<Mutex<...>>` fields); the trait-object form generalizes it
so a backend can produce its own control type without the adapter
knowing the concrete shape. The `Clone` constraint exists because the
adapter's control-chunk dispatcher needs to be handed off to the
spawned pump task (the POC's `session::drive_session` clones
`pty.control` for the client→backend pump). See ADR-053 and
`tty-backend.md`.
- **Cross-references**: ADR-053, [tty-backend.md](crates/tty/tty-backend.md),
`/workspace/alknet-tty-poc/src/local_pty.rs` (`PtyControl`)
### OQ-44: Terminal Modes (TTY modes)
- **Origin**: `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-02;
[crates/tty/tty-backend.md](crates/tty/tty-backend.md)
(`TerminalParams.modes` field).
- **Status**: deferred(scope)
- **Door type**: Two-way
- **Priority**: low
- **Blocked on**: a concrete mode-control use case (a deployment that
needs to set echo/raw/canonical/etc. modes on a PTY, beyond the backend's
defaults).
- **Resolution**: Not yet decidable. SSH's `pty_request` carries TTY
modes (echo, raw, canonical, etc.) as a packed bitmask. The common
case is "default terminal modes" — the `modes` field in
`TerminalParams` is `serde_json::Value` (reserved as `{}` in v1) for
when a concrete use case requires mode control. The backends
(`portable_pty`, docker `tty: true`, russh `pty_request`) all have
defaults that work for the common terminal case. Adding mode control
is additive (extend the `modes` JSON shape) and does not break
downstream; the decision is deferred until a use case forces it.
- **Cross-references**: ADR-053, [tty-backend.md](crates/tty/tty-backend.md),
[tty-wire.md](crates/tty/tty-wire.md)
### OQ-45: Flow Control for High-Throughput stdout
- **Origin**: `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-03;
[crates/tty/tty-wire.md](crates/tty/tty-wire.md) (no windowing in the
chunk format).
- **Status**: open (low risk)
- **Door type**: Two-way
- **Priority**: low
- **Resolution**: The chunk format has no windowing — QUIC's per-stream
flow control handles backpressure. This is expected to suffice for
high-throughput stdout (e.g., `cargo build` output): the docker POC's
logs subscription handled multi-line output without issue, and QUIC's
bidi-stream flow control is the established backpressure mechanism. A
POC with real high-volume output (a deliberately large `cargo build`
or `find /` over a high-bandwidth connection) would confirm. If a
flow-control problem surfaces, the reversal is a per-stream
flow-control window in the chunk format (a two-way-door extension to
the wire format, additive — a new control message type for window
updates, not a header change). Not blocking; the default assumption is
QUIC flow control suffices.
- **Cross-references**: ADR-052, [tty-wire.md](crates/tty/tty-wire.md),
[tty-adapter.md](crates/tty/tty-adapter.md)
### OQ-46: Runner API Surface
- **Origin**: `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-05;
[crates/tty/tty-local.md](crates/tty/tty-local.md) (the runner pattern
in pipe mode).
- **Status**: deferred(scope)
- **Door type**: Two-way
- **Priority**: low
- **Blocked on**: a concrete runner-policy use case that forces the API
surface (job management, log persistence, task graph integration).
- **Resolution**: Not yet decidable. The runner *mechanism* (pipe mode —
`TtyParams.terminal = None` → `std::process::Command` with piped stdio
→ framed byte stream + exit code) is in alknet-tty (ADR-054). The
runner *policy* (job management, log persistence, task graph
integration) is a downstream crate's job, not in scope for alknet-tty.
This OQ tracks whether a runner-policy crate (e.g., an
`alknet-runner` crate that builds on the pipe mode + the wire format
to provide job management) is needed, and what its API surface would
be. The decision is deferred until a concrete use case forces it; the
mechanism is preserved regardless. See ADR-054 and `tty-local.md`
§"The Runner Pattern".
- **Cross-references**: ADR-054, [tty-local.md](crates/tty/tty-local.md)
### OQ-47: Stdin Closure Canonical Signal
- **Origin**: `docs/research/alknet-docker/poc-summary.md` §"Open
Unknowns" #4 (stdin closure semantics for raw carriage);
[crates/tty/tty-wire.md](crates/tty/tty-wire.md) §"Stdin Closure".
- **Status**: resolved
- **Door type**: Two-way
- **Priority**: low
- **Resolution**: Either a zero-length stdin chunk (stream_type 0,
length 0 — the docker POC's sentinel) or a `{"type":"eof"}`
control chunk (stream_type 3 — the tty POC's explicit signal) closes
the client's stdin. Both are accepted by the adapter; the spec
recommends `eof` for explicitness (it's a control message, not a
data-length hack). The adapter handles both identically: signal EOF
to the backend's stdin (`ChildStdin::drop` / PTY writer close) and
keep pumping stdout until the exit resolves — the client may still
want to receive remaining output + the exit code. A third path
(client closes the write half of the bidi stream) is also accepted
and handled the same way. See ADR-052 and `tty-wire.md`.
- **Cross-references**: ADR-052, [tty-wire.md](crates/tty/tty-wire.md),
[tty-adapter.md](crates/tty/tty-adapter.md)