docs(arch): alknet-tty Phase 1 specs — wire format, backend trait, local backend, exit-chunk ordering
Grounded in the alknet-docker POC (seed codec) and the alknet-tty POC (2026-07-05, validated the control channel, the local-PTY blocking→async bridge, and the signal-delivery contract). Four ADRs: - ADR-052: wire format — alknet/tty ALPN, two-carriage (JSON negotiation then raw chunks), fixed channel set 0-3, control as JSON, negotiation- error framing disambiguation - ADR-053: TtyBackend trait + TtyHandle — the backend inversion point; exit_code as a Future; REQ-TTY-01 (backends need not be natively async) - ADR-054: local backend as alknet-tty-local sibling crate behind a feature re-export; PTY vs pipe per-session; runner pattern preserved - ADR-055: exit code on a stream_type 3 control chunk; "exit chunk is last" invariant; adapter owns the ordering Five component specs (crates/tty/): README, overview, tty-wire, tty- backend, tty-adapter, tty-local. The docker/SSH backends are future crates (out of scope); the trait shape is committed so they can be built against it. Five OQs (OQ-43…47): two resolved (TtyControl Clone, stdin closure), two deferred(scope) (terminal modes, runner API surface), one open low-risk (flow control). Zero critical issues from a general-subagent architecture review; warnings addressed (inline rationale trimmed to ADR refs, TtyError and StdinCmd defined, framing disambiguation documented, missing ADRs added to index tables).
This commit is contained in:
1 parent
14c8340380
commit
e4e6ccfb14
12 files changed
+2892
-1
No files matched your search
@@ -0,0 +1,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)
|
||||
@@ -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)
|
||||
@@ -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(¶ms)`, 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user