phase 4: architecture docs + BAST schema + renumbered ADRs
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
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
---
|
||||
status: draft (ported from alknet 2026-08-17; alknet-tty → alktty,
|
||||
alknet-tty-local → alktty's `local` feature module, alknet/tty →
|
||||
alk/tty, alknet-core → alkcall::core, alknet-call → alkcall, ADRs
|
||||
renumbered 052..093 → 001..008)
|
||||
last_updated: 2026-08-17
|
||||
---
|
||||
|
||||
# alktty — Local TTY Backend (`local` feature module)
|
||||
|
||||
The local backend: a `TtyBackend` implementation that wraps
|
||||
`portable_pty` for the PTY case (terminal semantics — resize, signal
|
||||
delivery, escape-sequence handling) and `tokio::process::Command` with
|
||||
`Stdio::piped()` for the pipe/runner case (process-streaming without
|
||||
terminal semantics). This document specifies the `LocalTtyBackend`, the
|
||||
blocking→async bridge pattern (REQ-TTY-01's reference implementation),
|
||||
and the signal-delivery contract (REQ-TTY-02). The module placement is
|
||||
decided in [ADR-003](decisions/003-local-backend-placement.md); the trait
|
||||
it implements is in [tty-backend.md](tty-backend.md).
|
||||
|
||||
## What
|
||||
|
||||
`LocalTtyBackend` lives in alktty's `local` feature module
|
||||
(`src/local/`, gated by the `local` cargo feature) and implements
|
||||
`TtyBackend`. The backend's `allocate()` branches on `TtyParams.terminal`:
|
||||
|
||||
- **`terminal: Some(TerminalParams { ... })`** — allocate a real PTY via
|
||||
`portable_pty::native_pty_system().openpty()`, spawn the command into
|
||||
the slave side, return a `TtyHandle` with merged stdout (stderr is
|
||||
`None` — kernel PTY property) and a real `TtyControl` (resize via
|
||||
`MasterPty::resize`, signal via `libc::kill(-pgid, sig)`).
|
||||
- **`terminal: None`** — pipe mode, the runner case. Spawn the command
|
||||
with `Stdio::piped()` for stdin/stdout/stderr, return a `TtyHandle`
|
||||
with separate stdout and stderr (stderr is `Some`) and a `TtyControl`
|
||||
whose `resize` is a no-op (no PTY) and `signal` calls
|
||||
`libc::kill(pid, sig)` (still works for signal forwarding without a
|
||||
PTY).
|
||||
|
||||
The backend is the reference implementation of REQ-TTY-01 (backends need
|
||||
not be natively async) and carries REQ-TTY-02 (signal forwarding to the
|
||||
process group).
|
||||
|
||||
## Why
|
||||
|
||||
The local backend is the simplest backend and the one that enables the
|
||||
runner pattern: a process whose stdin/stdout/stderr/exit-code stream over
|
||||
a framed bidi connection — the same shape as GitHub/Gitea Actions runners,
|
||||
just over alk's transport instead of HTTP polling. With
|
||||
`LocalTtyBackend`, the dispatch project (a reverse runner that currently
|
||||
requires SSH on the remote end) works without SSH — the endpoint runs
|
||||
the process directly and streams its I/O back. SSH becomes one transport
|
||||
option (for reaching hosts that don't run alk), not a requirement.
|
||||
|
||||
The PTY case is what makes a terminal a terminal: real resize (via
|
||||
`ioctl(TIOCSWINSZ)`), signal delivery to the foreground process group
|
||||
(via `libc::kill(-pgid, sig)`, REQ-TTY-02), and escape-sequence handling
|
||||
(the kernel PTY's line discipline). Without a PTY, it's a runner (piped
|
||||
process); with a PTY, it's a terminal. The per-session choice
|
||||
(`TtyParams.terminal`) lets one `LocalTtyBackend` serve both — see
|
||||
ADR-003.
|
||||
|
||||
The wrinkle that drove the Phase 0 POC: `portable_pty` is a **blocking
|
||||
`std::io` API**, not async. `MasterPty::try_clone_reader()` returns
|
||||
`Box<dyn std::io::Read + Send>`; `take_writer()` returns
|
||||
`Box<dyn std::io::Write + Send>`; `Child::wait()` blocks. The POC was
|
||||
built to discover how that constraint shapes the `TtyBackend` trait
|
||||
(REQ-TTY-01) and the signal-delivery contract (REQ-TTY-02). This spec
|
||||
records both as requirements, not open questions — the POC turned them
|
||||
into grounded requirements.
|
||||
|
||||
## Architecture
|
||||
|
||||
### PTY Mode (`terminal: Some`)
|
||||
|
||||
`allocate()` calls `portable_pty::native_pty_system().openpty(PtySize)`
|
||||
with the terminal dimensions, spawns the command into the slave side
|
||||
via `SlavePty::spawn_command(CommandBuilder)`, drops the slave (so the
|
||||
child sees EOF on its stdin when the master writer closes), and returns
|
||||
a `TtyHandle`.
|
||||
|
||||
The blocking→async bridge (REQ-TTY-01's reference implementation):
|
||||
**three dedicated std threads** feed tokio mpsc/oneshot channels. The
|
||||
writer thread consumes an mpsc of `StdinCmd`:
|
||||
|
||||
```rust
|
||||
pub enum StdinCmd {
|
||||
Bytes(Vec<u8>), // write these bytes to the master writer
|
||||
Eof, // close the master writer (EOF to the slave's stdin)
|
||||
}
|
||||
```
|
||||
|
||||
1. **Reader thread** — blocking reads from `MasterPty::try_clone_reader()`
|
||||
→ `mpsc::Sender<Bytes>`. The reader loop reads into an 8 KiB buffer,
|
||||
copies each chunk to `Bytes`, and `blocking_send`s to the mpsc. On EOF
|
||||
(the master reader returns EOF when the slave closes — the child has
|
||||
exited and the OS has drained the PTY buffer), the thread sends a
|
||||
zero-length `Bytes` sentinel (the "drained" signal) and exits. The
|
||||
async-facing `TtyHandle.stdout` is the `mpsc::Receiver<Bytes>`,
|
||||
wrapped as `Pin<Box<dyn Stream<Item = Bytes> + Send>>`.
|
||||
2. **Writer thread** — drains an `mpsc::Receiver<StdinCmd>` → blocking
|
||||
writes to `MasterPty::take_writer()`. `StdinCmd::Bytes(bytes)` writes
|
||||
and flushes; `StdinCmd::Eof` drops the writer (sends EOF to the
|
||||
slave's stdin) and exits. The async-facing `TtyHandle.stdin` is the
|
||||
`mpsc::Sender<StdinCmd>`, wrapped as `Box<dyn AsyncWrite + Send +
|
||||
Unpin>` (an `AsyncWrite` impl that wraps each `write` as a
|
||||
`StdinCmd::Bytes` and `flush` as a no-op; the `mpsc::Sender` is the
|
||||
sink).
|
||||
3. **Waiter thread** — blocking `Child::wait()` → `oneshot::Sender<i32>`
|
||||
with the exit code. The async-facing `TtyHandle.exit_code` is a
|
||||
`Future` wrapping this `oneshot::Receiver<i32>` PLUS a kill guard
|
||||
holding the `portable_pty::ChildKiller` (see "Cancel-Cleanup
|
||||
(ADR-005)" below). This is the `Future` the adapter awaits (ADR-002
|
||||
REQ-TTY-01; ADR-004); its `Drop`-on-cancel kills the child
|
||||
(ADR-005).
|
||||
|
||||
`TtyHandle.stderr` is `None` (PTY backends merge stdout/stderr — kernel
|
||||
PTY property, one output stream from the slave).
|
||||
|
||||
`TtyHandle.control` is a `PtyControl` struct (the POC's concrete type;
|
||||
the trait-object form per ADR-002 is the `Arc`-backed `Clone` newtype,
|
||||
OQ-43):
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
pub struct PtyControl {
|
||||
master: Arc<Mutex<Box<dyn MasterPty + Send>>>,
|
||||
killer: Arc<Mutex<Box<dyn portable_pty::ChildKiller + Send + Sync>>>,
|
||||
pid: Option<u32>,
|
||||
}
|
||||
```
|
||||
|
||||
`resize()` locks the master and calls `MasterPty::resize(PtySize)` —
|
||||
non-blocking (it issues an `ioctl`). `signal()` — see REQ-TTY-02 below.
|
||||
|
||||
### REQ-TTY-02: Signal Forwarding Must Target the Process Group
|
||||
|
||||
`libc::kill(pid, sig)` on the spawned child's pid alone is **insufficient**
|
||||
for terminal semantics: a shell running under a PTY will have spawned
|
||||
children (a `find | grep` pipeline, a `make` with sub-makes), and those
|
||||
children will not receive the signal. A real terminal forwards Ctrl-C to
|
||||
the **foreground process group**, which (under job-control shells) is the
|
||||
process group the shell most recently spawned for the foreground job.
|
||||
|
||||
`portable_pty` makes the child a session leader (when
|
||||
`controlling_tty = true`, the default — `CommandBuilder::set_controlling_tty(true)`),
|
||||
so the child's pid *is* its process-group id, and `libc::kill(-pid, sig)`
|
||||
(the negative pid) reaches the whole group. The POC's `PtyControl::signal`
|
||||
uses exactly this — `kill(-pgid, sig)` with a fallback to `kill(pid, sig)`
|
||||
if the group signal fails (e.g., the child already exited).
|
||||
|
||||
The spec records:
|
||||
|
||||
1. **The local backend MUST forward signals to the child's process
|
||||
group, not just the child pid.** Using `kill(-pgid, sig)` when the
|
||||
child is a session leader (the `portable_pty` default).
|
||||
2. **The local backend MUST spawn the child as a session leader with a
|
||||
controlling tty.** This is `portable_pty`'s default
|
||||
(`CommandBuilder::set_controlling_tty(true)`); disabling it (e.g.,
|
||||
for container-boundary workarounds) breaks signal forwarding and is
|
||||
therefore not supported for the terminal use case.
|
||||
3. **The `TtyControl::signal` contract is "best-effort delivery to the
|
||||
foreground process group,"** not "the child pid receives the signal."
|
||||
Unknown signal names fall back to the backend's default kill
|
||||
(`portable_pty`'s `ChildKiller::kill` sends SIGHUP); known names map
|
||||
to `libc` signal numbers (`HUP`, `INT`, `QUIT`, `TERM`, `KILL`,
|
||||
`USR1`, `USR2`, `TSTP`, `CONT`) and are sent to the group.
|
||||
|
||||
This pre-empts a class of "Ctrl-C doesn't kill my `cargo build`" bugs
|
||||
that would otherwise surface in Phase 2/3.
|
||||
|
||||
### Cancel-Cleanup (ADR-005)
|
||||
|
||||
The `TtyBackend` cleanup contract (ADR-005): **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-005 for the contract and the trait-level rationale.
|
||||
|
||||
### Pipe Mode (`terminal: None`)
|
||||
|
||||
`allocate()` spawns the command with `tokio::process::Command` and
|
||||
`Stdio::piped()` for stdin, stdout, and stderr. The async bridge is
|
||||
simpler than the PTY case — tokio's `Child` provides `AsyncRead` for
|
||||
stdout/stderr and `AsyncWrite` for stdin directly (no std-thread
|
||||
bridge needed). `TtyHandle.stderr` is `Some` (separate streams). The
|
||||
`exit_code` future is `Child::wait()` (async on tokio's `Child`).
|
||||
|
||||
`TtyHandle.control` is a `PipeControl` whose `resize()` is a no-op
|
||||
(no PTY — resize doesn't apply) and `signal()` calls `libc::kill(pid, sig)`
|
||||
on the child's pid. Signal forwarding to the process group is not
|
||||
applicable in pipe mode (there's no session leader / controlling tty);
|
||||
`kill(pid, sig)` reaches the direct child only. If the child has
|
||||
spawned its own children, they won't receive the signal — this is a
|
||||
known limitation of the runner case (a runner that needs
|
||||
process-group signal delivery uses the PTY case, not the pipe case).
|
||||
|
||||
### The Threading/Deadlock Caveat (DP-4, Acknowledged Constraint)
|
||||
|
||||
`std::process::Command` with piped stdio can deadlock if stdin writes
|
||||
block while stdout/stderr buffers fill — the classic pipe-buffer deadlock.
|
||||
The fix is concurrent reads on stdout/stderr alongside stdin writes,
|
||||
which is exactly what the bidirectional pump does (the POC's
|
||||
`drive_attach_raw` runs the two directions as concurrent
|
||||
`tokio::spawn` tasks). The same pattern works for `LocalTtyBackend`:
|
||||
spawn one task pumping stdin→process, one task pumping process→stdout-chunks,
|
||||
one for stderr if piped. This is a known constraint with a known solution
|
||||
(POC-validated); no design decision needed.
|
||||
|
||||
### Module Placement (ADR-003)
|
||||
|
||||
The local backend is folded into alktty behind a `local` cargo feature
|
||||
(the single-crate consolidation ADR-003 records):
|
||||
|
||||
```toml
|
||||
# alktty Cargo.toml
|
||||
[features]
|
||||
default = []
|
||||
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
|
||||
```
|
||||
|
||||
A consumer that wants the local backend enables `features = ["local"]`
|
||||
and gets `alktty::local::LocalTtyBackend`. A consumer that only wants
|
||||
docker/ssh uses the default features and depends on the backend crate
|
||||
directly — no `portable_pty` in the dependency tree. See ADR-003.
|
||||
|
||||
The single-crate consolidation resolves the alknet cyclic-dep
|
||||
workaround that motivated the original sibling-crate decision
|
||||
(alknet ADR-054): `alknet-tty-local` depended on `alknet-tty` for the
|
||||
trait, and ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend`
|
||||
behind a `local` feature — which cargo rejects (a crate cannot
|
||||
re-export from a sibling crate it depends on via an optional dep AND
|
||||
have that sibling depend back on it). The workaround in the alknet
|
||||
mono-repo was the assembly-layer pattern (consumer depends on both
|
||||
crates directly). In alktty, the local backend is in the same crate as
|
||||
the trait, so the cyclic-dep workaround doesn't apply. ADR-003 records
|
||||
both the original alknet decision and the alktty consolidation.
|
||||
|
||||
### Dependencies
|
||||
|
||||
```
|
||||
alktty (local feature)
|
||||
├── alktty (default) (TtyBackend trait, TtyHandle, TtyControl, wire types)
|
||||
├── alkcall::core (via alktty's re-export; not direct)
|
||||
├── portable_pty (PTY allocation — the heavy dep, Unix openpty + Windows ConPTY)
|
||||
├── libc (signal forwarding — REQ-TTY-02, Unix only)
|
||||
└── tokio (process, rt-multi-thread, mpsc, oneshot, AsyncRead/AsyncWrite)
|
||||
```
|
||||
|
||||
The `local` feature is inherently non-wasm (`portable-pty` +
|
||||
`tokio::process` need a real OS); enabling `local` on
|
||||
`wasm32-unknown-unknown` is a build error by design. The default crate
|
||||
(no features) stays wasm-clean — see the crate root's `# WASM target`
|
||||
doc comment.
|
||||
|
||||
## The Runner Pattern
|
||||
|
||||
The pipe mode (`terminal: None`) is the "runner" generalization the
|
||||
research identified. A coordinator sends a negotiation frame with
|
||||
`{ "backend": "local", "tty": null, "cmd": ["cargo", "test"] }`; the
|
||||
endpoint runs `cargo test` with piped stdio, streams stdout/stderr chunks
|
||||
back, sends `{"type":"exit","code":N}` when it finishes (ADR-004). The
|
||||
coordinator gets reliable completion notification (the exit control
|
||||
chunk + stream close) — no polling, no plugin state.
|
||||
|
||||
This is functionally identical to GitHub/Gitea Actions runners, just over
|
||||
alk's transport instead of HTTP polling. The dispatch project is a
|
||||
reverse runner that currently requires SSH on the remote end; with
|
||||
`LocalTtyBackend`, the same pattern works without SSH — the endpoint
|
||||
runs the process directly. SSH becomes one transport option (for
|
||||
reaching hosts that don't run alk), not a requirement.
|
||||
|
||||
The runner-specific API surface (job management, log persistence, task
|
||||
graph integration) is **out of scope for alktty** (OQ-46). alktty
|
||||
provides the *mechanism* (a framed byte stream for a process + exit
|
||||
code); the runner *policy* is a downstream crate's job. This spec
|
||||
commits to preserving the option (`terminal: None` → pipe mode) and not
|
||||
building runner policy into alktty.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **PTY mode requires `portable_pty`'s native PTY (Unix `openpty` /
|
||||
Windows ConPTY).** The blocking→async bridge (three std threads) is
|
||||
the documented pattern for any blocking-API backend (REQ-TTY-01).
|
||||
PTY mode is `#[cfg(unix)]`-only in the source; pipe mode is
|
||||
cross-platform.
|
||||
- **Signal forwarding in PTY mode targets the process group (REQ-TTY-02).**
|
||||
`kill(-pgid, sig)` when the child is a session leader
|
||||
(`controlling_tty = true`, the default). Disabling the controlling tty
|
||||
breaks signal forwarding and is not supported for the terminal use
|
||||
case.
|
||||
- **Pipe mode does not forward signals to the process group.** `kill(pid,
|
||||
sig)` reaches the direct child only; grandchildren don't receive it.
|
||||
A runner that needs process-group signal delivery uses the PTY case.
|
||||
- **The pipe-buffer deadlock is handled by the concurrent pump.** The
|
||||
adapter's three-pump driver (`tty-adapter.md`) reads stdout/stderr
|
||||
concurrently with writing stdin — the POC-validated pattern. No design
|
||||
decision needed; the spec notes it as a known constraint with a known
|
||||
solution.
|
||||
- **`LocalTtyBackend` takes no constructor dependencies.** Unlike
|
||||
`DockerTtyBackend` (wraps a `bollard::Docker` client) or
|
||||
`SshTtyBackend` (wraps an SSH session), the local backend is
|
||||
dependency-free at construction — the `portable_pty` system is
|
||||
process-global. The assembly layer constructs one `LocalTtyBackend`
|
||||
and registers it as `"local"`.
|
||||
- **The `exit_code` future's `Drop`-on-cancel kills the child
|
||||
(ADR-005).** 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-005.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| Local backend placement | [ADR-003](decisions/003-local-backend-placement.md) | alktty folds the local backend in behind a `local` feature (resolves the alknet cyclic-dep workaround); PTY vs pipe per-session |
|
||||
| `TtyBackend` trait and `TtyHandle` | [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) | The trait this backend implements; REQ-TTY-01 (backends need not be natively async) |
|
||||
| Wire format | [ADR-001](decisions/001-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-004](decisions/004-exit-code-on-control-chunk.md) | The waiter thread's `oneshot::Receiver<i32>` feeds the exit chunk |
|
||||
| Backend cleanup on session cancel | [ADR-005](decisions/005-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
|
||||
|
||||
- **OQ-46** (deferred(scope)): Runner API surface.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-003](decisions/003-local-backend-placement.md) — the module
|
||||
placement decision (single-crate consolidation)
|
||||
- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) — the
|
||||
trait this backend implements; REQ-TTY-01 (the blocking-backend
|
||||
accommodation)
|
||||
- [ADR-004](decisions/004-exit-code-on-control-chunk.md) — the
|
||||
waiter thread's `oneshot::Receiver<i32>` feeds the exit chunk
|
||||
- [ADR-005](decisions/005-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`)
|
||||
- `src/local/` — the Rust source this spec documents (`backend.rs`,
|
||||
`pty.rs`, `pipe.rs`)
|
||||
- [tty-backend.md](tty-backend.md) — the trait this backend implements
|
||||
- [tty-adapter.md](tty-adapter.md) — the session driver that consumes
|
||||
this backend's handles
|
||||
Reference in New Issue
Block a user