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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user