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:
glm-5.2 committed 2026-07-07 10:38:49 +00:00
1 parent b90d717ac2
commit 25f0f55592
11 files changed
+594 -92

No files matched your search

+2 -1
View File
@@ -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
+2 -1
View File
@@ -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 |
+1
View File
@@ -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 |
+47 -20
View File
@@ -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`,
+52 -14
View File
@@ -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"
+80 -5
View File
@@ -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)
+56 -3
View File
@@ -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