docs(arch): alknet-tty spec sanity-check fixes — TtyControlHandle newtype (C1), backend cleanup contract (W1, ADR-056), clarifications (S1-S5)
C1: Box<dyn TtyControl + Clone> does not compile (Clone is not object-safe). Replace with a TtyControlHandle newtype: #[derive(Clone)] wrapping Arc<dyn TtyControl + Send + Sync>. The trait stays object-safe; the newtype carries the Clone-ability. Updated across tty-backend.md, ADR-053, OQ-43, and the tty README. W1: 'Drop is the cleanup, threads exit on channel close' was false for the local PTY waiter thread (blocked in Child::wait(), does not observe channel close) — a child that ignores stdin EOF would be orphaned on session cancel. New ADR-056 commits a backend cleanup contract: dropping the exit_code future MUST kill the session target. The local backend implements it via a ChildKiller held in the exit_code future's Drop guard (disarmed on resolve). Corrected the lifecycle section in tty-adapter.md, added the mechanism + constraint to tty-local.md, and referenced ADR-056 from tty-backend.md, overview.md, both READMEs. S1: error-frame < 16 MiB stated as a wire-format invariant (not an assumption) so the 0x00-first-byte disambiguation trick is sound by construction. S2: carriage field validation (MUST be "raw" in v1, else malformed_negotiation) specified. S3: NegotiateRequest + TerminalParamsWire Rust structs added with serde defaults and validation rules. S4: modes field annotated 'backends MUST ignore content in v1' in both tty-backend.md and ADR-053. S5: AsyncWrite/Stream qualified with crate origins (tokio::io, futures_core) in the TtyHandle struct definitions.
This commit is contained in:
1 parent
b90d717ac2
commit
25f0f55592
11 files changed
+594
-92
No files matched your search
@@ -22,7 +22,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
|
||||
|
||||
**Next step**: The storage/repo-pattern ADRs (030–033) are accepted and amend the core and call specs. The next implementation phase is the ADR-029 migration (peer-keyed overlays, `PeerRef` routing, retire `remote_safe`/`trusted_peer`) with the ADR-030 `PeerEntry` change and the ADR-032 `forwarded_for` field folded in — the `OperationContext`, `from_call` handler, and `AuthPolicy` are all under edit, making this the cheapest window. After that: alknet-http implementation (specs drafted; `h3`/WebTransport deferred per ADR-044, browser bidirectional path uses WebSocket), which consumes the `CredentialStore` trait and the `OperationAdapter` contract. The alknet-ssh crate (the other post-core crate, specced in parallel) proceeds independently — it depends on `alknet-core`, not `alknet-call`.
|
||||
|
||||
**alknet-tty specs drafted.** The alknet-tty crate (terminal session protocol handler — `ProtocolHandler` on `alknet/tty`, two-carriage wire format with a raw chunk codec + JSON control channel, backend-agnostic via a `TtyBackend` trait) now has architecture specs: [crates/tty/](crates/tty/) (overview, tty-wire, tty-backend, tty-adapter, tty-local) and four ADRs — [ADR-052](decisions/052-alknet-tty-wire-format-and-two-carriage.md) (wire format: `alknet/tty` ALPN, JSON negotiation frame then raw chunks, fixed channel set 0-3, control as JSON), [ADR-053](decisions/053-ttybackend-trait-and-ttyhandle.md) (`TtyBackend` trait + `TtyHandle`; `exit_code` as a `Future`; backends need not be natively async — REQ-TTY-01 from the local-PTY POC), [ADR-054](decisions/054-local-tty-backend-sibling-crate.md) (`alknet-tty-local` sibling crate behind a `local` feature re-export; PTY vs pipe per-session; the runner pattern preserved), [ADR-055](decisions/055-exit-code-on-control-chunk.md) (exit code on a stream_type 3 control chunk; "exit chunk is last" invariant; adapter owns the ordering). The specs are grounded in the alknet-docker POC (`docs/research/alknet-docker/poc-summary.md`) and the alknet-tty POC (`/workspace/alknet-tty-poc/`, built 2026-07-05), which validated the wire format, the control channel, the local-PTY bridge, and the signal-delivery contract (REQ-TTY-02). The docker and SSH backends are future crates that implement the `TtyBackend` trait — out of scope for this spec set, but the trait shape is committed so they can be built against it.
|
||||
**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 five 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; `TtyControlHandle` newtype for `Clone`-ability — `Clone` is not object-safe), [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), [ADR-056](decisions/056-backend-cleanup-on-session-cancel.md) (backend cleanup contract: dropping the `exit_code` future on session cancel MUST kill the session target — closes the orphaned-process gap the local-PTY POC surfaced for the waiter thread). 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
|
||||
|
||||
@@ -117,6 +117,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
|
||||
| [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 |
|
||||
| [056](decisions/056-backend-cleanup-on-session-cancel.md) | Backend Cleanup on Session Cancel (Drop of `exit_code` Kills) | Accepted |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -41,12 +41,13 @@ the PTY (docker, SSH, local process) via a `TtyBackend` trait.
|
||||
| [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 |
|
||||
| [056](../../decisions/056-backend-cleanup-on-session-cancel.md) | Backend Cleanup on Session Cancel | Dropping the `exit_code` future (session cancel) MUST kill the session target; behavioral contract on the `TtyBackend` trait |
|
||||
|
||||
## 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-43 | `TtyControl` trait object `Clone` constraint | resolved | `control: Option<TtyControlHandle>` via a `#[derive(Clone)]` newtype wrapping `Arc<dyn TtyControl + Send + Sync>`; the trait is NOT `Clone` (not object-safe) — the newtype carries `Clone`-ability; 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 | resolved | QUIC per-stream flow control is the backpressure mechanism (chain complete by construction); no application-level windowing. Reversal is an additive `ControlMessage` variant, not a wire-format change |
|
||||
| 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 |
|
||||
|
||||
@@ -245,6 +245,7 @@ PTY allocation code. See [ADR-054](../../decisions/054-local-tty-backend-sibling
|
||||
| `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 |
|
||||
| Backend cleanup on session cancel | [ADR-056](../../decisions/056-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future (cancel) kills the session target; the adapter triggers it by dropping the `TtyHandle` |
|
||||
| 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 |
|
||||
|
||||
@@ -10,7 +10,8 @@ The `TtyAdapter` is the `ProtocolHandler` for `alknet/tty`: it receives a
|
||||
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.
|
||||
exit-chunk ordering (ADR-055), session-cancel cleanup (ADR-056), and
|
||||
access control.
|
||||
|
||||
## What
|
||||
|
||||
@@ -138,23 +139,24 @@ 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
|
||||
big-endian length prefix always starts with `0x00` (error frames MUST
|
||||
be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this is
|
||||
a wire-format invariant, not an assumption), 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.
|
||||
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) and MUST be under 16 MiB; 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)
|
||||
|
||||
@@ -247,19 +249,30 @@ architectural commitment.
|
||||
|
||||
- **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.
|
||||
are dropped (Rust `Drop`); the `TtyHandle` is dropped; the
|
||||
`exit_code` future is dropped without being driven to completion,
|
||||
which triggers the backend's cancel-cleanup — the session target is
|
||||
killed (ADR-056). For the local backend, the `exit_code` future's
|
||||
`Drop` calls `ChildKiller::kill(SIGHUP)`, the child exits, the
|
||||
waiter thread's `wait()` reaps it and exits, and the reader/writer
|
||||
threads exit on channel close. For docker/SSH backends (future), the
|
||||
`Drop` issues the backend's kill (container kill / channel close).
|
||||
See ADR-056 for the contract and the mechanism.
|
||||
- **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.
|
||||
tasks exit; the `TtyHandle` is dropped; the cancel-cleanup runs
|
||||
(ADR-056). 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.
|
||||
the exit chunk is sent — the client just stopped sending input. This
|
||||
is NOT a cancel from the adapter's perspective (the session runs to
|
||||
completion); the cancel-cleanup (ADR-056) is not triggered. The
|
||||
cancel-cleanup is triggered only when the *adapter* drops the handle
|
||||
(connection drop, stream reset, panic), not when the client closes
|
||||
the write half.
|
||||
|
||||
## Constraints
|
||||
|
||||
@@ -279,6 +292,16 @@ architectural commitment.
|
||||
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.
|
||||
- **The adapter triggers backend cleanup by dropping the `TtyHandle`
|
||||
(ADR-056).** On connection drop, stream reset, or pump-task panic, the
|
||||
adapter's pump tasks are dropped, which drops the `TtyHandle`, which
|
||||
drops the `exit_code` future without driving it to completion. The
|
||||
`exit_code` future's `Drop` is the backend's cancel-cleanup path
|
||||
(kill the session target). The adapter has no separate kill method to
|
||||
call; the cleanup is wired into the `exit_code` future's `Drop` by the
|
||||
backend. A backend that returns an `exit_code` future without a
|
||||
kill-on-`Drop` guard violates the contract and will orphan processes
|
||||
on cancel. See ADR-056.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
@@ -287,6 +310,7 @@ architectural commitment.
|
||||
| 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 |
|
||||
| Backend cleanup on session cancel | [ADR-056](../../decisions/056-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future kills the session target; the adapter triggers it by dropping the `TtyHandle` on cancel |
|
||||
| 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 |
|
||||
@@ -305,6 +329,9 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
backend trait the adapter dispatches to
|
||||
- [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) — the
|
||||
exit-chunk ordering the adapter enforces
|
||||
- [ADR-056](../../decisions/056-backend-cleanup-on-session-cancel.md) —
|
||||
the cancel-cleanup contract the adapter triggers by dropping the
|
||||
`TtyHandle` on session cancel
|
||||
- [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`,
|
||||
|
||||
@@ -159,7 +159,7 @@ pub struct TerminalParams {
|
||||
pub rows: u16,
|
||||
pub pixel_width: u16,
|
||||
pub pixel_height: u16,
|
||||
pub modes: serde_json::Value, // reserved — OQ-44
|
||||
pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1
|
||||
}
|
||||
```
|
||||
|
||||
@@ -215,13 +215,18 @@ and why the typed-enum alternative (with `SshChannelRef`) was rejected.
|
||||
```rust
|
||||
pub struct TtyHandle {
|
||||
/// Stdin writer — bytes the adapter pumps from client stdin chunks.
|
||||
pub stdin: Box<dyn AsyncWrite + Send + Unpin>,
|
||||
/// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io`
|
||||
/// one — they are incompatible traits; the tokio stack is the
|
||||
/// adapter's runtime).
|
||||
pub stdin: Box<dyn tokio::io::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>>,
|
||||
/// `futures_core::Stream<Item = bytes::Bytes>` (re-exported by
|
||||
/// `tokio_stream::StreamExt` for extension methods).
|
||||
pub stdout: Pin<Box<dyn futures_core::Stream<Item = bytes::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>>>,
|
||||
pub stderr: Option<Pin<Box<dyn futures_core::Stream<Item = bytes::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
|
||||
@@ -232,14 +237,14 @@ pub struct TtyHandle {
|
||||
/// 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>>,
|
||||
pub control: Option<TtyControlHandle>,
|
||||
}
|
||||
```
|
||||
|
||||
### `TtyControl` trait
|
||||
### `TtyControl` trait and `TtyControlHandle`
|
||||
|
||||
```rust
|
||||
pub trait TtyControl: Send {
|
||||
pub trait TtyControl: Send + Sync {
|
||||
/// 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.
|
||||
@@ -250,15 +255,31 @@ pub trait TtyControl: Send {
|
||||
/// back to the backend's default kill.
|
||||
fn signal(&self, name: &str);
|
||||
}
|
||||
|
||||
/// The `Clone`-able handle to a backend's control path. The `TtyControl`
|
||||
/// trait is NOT `Clone` (`Clone` is not object-safe — `fn clone(&self) ->
|
||||
/// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability
|
||||
/// lives on this concrete newtype, which holds the trait object behind an
|
||||
/// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned
|
||||
/// control-chunk dispatcher. See OQ-43.
|
||||
#[derive(Clone)]
|
||||
pub struct TtyControlHandle(Arc<dyn TtyControl + Send + Sync>);
|
||||
|
||||
impl TtyControlHandle {
|
||||
pub fn new(control: Arc<dyn TtyControl + Send + Sync>) -> Self { Self(control) }
|
||||
pub fn resize(&self, c: u16, r: u16, pw: u16, ph: u16) { self.0.resize(c, r, pw, ph) }
|
||||
pub fn signal(&self, name: &str) { self.0.signal(name) }
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
The trait is kept object-safe by NOT putting `Clone` on it; the `Clone`
|
||||
newtype (`TtyControlHandle`) holds the trait object behind an `Arc`. The
|
||||
POC used a concrete `PtyControl` struct (inherently `Clone` — it held
|
||||
`Arc<Mutex<...>>` fields); this newtype generalizes the POC's shape so a
|
||||
backend produces its own control type via `TtyControlHandle::new(Arc::new(MyControl))`
|
||||
without the adapter knowing the concrete shape. See OQ-43 for the
|
||||
confirmation and the rationale for why `Clone` cannot live on the trait
|
||||
itself.
|
||||
|
||||
### REQ-TTY-01: backends are not required to be natively async
|
||||
|
||||
@@ -352,6 +373,19 @@ ALPN (`alknet/tty`), not hedged inside alknet-ssh.
|
||||
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.
|
||||
- **Dropping the `exit_code` future MUST kill the session target
|
||||
(ADR-056).** The `exit_code` field is a `BoxFuture<'static,
|
||||
Result<i32, TtyError>>` whose `Drop`-on-cancel (i.e., dropped without
|
||||
being driven to completion) MUST kill the child/container/SSH process.
|
||||
This is a behavioral contract on the `TtyBackend` trait — the adapter
|
||||
triggers it by dropping the `TtyHandle` on session cancel (connection
|
||||
drop, stream reset); the backend wires the kill into the `exit_code`
|
||||
future's `Drop`. A backend that returns a bare `oneshot::Receiver<i32>`
|
||||
(or any future without a kill-on-`Drop` guard) as `exit_code`
|
||||
violates the contract and will orphan processes on cancel. See
|
||||
[ADR-056](../../decisions/056-backend-cleanup-on-session-cancel.md)
|
||||
and [tty-local.md](tty-local.md) §"Cancel-Cleanup (ADR-056)" for the
|
||||
local backend's mechanism.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
@@ -361,6 +395,7 @@ ALPN (`alknet/tty`), not hedged inside alknet-ssh.
|
||||
| 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 |
|
||||
| Backend cleanup on session cancel | [ADR-056](../../decisions/056-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future (cancel) MUST kill the session target; contract on the `TtyBackend` trait |
|
||||
| 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
|
||||
@@ -378,6 +413,9 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
— 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-056](../../decisions/056-backend-cleanup-on-session-cancel.md) —
|
||||
the cancel-cleanup contract on this trait (`exit_code` future's
|
||||
`Drop`-on-cancel kills the session target)
|
||||
- [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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-06
|
||||
last_updated: 2026-07-07
|
||||
---
|
||||
|
||||
# alknet-tty — Local TTY Backend (`alknet-tty-local`)
|
||||
@@ -104,10 +104,12 @@ pub enum StdinCmd {
|
||||
`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).
|
||||
with the exit code. The async-facing `TtyHandle.exit_code` is a
|
||||
`Future` wrapping this `oneshot::Receiver<i32>` PLUS a kill guard
|
||||
holding the `portable_pty::ChildKiller` (see "Cancel-Cleanup
|
||||
(ADR-056)" below). This is the `Future` the adapter awaits (ADR-053
|
||||
REQ-TTY-01; ADR-055); its `Drop`-on-cancel kills the child
|
||||
(ADR-056).
|
||||
|
||||
`TtyHandle.stderr` is `None` (PTY backends merge stdout/stderr — kernel
|
||||
PTY property, one output stream from the slave).
|
||||
@@ -164,6 +166,64 @@ The spec records:
|
||||
This pre-empts a class of "Ctrl-C doesn't kill my `cargo build`" bugs
|
||||
that would otherwise surface in Phase 2/3.
|
||||
|
||||
### Cancel-Cleanup (ADR-056)
|
||||
|
||||
The `TtyBackend` cleanup contract (ADR-056): **dropping the `exit_code`
|
||||
future kills the session target.** The local backend implements this for
|
||||
both PTY and pipe modes.
|
||||
|
||||
**PTY mode.** `allocate()` obtains a `portable_pty::Child` (with
|
||||
`wait()`) and a `portable_pty::ChildKiller` (with `kill()`) — the two
|
||||
handles `portable_pty` exposes alongside each other. The `Child` moves
|
||||
into the waiter thread (which blocks on `wait()`). The `ChildKiller`
|
||||
moves into the `exit_code` future's `Drop` guard, alongside the
|
||||
`oneshot::Receiver<i32>` from the waiter thread. The future's `poll`
|
||||
delegates to the oneshot receiver (resolves on natural exit); the
|
||||
future's `Drop` (runs on cancel only — on resolve, the guard is
|
||||
disarmed) calls `ChildKiller::kill(SIGHUP)`:
|
||||
|
||||
```rust
|
||||
struct LocalExitFuture {
|
||||
rx: oneshot::Receiver<i32>,
|
||||
killer: Option<portable_pty::ChildKiller>, // None after resolve (disarmed)
|
||||
}
|
||||
|
||||
impl Future for LocalExitFuture { /* poll delegates to rx; on Ready, take killer */ }
|
||||
impl Drop for LocalExitFuture {
|
||||
fn drop(&mut self) {
|
||||
if let Some(killer) = self.killer.take() {
|
||||
let _ = killer.kill(SIGHUP); // best-effort; child may already be exiting
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
On cancel: the `Drop` kills the child (SIGHUP); the child exits; the
|
||||
waiter thread's `wait()` reaps it and exits (its `oneshot::send` fails
|
||||
silently — the receiver was dropped with the future, which is expected);
|
||||
the reader/writer threads exit on channel close. The child is reaped
|
||||
(no zombie) by the waiter thread's `wait()` returning after the kill.
|
||||
|
||||
**Pipe mode.** The same pattern with `tokio::process::Child` instead of
|
||||
`portable_pty::Child`. The `exit_code` future's `Drop` guard holds the
|
||||
`Child` handle (or a `Child`-kill wrapper) and calls
|
||||
`Child::start_kill()` on cancel. The waiter task (`Child::wait()`)
|
||||
reaps the killed child.
|
||||
|
||||
**The happy path is unaffected.** When the adapter drives `exit_code`
|
||||
to completion (the child exits naturally), the future resolves, the
|
||||
guard is disarmed (the `Option::take()` in `poll`'s `Ready` branch),
|
||||
and the subsequent `Drop` is a no-op. The contract is "kill on cancel;
|
||||
no-op on resolve."
|
||||
|
||||
This closes the orphaned-process gap the local-PTY POC surfaced: a
|
||||
child that ignores stdin EOF (a daemon, a long-lived process with no
|
||||
stdin reader) is killed when the session is cancelled, not left
|
||||
running. The POC's `LocalPty::exit_code` was a bare
|
||||
`oneshot::Receiver<i32>` with no kill guard — an implementer who
|
||||
copies the POC's shape without the guard violates the contract. See
|
||||
ADR-056 for the contract and the trait-level rationale.
|
||||
|
||||
### Pipe Mode (`terminal: None`)
|
||||
|
||||
`allocate()` spawns the command with `std::process::Command` and
|
||||
@@ -271,6 +331,14 @@ building runner policy into alknet-tty.
|
||||
dependency-free at construction — the `portable_pty` system is
|
||||
process-global. The assembly layer constructs one `LocalTtyBackend`
|
||||
and registers it as `"local"`.
|
||||
- **The `exit_code` future's `Drop`-on-cancel kills the child
|
||||
(ADR-056).** The local backend MUST NOT return a bare
|
||||
`oneshot::Receiver<i32>` as `TtyHandle.exit_code` — it must wrap it
|
||||
in a `Future` whose `Drop` calls `ChildKiller::kill(SIGHUP)` (PTY) or
|
||||
`Child::start_kill()` (pipe) when dropped without resolving. An
|
||||
implementer who copies the POC's bare `oneshot::Receiver<i32>` shape
|
||||
without the kill guard violates the contract and will orphan
|
||||
processes on session cancel. See ADR-056.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
@@ -280,6 +348,7 @@ building runner policy into alknet-tty.
|
||||
| `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 |
|
||||
| Backend cleanup on session cancel | [ADR-056](../../decisions/056-backend-cleanup-on-session-cancel.md) | The `exit_code` future's `Drop`-on-cancel kills the child via `ChildKiller` (PTY) / `start_kill` (pipe); the waiter thread reaps |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -294,6 +363,12 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
- [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) — the
|
||||
trait this backend implements; REQ-TTY-01 (the blocking-backend
|
||||
accommodation)
|
||||
- [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) — the
|
||||
waiter thread's `oneshot::Receiver<i32>` feeds the exit chunk
|
||||
- [ADR-056](../../decisions/056-backend-cleanup-on-session-cancel.md) —
|
||||
the cancel-cleanup contract this backend implements (the `exit_code`
|
||||
future's `Drop`-on-cancel kills the child via `ChildKiller` /
|
||||
`start_kill`)
|
||||
- `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)
|
||||
|
||||
@@ -80,7 +80,13 @@ The payload shape:
|
||||
Fields:
|
||||
|
||||
- `carriage` — `"raw"` for terminal sessions (the only carriage in v1).
|
||||
Selects the post-negotiation byte format.
|
||||
Selects the post-negotiation byte format. MUST be `"raw"` in v1; any
|
||||
other value (e.g., `"json"`, an unknown carriage, or the field
|
||||
absent) is a `malformed_negotiation` error and the adapter closes the
|
||||
stream without entering raw mode. A future carriage (e.g., a
|
||||
structured JSON-only mode for a non-terminal use case) is a v2
|
||||
addition; in v1 the field is required and must be the literal
|
||||
`"raw"`.
|
||||
- `backend` — the backend selector string (`"local"`, `"docker"`,
|
||||
`"ssh"`). The adapter dispatches to the registered `TtyBackend` by this
|
||||
key (ADR-053 §5).
|
||||
@@ -95,6 +101,49 @@ Fields:
|
||||
- `cwd` — working directory (`null` = inherit/default).
|
||||
- `env` — environment variables (empty = inherit).
|
||||
|
||||
The Rust struct the adapter parses the frame into:
|
||||
|
||||
```rust
|
||||
#[derive(Deserialize)]
|
||||
pub struct NegotiateRequest {
|
||||
pub carriage: String, // "raw" in v1; any other value → malformed_negotiation
|
||||
pub backend: String, // backend selector key ("local", "docker", "ssh")
|
||||
pub tty: Option<TerminalParamsWire>, // None = pipe mode (ADR-054)
|
||||
pub cmd: Vec<String>, // argv[0] + args; non-empty
|
||||
#[serde(default)]
|
||||
pub cwd: Option<PathBuf>, // None = inherit/default
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>, // empty = inherit
|
||||
#[serde(default)]
|
||||
pub backend_params: serde_json::Map<String, serde_json::Value>, // opaque; backend-deserialized
|
||||
// plus backend-specific fields, captured into backend_params via serde(flatten)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TerminalParamsWire {
|
||||
pub term: Option<String>, // None = backend default
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
#[serde(default)]
|
||||
pub pixel_width: u16,
|
||||
#[serde(default)]
|
||||
pub pixel_height: u16,
|
||||
#[serde(default)]
|
||||
pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1
|
||||
}
|
||||
```
|
||||
|
||||
Validation: `carriage` MUST be `"raw"` (else `malformed_negotiation`);
|
||||
`cmd` MUST be non-empty (else `malformed_negotiation`); `backend` MUST
|
||||
be a registered backend key (else `unknown_backend`). Backend-specific
|
||||
params validation is the backend's job (in `allocate()`); the adapter
|
||||
does not interpret `backend_params`. The struct's `serde(flatten)` for
|
||||
backend-specific fields means the negotiation frame's top-level JSON
|
||||
object carries both the shared fields (`carriage`, `backend`, `tty`,
|
||||
`cmd`, `cwd`, `env`) and the backend-specific fields (e.g.,
|
||||
`"container": "abc123"` for docker); the latter land in
|
||||
`backend_params`.
|
||||
|
||||
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 remaining backend-specific
|
||||
@@ -258,8 +307,12 @@ a session — see [tty-adapter.md](tty-adapter.md).
|
||||
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".
|
||||
without entering raw mode. The error response MUST be under 16 MiB
|
||||
(`MAX_CHUNK_LEN`) so the 4-byte big-endian length prefix's high byte
|
||||
is `0x00` — this is what makes the framing-disambiguation trick
|
||||
(first byte `0x00` = error frame, first byte `1`/`2`/`3` = raw chunk)
|
||||
sound; it is a wire-format invariant, not an empirical observation.
|
||||
See [tty-adapter.md](tty-adapter.md) §"Negotiation errors".
|
||||
|
||||
## Design Decisions
|
||||
|
||||
|
||||
@@ -194,17 +194,18 @@ closes after it.
|
||||
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
|
||||
big-endian length prefix always starts with `0x00` (error frames MUST
|
||||
be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this is a
|
||||
wire-format invariant, not an assumption), 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) and MUST be under 16 MiB; 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.
|
||||
|
||||
|
||||
@@ -59,11 +59,13 @@ 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.
|
||||
not a 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
|
||||
`TtyControlHandle` newtype — a concrete `#[derive(Clone)]` struct
|
||||
wrapping `Arc<dyn TtyControl + Send + Sync>` (the trait is NOT `Clone`;
|
||||
`Clone` is not object-safe — see OQ-43). The newtype carries the
|
||||
`Clone`-ability; the trait stays object-safe.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -122,7 +124,7 @@ pub struct TerminalParams {
|
||||
pub rows: u16,
|
||||
pub pixel_width: u16,
|
||||
pub pixel_height: u16,
|
||||
pub modes: serde_json::Value, // reserved — see OQ-44
|
||||
pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1
|
||||
}
|
||||
```
|
||||
|
||||
@@ -209,14 +211,19 @@ below.
|
||||
```rust
|
||||
pub struct TtyHandle {
|
||||
/// Stdin writer — bytes the adapter pumps from client stdin chunks.
|
||||
pub stdin: Box<dyn AsyncWrite + Send + Unpin>,
|
||||
/// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io`
|
||||
/// one — they are incompatible traits; the tokio stack is the
|
||||
/// adapter's runtime).
|
||||
pub stdin: Box<dyn tokio::io::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>>,
|
||||
/// `futures_core::Stream<Item = bytes::Bytes>` (re-exported by
|
||||
/// `tokio_stream::StreamExt` for extension methods).
|
||||
pub stdout: Pin<Box<dyn futures_core::Stream<Item = bytes::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>>>,
|
||||
pub stderr: Option<Pin<Box<dyn futures_core::Stream<Item = bytes::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
|
||||
@@ -228,12 +235,12 @@ pub struct TtyHandle {
|
||||
/// 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>>,
|
||||
/// but resize is a no-op). See OQ-43 for the `TtyControlHandle`
|
||||
/// newtype rationale.
|
||||
pub control: Option<TtyControlHandle>,
|
||||
}
|
||||
|
||||
pub trait TtyControl: Send {
|
||||
pub trait TtyControl: Send + Sync {
|
||||
/// 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
|
||||
@@ -245,6 +252,15 @@ pub trait TtyControl: Send {
|
||||
/// back to the backend's default kill.
|
||||
fn signal(&self, name: &str);
|
||||
}
|
||||
|
||||
/// The `Clone`-able handle to a backend's control path. The `TtyControl`
|
||||
/// trait is NOT `Clone` (`Clone` is not object-safe — `fn clone(&self) ->
|
||||
/// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability
|
||||
/// lives on this concrete newtype, which holds the trait object behind an
|
||||
/// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned
|
||||
/// control-chunk dispatcher. See OQ-43.
|
||||
#[derive(Clone)]
|
||||
pub struct TtyControlHandle(Arc<dyn TtyControl + Send + Sync>);
|
||||
```
|
||||
|
||||
### 4. Backends are not required to be natively async (REQ-TTY-01)
|
||||
@@ -320,13 +336,16 @@ layer chooses what's available.
|
||||
full rationale and why the typed-enum alternative was rejected (Rust
|
||||
enums are closed; the earlier `SshChannelRef` variant was an output
|
||||
modeled as an input and created a dependency contradiction).
|
||||
- `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.
|
||||
- **`TtyControl` is not `Clone`; the `TtyControlHandle` newtype is.**
|
||||
`Clone` is not object-safe (`fn clone(&self) -> Self` returns `Self`,
|
||||
which forbids `dyn` dispatch), so `Box<dyn TtyControl + Clone>` does
|
||||
not compile. The design splits the concerns: the `TtyControl` trait
|
||||
stays object-safe (`Send + Sync`, no `Clone`); the `TtyControlHandle`
|
||||
newtype (a concrete struct holding `Arc<dyn TtyControl + Send +
|
||||
Sync>`) implements `Clone` by cloning the `Arc`. 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 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`
|
||||
@@ -350,12 +369,15 @@ impl doesn't break existing implementors); its return type
|
||||
|
||||
## 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.
|
||||
1. **The `TtyControl` trait is kept object-safe by NOT putting `Clone`
|
||||
on it; the `TtyControlHandle` newtype holds the trait object behind an
|
||||
`Arc` and implements `Clone` by cloning the `Arc`.** The POC used a
|
||||
concrete `PtyControl` struct (inherently `Clone` — it held
|
||||
`Arc<Mutex<...>>` fields). The newtype generalizes the POC's shape so
|
||||
a backend produces its own control type via
|
||||
`TtyControlHandle::new(Arc::new(MyControl))` without the adapter
|
||||
knowing the concrete shape. `Clone` cannot live on the trait itself
|
||||
(it is not object-safe); the newtype is the seam. OQ-43 confirms.
|
||||
|
||||
2. **Backends produce a single session per `allocate()` call.** The
|
||||
adapter calls `allocate()` once per accepted bidi stream (one session
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
# ADR-056: Backend Cleanup on Session Cancel (Drop of `exit_code` Kills)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
A `alknet/tty` session can be cancelled before the child process exits
|
||||
naturally. Three cancel paths exist (see `tty-adapter.md` §"Connection and
|
||||
Stream Lifecycle"):
|
||||
|
||||
1. **Connection drop** — the QUIC connection closes; all in-flight sessions
|
||||
on it are cancelled.
|
||||
2. **Stream reset** — the client (or transport) resets the bidi stream
|
||||
mid-session.
|
||||
3. **Task panic / adapter shutdown** — the session pump task exits without
|
||||
completing the normal exit-chunk sequence.
|
||||
|
||||
In all three, the adapter's per-session pump tasks are dropped (Rust
|
||||
`Drop`). The pump tasks hold the `TtyHandle`, whose fields — `stdin`,
|
||||
`stdout`, `stderr`, `exit_code`, `control` — are dropped too.
|
||||
|
||||
The problem the local-PTY POC surfaced: **`Drop` alone is not sufficient
|
||||
cleanup for a backend whose child may outlive the session.** The local
|
||||
backend's three std threads illustrate the gap:
|
||||
|
||||
- **Reader thread** — blocking `MasterPty::try_clone_reader()` reads. On
|
||||
`mpsc::Sender::drop` (the stdout channel's sender side drops when the
|
||||
handle drops), the reader's next `blocking_send` returns a `SendError`;
|
||||
the thread exits. ✓ `Drop` works.
|
||||
- **Writer thread** — drains an `mpsc::Receiver<StdinCmd>`. On
|
||||
`Receiver::drop`, the writer's `recv()` returns `None`; the thread
|
||||
drops the master writer (EOF to the slave's stdin) and exits. ✓ `Drop`
|
||||
works.
|
||||
- **Waiter thread** — blocking `Child::wait()`. This is a syscall that
|
||||
returns **only when the child is reaped**. It does not observe channel
|
||||
close. If the child ignores stdin EOF (a daemon, a long-lived process
|
||||
with no stdin reader, a process in an uninterruptible state), the
|
||||
waiter thread stays blocked indefinitely and the child is **orphaned**.
|
||||
✗ `Drop` does not work here.
|
||||
|
||||
The same concern applies to any backend whose session target can outlive
|
||||
the bidi stream: a docker container with `tty: true` whose process ignores
|
||||
the channel close; an SSH exec whose remote process doesn't exit on
|
||||
channel close. The unifying property is **a backend-allocated session
|
||||
target that may outlive the client's interest in it.**
|
||||
|
||||
The earlier spec text (`tty-adapter.md` §"Connection and Stream
|
||||
Lifecycle" pre-this-ADR) asserted:
|
||||
|
||||
> No explicit cleanup is needed — `Drop` is the cleanup.
|
||||
|
||||
That is wrong for the waiter thread and any backend in the same shape.
|
||||
This ADR corrects the claim and commits a cleanup contract that closes
|
||||
the gap.
|
||||
|
||||
### Why this is architectural, not implementation
|
||||
|
||||
The cleanup contract is part of the `TtyBackend` trait's behavioral
|
||||
contract (ADR-053), not a backend-internal detail, for two reasons:
|
||||
|
||||
1. **The adapter depends on the property.** The adapter's session
|
||||
driver holds the `TtyHandle` and, on cancel, drops it. The adapter
|
||||
cannot itself call a backend-specific kill — it doesn't know the
|
||||
child's pid (the local backend owns it; the adapter never sees it).
|
||||
The kill must be wired into the backend's handle shape, specifically
|
||||
into the `exit_code` future the adapter drops on cancel. The
|
||||
contract is what makes "the adapter drops the handle" sufficient.
|
||||
2. **A missing contract leaks processes.** An implementer writing a
|
||||
backend from the trait sketch, without this contract, would ship a
|
||||
backend that orphans processes on cancel. The bug is silent (the
|
||||
client got what it wanted; the orphaned process is a server-side
|
||||
leak), surfaces only under cancel-heavy workloads or long-lived
|
||||
sessions, and is expensive to attribute after the fact. The contract
|
||||
makes the property load-bearing at the seam, not a property each
|
||||
backend rediscovers.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. The `TtyBackend` cleanup contract: cancelling the `exit_code` future kills the session target
|
||||
|
||||
When the adapter cancels a session (drops the pump tasks, which drops the
|
||||
`TtyHandle`), the backend's `exit_code` future — the
|
||||
`BoxFuture<'static, Result<i32, TtyError>>` field of `TtyHandle`
|
||||
(ADR-053) — is dropped *without being driven to completion*. The cleanup
|
||||
contract:
|
||||
|
||||
> **Dropping the `exit_code` future MUST kill the session target (the
|
||||
> child process, the docker exec, the SSH channel's process).** The kill
|
||||
> is best-effort (the target may already be exiting; the kill is a
|
||||
> no-op then), but it MUST be attempted. The kill MUST be delivered
|
||||
> even when the session target is blocked in a state that ignores stdin
|
||||
> EOF (a daemon, a process in uninterruptible sleep, a container whose
|
||||
> process ignores channel close).
|
||||
|
||||
`exit_code`'s `Drop` is the cancel path. The adapter drives the future to
|
||||
completion (the happy path — the child exits, the future resolves, the
|
||||
adapter sends the exit chunk); on cancel, the adapter drops the future
|
||||
(their `Drop`), which runs the kill.
|
||||
|
||||
This is a behavioral contract on the `TtyBackend` trait, not a new method.
|
||||
The trait's `allocate()` returns a `TtyHandle` whose `exit_code` field is
|
||||
a `Future` with a `Drop`-on-cancel that kills. The mechanism is
|
||||
backend-specific (see §3 for the local backend); the contract is
|
||||
backend-agnostic.
|
||||
|
||||
### 2. `exit_code`'s `Drop`-on-cancel MUST be safe to run after the future resolves
|
||||
|
||||
If the future resolved normally (the adapter awaited it, got the exit
|
||||
code, sent the exit chunk), the `Drop` runs on an already-resolved
|
||||
future. The kill MUST be a no-op in that case — the child is already
|
||||
reaped, the kill is delivered to a nonexistent pid, etc. This is the
|
||||
"best-effort" qualifier: a kill on an already-exited child is not an
|
||||
error. Backends implement this with a guard that distinguishes
|
||||
"resolved" from "cancelled" (a flag, an `Option` taken on resolve, an
|
||||
`Arc`-shared state).
|
||||
|
||||
### 3. Local backend mechanism: `ChildKiller` held in the `exit_code` future's `Drop` guard
|
||||
|
||||
The local backend (`alknet-tty-local`, ADR-054) implements the contract
|
||||
using `portable_pty::ChildKiller` — the kill handle `portable_pty`
|
||||
exposes alongside `Child::wait()`. The pattern:
|
||||
|
||||
- `allocate()` spawns the child via `portable_pty`, obtaining a `Child`
|
||||
(with `wait()`) and a `ChildKiller` (with `kill()`). It moves the
|
||||
`Child` into the waiter thread (which blocks on `wait()`). It wraps
|
||||
the `ChildKiller` and the `oneshot::Receiver<i32>` (from the waiter
|
||||
thread) into a `Future` that becomes `TtyHandle.exit_code`.
|
||||
- The `exit_code` future's `poll` delegates to the inner
|
||||
`oneshot::Receiver::poll` (resolves when the waiter thread sends the
|
||||
exit code).
|
||||
- The `exit_code` future's `Drop` (runs on cancel only — on resolve, the
|
||||
`Drop` is a no-op via the guard) calls `ChildKiller::kill(SIGHUP)`
|
||||
(or the backend's configured cancel signal). The kill causes the
|
||||
child to exit; the waiter thread's `wait()` returns; the waiter
|
||||
thread's `oneshot::send` fails silently (the receiver was dropped
|
||||
with the future). The waiter thread then exits. The child is reaped
|
||||
by the waiter thread's `wait()`; no zombie.
|
||||
|
||||
For pipe mode (`terminal: None`), the same pattern applies with
|
||||
`tokio::process::Child::start_kill()` (or `Child::kill()`) instead of
|
||||
`ChildKiller`. The `exit_code` future's `Drop` guard calls
|
||||
`start_kill()`; the `Child` is reaped by the future's `wait()` (or by
|
||||
the waiter task).
|
||||
|
||||
### 4. Future backends (docker, SSH) follow the same contract
|
||||
|
||||
- **Docker (`DockerTtyBackend`)** — `bollard`'s exec stream is cancelled
|
||||
by dropping the `AttachContainer` / `start_exec` stream and calling
|
||||
`bollard::container::kill_container` (or `exec::kill_exec` if
|
||||
available). The `exit_code` future's `Drop` holds the container/exec
|
||||
id and the `bollard::Docker` client; on cancel, it issues the kill.
|
||||
- **SSH (`SshTtyBackend`)** — russh's `Channel::close()` and/or
|
||||
`Channel::signal(SIGHUP)` terminate the remote process. The
|
||||
`exit_code` future's `Drop` holds the russh channel handle; on
|
||||
cancel, it closes the channel.
|
||||
|
||||
The docker and SSH backends are future work (out of scope for this spec
|
||||
set); the contract is what they implement. A future backend that does
|
||||
NOT fit the contract (e.g., a "recorded session replay" backend with no
|
||||
live process) implements a no-op `Drop`-on-cancel — the contract is
|
||||
"kill if there is a killable target; no-op if not."
|
||||
|
||||
### 5. The adapter does not call a backend kill method
|
||||
|
||||
The adapter has no `TtyBackend::cancel()` or `TtyHandle::kill()` method
|
||||
to call — the cleanup is wired into the `exit_code` future's `Drop`,
|
||||
which the adapter triggers by dropping the future. This keeps the trait
|
||||
surface unchanged (no new method) and the cleanup in the backend (where
|
||||
the kill handle lives). The adapter's only responsibility is to drop the
|
||||
`TtyHandle` (and therefore the `exit_code` future) when the session is
|
||||
cancelled — which it already does by virtue of dropping the pump tasks.
|
||||
|
||||
The `TtyControl::signal("HUP")` path (ADR-053) is the *client-initiated*
|
||||
signal forwarding path — a client sends a `{"type":"signal","name":"HUP"}`
|
||||
control chunk. It is NOT the cancel-cleanup path. The cancel-cleanup
|
||||
path is server-internal (the adapter drops the handle) and does not
|
||||
involve the wire format. These are two different signal paths; both end
|
||||
in the child receiving SIGHUP (or the backend's configured cancel
|
||||
signal), but they are triggered by different actors (client vs. server
|
||||
cancel).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- A backend that conforms to the contract cannot orphan a process on
|
||||
cancel. The local-PTY POC's waiter-thread gap is closed at the
|
||||
contract level, not left to each backend to rediscover.
|
||||
- The cleanup is idiomatic Rust — `Drop`-on-cancel of a `Future` is the
|
||||
standard pattern for resource cleanup in async Rust (the same pattern
|
||||
`tokio::process::Child` uses; the same pattern `tokio::io` AsyncRead
|
||||
guards use). No new trait method; no adapter-side kill call.
|
||||
- The contract is backend-agnostic — the mechanism (`ChildKiller` for
|
||||
local, `kill_container` for docker, `channel::close` for SSH) lives
|
||||
in the backend; the contract ("drop the future, the target dies")
|
||||
lives at the seam.
|
||||
- The happy path (the child exits, the adapter drives `exit_code` to
|
||||
completion, sends the exit chunk, then drops the resolved future) is
|
||||
unaffected — the `Drop`-on-resolve is a no-op via the guard.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- The `exit_code` future is no longer a trivial `oneshot::Receiver<i32>`
|
||||
wrapper; it carries a kill guard. This is a small implementation
|
||||
complexity increase (a struct with a `Drop` impl and a resolved-flag),
|
||||
but it is the cost of the contract. The POC's `LocalPty::exit_code`
|
||||
was a bare `oneshot::Receiver<i32>`; the spec'd `TtyHandle.exit_code`
|
||||
is a struct wrapping it. An implementer who copies the POC's bare
|
||||
shape without the kill guard violates the contract.
|
||||
- The contract is behavioral, not type-enforced. Rust cannot require
|
||||
"the `Drop` of the future returned by `allocate()` kills the child"
|
||||
in the type system. The contract is documented in the `TtyBackend`
|
||||
trait's doc comment and in this ADR; conformance is the
|
||||
implementer's responsibility. A test (a "cancel mid-session" test
|
||||
that asserts the child is reaped after the session is dropped)
|
||||
should be part of each backend's integration suite.
|
||||
- A backend whose session target genuinely cannot be killed (a
|
||||
backend that wraps an immutable shared resource, e.g., a "view a
|
||||
log stream" backend) implements the contract as a no-op. The
|
||||
contract is "best-effort kill if there is a killable target"; a
|
||||
no-op `Drop`-on-cancel is conformant for a non-killable target.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way.** The cleanup contract is part of the `TtyBackend` behavioral
|
||||
contract. Clients (the adapter) depend on "drop the handle, the session
|
||||
is cleaned up." Changing the contract after backends exist — e.g.,
|
||||
adding a separate `TtyBackend::cancel()` method and migrating the
|
||||
cleanup out of `exit_code`'s `Drop` — would require every backend to
|
||||
change. The `exit_code`-future-`Drop`-on-cancel mechanism is the seam.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **The `exit_code` future's `Drop` is the only cancel path.** The
|
||||
adapter does not call a separate kill method; it drops the handle.
|
||||
This means the cleanup runs in the same place the cancel happens (the
|
||||
pump task's `Drop`), not in a separate cancel call. This is the
|
||||
idiomatic Rust async cancel pattern and the one the trait commits.
|
||||
2. **The kill signal is the backend's configured cancel signal
|
||||
(SIGHUP for the local backend, the docker/SSH equivalent).** This is
|
||||
a server-internal signal path, distinct from the
|
||||
client-initiated `TtyControl::signal()` path (ADR-053). The cancel
|
||||
signal is not configurable from the wire format in v1; a backend
|
||||
that needs a different cancel signal configures it internally.
|
||||
3. **The waiter thread (local backend) reaps the killed child.** After
|
||||
the `Drop`-on-cancel calls `ChildKiller::kill(SIGHUP)`, the child
|
||||
exits; the waiter thread's `wait()` returns and reaps it (no
|
||||
zombie). The waiter thread then exits. The `oneshot::send` from the
|
||||
waiter thread fails silently (the receiver was dropped with the
|
||||
future) — this is expected and not an error.
|
||||
|
||||
## References
|
||||
|
||||
- `tty-adapter.md` §"Connection and Stream Lifecycle" — the cancel paths
|
||||
(connection drop, stream reset) that trigger the contract
|
||||
- `tty-local.md` §"PTY Mode" — the local backend's three-thread bridge
|
||||
and the waiter-thread gap this ADR closes
|
||||
- [ADR-052](052-alknet-tty-wire-format-and-two-carriage.md) — the wire
|
||||
format the cancel does not involve (the cleanup is server-internal)
|
||||
- [ADR-053](053-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend`
|
||||
trait this contract is part of; the `exit_code` field the cleanup
|
||||
wires into; the `TtyControl::signal()` path the cancel-cleanup path
|
||||
is distinct from
|
||||
- [ADR-054](054-local-tty-backend-sibling-crate.md) — the local backend
|
||||
this ADR's reference mechanism (`ChildKiller`) is for
|
||||
- [ADR-055](055-exit-code-on-control-chunk.md) — the happy-path
|
||||
exit-chunk sequence (the cancel path bypasses it; no exit chunk is
|
||||
sent on cancel — see `tty-adapter.md` §"Stream reset")
|
||||
- `/workspace/alknet-tty-poc/src/local_pty.rs` — the POC's `LocalPty`
|
||||
shape (the reference the contract generalizes; the POC's bare
|
||||
`oneshot::Receiver<i32>` `exit_code` is the shape an implementer
|
||||
must NOT copy without adding the kill guard)
|
||||
- `portable-pty` 0.9 `ChildKiller` — the kill handle the local
|
||||
backend's cancel-cleanup uses
|
||||
- Spec: [crates/tty/tty-backend.md](../crates/tty/tty-backend.md),
|
||||
[crates/tty/tty-adapter.md](../crates/tty/tty-adapter.md),
|
||||
[crates/tty/tty-local.md](../crates/tty/tty-local.md)
|
||||
@@ -6,18 +6,22 @@
|
||||
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)
|
||||
`TtyBackend` API surface — ADR-053), two-way (the concrete
|
||||
`TtyControlHandle` 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
|
||||
- **Resolution**: `TtyHandle.control` is `Option<TtyControlHandle>`.
|
||||
`TtyControlHandle` is a concrete `#[derive(Clone)]` newtype wrapping
|
||||
`Arc<dyn TtyControl + Send + Sync>`; the adapter clones the `Arc` to
|
||||
hand a handle to the spawned control-chunk dispatcher. The
|
||||
`TtyControl` trait itself is NOT `Clone` — `Clone` is not object-safe
|
||||
(`fn clone(&self) -> Self` returns `Self`, which forbids `dyn`
|
||||
dispatch), so `Box<dyn TtyControl + Clone>` does not compile. The
|
||||
design splits the concerns: the trait stays object-safe (`Send +
|
||||
Sync`, no `Clone`); the newtype carries the `Clone`-ability. The
|
||||
local-PTY POC used a concrete `PtyControl` struct (inherently `Clone`
|
||||
— it held `Arc<Mutex<...>>` fields); the newtype generalizes the POC's
|
||||
shape so a backend produces its own control type via
|
||||
`TtyControlHandle::new(Arc::new(MyControl))` 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
|
||||
|
||||
Reference in new issue
Block a user