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`.
|
**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
|
## Architecture Documents
|
||||||
|
|
||||||
| Document | Status | Description |
|
| 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-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/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/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/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/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) |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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
|
## Open Questions
|
||||||
|
|
||||||
@@ -160,9 +172,18 @@ See [open-questions.md](open-questions.md) for the full tracker.
|
|||||||
**Resolved (blocks lifted, ADR drafting can proceed):**
|
**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.
|
- **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):**
|
**Deferred (not active):**
|
||||||
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
|
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
|
||||||
- **OQ-10**: Git adapter scope — start with smart protocol, add ERC721 later
|
- **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
|
## 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
|
status: draft
|
||||||
last_updated: 2026-07-05
|
last_updated: 2026-07-06
|
||||||
---
|
---
|
||||||
|
|
||||||
# Open Questions
|
# Open Questions
|
||||||
@@ -1187,3 +1187,124 @@ is a feature extension, not an unmade architecture decision.
|
|||||||
(`AccessControl`, `OperationSpec` — `resource_id_path` addition),
|
(`AccessControl`, `OperationSpec` — `resource_id_path` addition),
|
||||||
[alknet-docker POC summary](../../research/alknet-docker/poc-summary.md)
|
[alknet-docker POC summary](../../research/alknet-docker/poc-summary.md)
|
||||||
§"Open Unknowns" #3
|
§"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