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

+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