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
@@ -22,6 +22,8 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
|
||||
|
||||
**Next step**: The storage/repo-pattern ADRs (030–033) are accepted and amend the core and call specs. The next implementation phase is the ADR-029 migration (peer-keyed overlays, `PeerRef` routing, retire `remote_safe`/`trusted_peer`) with the ADR-030 `PeerEntry` change and the ADR-032 `forwarded_for` field folded in — the `OperationContext`, `from_call` handler, and `AuthPolicy` are all under edit, making this the cheapest window. After that: alknet-http implementation (specs drafted; `h3`/WebTransport deferred per ADR-044, browser bidirectional path uses WebSocket), which consumes the `CredentialStore` trait and the `OperationAdapter` contract. The alknet-ssh crate (the other post-core crate, specced in parallel) proceeds independently — it depends on `alknet-core`, not `alknet-call`.
|
||||
|
||||
**alknet-tty specs drafted.** The alknet-tty crate (terminal session protocol handler — `ProtocolHandler` on `alknet/tty`, two-carriage wire format with a raw chunk codec + JSON control channel, backend-agnostic via a `TtyBackend` trait) now has architecture specs: [crates/tty/](crates/tty/) (overview, tty-wire, tty-backend, tty-adapter, tty-local) and four ADRs — [ADR-052](decisions/052-alknet-tty-wire-format-and-two-carriage.md) (wire format: `alknet/tty` ALPN, JSON negotiation frame then raw chunks, fixed channel set 0-3, control as JSON), [ADR-053](decisions/053-ttybackend-trait-and-ttyhandle.md) (`TtyBackend` trait + `TtyHandle`; `exit_code` as a `Future`; backends need not be natively async — REQ-TTY-01 from the local-PTY POC), [ADR-054](decisions/054-local-tty-backend-sibling-crate.md) (`alknet-tty-local` sibling crate behind a `local` feature re-export; PTY vs pipe per-session; the runner pattern preserved), [ADR-055](decisions/055-exit-code-on-control-chunk.md) (exit code on a stream_type 3 control chunk; "exit chunk is last" invariant; adapter owns the ordering). The specs are grounded in the alknet-docker POC (`docs/research/alknet-docker/poc-summary.md`) and the alknet-tty POC (`/workspace/alknet-tty-poc/`, built 2026-07-05), which validated the wire format, the control channel, the local-PTY bridge, and the signal-delivery contract (REQ-TTY-02). The docker and SSH backends are future crates that implement the `TtyBackend` trait — out of scope for this spec set, but the trait shape is committed so they can be built against it.
|
||||
|
||||
## Architecture Documents
|
||||
|
||||
| Document | Status | Description |
|
||||
@@ -44,6 +46,12 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
|
||||
| [crates/http/http-adapters.md](crates/http/http-adapters.md) | draft | from_openapi (reqwest; JSON + YAML input per ADR-051) and to_openapi (projection); no-env-vars injection point |
|
||||
| [crates/http/http-mcp.md](crates/http/http-mcp.md) | draft | from_mcp / to_mcp (feature-gated), streamable-HTTP-only, stdio exclusion |
|
||||
| [crates/http/webtransport.md](crates/http/webtransport.md) | deferred | h3/WebTransport handler — deferred per ADR-044; browser bidirectional path uses WebSocket (see http-server.md). Spec kept intact for revival. |
|
||||
| [crates/tty/README.md](crates/tty/README.md) | draft | alknet-tty crate index |
|
||||
| [crates/tty/overview.md](crates/tty/overview.md) | draft | Crate purpose, two-carriage model, dependencies, ALPN, backend location map, feature gates |
|
||||
| [crates/tty/tty-wire.md](crates/tty/tty-wire.md) | draft | Wire format: negotiation frame (JSON carriage), raw chunk codec (`[stream_type: u8][length: u32 be][payload]`), control channel (stream_type 3, JSON control messages), sentinels |
|
||||
| [crates/tty/tty-backend.md](crates/tty/tty-backend.md) | draft | `TtyBackend` trait, `TtyParams`, `TtyHandle`, `TtyControl` — the backend inversion point. Carries REQ-TTY-01 (backends need not be natively async) |
|
||||
| [crates/tty/tty-adapter.md](crates/tty/tty-adapter.md) | draft | `TtyAdapter` (`ProtocolHandler` on `alknet/tty`): session lifecycle, three-pump bidirectional driver, negotiation errors, exit-chunk ordering (ADR-055), access control |
|
||||
| [crates/tty/tty-local.md](crates/tty/tty-local.md) | draft | `alknet-tty-local` sibling crate: `LocalTtyBackend` via `portable_pty` (PTY) and `std::process::Command` (pipe/runner). Carries REQ-TTY-02 (signal forwarding to the process group) |
|
||||
| [crates/vault/README.md](crates/vault/README.md) | stable | alknet-vault crate index |
|
||||
| [crates/vault/mnemonic-derivation.md](crates/vault/mnemonic-derivation.md) | stable | BIP39, SLIP-0010, BIP-0032, derivation paths, key types |
|
||||
| [crates/vault/encryption.md](crates/vault/encryption.md) | stable | AES-256-GCM, EncryptedData, key versioning, salt (Phase B reserved) |
|
||||
@@ -105,6 +113,10 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
|
||||
| [049](decisions/049-streaming-handler-for-subscriptions.md) | Streaming Handler for Subscription Operations | Accepted |
|
||||
| [050](decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership for Runtime-Spawned Resources | Accepted |
|
||||
| [051](decisions/051-yaml-input-for-from-openapi.md) | YAML Input Format for from_openapi | Accepted |
|
||||
| [052](decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format and Two-Carriage Model | Accepted |
|
||||
| [053](decisions/053-ttybackend-trait-and-ttyhandle.md) | TtyBackend Trait and TtyHandle — the Backend Inversion Point | Accepted |
|
||||
| [054](decisions/054-local-tty-backend-sibling-crate.md) | Local TTY Backend as a Sibling Crate (`alknet-tty-local`) | Accepted |
|
||||
| [055](decisions/055-exit-code-on-control-chunk.md) | Exit Code on a Control Chunk (the Last Chunk Before Stream Close) | Accepted |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -160,9 +172,18 @@ See [open-questions.md](open-questions.md) for the full tracker.
|
||||
**Resolved (blocks lifted, ADR drafting can proceed):**
|
||||
- **OQ-42**: Dynamic resource ownership for runtime-spawned resources — **resolved**. Storage reuses the repo/adapter pattern (ADR-033, fourth instance); integration is Option 2 (`AccessControl::check` consults an ownership provider directly, `OperationSpec` gains `resource_id_path`); access pattern is proxy-only (spawner owns, proxy to share, teardown revokes; no grant mechanism in core — "poking holes" is a downstream-app concern, additive if ever needed). Four edge specifics pinned: `list` = scope-gate + result-filter; teardown = automatic, handler-driven; fleet = per-node ownership, downstream app tracks "who is this for"; composition = two orthogonal checks, ADR-015/022 unchanged. Ready for ADR drafting; dependent crate specs (docker, tty, runner, fleet) can declare their `AccessControl` shapes against this model.
|
||||
|
||||
**Resolved by the alknet-tty spec set (ADR-052–055):**
|
||||
- **OQ-43**: `TtyControl` as a `Clone` trait object — **resolved**. `Arc`-backed `Clone` newtype; confirmed by the local-PTY POC's concrete `PtyControl`.
|
||||
- **OQ-47**: Stdin closure canonical signal — **resolved**. Either a zero-length stdin chunk or a `{"type":"eof"}` control chunk; both accepted; `eof` recommended.
|
||||
|
||||
**Deferred (not active):**
|
||||
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
|
||||
- **OQ-10**: Git adapter scope — start with smart protocol, add ERC721 later
|
||||
- **OQ-44**: Terminal modes (TTY modes) — `TerminalParams.modes` reserved; default terminal modes suffice for the current scope; blocked on a concrete mode-control use case.
|
||||
- **OQ-46**: Runner API surface — the runner mechanism (pipe mode) is in alknet-tty; runner policy (job management, log persistence, task graph) is a downstream crate, not in scope; blocked on a concrete runner-policy use case.
|
||||
|
||||
**Open (low risk, not blocking):**
|
||||
- **OQ-45**: Flow control for high-throughput stdout — QUIC per-stream flow control is expected to suffice; a high-volume POC would confirm.
|
||||
|
||||
## Document Lifecycle
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,324 @@
|
||||
# ADR-052: alknet-tty Wire Format and Two-Carriage Model
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The alknet-docker POC (`docs/research/alknet-docker/poc-summary.md`)
|
||||
validated that interactive attach — bidirectional byte pumping over a framed
|
||||
bidi stream with a 1-byte stream-type multiplexer — is the same problem
|
||||
regardless of whether the backend is `bollard::attach_container()` or
|
||||
russh's `pty_request`. The POC's raw chunk format
|
||||
(`[stream_type: u8][length: u32 be][payload bytes]`, stream_type 0=stdin,
|
||||
1=stdout, 2=stderr) is a deliberately impoverished version of SSH's channel
|
||||
multiplexer: fixed set of channel types, no negotiation, no open/close
|
||||
handshake, no windowing (QUIC provides flow control on the bidi stream).
|
||||
That impoverishment is the feature — a terminal session needs exactly those
|
||||
channels and no more.
|
||||
|
||||
The alknet-tty POC (`/workspace/alknet-tty-poc`, built 2026-07-05) extended
|
||||
that format with a 4th stream_type (3 = control) carrying JSON control
|
||||
messages (resize, signal, eof, exit) and validated the full round-trip
|
||||
against a real `portable_pty` PTY: negotiate → PTY alloc → bidirectional
|
||||
echo → mid-session resize → EOF → exit code, plus SIGINT forwarding to a
|
||||
child process group. The wire format this ADR commits is the POC's
|
||||
`src/raw.rs` + `src/control.rs`, generalized from one backend to the
|
||||
backend-agnostic crate.
|
||||
|
||||
Three load-bearing questions are decided here:
|
||||
|
||||
1. **Separate ALPN with raw carriage, not call-protocol operations.** A
|
||||
terminal session could be modeled as a call-protocol `Subscription`
|
||||
operation (`tty/open`) streaming `call.responded` events. That is
|
||||
rejected: JSON-encoding every byte chunk is wasteful (base64 for binary,
|
||||
per-chunk `EventEnvelope` overhead) and lossy (a TTY streams partial
|
||||
bytes with no message boundary that maps to a JSON object). The
|
||||
two-carriage model — a single JSON negotiation frame, then raw chunks —
|
||||
keeps the call protocol's JSON-RPC shape for the *request* and switches
|
||||
to bytes for the *body*, which is the part that is actually bytes. This
|
||||
is the pattern the docker POC validated and the SSH research
|
||||
(`docs/research/alknet-ssh/phase-0-findings.md`) independently arrived
|
||||
at for PTY.
|
||||
|
||||
2. **Fixed channel set, not extensible.** SSH multiplexes arbitrary
|
||||
services (forwarding, SFTP, agent, X11) over `ChannelId(u32)` with
|
||||
string-named types negotiated per channel. alknet-tty multiplexes one
|
||||
service — a terminal session — with a fixed `u8` set and no negotiation.
|
||||
Adding a 5th channel type is a wire-format change (one-way door). The
|
||||
ALPN model handles extensibility at the protocol level: a genuinely new
|
||||
sideband (e.g., file transfer alongside the terminal) is a different
|
||||
ALPN, not a 5th tty channel type. A new ALPN is cheap; a wire-format
|
||||
change is not.
|
||||
|
||||
3. **Control messages as JSON, not binary.** A binary control format
|
||||
(`[control_type: u8][params...]`) would be faster but harder to extend
|
||||
and inconsistent with the negotiation layer. Control messages are rare
|
||||
(resize on window drag, signal on Ctrl-C, one eof, one exit per
|
||||
session) — serialization cost is negligible against the data chunks. If
|
||||
a hot control path appears, a binary `control_type` can be added without
|
||||
breaking the chunk format (additive within the control channel).
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. alknet-tty is a `ProtocolHandler` on ALPN `alknet/tty`
|
||||
|
||||
`alknet/tty` is a custom ALPN per the ADR-006 `alknet/<name>` convention.
|
||||
The `TtyAdapter` implements `ProtocolHandler` (ADR-002, revised by ADR-007
|
||||
to receive a `Connection`). The handler owns the entire connection
|
||||
lifecycle and accepts one bidi stream per terminal session. This is a
|
||||
separate ALPN, not a set of operations in the call protocol's
|
||||
`OperationRegistry` — the raw-carriage byte pump is not a `StreamingHandler`
|
||||
(ADR-049); it is its own wire format after a single JSON negotiation frame.
|
||||
|
||||
### 2. Two-carriage model: JSON negotiation, then raw chunks
|
||||
|
||||
The bidi stream has two phases:
|
||||
|
||||
- **Negotiation (JSON carriage).** The client opens a bidi stream and
|
||||
writes a single length-prefixed JSON frame — the same 4-byte
|
||||
big-endian length prefix + UTF-8 JSON body framing as alknet-call's
|
||||
`FrameFramedReader`/`FrameFramedWriter`
|
||||
(`crates/alknet-call/src/protocol/wire.rs`). The frame carries the
|
||||
terminal parameters and backend selector the server needs to allocate
|
||||
the session:
|
||||
|
||||
```json
|
||||
{
|
||||
"carriage": "raw",
|
||||
"backend": "local",
|
||||
"tty": {
|
||||
"term": "xterm-256color",
|
||||
"cols": 80,
|
||||
"rows": 24,
|
||||
"pixel_width": 0,
|
||||
"pixel_height": 0,
|
||||
"modes": {}
|
||||
},
|
||||
"cmd": ["/bin/bash"],
|
||||
"cwd": null,
|
||||
"env": {}
|
||||
}
|
||||
```
|
||||
|
||||
The `carriage` field is `"raw"` for terminal sessions (the only
|
||||
carriage in v1). The `tty` block is `null` for the pipe/runner case
|
||||
(no PTY — see ADR-054). The `backend` field selects the `TtyBackend`
|
||||
(ADR-053); backend-specific fields (e.g., `container` for docker)
|
||||
ride alongside.
|
||||
|
||||
- **Raw carriage.** After the negotiation frame, the stream switches to
|
||||
the chunk format below for the life of the session. There is no
|
||||
`call.responded`/`call.completed` — this is not the call protocol.
|
||||
|
||||
### 3. Chunk format
|
||||
|
||||
```text
|
||||
[stream_type: u8][length: u32 be][payload bytes]
|
||||
```
|
||||
|
||||
- `stream_type` — fixed set, no negotiation:
|
||||
|
||||
| stream_type | channel | direction | payload |
|
||||
|---|---|---|---|
|
||||
| 0 | data-in (stdin) | client→server | raw bytes |
|
||||
| 1 | data-out (stdout) | server→client | raw bytes |
|
||||
| 2 | data-err (stderr) | server→client | raw bytes |
|
||||
| 3 | control | bidirectional | JSON control message |
|
||||
|
||||
`stream_type > 3` is a protocol error (`InvalidStreamType`). There is
|
||||
no extension escape hatch in the byte — a 5th channel is a wire-format
|
||||
change requiring a new ALPN (`alknet/tty/v2` per ADR-006), not a
|
||||
negotiated addition to this format.
|
||||
|
||||
- `length` — payload length in bytes, u32 big-endian, max 16 MiB. A
|
||||
chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). The
|
||||
16 MiB bound accommodates large paste operations and large `env`
|
||||
blocks while bounding memory per chunk (a reader can allocate a
|
||||
bounded buffer up front); it mirrors the docker POC's limit.
|
||||
|
||||
- Zero-length data chunks are sentinels: a zero-length stdin chunk is
|
||||
EOF from the client; a zero-length stdout chunk is "drained" from the
|
||||
server (output stream ended). Control chunks are never zero-length
|
||||
(the JSON payload is at least `{}`).
|
||||
|
||||
### 4. Control channel (stream_type 3) carries JSON control messages
|
||||
|
||||
Control chunks carry a small JSON payload, tagged by `type`:
|
||||
|
||||
| Direction | Message | Shape |
|
||||
|---|---|---|
|
||||
| client→server | resize | `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}` |
|
||||
| client→server | signal | `{"type":"signal","name":"INT"}` |
|
||||
| client→server | eof | `{"type":"eof"}` |
|
||||
| server→client | exit | `{"type":"exit","code":0}` |
|
||||
|
||||
- **resize** — window-size change. Maps to SSH `window-change`, docker
|
||||
exec resize, or `ioctl(TIOCSWINSZ)` on a local PTY.
|
||||
- **signal** — signal forwarding. `name` is an uppercase string
|
||||
(`"INT"`, `"TERM"`, `"HUP"`, `"QUIT"`, `"TSTP"`, `"CONT"`, `"KILL"`,
|
||||
`"USR1"`, `"USR2"`). Unknown names fall back to the backend's default
|
||||
kill (see ADR-053 / `tty-local.md` REQ-TTY-02).
|
||||
- **eof** — client signals no more stdin. Maps to SSH channel EOF,
|
||||
docker stdin close, or `ChildStdin::drop` / PTY writer close. This is
|
||||
one of two canonical "stdin done" signals; the other is a zero-length
|
||||
stdin chunk (the docker POC's sentinel). Both are accepted as
|
||||
equivalent — `eof` is recommended for explicitness (it's a control
|
||||
message, not a data-length hack); the zero-length stdin chunk is kept
|
||||
for compatibility with the docker POC's pattern. See OQ-47.
|
||||
- **exit** — server signals process exit with code. This is the last
|
||||
control chunk before stream close (see ADR-055).
|
||||
|
||||
The `type` tag is the extensibility seam: new control message types are
|
||||
added by extending the tagged enum. Unknown `type` values are ignored
|
||||
(not a protocol error) so that a newer client sending a control message
|
||||
an older server doesn't recognize degrades gracefully rather than
|
||||
tearing down the session. This is a two-way-door extension point within
|
||||
the one-way-door wire format — adding a control message type is
|
||||
additive; changing the chunk header is not.
|
||||
|
||||
### 5. Negotiation errors use the JSON framing, not the raw chunk format
|
||||
|
||||
If the server cannot allocate the session (unknown backend, PTY
|
||||
allocation failed, the command is invalid), it sends a JSON error
|
||||
response in the same 4-byte length-prefixed framing as the negotiation
|
||||
frame and closes the stream without entering raw mode. The error
|
||||
response shape is `{"error":"<code>","message":"..."}` (see
|
||||
[tty-adapter.md](../crates/tty/tty-adapter.md) §"Negotiation errors" for
|
||||
the codes). This is not `call.error` — this is not the call protocol;
|
||||
the error is a JSON response in the negotiation framing, and the stream
|
||||
closes after it.
|
||||
|
||||
**Framing disambiguation (success vs error).** Both a successful
|
||||
allocation (raw chunks) and a failed allocation (JSON error frame) begin
|
||||
with bytes the client must read before knowing which framing applies.
|
||||
The disambiguation is by the first byte: a JSON error frame's 4-byte
|
||||
big-endian length prefix always starts with `0x00` (error frames are
|
||||
small — under 16 MiB, so the high byte is zero), while a raw chunk's
|
||||
first byte is a `stream_type` in `{0, 1, 2, 3}`. A stream_type of `0`
|
||||
(stdin from server) is invalid — the server never sends stdin chunks —
|
||||
so the client distinguishes: read the first byte; if it is `0x00`,
|
||||
interpret the next 4 bytes as a big-endian length prefix and read that
|
||||
many bytes as a JSON error frame; otherwise interpret it as a
|
||||
`stream_type` byte and continue reading the raw chunk header. This is a
|
||||
one-way-door wire-format invariant: error frames use the negotiation
|
||||
framing (length prefix), success uses the raw chunk framing
|
||||
(stream_type byte first); the `0x00`-as-length-prefix vs
|
||||
`0x00`-as-invalid-stream_type disambiguation is what makes the two
|
||||
distinguishable on the wire.
|
||||
|
||||
### 6. Negotiation frame reuses alknet-call's framing, not its types
|
||||
|
||||
The 4-byte length prefix + JSON body is byte-identical to
|
||||
`alknet_call::protocol::wire::FrameFramedReader`/`FrameFramedWriter`.
|
||||
alknet-tty reuses the *framing utility* (the length-prefix read/write),
|
||||
not the `EventEnvelope` type — the negotiation payload is a
|
||||
tty-specific struct (`NegotiateRequest`), not a `call.requested` event.
|
||||
This keeps alknet-tty's dependency on alknet-call limited to the framing
|
||||
codec, not the call protocol's type system. (See ADR-053 for the
|
||||
dependency edge and ADR-003 Amendment 1 for the protocol-foundation
|
||||
exception.)
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- The wire format is POC-validated twice (docker POC for stream_type 0/1/2
|
||||
+ bidirectional pump; tty POC for stream_type 3 + control messages +
|
||||
local PTY). No new wire-format invention in Phase 1.
|
||||
- The fixed channel set is a `match`, not a hash lookup — fast on the hot
|
||||
path where every chunk is data.
|
||||
- The two-carriage model keeps the call protocol's JSON-RPC shape for the
|
||||
structured request while letting the body be raw bytes, which is what a
|
||||
terminal actually is. No base64, no per-chunk `EventEnvelope` overhead.
|
||||
- Control messages as JSON are consistent with the negotiation layer and
|
||||
trivially extensible (tagged enum), at negligible cost for rare messages.
|
||||
- A separate ALPN composes with the ALPN dispatch model (ADR-001/006):
|
||||
the endpoint routes `alknet/tty` to the `TtyAdapter`; the call protocol
|
||||
is unaffected. Browser terminals (xterm.js over WebTransport, when
|
||||
WebTransport revives) connect to `alknet/tty` directly without
|
||||
implementing SSH or the call protocol.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- A 5th channel type is a wire-format change (one-way door). The
|
||||
justification is that the use cases are bounded — a terminal session
|
||||
has stdin, stdout, stderr, and control. New sideband needs are
|
||||
different ALPNs, not 5th channels. If this proves wrong, the reversal
|
||||
is a new ALPN string (`alknet/tty/v2`), which coexists with the old one
|
||||
rather than replacing it — but every client and server implementing
|
||||
the old format would need updating to speak the new one.
|
||||
- Control as JSON means a `serde_json` deserialize per control chunk.
|
||||
Control chunks are rare (one per resize, one per signal, one eof, one
|
||||
exit), so this is negligible. A hot control path would warrant a
|
||||
binary format — the `type`-tagged enum leaves that door open without a
|
||||
wire-format change.
|
||||
- The negotiation frame is a custom JSON shape, not a `call.requested`
|
||||
event, so a client library can't reuse its call-protocol client to open
|
||||
a tty session — it speaks the tty wire format directly. This is
|
||||
intentional (the tty session is not a call-protocol operation) but
|
||||
means the tty client is a separate small client, not a `CallClient`
|
||||
method.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way.** The chunk header (5 bytes: 1 type + 4 length), the fixed
|
||||
stream_type set (0-3), and the two-carriage sequence (JSON frame → raw
|
||||
chunks) are bytes clients and servers parse. Changing any of them breaks
|
||||
every client and server implementing the format. The reversal path is a
|
||||
new ALPN (`alknet/tty/v2`), which coexists rather than replaces — but
|
||||
the cost of migrating every consumer is the one-way-door cost.
|
||||
|
||||
The control message `type` enum is a two-way-door extension point within
|
||||
the one-way wire format: adding a control message type is additive
|
||||
(unknown types are ignored), changing the meaning of an existing type is
|
||||
not.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **QUIC per-stream flow control is sufficient for terminal output.**
|
||||
The chunk format has no windowing — QUIC's bidi-stream flow control
|
||||
handles backpressure. High-throughput stdout (e.g., `cargo build`
|
||||
output) is expected to work; a concrete high-volume use case that
|
||||
surfaces a flow-control problem would require revisiting. See OQ-45.
|
||||
|
||||
2. **The negotiation frame fits in one chunk of the underlying stream's
|
||||
initial flow-control window.** The frame is small (terminal params +
|
||||
command + env, typically < 4 KiB). QUIC's default initial bidi-window
|
||||
(quinn defaults are tens of KiB) accommodates it without a
|
||||
flow-control round-trip. A pathological `env` block larger than the
|
||||
window would stall until the window opens; the 16 MiB chunk limit is
|
||||
the hard cap.
|
||||
|
||||
3. **Control messages are rare enough that JSON serialization cost is
|
||||
negligible.** Validated by the tty POC: resize on window drag, one
|
||||
signal per Ctrl-C, one eof, one exit per session. No measurable cost
|
||||
observed.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-tty/phase-0-findings.md` — Phase 0 research; the
|
||||
wire format section is the seed of this ADR
|
||||
- `docs/research/alknet-docker/poc-summary.md` — the POC that validated
|
||||
the raw chunk format (stream_type 0/1/2) and the two-carriage model
|
||||
- `/workspace/alknet-tty-poc/src/raw.rs` — the chunk codec
|
||||
(`ChunkReader`/`ChunkWriter`, stream_type 0-3) this ADR commits
|
||||
- `/workspace/alknet-tty-poc/src/control.rs` — the JSON control schema
|
||||
(`ControlMessage` tagged enum) this ADR commits
|
||||
- `/workspace/alknet-docker-poc/src/raw.rs` — the seed codec
|
||||
(stream_type 0/1/2) the tty POC extended
|
||||
- [ADR-001](001-alpn-protocol-dispatch.md) — ALPN-based dispatch
|
||||
- [ADR-002](002-protocol-handler-trait.md) — ProtocolHandler trait
|
||||
- [ADR-006](006-alpn-convention-and-connection-model.md) — `alknet/<name>`
|
||||
ALPN convention; one ALPN per connection; new ALPN for incompatible
|
||||
versions
|
||||
- [ADR-007](007-bistream-type-definition.md) — handler receives a
|
||||
`Connection`, accepts bidi streams
|
||||
- [ADR-003](003-crate-decomposition.md) Amendment 1 — alknet-call as
|
||||
protocol-foundation crate (framing utility reuse)
|
||||
- [ADR-012](012-call-protocol-stream-model.md) — the call protocol's
|
||||
stream model (which tty is *not* using for the body, by design)
|
||||
- [ADR-049](049-streaming-handler-for-subscriptions.md) — the
|
||||
`StreamingHandler` path tty explicitly does not use for the byte body
|
||||
- Spec: [crates/tty/tty-wire.md](../crates/tty/tty-wire.md)
|
||||
@@ -0,0 +1,319 @@
|
||||
# ADR-053: TtyBackend Trait and TtyHandle — the Backend Inversion Point
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
alknet-tty's wire format (ADR-052) is backend-agnostic — the chunk codec
|
||||
pumps bytes and JSON control messages without knowing whether the backend
|
||||
is a docker container, an SSH session channel, or a local process. The
|
||||
question this ADR answers: **what is the seam between the wire-format
|
||||
adapter and the backends?**
|
||||
|
||||
The alknet-tty research (`docs/research/alknet-tty/phase-0-findings.md`)
|
||||
identified the `TtyBackend` trait as the inversion point. The guiding
|
||||
insight:
|
||||
|
||||
> A terminal session is not an SSH concern, or a Docker concern — it is a
|
||||
> terminal concern. SSH and Docker are just two backends that can allocate
|
||||
> a PTY.
|
||||
|
||||
The trait is what makes that insight load-bearing: alknet-tty defines the
|
||||
trait and the wire-format adapter; the backend crates (alknet-docker,
|
||||
alknet-ssh, alknet-tty-local) implement the trait. This preserves ADR-003's
|
||||
no-handler-depends-on-another-handler rule (amended by ADR-003 Amendment 1
|
||||
for protocol-foundation crates): alknet-tty depends on alknet-core;
|
||||
backend crates depend on alknet-tty for the trait; alknet-tty does not
|
||||
depend on any backend.
|
||||
|
||||
### What the local-PTY POC discovered about the trait shape
|
||||
|
||||
The Phase 0 POC (`/workspace/alknet-tty-poc`) was built *before* this ADR
|
||||
specifically to discover constraints the trait sketch would have missed by
|
||||
reading docs alone. Two requirements fell out of it (recorded as
|
||||
REQ-TTY-01 and REQ-TTY-02 in the findings doc):
|
||||
|
||||
- **REQ-TTY-01: backends are not required to be natively async.**
|
||||
`portable_pty` is a blocking `std::io::{Read, Write}` API with a blocking
|
||||
`Child::wait()`. The POC bridges it to async via three dedicated std
|
||||
threads (reader, writer, waiter) feeding tokio mpsc/oneshot channels —
|
||||
the same pattern wezterm (portable_pty's primary consumer) uses. The
|
||||
trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`,
|
||||
`BoxFuture`) are the *adapter's* contract; a backend may expose blocking
|
||||
handles internally and bridge them. The bridging pattern is a
|
||||
documented, supported implementation strategy, not a workaround.
|
||||
|
||||
- **`exit_code` is a `Future` the adapter awaits, not a method on
|
||||
`TtyHandle`.** A `oneshot::Receiver<i32>` (or any
|
||||
`BoxFuture<'static, i32>`) lets the adapter `select` between exit and
|
||||
stream-close without coupling to the handle's other fields. The local
|
||||
backend's waiter thread produces exactly this shape for free.
|
||||
|
||||
The POC's `LocalPty` struct (`src/local_pty.rs`) is the reference
|
||||
implementation of what a backend produces: `stdout: mpsc::Receiver<Bytes>`,
|
||||
`stdin: mpsc::Sender<StdinCmd>`, `control: PtyControl` (Clone), `exit_code:
|
||||
oneshot::Receiver<i32>`. The trait shape below generalizes this.
|
||||
|
||||
### What the local-PTY POC did not resolve
|
||||
|
||||
The POC used a separate cloneable `PtyControl` struct for resize/signal,
|
||||
not a `Box<dyn TtyControl + Send + Unpin>` trait object. The research
|
||||
noted this worked cleanly because the control-chunk dispatcher needs to be
|
||||
`Clone` to hand off to the spawned pump task. Phase 1 confirms the `control`
|
||||
field as a separate `Clone` trait object — see OQ-43 for the confirmation
|
||||
and the `Clone` constraint rationale.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. `TtyBackend` trait
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait TtyBackend: Send + Sync {
|
||||
/// Allocate a terminal/process session and return the handles the
|
||||
/// adapter pumps. The `backend` field of the negotiation frame
|
||||
/// (ADR-052) selects which registered backend's `allocate` is called.
|
||||
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError>;
|
||||
}
|
||||
```
|
||||
|
||||
The adapter holds a `HashMap<String, Arc<dyn TtyBackend>>` keyed by the
|
||||
negotiation frame's `backend` string (`"local"`, `"docker"`, `"ssh"`).
|
||||
The assembly layer registers backends at startup; the adapter dispatches
|
||||
by the `backend` field. A backend is the *thing that allocates a session*;
|
||||
the wire-format pump is backend-agnostic.
|
||||
|
||||
### 2. `TtyParams` — the allocation request
|
||||
|
||||
```rust
|
||||
pub struct TtyParams {
|
||||
/// Terminal parameters. `None` = pipe mode (no PTY — the runner case,
|
||||
/// ADR-054). `Some` = allocate a PTY with these dimensions.
|
||||
pub terminal: Option<TerminalParams>,
|
||||
/// Command vector (argv[0] + args).
|
||||
pub cmd: Vec<String>,
|
||||
/// Working directory (backend-specific; None = inherit/default).
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// Environment variables (backend-specific; empty = inherit).
|
||||
pub env: HashMap<String, String>,
|
||||
/// Backend-specific selector fields from the negotiation frame
|
||||
/// (e.g., `container` for docker, `SshChannelRef` for ssh). The
|
||||
/// adapter parses the negotiation frame's backend-specific fields
|
||||
/// and passes them here. The exact shape is backend-defined; the
|
||||
/// adapter does not interpret it.
|
||||
pub backend_params: BackendParams,
|
||||
}
|
||||
|
||||
pub struct TerminalParams {
|
||||
pub term: Option<String>, // e.g., "xterm-256color"; None = backend default
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
pub pixel_width: u16,
|
||||
pub pixel_height: u16,
|
||||
pub modes: serde_json::Value, // reserved — see OQ-44
|
||||
}
|
||||
|
||||
pub enum BackendParams {
|
||||
Local,
|
||||
Docker { container: String },
|
||||
Ssh { channel: SshChannelRef },
|
||||
// Extensible: a backend crate may add variants. (See Consequences.)
|
||||
}
|
||||
```
|
||||
|
||||
`terminal: None` is the pipe/runner case — no PTY, separate stdout/stderr
|
||||
(ADR-054). `terminal: Some` is the PTY case — stdout/stderr merged into
|
||||
the single stdout stream (`TtyHandle.stderr` is `None`), real terminal
|
||||
semantics (resize, signal delivery to process group).
|
||||
|
||||
### 3. `TtyHandle` — what a backend produces
|
||||
|
||||
```rust
|
||||
pub struct TtyHandle {
|
||||
/// Stdin writer — bytes the adapter pumps from client stdin chunks.
|
||||
pub stdin: Box<dyn AsyncWrite + Send + Unpin>,
|
||||
/// Stdout stream — bytes the adapter pumps to client stdout chunks.
|
||||
/// Ends when the backend's stdout reaches EOF (process exited,
|
||||
/// container output stream ended, SSH channel closed).
|
||||
pub stdout: Pin<Box<dyn Stream<Item = Bytes> + Send>>,
|
||||
/// Stderr stream — `None` for PTY backends (stdout/stderr merged
|
||||
/// into `stdout`). `Some` for pipe backends (separate streams).
|
||||
pub stderr: Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>>,
|
||||
/// Exit code — a `Future` the adapter awaits. Resolves when the
|
||||
/// process/container/SSH exec exits. The adapter sends the result
|
||||
/// as the `{"type":"exit","code":N}` control chunk (ADR-055) and
|
||||
/// closes the stream. This is `BoxFuture`, not a method on
|
||||
/// `TtyHandle`, so the adapter can `select` between exit and
|
||||
/// stream-close without coupling to the other fields. (REQ-TTY-01.)
|
||||
pub exit_code: BoxFuture<'static, Result<i32, TtyError>>,
|
||||
/// Control handle (resize, signal) — `Clone` so the adapter can
|
||||
/// hand it to the spawned control-chunk dispatcher. `None` only
|
||||
/// when the backend genuinely has no control path (e.g., a pipe
|
||||
/// backend with no PTY — signal still works via `kill(pid, sig)`,
|
||||
/// but resize is a no-op). See OQ-43 for the trait-object-vs-methods
|
||||
/// confirmation.
|
||||
pub control: Option<Box<dyn TtyControl + Send + Unpin + Clone>>,
|
||||
}
|
||||
|
||||
pub trait TtyControl: Send {
|
||||
/// Resize the terminal. Maps to SSH `window-change`, docker exec
|
||||
/// resize, or `ioctl(TIOCSWINSZ)` on a local PTY. No-op for pipe
|
||||
/// backends without a PTY (the adapter still calls it; the backend
|
||||
/// ignores).
|
||||
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16);
|
||||
|
||||
/// Forward a signal by name. Best-effort delivery to the foreground
|
||||
/// process group (see tty-local.md REQ-TTY-02). Unknown names fall
|
||||
/// back to the backend's default kill.
|
||||
fn signal(&self, name: &str);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Backends are not required to be natively async (REQ-TTY-01)
|
||||
|
||||
The trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`,
|
||||
`BoxFuture`, the `TtyControl` trait object) are the **adapter's contract**.
|
||||
A backend may expose blocking handles internally and bridge them to these
|
||||
async-facing types. The bridging pattern — blocking `std::io` on dedicated
|
||||
std threads or `tokio::task::spawn_blocking`, feeding tokio mpsc/oneshot
|
||||
channels — is a **documented, supported implementation strategy**, not a
|
||||
workaround.
|
||||
|
||||
The local backend (ADR-054) uses this pattern: `portable_pty` is a blocking
|
||||
API, and the backend's `allocate()` spawns reader/writer/waiter threads
|
||||
that feed `mpsc::Receiver<Bytes>` (stdout), `mpsc::Sender<StdinCmd>` (stdin
|
||||
wrapped as `AsyncWrite`), and `oneshot::Receiver<i32>` (exit). The
|
||||
adapter consumes the bridged async-facing types and is unaware of the
|
||||
threading. See `tty-local.md` for the bridge details.
|
||||
|
||||
### 5. Backend registration and the assembly layer
|
||||
|
||||
The `TtyAdapter` does not know the set of backends at compile time — it
|
||||
holds a `HashMap<String, Arc<dyn TtyBackend>>` populated at construction.
|
||||
The assembly layer (the CLI binary) constructs backends with their
|
||||
dependencies (a `DockerTtyBackend` wraps a `bollard::Docker` client; an
|
||||
`SshTtyBackend` wraps an SSH session; a `LocalTtyBackend` takes no
|
||||
deps) and registers them:
|
||||
|
||||
```rust
|
||||
let mut backends = HashMap::new();
|
||||
backends.insert("local".into(), Arc::new(LocalTtyBackend::new()) as Arc<dyn TtyBackend>);
|
||||
backends.insert("docker".into(), Arc::new(DockerTtyBackend::new(docker_client)) as _);
|
||||
backends.insert("ssh".into(), Arc::new(SshTtyBackend::new(ssh_session)) as _);
|
||||
let tty_adapter = TtyAdapter::new(Arc::new(backends));
|
||||
```
|
||||
|
||||
A deployment that doesn't want docker registers only `local`. A browser
|
||||
terminal endpoint that proxies to remote docker/ssh registers `docker`
|
||||
and/or `ssh` backends. The adapter is backend-agnostic; the assembly
|
||||
layer chooses what's available.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- The wire-format adapter is backend-agnostic and testable with a mock
|
||||
backend (in-memory pipes). The build order (ADR-052 wire format + mock
|
||||
backend first, real backends last) follows directly.
|
||||
- alknet-tty stays dependency-light: no bollard, no russh, no
|
||||
`portable_pty` in the core crate. The heavy deps live in the backend
|
||||
crates. This is the same inversion as `OperationAdapter` (ADR-017):
|
||||
the trait lives where the types live; the implementations live where
|
||||
their transport dependencies live.
|
||||
- Blocking-API backends (portable_pty) are first-class — the trait
|
||||
accommodates them by making the adapter-facing types the contract and
|
||||
the bridging pattern a documented strategy. No re-spec required when a
|
||||
future backend is also blocking.
|
||||
- `exit_code` as a `Future` (not a method on the handle) lets the adapter
|
||||
`select` between exit and stream-close — the load-bearing composition
|
||||
the session lifecycle needs (the exit chunk is sent after the child is
|
||||
reaped, then the stream closes — ADR-055).
|
||||
|
||||
**Negative:**
|
||||
|
||||
- `BackendParams` is an enum, which means a new backend crate adding a
|
||||
variant modifies the enum. This is a two-way door (the enum is
|
||||
`#[non_exhaustive]`; a new variant is additive, and the adapter's
|
||||
`match` has a `_ => unsupported backend` arm), but it means
|
||||
`BackendParams` lives in alknet-tty and backend crates extend it rather
|
||||
than defining their own. An alternative — backend-specific params as
|
||||
`serde_json::Value` — was rejected: it loses type safety and forces the
|
||||
adapter to parse backend-specific JSON it shouldn't interpret. The
|
||||
enum-with-`#[non_exhaustive]` trade is the right one: the adapter
|
||||
dispatches by string key, the params enum carries the typed shape, and
|
||||
new backends add variants additively.
|
||||
- `Box<dyn TtyControl + Send + Unpin + Clone>` is an unusual trait-object
|
||||
bound (`Clone` on a trait object requires a workaround — typically a
|
||||
custom clone-via-`Arc` or a `Box<dyn TtyControl>` wrapped in a small
|
||||
`Clone` newtype). This is the cost of the POC-discovered constraint
|
||||
that the control-chunk dispatcher needs to be `Clone` to hand off to
|
||||
the spawned pump task. See OQ-43 for the confirmation and the concrete
|
||||
`Clone` newtype approach.
|
||||
- A backend that produces neither a PTY nor a process (a hypothetical
|
||||
"recorded session replay" backend) would have a no-op `TtyControl` and
|
||||
a synthetic `exit_code`. The trait accommodates it but the `TtyParams`
|
||||
shape (`cmd` is `Vec<String>`, `terminal` is `Option`) assumes
|
||||
command-spawning. A non-command backend would need a different
|
||||
`BackendParams` variant and a backend-internal `cmd` synthesis. Not a
|
||||
current use case; the trait shape doesn't preclude it but doesn't
|
||||
optimize for it.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way.** The `TtyBackend` trait, `TtyHandle` field set, and
|
||||
`TtyControl` trait are the API surface every backend crate implements and
|
||||
the adapter consumes. Changing the trait shape after backends exist is a
|
||||
rewrite across crates. The `BackendParams` enum is `#[non_exhaustive]`
|
||||
(additive — two-way for new variants), but the trait and handle shapes are
|
||||
one-way.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **The `TtyControl` trait object can be made `Clone` via a small
|
||||
newtype.** The POC used a concrete `PtyControl` struct (inherently
|
||||
`Clone`). The trait-object form needs a `Clone` newtype wrapping the
|
||||
`Box` (e.g., a struct holding `Arc<dyn TtyControl>` so `Clone` is an
|
||||
`Arc` clone). This is a known Rust pattern; OQ-43 confirms the
|
||||
approach.
|
||||
|
||||
2. **Backends produce a single session per `allocate()` call.** The
|
||||
adapter calls `allocate()` once per accepted bidi stream (one session
|
||||
per stream — ADR-052). A backend that multiplexed multiple sessions
|
||||
over one `allocate()` would not fit the trait; no such backend is
|
||||
contemplated.
|
||||
|
||||
3. **The adapter, not the backend, owns the exit-chunk ordering.** The
|
||||
backend resolves `exit_code`; the adapter awaits it, sends the exit
|
||||
control chunk, and closes the stream (ADR-055). The backend does not
|
||||
write to the wire — it produces handles; the adapter pumps. This keeps
|
||||
the wire-format logic in one place (the adapter) and the backend
|
||||
focused on its allocation target (docker, ssh, local process).
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-tty/phase-0-findings.md` — Phase 0 research; §"The
|
||||
Backend Trait" is the seed of this ADR; §"Requirements from the
|
||||
local-PTY POC" (REQ-TTY-01) is the load-bearing constraint
|
||||
- `/workspace/alknet-tty-poc/src/local_pty.rs` — the reference
|
||||
implementation of what a backend produces (`LocalPty`: stdout mpsc,
|
||||
stdin mpsc, control `PtyControl` (Clone), exit oneshot)
|
||||
- `/workspace/alknet-tty-poc/src/session.rs` — the adapter-side pump that
|
||||
consumes `TtyHandle`-shaped fields (the reference for how the adapter
|
||||
uses the trait)
|
||||
- [ADR-003](003-crate-decomposition.md) + Amendment 1 — no-handler-depends-
|
||||
on-another-handler; alknet-tty depends on alknet-core; backends depend
|
||||
on alknet-tty for the trait
|
||||
- [ADR-007](007-bistream-type-definition.md) — `Connection`, `SendStream`,
|
||||
`RecvStream` (the adapter receives a `Connection`, accepts bidi streams,
|
||||
pumps per-session)
|
||||
- [ADR-052](052-alknet-tty-wire-format-and-two-carriage.md) — the wire
|
||||
format this trait's backends feed
|
||||
- [ADR-054](054-local-tty-backend-sibling-crate.md) — the local backend's
|
||||
crate placement (sibling crate, behind a feature re-export)
|
||||
- [ADR-055](055-exit-code-on-control-chunk.md) — the exit chunk ordering
|
||||
this trait's `exit_code` field feeds into
|
||||
- OQ-43 — `TtyControl` as `Clone` trait object (resolved: confirmed)
|
||||
- OQ-44 — terminal modes (deferred(scope): not needed for current scope)
|
||||
- Spec: [crates/tty/tty-backend.md](../crates/tty/tty-backend.md)
|
||||
@@ -0,0 +1,206 @@
|
||||
# ADR-054: Local TTY Backend as a Sibling Crate (`alknet-tty-local`)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The alknet-tty research (`docs/research/alknet-tty/phase-0-findings.md`
|
||||
DP-1) posed the placement question for the local-process backend
|
||||
(`std::process::Command` with piped stdio, or `portable_pty` for a real
|
||||
PTY):
|
||||
|
||||
- **(a) In alknet-tty**: the crate ships with the local backend built-in.
|
||||
Pro: zero-config runner, one crate gets a terminal/process-streaming
|
||||
endpoint. Con: alknet-tty pulls in `portable_pty` even for deployments
|
||||
that only use docker/ssh backends.
|
||||
- **(b) In a sibling crate (`alknet-tty-local`)**: alknet-tty defines the
|
||||
trait (ADR-053); the local backend is a separate crate. Pro:
|
||||
alknet-tty stays dependency-light; consumers opt into the local
|
||||
backend explicitly. Con: one extra crate for the common case.
|
||||
|
||||
The local backend is the simplest backend and the one that enables the
|
||||
runner pattern (a process whose stdin/stdout/stderr/exit-code stream over
|
||||
a framed bidi connection — the same shape as GitHub/Gitea Actions runners,
|
||||
just over alknet's transport instead of HTTP polling). It has no heavy
|
||||
dependencies in the pipe case — just `std` — but the PTY case pulls in
|
||||
`portable_pty` (a non-trivial native dependency that builds on Unix
|
||||
`openpty`/`ioctl` and Windows ConPTY).
|
||||
|
||||
The relevant constraint from ADR-053: alknet-tty itself depends only on
|
||||
alknet-core and the wire-format codec (ADR-052). The `portable_pty`
|
||||
dependency does not belong in the core tty crate — a docker-only
|
||||
deployment or an SSH-PTY-only deployment should not pull in PTY allocation
|
||||
code. This is the same inversion as `OperationAdapter` (ADR-017): the
|
||||
trait lives where the types live; the implementations live where their
|
||||
transport dependencies live.
|
||||
|
||||
### The feature-re-export compromise
|
||||
|
||||
The research recommended (b) sibling crate **behind a feature flag on
|
||||
alknet-tty** for the common case (`features = ["local"]` → re-export from
|
||||
`alknet-tty-local`). This keeps alknet-tty's default dependency surface
|
||||
minimal while making the local backend a one-feature opt-in. The
|
||||
`portable_pty` dependency lives in `alknet-tty-local`; alknet-tty itself
|
||||
never depends on `portable_pty`.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. The local backend is a sibling crate, `alknet-tty-local`
|
||||
|
||||
`alknet-tty-local` implements `TtyBackend` for `LocalTtyBackend` (ADR-053),
|
||||
backed by `portable_pty` for the PTY case and `std::process::Command` with
|
||||
`Stdio::piped()` for the pipe/runner case. It depends on alknet-tty (for
|
||||
the `TtyBackend`/`TtyHandle`/`TtyControl` traits) and on `portable_pty`
|
||||
for the PTY case.
|
||||
|
||||
```
|
||||
alknet-tty-local
|
||||
├── alknet-tty (TtyBackend trait, TtyHandle, TtyControl, wire types)
|
||||
├── alknet-core (Connection — via alknet-tty's re-export; not direct)
|
||||
├── portable_pty (PTY allocation — the heavy dep, here not in alknet-tty)
|
||||
└── libc (signal forwarding — REQ-TTY-02, Unix only)
|
||||
```
|
||||
|
||||
### 2. `alknet-tty` re-exports the local backend behind a `local` feature
|
||||
|
||||
```toml
|
||||
# alknet-tty Cargo.toml
|
||||
[features]
|
||||
default = []
|
||||
local = ["dep:alknet-tty-local"] # re-export LocalTtyBackend
|
||||
|
||||
[dependencies]
|
||||
alknet-tty-local = { path = "../alknet-tty-local", optional = true }
|
||||
```
|
||||
|
||||
A consumer that wants the local backend enables `features = ["local"]`
|
||||
on alknet-tty and gets `alknet_tty::local::LocalTtyBackend` re-exported
|
||||
from `alknet-tty-local`. A consumer that only wants docker/ssh backends
|
||||
uses the default features and depends on `alknet-tty-docker` /
|
||||
`alknet-tty-ssh` (or their own backend crate) directly — no
|
||||
`portable_pty` in the dependency tree.
|
||||
|
||||
This is the same feature-re-export pattern the Rust ecosystem uses for
|
||||
optional heavy dependencies (e.g., `tokio`'s `full` feature pulling in
|
||||
`tokio-util`, `h2`, etc.). The seam is the `TtyBackend` trait; extraction
|
||||
is cheap because the trait is the contract.
|
||||
|
||||
### 3. PTY vs pipe is a per-session choice, not a per-deployment choice
|
||||
|
||||
`TtyParams.terminal: Option<TerminalParams>` (ADR-053) selects the mode:
|
||||
|
||||
- **`terminal: Some(TerminalParams { ... })`** — allocate a real PTY
|
||||
via `portable_pty`. Terminal semantics: resize (via
|
||||
`ioctl(TIOCSWINSZ)`), signal delivery to the foreground process group
|
||||
(via `libc::kill(-pgid, sig)`, REQ-TTY-02), escape-sequence handling
|
||||
(the kernel PTY's line discipline). stdout and stderr are merged
|
||||
(kernel PTY property — one output stream from the slave), so
|
||||
`TtyHandle.stderr` is `None`.
|
||||
- **`terminal: None`** — pipe mode, no PTY. `std::process::Command` with
|
||||
`Stdio::piped()` for stdin/stdout/stderr. No resize, no
|
||||
escape-sequence handling, but `kill(pid, sig)` still works for signal
|
||||
forwarding. stdout and stderr are separate streams, so
|
||||
`TtyHandle.stderr` is `Some`. This is the **runner** case — a
|
||||
command-streaming endpoint with no terminal semantics.
|
||||
|
||||
The same `LocalTtyBackend` serves both; the `allocate()` call branches on
|
||||
`params.terminal`. A deployment that only does terminals always sends
|
||||
`Some`; a deployment that only does runners always sends `None`; a
|
||||
deployment that does both (a hub that runs agents in PTYs and runs
|
||||
`cargo test` as a runner) sends the appropriate one per session.
|
||||
|
||||
### 4. The runner pattern is preserved, not specialized
|
||||
|
||||
The pipe mode (`terminal: None`) is the "runner" generalization the
|
||||
research identified: a process whose stdin/stdout/stderr/exit-code stream
|
||||
over a framed bidi connection. This is functionally identical to
|
||||
GitHub/Gitea Actions runners, just over alknet's transport instead of
|
||||
HTTP polling:
|
||||
|
||||
- A coordinator sends a negotiation frame with
|
||||
`{ "backend": "local", "tty": null, "cmd": ["cargo", "test"] }`.
|
||||
- The endpoint runs `cargo test` with piped stdio, streams stdout/stderr
|
||||
chunks back, sends `{"type":"exit","code":N}` when it finishes (ADR-055).
|
||||
- The coordinator gets reliable completion notification (the exit
|
||||
control chunk + stream close) — no polling.
|
||||
|
||||
The runner-specific API surface (job management, log persistence, task
|
||||
graph integration) is **out of scope for alknet-tty**. alknet-tty provides
|
||||
the *mechanism* (a framed byte stream for a process); the runner *policy*
|
||||
is a downstream crate's job. This ADR commits to preserving the option
|
||||
(`terminal: None` → pipe mode) and not building runner policy into
|
||||
alknet-tty. See OQ-46.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- alknet-tty's default dependency surface is minimal (alknet-core + the
|
||||
wire-format codec). A docker-only or ssh-only deployment never pulls
|
||||
in `portable_pty`.
|
||||
- The local backend is a one-feature opt-in (`features = ["local"]`) for
|
||||
the common case — a consumer that wants a terminal/runner endpoint with
|
||||
no docker or SSH gets it with one feature flag, not a separate
|
||||
dependency.
|
||||
- PTY vs pipe is per-session, so one `LocalTtyBackend` serves terminals
|
||||
and runners. A hub that does both doesn't need two backends.
|
||||
- The runner pattern is preserved without baking runner policy into
|
||||
alknet-tty. The mechanism is the framed byte stream; the policy is
|
||||
downstream.
|
||||
- The sibling-crate placement composes with ADR-003's no-handler-depends-
|
||||
on-another-handler rule: alknet-tty-local depends on alknet-tty (for
|
||||
the trait), alknet-tty does not depend on alknet-tty-local (the
|
||||
feature re-export is optional).
|
||||
|
||||
**Negative:**
|
||||
|
||||
- A consumer that wants the local backend must enable a feature flag
|
||||
(`features = ["local"]`). Forgetting the flag results in the
|
||||
`alknet_tty::local` module not existing — a compile error, not a silent
|
||||
miss. This is the standard Rust feature-flag trade and is
|
||||
self-documenting.
|
||||
- One extra crate in the workspace (`alknet-tty-local` alongside
|
||||
`alknet-tty`). The workspace `Cargo.toml` gains a member; version
|
||||
coordination is per-crate. This is the established pattern (alknet-http
|
||||
is one crate with colocated server + adapters; alknet-call is separate
|
||||
from alknet-core).
|
||||
- The runner-specific API surface (job management, log persistence) is
|
||||
not in alknet-tty. A downstream crate that wants a full runner builds
|
||||
on the pipe mode + the wire format. This is the right layering
|
||||
(mechanism vs policy) but means a "runner crate" is a separate future
|
||||
deliverable, not part of alknet-tty. See OQ-46.
|
||||
|
||||
## Door type
|
||||
|
||||
**Two-way.** The sibling-crate placement is reversible: if the local
|
||||
backend turned out to be the only backend anyone used, merging
|
||||
`alknet-tty-local` back into `alknet-tty` (behind the same `local`
|
||||
feature, just in the same crate) is mechanical — the trait is the seam,
|
||||
and the feature gate already exists. The cost of reversal is low
|
||||
(re-exports become local modules), and no downstream consumer breaks (the
|
||||
`alknet_tty::local::LocalTtyBackend` path stays valid).
|
||||
|
||||
This is a two-way door that is **decided** (sibling crate + feature
|
||||
re-export), not deferred. The decision is made now; the reversal is cheap
|
||||
if a future consolidation warrants it. See ADR-009 §"What this framework
|
||||
is NOT" — door type classifies reversal cost, not urgency.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-tty/phase-0-findings.md` DP-1 — the placement
|
||||
question this ADR resolves
|
||||
- [ADR-003](003-crate-decomposition.md) + Amendment 1 — crate
|
||||
decomposition rule (the sibling-crate placement preserves it)
|
||||
- [ADR-009](009-one-way-door-decision-framework.md) — door-type-as-deferral
|
||||
anti-pattern (this ADR's two-way-door classification is reversal cost,
|
||||
not a deferral)
|
||||
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) — the
|
||||
adapter-location-map pattern (trait where types live, implementation
|
||||
where deps live) this ADR follows
|
||||
- [ADR-053](053-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend`
|
||||
trait this crate implements
|
||||
- OQ-46 — runner API surface (deferred(scope): mechanism in alknet-tty,
|
||||
policy is a downstream crate)
|
||||
- Spec: [crates/tty/tty-local.md](../crates/tty/tty-local.md)
|
||||
@@ -0,0 +1,204 @@
|
||||
# ADR-055: Exit Code on a Control Chunk (the Last Chunk Before Stream Close)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The alknet-docker POC validated exit-code propagation for the JSON
|
||||
carriage path: exec with an exit code rides on a final `call.responded`
|
||||
frame `{ "exitCode": N }` before `call.completed`. That works because
|
||||
the JSON carriage path is the call protocol — `call.responded` and
|
||||
`call.completed` exist and carry the result.
|
||||
|
||||
The raw-carriage path (ADR-052) has no `call.responded` and no
|
||||
`call.completed` — after the negotiation frame, the stream is raw chunks.
|
||||
The exit code must ride on the chunk format itself. Two options:
|
||||
|
||||
- **(a) Control chunk**: `{"type":"exit","code":N}` as the last control
|
||||
chunk (stream_type 3) before stream close. Clean, explicit, carries the
|
||||
code as structured data on the channel that already exists for control
|
||||
metadata.
|
||||
- **(b) Final data chunk with exit code**: a special stdout chunk with an
|
||||
exit-code payload. Overloads the data channel for metadata — a client
|
||||
parsing stdout chunks would have to special-case "this stdout chunk is
|
||||
actually an exit code," conflating data and control.
|
||||
|
||||
The local-PTY POC (`/workspace/alknet-tty-poc`, built 2026-07-05)
|
||||
validated option (a) end-to-end: the `{"type":"exit","code":N}` chunk
|
||||
fires after the child is reaped (the waiter thread's
|
||||
`oneshot::Receiver<i32>` resolves) and is the last control chunk before
|
||||
the stream closes. The POC's `session.rs` `pump_exit` task awaits
|
||||
`pty.exit_code`, serializes the result as `ControlMessage::Exit { code }`,
|
||||
enqueues it as a control chunk, and the drainer writes it to the client
|
||||
before the writer closes.
|
||||
|
||||
### Why this is a one-way door
|
||||
|
||||
Clients will depend on the **"exit chunk is last"** invariant: after the
|
||||
exit control chunk, no more data chunks follow, and the stream closes.
|
||||
This is the deterministic completion notification the docker POC
|
||||
identified as the stopgap coordination property — a coordinator spawns a
|
||||
process, streams its output, and gets a reliable "it exited with code N"
|
||||
signal without polling or plugin state. Changing the ordering after
|
||||
clients exist would break every consumer that reads stdout until the exit
|
||||
chunk and then stops.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. The exit code rides on a control chunk, not a data chunk
|
||||
|
||||
The exit code is control metadata (the process's termination status), not
|
||||
data (process output). It rides on the control channel (stream_type 3,
|
||||
ADR-052) as:
|
||||
|
||||
```json
|
||||
{"type":"exit","code":0}
|
||||
```
|
||||
|
||||
The `code` is an `i32` (matches `std::process::ExitStatus::code()` and
|
||||
Unix wait-status convention; negative values are signal-terminated, e.g.,
|
||||
`-9` for SIGKILL, matching `ExitStatus::code()`'s behavior on Unix). The
|
||||
chunk is the last control chunk before stream close.
|
||||
|
||||
### 2. The "exit chunk is last" invariant
|
||||
|
||||
After the `{"type":"exit","code":N}` control chunk:
|
||||
|
||||
- The server sends no more data chunks (stdout/stderr) and no more
|
||||
control chunks.
|
||||
- The server closes the write half of the bidi stream.
|
||||
|
||||
A client reads stdout/stderr/control chunks until it sees the exit chunk,
|
||||
records the exit code, and treats subsequent stream close as the session
|
||||
end. The exit chunk is the deterministic completion signal.
|
||||
|
||||
### 3. The adapter owns the exit-chunk ordering, not the backend
|
||||
|
||||
Per ADR-053 assumption 3, the backend resolves `exit_code` (a
|
||||
`BoxFuture<'static, Result<i32, TtyError>>`); the adapter awaits it,
|
||||
sends the exit control chunk, and closes the stream. The backend does not
|
||||
write to the wire — it produces handles; the adapter pumps. This keeps
|
||||
the wire-format logic (including the "exit is last" invariant) in one
|
||||
place (the adapter's session driver) and the backend focused on its
|
||||
allocation target.
|
||||
|
||||
The adapter's session driver (see `tty-adapter.md`) runs three concurrent
|
||||
pumps:
|
||||
|
||||
1. **stdout → client**: backend stdout → stdout chunks (and stderr chunks
|
||||
if `TtyHandle.stderr` is `Some`).
|
||||
2. **client → backend**: stdin chunks → backend stdin; control chunks →
|
||||
`TtyControl::resize`/`signal`/`eof`.
|
||||
3. **exit → exit chunk**: await `TtyHandle.exit_code`; on resolve, enqueue
|
||||
`{"type":"exit","code":N}` as a control chunk; after the drainer writes
|
||||
it, close the write half.
|
||||
|
||||
The exit-chunk task coordinates with the stdout pump: the stdout pump
|
||||
completes (backend stdout EOF) before or concurrently with the exit
|
||||
resolve, and the exit chunk is enqueued only after the exit resolves. The
|
||||
drainer writes chunks in arrival order; the exit chunk is last because
|
||||
the exit is the last thing to resolve (the child must exit before its
|
||||
stdout drains, but the exit chunk is sent only after `exit_code` resolves,
|
||||
which is after `Child::wait()` returns — i.e., after the child is reaped).
|
||||
|
||||
### 4. Error exit codes
|
||||
|
||||
A backend `TtyError` during `allocate()` (the PTY couldn't be allocated,
|
||||
the docker exec failed to start, the SSH channel request was rejected)
|
||||
is handled before the raw-carriage phase begins — the adapter sends a
|
||||
JSON error response to the negotiation frame and closes the stream
|
||||
without entering raw mode. See `tty-adapter.md` §"Negotiation errors".
|
||||
|
||||
A `TtyError` from the `exit_code` future (the child couldn't be reaped,
|
||||
or the backend's wait path failed) is serialized as an exit code of `-1`
|
||||
(`ControlMessage::Exit { code: -1 }`) and the stream closes. The client
|
||||
treats `-1` as "the backend reported an exit error, not a real exit
|
||||
code." This is a best-effort signal; a backend that cannot determine the
|
||||
exit code still sends the exit chunk so the client gets the completion
|
||||
notification.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- The exit code is structured data on the control channel, not a hacky
|
||||
overload of the data channel. Clients parse it as a `ControlMessage::Exit`,
|
||||
not as a special-cased stdout chunk.
|
||||
- The "exit chunk is last" invariant gives coordinators deterministic
|
||||
completion notification — the same stopgap property the docker POC
|
||||
validated for logs subscriptions. No polling, no plugin state; the
|
||||
process exiting is the signal.
|
||||
- The adapter owns the ordering, so the invariant is enforced in one
|
||||
place; backends don't have to know the wire format's completion
|
||||
semantics.
|
||||
- The error-exit `-1` fallback keeps the completion notification
|
||||
reliable even when the backend can't determine the real code — the
|
||||
client still knows the session ended.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- The "exit chunk is last" invariant is a one-way door — clients depend on
|
||||
it. Reversing it (allowing data chunks after the exit chunk, or moving
|
||||
the exit code to a data chunk) would break every consumer. This is the
|
||||
intended commitment: the invariant is the value.
|
||||
- A client that doesn't read until the exit chunk (e.g., a runner that
|
||||
cancels mid-stream by closing the write half) won't see the exit code.
|
||||
That's correct — a cancelled stream doesn't have a deterministic exit;
|
||||
the client that cancels already knows it cancelled. The exit chunk is
|
||||
for the client that reads to completion.
|
||||
- The `-1` error-exit code conflates "the backend couldn't determine the
|
||||
exit" with "the process exited with code -1" (which doesn't happen on
|
||||
Unix — `ExitStatus::code()` returns `None` for signal termination, not
|
||||
-1; the POC's waiter thread sends -1 only on `wait()` failure, not on
|
||||
signal termination — signal termination sends the negative signal
|
||||
number, e.g., -9 for SIGKILL). A client that needs to distinguish
|
||||
"real exit -1" from "backend error" can't from the code alone. This is
|
||||
a documented edge case; if it becomes load-bearing, a future control
|
||||
message type (`{"type":"exit_error","message":"..."}`) can carry the
|
||||
distinction additively (the `type`-tagged enum is the extension seam
|
||||
per ADR-052).
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way.** The "exit chunk is last" invariant is what clients depend
|
||||
on for deterministic completion. Changing it after clients exist breaks
|
||||
every consumer. The `{"type":"exit","code":N}` shape is also one-way
|
||||
(clients parse it as a `ControlMessage::Exit`), though the `type`-tagged
|
||||
enum (ADR-052) makes adding *new* control message types additive.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **The child exits before its stdout fully drains, and the exit chunk
|
||||
is sent after `exit_code` resolves.** On Unix, `Child::wait()` blocks
|
||||
until the child is reaped, which happens after the child exits and its
|
||||
stdout pipe/PTY buffer drains. The POC validated this ordering: the
|
||||
reader thread sees EOF (buffer drained), the waiter thread reaps
|
||||
(exit code available), and the exit chunk is enqueued after the exit
|
||||
resolves. There is no race where stdout chunks arrive after the exit
|
||||
chunk.
|
||||
|
||||
2. **`exit_code` resolving implies the stdout pump is done or will be
|
||||
soon.** The adapter's session driver waits for both the stdout pump
|
||||
to complete (backend stdout EOF) and the exit to resolve before
|
||||
sending the exit chunk and closing. If a backend's stdout outlives the
|
||||
exit resolve (a hypothetical backend where the process exits but a
|
||||
buffer flush is still in flight), the adapter waits for the stdout
|
||||
pump before the exit chunk. The `TtyHandle.stderr` (if `Some`) is
|
||||
pumped concurrently with stdout and also drains before the exit chunk.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-tty/phase-0-findings.md` DP-5 — the decision
|
||||
question this ADR resolves
|
||||
- `docs/research/alknet-docker/poc-summary.md` — the JSON-carriage exit
|
||||
code path (the analog this ADR's raw-carriage path mirrors)
|
||||
- `/workspace/alknet-tty-poc/src/session.rs` `pump_exit` — the reference
|
||||
implementation of the exit-chunk ordering this ADR commits
|
||||
- [ADR-052](052-alknet-tty-wire-format-and-two-carriage.md) — the wire
|
||||
format (control channel, stream_type 3) this ADR's exit chunk rides on
|
||||
- [ADR-053](053-ttybackend-trait-and-ttyhandle.md) — the `TtyHandle.exit_code`
|
||||
field (the `Future` the adapter awaits) this ADR's ordering consumes
|
||||
- Spec: [crates/tty/tty-adapter.md](../crates/tty/tty-adapter.md) (the
|
||||
session driver that enforces the ordering)
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-05
|
||||
last_updated: 2026-07-06
|
||||
---
|
||||
|
||||
# Open Questions
|
||||
@@ -1187,3 +1187,124 @@ is a feature extension, not an unmade architecture decision.
|
||||
(`AccessControl`, `OperationSpec` — `resource_id_path` addition),
|
||||
[alknet-docker POC summary](../../research/alknet-docker/poc-summary.md)
|
||||
§"Open Unknowns" #3
|
||||
|
||||
## Theme: alknet-tty
|
||||
|
||||
### OQ-43: `TtyControl` as a `Clone` trait object
|
||||
|
||||
- **Origin**: [crates/tty/tty-backend.md](crates/tty/tty-backend.md)
|
||||
(the `TtyHandle.control` field shape);
|
||||
`docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-01 (the trait-
|
||||
shape open question the local-PTY POC resolved).
|
||||
- **Status**: resolved
|
||||
- **Door type**: One-way (the `TtyControl` trait shape is part of the
|
||||
`TtyBackend` API surface — ADR-053), two-way (the concrete `Clone`
|
||||
newtype mechanism)
|
||||
- **Priority**: medium
|
||||
- **Resolution**: `TtyHandle.control` is
|
||||
`Option<Box<dyn TtyControl + Send + Unpin + Clone>>`. The `Clone`
|
||||
trait-object bound is satisfied via an `Arc`-backed `Clone` newtype: a
|
||||
small struct holding `Arc<dyn TtyControlInner>` where `TtyControlInner:
|
||||
Send + Sync` has the `resize`/`signal` methods, and the public
|
||||
`TtyControl` newtype implements `Clone` by cloning the `Arc`. The
|
||||
local-PTY POC used a concrete `PtyControl` struct (inherently `Clone` —
|
||||
it held `Arc<Mutex<...>>` fields); the trait-object form generalizes it
|
||||
so a backend can produce its own control type without the adapter
|
||||
knowing the concrete shape. The `Clone` constraint exists because the
|
||||
adapter's control-chunk dispatcher needs to be handed off to the
|
||||
spawned pump task (the POC's `session::drive_session` clones
|
||||
`pty.control` for the client→backend pump). See ADR-053 and
|
||||
`tty-backend.md`.
|
||||
- **Cross-references**: ADR-053, [tty-backend.md](crates/tty/tty-backend.md),
|
||||
`/workspace/alknet-tty-poc/src/local_pty.rs` (`PtyControl`)
|
||||
|
||||
### OQ-44: Terminal Modes (TTY modes)
|
||||
|
||||
- **Origin**: `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-02;
|
||||
[crates/tty/tty-backend.md](crates/tty/tty-backend.md)
|
||||
(`TerminalParams.modes` field).
|
||||
- **Status**: deferred(scope)
|
||||
- **Door type**: Two-way
|
||||
- **Priority**: low
|
||||
- **Blocked on**: a concrete mode-control use case (a deployment that
|
||||
needs to set echo/raw/canonical/etc. modes on a PTY, beyond the backend's
|
||||
defaults).
|
||||
- **Resolution**: Not yet decidable. SSH's `pty_request` carries TTY
|
||||
modes (echo, raw, canonical, etc.) as a packed bitmask. The common
|
||||
case is "default terminal modes" — the `modes` field in
|
||||
`TerminalParams` is `serde_json::Value` (reserved as `{}` in v1) for
|
||||
when a concrete use case requires mode control. The backends
|
||||
(`portable_pty`, docker `tty: true`, russh `pty_request`) all have
|
||||
defaults that work for the common terminal case. Adding mode control
|
||||
is additive (extend the `modes` JSON shape) and does not break
|
||||
downstream; the decision is deferred until a use case forces it.
|
||||
- **Cross-references**: ADR-053, [tty-backend.md](crates/tty/tty-backend.md),
|
||||
[tty-wire.md](crates/tty/tty-wire.md)
|
||||
|
||||
### OQ-45: Flow Control for High-Throughput stdout
|
||||
|
||||
- **Origin**: `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-03;
|
||||
[crates/tty/tty-wire.md](crates/tty/tty-wire.md) (no windowing in the
|
||||
chunk format).
|
||||
- **Status**: open (low risk)
|
||||
- **Door type**: Two-way
|
||||
- **Priority**: low
|
||||
- **Resolution**: The chunk format has no windowing — QUIC's per-stream
|
||||
flow control handles backpressure. This is expected to suffice for
|
||||
high-throughput stdout (e.g., `cargo build` output): the docker POC's
|
||||
logs subscription handled multi-line output without issue, and QUIC's
|
||||
bidi-stream flow control is the established backpressure mechanism. A
|
||||
POC with real high-volume output (a deliberately large `cargo build`
|
||||
or `find /` over a high-bandwidth connection) would confirm. If a
|
||||
flow-control problem surfaces, the reversal is a per-stream
|
||||
flow-control window in the chunk format (a two-way-door extension to
|
||||
the wire format, additive — a new control message type for window
|
||||
updates, not a header change). Not blocking; the default assumption is
|
||||
QUIC flow control suffices.
|
||||
- **Cross-references**: ADR-052, [tty-wire.md](crates/tty/tty-wire.md),
|
||||
[tty-adapter.md](crates/tty/tty-adapter.md)
|
||||
|
||||
### OQ-46: Runner API Surface
|
||||
|
||||
- **Origin**: `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-05;
|
||||
[crates/tty/tty-local.md](crates/tty/tty-local.md) (the runner pattern
|
||||
in pipe mode).
|
||||
- **Status**: deferred(scope)
|
||||
- **Door type**: Two-way
|
||||
- **Priority**: low
|
||||
- **Blocked on**: a concrete runner-policy use case that forces the API
|
||||
surface (job management, log persistence, task graph integration).
|
||||
- **Resolution**: Not yet decidable. The runner *mechanism* (pipe mode —
|
||||
`TtyParams.terminal = None` → `std::process::Command` with piped stdio
|
||||
→ framed byte stream + exit code) is in alknet-tty (ADR-054). The
|
||||
runner *policy* (job management, log persistence, task graph
|
||||
integration) is a downstream crate's job, not in scope for alknet-tty.
|
||||
This OQ tracks whether a runner-policy crate (e.g., an
|
||||
`alknet-runner` crate that builds on the pipe mode + the wire format
|
||||
to provide job management) is needed, and what its API surface would
|
||||
be. The decision is deferred until a concrete use case forces it; the
|
||||
mechanism is preserved regardless. See ADR-054 and `tty-local.md`
|
||||
§"The Runner Pattern".
|
||||
- **Cross-references**: ADR-054, [tty-local.md](crates/tty/tty-local.md)
|
||||
|
||||
### OQ-47: Stdin Closure Canonical Signal
|
||||
|
||||
- **Origin**: `docs/research/alknet-docker/poc-summary.md` §"Open
|
||||
Unknowns" #4 (stdin closure semantics for raw carriage);
|
||||
[crates/tty/tty-wire.md](crates/tty/tty-wire.md) §"Stdin Closure".
|
||||
- **Status**: resolved
|
||||
- **Door type**: Two-way
|
||||
- **Priority**: low
|
||||
- **Resolution**: Either a zero-length stdin chunk (stream_type 0,
|
||||
length 0 — the docker POC's sentinel) or a `{"type":"eof"}`
|
||||
control chunk (stream_type 3 — the tty POC's explicit signal) closes
|
||||
the client's stdin. Both are accepted by the adapter; the spec
|
||||
recommends `eof` for explicitness (it's a control message, not a
|
||||
data-length hack). The adapter handles both identically: signal EOF
|
||||
to the backend's stdin (`ChildStdin::drop` / PTY writer close) and
|
||||
keep pumping stdout until the exit resolves — the client may still
|
||||
want to receive remaining output + the exit code. A third path
|
||||
(client closes the write half of the bidi stream) is also accepted
|
||||
and handled the same way. See ADR-052 and `tty-wire.md`.
|
||||
- **Cross-references**: ADR-052, [tty-wire.md](crates/tty/tty-wire.md),
|
||||
[tty-adapter.md](crates/tty/tty-adapter.md)
|
||||
Reference in new issue
Block a user