Port the alknet-tty architecture docs into alktty and add the BAST document for the alk/tty wire format. Docs-only; no Rust source changes. Spec docs (docs/architecture/, flat layout — single-crate repo): - overview.md — crate purpose, two-carriage model, deps, ALPN, backend location map, feature gates - tty-wire.md — 5-byte chunk codec, control channel split (STREAM_CTRL_IN=3 / STREAM_CTRL_OUT=4), sentinels - tty-backend.md — TtyBackend trait, TtyHandle, TtyControl, REQ-TTY-01 (backends need not be natively async) - tty-adapter.md — TtyAdapter, three-pump driver, exit-chunk ordering (ADR-004), cancel cleanup (ADR-005), access control - tty-local.md — LocalTtyBackend (local feature module), PTY + pipe modes, REQ-TTY-02 (signal forwarding to process group) - README.md — architecture index ADRs (docs/architecture/decisions/, renumbered 001..008 from alknet 052,053,054,055,056,057,077,093 in order): - 001 wire format + two-carriage model (incl. Phase 7 control- channel split amendment) - 002 TtyBackend trait + TtyHandle - 003 local backend placement (records both the alknet sibling- crate decision and the alktty single-crate consolidation behind a local feature) - 004 exit code on a control chunk - 005 backend cleanup on session cancel - 006 self-contained negotiation framing - 007 tty inside channels (reversed by 008; kept for historical context with reversal notice) - 008 channels pure channel multiplexing (reverses 007; TTY always uses its 5-byte format) BAST document (docs/architecture/tty-bast.md): - Normative JSON spec for the alk/tty wire format, conforming to the BAST meta-schema at https://alk.dev/bast/v1/schema - 5-byte chunk header (struct, big-endian: stream_type uint8, length uint32) + StreamType enum (Stdin=0..CtrlOut=4) - ControlMessage union (field-name discriminator on type: resize/signal/eof/exit) with documented deviation that on-wire control payloads are UTF-8 JSON, not BAST's binary union encoding - NegotiationFrame (4-byte BE length + UTF-8 JSON body) + NegotiateRequest / TerminalParams JSON shapes - StreamType enum deviation noted: on-wire uint8, not BAST's standard u32 enum index (chunk header is 5 bytes, not 8) - alktty does not depend on alktype; the hand-rolled wire.rs is the runtime codec, the BAST is the human-readable contract AGENTS.md: fixed the ADR mapping table to match the plan's 8-to-8 mapping (the previous table substituted ADR-050 for 054, relabeled 056 as control-message split, dropped 077, and added a new control-split ADR at 006 — inconsistent with both the plan and the prose). ADR-050 (dynamic resource ownership) is an alkcall/alknet- core ADR, not tty-specific, and is not ported; the Phase 7 control split stays as an amendment inside ADR-001, mirroring alknet. Verification (all pass, no Rust source changed): - cargo test (80 passed) - cargo test --all-features (103 passed) - cargo clippy --all-targets -- -D warnings (clean) - cargo fmt --check (clean) - cargo check --target wasm32-unknown-unknown (clean) - cargo clippy --target wasm32-unknown-unknown -- -D warnings (clean) - cargo doc --no-deps: 9 pre-existing intra-doc-link warnings in src/session.rs and src/channels.rs (untouched by this commit; not introduced here) - BAST JSON parses; StreamType indices match wire.rs constants (0=Stdin..4=CtrlOut) - all markdown cross-reference links resolve
298 lines
15 KiB
Markdown
298 lines
15 KiB
Markdown
# ADR-005: Backend Cleanup on Session Cancel (Drop of `exit_code` Kills)
|
|
|
|
## Status
|
|
|
|
Accepted (ported from alknet ADR-056 2026-08-17; cross-references
|
|
renumbered to alktty's ADR range — ADR-052→001, ADR-053→002,
|
|
ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007,
|
|
ADR-093→008. The alknet ADR referenced by alknet number (052, 053, 054,
|
|
055) is not ported into alktty's ADR range as a separate ADR — it is
|
|
ADR-001, 002, 003, 004 here. The alknet originals at
|
|
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
|
|
authoritative for any ADR not yet ported.)
|
|
|
|
## Context
|
|
|
|
A `alk/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-002), 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-002) — 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 (ADR-003) 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-002) 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-002). 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](../tty-adapter.md) §"Connection and Stream
|
|
Lifecycle" — the cancel paths (connection drop, stream reset) that
|
|
trigger the contract
|
|
- [tty-local.md](../tty-local.md) §"Cancel-Cleanup (ADR-005)" — the
|
|
local backend's three-thread bridge and the waiter-thread gap this
|
|
ADR closes
|
|
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format the
|
|
cancel does not involve (the cleanup is server-internal)
|
|
- [ADR-002](002-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-003](003-local-backend-placement.md) — the local backend this
|
|
ADR's reference mechanism (`ChildKiller`) is for
|
|
- [ADR-004](004-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")
|
|
- `src/local/pty.rs` — the local backend's `LocalExitFuture` (the
|
|
`Future` + `Drop` guard this ADR specifies, with the `ChildKiller`
|
|
held in the guard)
|
|
- `src/local/pipe.rs` — the pipe-mode equivalent
|
|
(`tokio::process::Child::start_kill()` on `Drop`-on-cancel)
|
|
- `portable-pty` 0.9 `ChildKiller` — the kill handle the local
|
|
backend's cancel-cleanup uses
|
|
- Spec: [tty-backend.md](../tty-backend.md),
|
|
[tty-adapter.md](../tty-adapter.md),
|
|
[tty-local.md](../tty-local.md)
|
|
- Port origin: alknet ADR-056 at
|
|
`/workspace/@alkdev/alknet/docs/architecture/decisions/056-backend-cleanup-on-session-cancel.md` |