tasks(tty): decompose alknet-tty + alknet-tty-local into 15 implementation tasks

Break the tty spec set (docs/architecture/crates/tty/) into atomic,
dependency-ordered tasks across two crates:

alknet-tty (lean core, depends on alknet-core only — ADR-057):
  - crate-init, wire-codec, control-messages, negotiation, backend-trait,
    adapter, review-tty

alknet-tty-local (sibling, portable_pty + libc — ADR-054):
  - crate-init, pty-mode, pipe-mode, backend-impl, review-tty-local

integration:
  - local-feature-reexport, integration-test, review-tty-final

Three review checkpoints at critical points: after the core crate (before
the local backend builds on the one-way-door TtyBackend trait — ADR-053),
after the local backend (validating REQ-TTY-01/02 and ADR-056), and a final
merge-readiness review. The high-risk tasks (backend-trait, adapter,
pty-mode) are flagged; the ADR-056 kill-on-Drop guard the POC lacked is
called out in both pty-mode and pipe-mode.

Validated with taskgraph: 115 tasks, no cycles, 10-generation parallel
structure with 3-way parallelism after crate-init and 2-way after the
backend trait.
This commit is contained in:
glm-5.2 committed 2026-07-07 12:22:58 +00:00
1 parent dbac972aaa
commit 8d20f89902
15 files changed
+2409

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
---
id: tty-local/backend-impl
name: Implement LocalTtyBackend (TtyBackend) branching on terminal Some/None
status: pending
depends_on: [tty-local/pty-mode, tty-local/pipe-mode]
scope: narrow
risk: low
impact: component
level: implementation
---
## Description
Implement `LocalTtyBackend` in `src/backend.rs`: the `TtyBackend` implementation
that branches on `TtyParams.terminal` to dispatch to PTY mode (task
`tty-local/pty-mode`) or pipe mode (task `tty-local/pipe-mode`).
### LocalTtyBackend
```rust
pub struct LocalTtyBackend;
impl LocalTtyBackend {
pub fn new() -> Self;
}
#[async_trait]
impl TtyBackend for LocalTtyBackend {
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError> {
match &params.terminal {
Some(terminal) => pty::allocate_pty(
terminal.clone(),
params.cmd.clone(),
params.cwd.clone(),
params.env.clone(),
).await,
None => pipe::allocate_pipe(
params.cmd.clone(),
params.cwd.clone(),
params.env.clone(),
).await,
}
}
fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> {
None // local backend creates its own resource (process); no pre-existing resource
}
}
```
`LocalTtyBackend` takes no constructor dependencies (unlike
`DockerTtyBackend` which wraps a `bollard::Docker` client). The
`portable_pty` system is process-global. The assembly layer constructs one
`LocalTtyBackend` and registers it as `"local"`.
### Validation
`allocate()` validates `params.cmd` is non-empty (the adapter already checks
this at negotiation, but the backend should fail gracefully if called
directly with an empty cmd — return `TtyError::AllocFailed`). The
`backend_params` map is ignored by the local backend (it has no
backend-specific selector fields); if a caller passes unexpected
`backend_params`, they are silently ignored (the local backend doesn't
define a typed params struct).
### Default trait methods
`resource_id()` returns `None` (the local backend creates its own resource —
a process — so there's no pre-existing resource for the ownership check). The
default impl on `TtyBackend` already returns `None`, so this can be omitted
unless an explicit impl is preferred for clarity.
### Tests
- **PTY dispatch**: `allocate` with `terminal: Some` → returns a `TtyHandle`
with `stderr: None` and a `PtyControl`.
- **Pipe dispatch**: `allocate` with `terminal: None` → returns a `TtyHandle`
with `stderr: Some` and a `PipeControl`.
- **Empty cmd**: `allocate` with empty `cmd` → `TtyError::AllocFailed`.
- **resource_id**: returns `None`.
- These can be lightweight (the heavy tests are in `pty-mode` and `pipe-mode`);
this task verifies the dispatch wiring.
## Acceptance Criteria
- [ ] `LocalTtyBackend` struct with `new()` constructor (no deps)
- [ ] `impl TtyBackend for LocalTtyBackend` with `allocate()` branching on `params.terminal`
- [ ] `terminal: Some` → dispatches to `pty::allocate_pty`
- [ ] `terminal: None` → dispatches to `pipe::allocate_pipe`
- [ ] `resource_id()` returns `None` (or uses the default)
- [ ] Empty `cmd` → `TtyError::AllocFailed`
- [ ] `LocalTtyBackend` re-exported from `lib.rs` (`pub use backend::LocalTtyBackend`)
- [ ] Unit tests: PTY dispatch, pipe dispatch, empty cmd, resource_id
- [ ] `cargo test -p alknet-tty-local` succeeds
- [ ] `cargo clippy -p alknet-tty-local` succeeds with no warnings
## References
- docs/architecture/crates/tty/tty-local.md — §"PTY Mode", §"Pipe Mode", §"LocalTtyBackend takes no constructor dependencies"
- docs/architecture/crates/tty/tty-backend.md — `TtyBackend` trait, `resource_id()`
- docs/architecture/decisions/054-local-tty-backend-sibling-crate.md — ADR-054 (per-session PTY vs pipe choice)
## Notes
> This is the wiring task — the heavy lifting is in `pty-mode` and
> `pipe-mode`. The branch on `TtyParams.terminal` is the per-session choice
> (ADR-054): one `LocalTtyBackend` serves both terminal and runner use cases.
> The local backend ignores `backend_params` (no backend-specific selector
> fields); a docker/SSH backend would deserialize its own typed params from
> it.
## Summary
> To be filled on completion
+116
View File
@@ -0,0 +1,116 @@
---
id: tty-local/crate-init
name: Initialize alknet-tty-local crate with Cargo.toml, dependencies, and module skeleton
status: pending
depends_on: [tty/backend-trait]
scope: moderate
risk: low
impact: project
level: implementation
---
## Description
Initialize the `alknet-tty-local` sibling crate (ADR-054). This crate
implements `TtyBackend` for `LocalTtyBackend` via `portable_pty` (PTY mode)
and `std::process::Command` (pipe/runner mode). It depends on `alknet-tty`
for the trait and types; the heavy `portable_pty` dependency lives here, not
in the core crate.
### Crate setup
Create `crates/alknet-tty-local/` with:
- `Cargo.toml` — package metadata, dependencies
- `src/lib.rs` — crate root with module declarations and re-exports
- Module skeleton files for:
- `src/pty.rs` — PTY mode: three-thread bridge, `PtyControl`, REQ-TTY-02
signal forwarding, ADR-056 kill guard
- `src/pipe.rs` — pipe mode: `tokio::process`, `PipeControl`, ADR-056 kill
guard
- `src/backend.rs` — `LocalTtyBackend` implementing `TtyBackend`, branching
on `TtyParams.terminal`
### Dependencies
Per the architecture spec (tty-local.md §"Dependencies"):
| Crate | Purpose |
|-------|---------|
| `alknet-tty` | `TtyBackend` trait, `TtyHandle`, `TtyControl`, `TtyControlHandle`, `TtyParams`, `TerminalParams`, `TtyError`, `ControlMessage`, `signal_from_name` (workspace path) |
| `portable_pty` | PTY allocation — Unix `openpty` + Windows ConPTY (the heavy dep) |
| `libc` | Signal forwarding — REQ-TTY-02, Unix only |
| `tokio` 1 (full) | mpsc, oneshot, `AsyncRead`/`AsyncWrite` for the pipe case |
| `bytes` 1 | `Bytes` for stdout/stderr streams |
| `futures-core` | `Stream` trait for `TtyHandle.stdout`/`stderr` |
| `tracing` 0.1 | Structured logging |
| `thiserror` 2 | Error conversion (if needed beyond `TtyError`) |
`alknet-tty-local` does NOT depend on `alknet-core` directly (it accesses core
types via `alknet-tty`'s re-exports if needed). `portable_pty` and `libc` are
the heavy deps that motivate the sibling-crate split (ADR-054).
### Feature flags
No feature flags — the crate is unconditional. The `local` feature gate lives
on `alknet-tty` (the consumer enables it to pull this crate in).
### Workspace Cargo.toml
Add `crates/alknet-tty-local` to the workspace `members` list in the root
`Cargo.toml`.
### Module skeleton
```rust
// src/lib.rs
//! alknet-tty-local: Local TTY backend for alknet-tty.
//!
//! `LocalTtyBackend` implements `alknet_tty::TtyBackend` via `portable_pty`
//! (PTY mode, terminal semantics) and `std::process::Command` (pipe mode,
//! the runner case). The blocking→async bridge for PTY mode uses three
//! dedicated std threads feeding tokio mpsc/oneshot channels (REQ-TTY-01).
//! Signal forwarding targets the foreground process group (REQ-TTY-02).
pub mod pty;
pub mod pipe;
pub mod backend;
pub use backend::LocalTtyBackend;
```
Each module file gets a doc comment and `// TODO: implement` marker.
## Acceptance Criteria
- [ ] `crates/alknet-tty-local/Cargo.toml` exists with all dependencies
- [ ] `crates/alknet-tty-local/src/lib.rs` exists with module declarations and `pub use backend::LocalTtyBackend`
- [ ] Module skeleton files exist: `pty.rs`, `pipe.rs`, `backend.rs`
- [ ] Root `Cargo.toml` `members` list includes `crates/alknet-tty-local`
- [ ] `cargo check -p alknet-tty-local` succeeds
- [ ] `cargo clippy -p alknet-tty-local` succeeds with no warnings
- [ ] Dual licensing: `MIT OR Apache-2.0` (workspace-inherited)
- [ ] `alknet-tty` dependency uses workspace path (`path = "../alknet-tty"`)
- [ ] `portable_pty` and `libc` dependencies present
- [ ] No `alknet-core` direct dependency (accessed via `alknet-tty` if needed)
## References
- docs/architecture/crates/tty/tty-local.md — the authoritative spec for this crate
- docs/architecture/decisions/054-local-tty-backend-sibling-crate.md — ADR-054 (sibling crate placement)
- docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md — ADR-053 (the trait this crate implements)
- /workspace/alknet-tty-poc/src/local_pty.rs — the reference PTY implementation
## Notes
> This crate can be initialized in parallel with the remaining core crate
> tasks (`tty/control-messages`, `tty/negotiation`, `tty/adapter`) since it
> only depends on `tty/backend-trait` for the trait and types. The
> `portable_pty` dependency is the heavy dep that motivates the sibling-crate
> split — a docker-only deployment doesn't pull it in. The POC's
> `local_pty.rs` is the reference for the PTY mode; the pipe mode is simpler
> (tokio's `Child` is natively async).
## Summary
> To be filled on completion
+185
View File
@@ -0,0 +1,185 @@
---
id: tty-local/pipe-mode
name: Implement pipe mode (tokio::process, PipeControl, ADR-056 kill guard)
status: pending
depends_on: [tty-local/crate-init]
scope: moderate
risk: medium
impact: component
level: implementation
---
## Description
Implement the pipe mode in `src/pipe.rs`: the runner case (`terminal: None`,
no PTY). Spawn the command with `tokio::process::Command` (or
`std::process::Command` with `Stdio::piped()`) for stdin/stdout/stderr, return
a `TtyHandle` with separate stdout and stderr (stderr is `Some`) and a
`PipeControl` whose `resize()` is a no-op (no PTY) and `signal()` calls
`libc::kill(pid, sig)` on the child's pid.
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).
### Pipe mode (`terminal: None`)
`allocate_pipe` spawns the command with `Stdio::piped()` for stdin, stdout,
and stderr. `TtyHandle.stderr` is `Some` (separate streams). The `exit_code`
future is `Child::wait()` (async on tokio's `Child`).
### Types
```rust
pub fn allocate_pipe(
cmd: Vec<String>,
cwd: Option<PathBuf>,
env: HashMap<String, String>,
) -> Result<TtyHandle, TtyError>;
```
- `TtyHandle.stdin` — `Child::stdin` (`ChildStdin` implements `AsyncWrite`),
boxed as `Box<dyn AsyncWrite + Send + Unpin>`.
- `TtyHandle.stdout` — `Child::stdout` wrapped as a `Stream<Item = Bytes>`.
Use `tokio_util::io::ReaderStream` or a manual `AsyncRead`→`Stream` adapter
to convert `ChildStdout` (`AsyncRead`) into `Pin<Box<dyn Stream<Item = Bytes> + Send>>`.
- `TtyHandle.stderr` — `Some`, same wrapping as stdout.
- `TtyHandle.exit_code` — a `Future` wrapping `Child::wait()` PLUS a kill
guard (ADR-056, see below).
- `TtyHandle.control` — `Some(TtyControlHandle::new(Arc::new(PipeControl)))`.
### PipeControl (implements TtyControl)
```rust
pub struct PipeControl {
pid: Option<u32>,
}
impl TtyControl for PipeControl {
fn resize(&self, _cols: u16, _rows: u16, _pw: u16, _ph: u16) {
// No-op — no PTY in pipe mode.
}
fn signal(&self, name: &str) {
// libc::kill(pid, sig) on the child's pid (NOT the process group —
// pipe mode has no session leader / controlling tty).
}
}
```
`resize()` is a no-op (no PTY — resize doesn't apply). `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). Document this in the
`PipeControl::signal` doc comment.
Use `alknet_tty::signal_from_name` for the name→number mapping. Unknown names
fall back to `Child::start_kill()` (SIGKILL) — there's no `ChildKiller` in
pipe mode; `tokio::process::Child::start_kill()` is the kill path. On non-Unix,
`signal()` calls `start_kill()` directly (no `libc::kill`).
### Cancel-Cleanup (ADR-056)
The `exit_code` future's `Drop`-on-cancel MUST kill the child. Implement a
`PipeExitFuture` wrapping the `Child::wait()` future plus a kill guard holding
the `tokio::process::Child` handle:
```rust
struct PipeExitFuture {
wait: Pin<Box<dyn Future<Output = Result<ExitStatus, io::Error>> + Send>>,
child: Option<tokio::process::Child>, // None after resolve (disarmed)
}
impl Future for PipeExitFuture {
// poll delegates to wait; on Ready, take child (disarm), map ExitStatus to i32
}
impl Drop for PipeExitFuture {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.start_kill(); // best-effort; child may already be exiting
}
}
}
```
On cancel: `Drop` calls `start_kill()` (SIGKILL); the child exits; the `wait`
future reaps it. On happy path: the future resolves, the guard is disarmed,
the subsequent `Drop` is a no-op.
**Critical**: a bare `Child::wait()` future without the kill guard violates
the ADR-056 contract and will orphan processes on cancel. This task MUST add
the guard. Note: `tokio::process::Child` must be created with
`kill_on_drop(true)` OR the explicit guard — the guard is the spec-compliant
mechanism; `kill_on_drop` alone is insufficient because the `Child` is moved
into the future and the future's `Drop` is the cancel path, not the `Child`'s.
Use the explicit guard.
### The Threading/Deadlock Caveat (DP-4)
`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 adapter's three-pump driver does (task `tty/adapter`). No design
decision needed; the spec notes it as a known constraint with a known
(POC-validated) solution.
### Tests
- **Happy path**: spawn `echo hello` in pipe mode, read stdout stream to EOF,
await exit_code → 0, assert stderr is empty.
- **Stdin round-trip**: spawn `cat`, write via `AsyncWrite` stdin, read back
via stdout, close stdin, await exit 0.
- **Separate stderr**: spawn a command that writes to stderr (e.g.,
`sh -c "echo err >&2"`), assert stderr stream receives the bytes, stdout
is empty.
- **Signal (Unix)**: spawn `sleep 60`, send `signal("TERM")`, assert the child
exits with signal-terminated status.
- **Cancel cleanup (ADR-056)**: spawn `sleep 60`, drop the `TtyHandle` without
awaiting exit_code, assert the child is killed (no orphan).
- **Resize no-op**: call `PipeControl::resize`, assert no error (it's a no-op).
- **Unknown signal name**: send `signal("NOSUCH")`, assert it falls back to
`start_kill()` (SIGKILL).
## Acceptance Criteria
- [ ] `allocate_pipe` function spawns via `tokio::process::Command` with `Stdio::piped()`
- [ ] `TtyHandle.stdin` is `ChildStdin` boxed as `Box<dyn AsyncWrite + Send + Unpin>`
- [ ] `TtyHandle.stdout` is `ChildStdout` wrapped as `Pin<Box<dyn Stream<Item = Bytes> + Send>>`
- [ ] `TtyHandle.stderr` is `Some` (same wrapping as stdout)
- [ ] `TtyHandle.exit_code` is `PipeExitFuture` (wait + kill guard), `BoxFuture<Result<i32, TtyError>>`
- [ ] `TtyHandle.control` is `Some(TtyControlHandle::new(Arc::new(PipeControl)))`
- [ ] `PipeControl` implements `alknet_tty::TtyControl`
- [ ] `PipeControl::resize` is a no-op
- [ ] `PipeControl::signal` uses `libc::kill(pid, sig)` (Unix) / `start_kill()` (non-Unix); NOT process-group targeting
- [ ] `PipeControl::signal` uses `alknet_tty::signal_from_name`; unknown names fall back to `start_kill()`
- [ ] `PipeExitFuture::Drop` calls `Child::start_kill()` when dropped without resolving (ADR-056)
- [ ] `PipeExitFuture` disarms the guard on resolve (no-op Drop on happy path)
- [ ] Doc comment on `PipeControl::signal` notes the no-process-group limitation
- [ ] Integration tests: happy path, stdin round-trip, separate stderr, signal, cancel cleanup, resize no-op, unknown signal
- [ ] `cargo test -p alknet-tty-local` succeeds
- [ ] `cargo clippy -p alknet-tty-local` succeeds with no warnings
## References
- docs/architecture/crates/tty/tty-local.md — §"Pipe Mode (`terminal: None`)", §"Cancel-Cleanup (ADR-056)", §"The Threading/Deadlock Caveat"
- docs/architecture/crates/tty/tty-backend.md — `TtyHandle`, `TtyControl` (the types this produces)
- docs/architecture/decisions/056-backend-cleanup-on-session-cancel.md — ADR-056 (kill-on-Drop)
- docs/architecture/decisions/054-local-tty-backend-sibling-crate.md — ADR-054 (pipe mode = runner case)
## Notes
> Pipe mode is simpler than PTY mode (tokio's `Child` is natively async, no
> std-thread bridge). The two subtleties: (1) the `AsyncRead`→`Stream<Item=Bytes>`
> conversion for stdout/stderr (use `tokio_util::io::ReaderStream` or a manual
> adapter), and (2) the ADR-056 kill guard on the `exit_code` future. Do NOT
> rely on `tokio::process::Child::kill_on_drop(true)` alone — the explicit
> guard in the future's `Drop` is the spec-compliant mechanism. Signal
> forwarding in pipe mode targets the direct child only (no process group);
> document this limitation.
## Summary
> To be filled on completion
+232
View File
@@ -0,0 +1,232 @@
---
id: tty-local/pty-mode
name: Implement PTY mode (three-thread bridge, PtyControl, REQ-TTY-02, ADR-056 kill guard)
status: pending
depends_on: [tty-local/crate-init]
scope: broad
risk: high
impact: component
level: implementation
---
## Description
Implement the PTY mode in `src/pty.rs`: allocate a real PTY via
`portable_pty::native_pty_system().openpty()`, spawn the command into the
slave side, and 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)`).
This 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). It is a port + generalization of the POC's
`/workspace/alknet-tty-poc/src/local_pty.rs`, adapted to produce a `TtyHandle`
(the trait's handle type) instead of the POC's `LocalPty` struct.
### The blocking→async bridge (REQ-TTY-01)
`portable_pty` is a blocking `std::io` API. `MasterPty::try_clone_reader()`
returns `Box<dyn std::io::Read + Send>`; `take_writer()` returns
`Box<dyn std::io::Write + Send>`; `Child::wait()` blocks. Bridge to async via
**three dedicated std threads** feeding tokio mpsc/oneshot channels:
1. **Reader thread** — blocking reads from `MasterPty::try_clone_reader()` →
`mpsc::Sender<Bytes>`. Reads into an 8 KiB buffer, copies each chunk to
`Bytes`, `blocking_send`s to the mpsc. On EOF (master reader returns EOF
when the slave closes — child exited, PTY buffer drained), sends a
zero-length `Bytes` sentinel 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 (EOF to slave 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).
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 ADR-056 below).
`TtyHandle.stderr` is `None` (PTY backends merge stdout/stderr).
### 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)
}
```
### AsyncWrite wrapper for stdin
Implement an `AsyncWrite` impl over `mpsc::Sender<StdinCmd>`: `poll_write`
sends `StdinCmd::Bytes(bytes.to_vec())` (awaiting the send via `poll`), `poll_flush`
is a no-op (the writer thread flushes), `poll_close` sends `StdinCmd::Eof`. This
wraps the tokio mpsc sender as the `Box<dyn AsyncWrite + Send + Unpin>` the
`TtyHandle.stdin` field requires.
### PtyControl (implements TtyControl)
```rust
pub struct PtyControl {
master: Arc<Mutex<Box<dyn MasterPty + Send>>>,
killer: Arc<Mutex<Box<dyn portable_pty::ChildKiller + Send + Sync>>>,
pid: Option<u32>,
}
impl TtyControl for PtyControl {
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16);
fn signal(&self, name: &str);
}
```
- `resize()` locks the master and calls `MasterPty::resize(PtySize)` —
non-blocking (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 under a PTY will have spawned children (a
`find | grep` pipeline, a `make` with sub-makes), and those children won't
receive the signal. A real terminal forwards Ctrl-C to the **foreground
process group**.
`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 (tty-local.md §"REQ-TTY-02"):
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.
2. The local backend MUST spawn the child as a session leader with a
controlling tty (`CommandBuilder::set_controlling_tty(true)` — the default).
3. The `TtyControl::signal` contract is "best-effort delivery to the
foreground process group." 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 via `alknet_tty::signal_from_name` and
are sent to the group.
Use `alknet_tty::signal_from_name` (from `alknet-tty`'s `control` module) for
the name→number mapping. On non-Unix, fall back to `ChildKiller::kill`.
### Cancel-Cleanup (ADR-056)
The `exit_code` future's `Drop`-on-cancel MUST kill the child. Implement a
`LocalExitFuture` wrapping the `oneshot::Receiver<i32>` plus a kill guard:
```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 (disarm)
}
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: `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.
On happy path: the future resolves, the guard is disarmed (`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."
**Critical**: 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 and will orphan processes on cancel. This task
MUST add the guard.
### allocate_pty function
```rust
pub fn allocate_pty(
terminal: TerminalParams,
cmd: Vec<String>,
cwd: Option<PathBuf>,
env: HashMap<String, String>,
) -> Result<TtyHandle, TtyError>;
```
This is called by `LocalTtyBackend::allocate()` (task `tty-local/backend-impl`)
when `TtyParams.terminal` is `Some`. It returns a fully-wired `TtyHandle`.
### Tests
- **Happy path**: spawn `echo hello` into a PTY, read stdout until the
zero-length sentinel, await exit_code → 0.
- **Stdin round-trip**: spawn `cat` into a PTY, write bytes via the
`AsyncWrite` stdin, read them back via stdout, send `Eof`, await exit 0.
- **Resize**: call `PtyControl::resize`, assert no error (the ioctl succeeds).
- **Signal (Unix)**: spawn `sleep 60`, send `signal("INT")`, assert the child
exits with a signal-terminated status (negative exit code or 130 for SIGINT).
Test process-group targeting: spawn `bash -c "sleep 60"`, send INT, assert
the `sleep` child also receives it (the group signal).
- **Cancel cleanup (ADR-056)**: spawn `sleep 60`, drop the `TtyHandle` (and
thus the `exit_code` future) without awaiting it, assert the child is
killed (reaped by the waiter thread; no orphan). Use a short delay then
check the process is gone.
- **Unknown signal name**: send `signal("NOSUCH")`, assert it falls back to
`ChildKiller::kill` (SIGHUP); the child exits.
## Acceptance Criteria
- [ ] `allocate_pty` function spawns via `portable_pty::native_pty_system().openpty()`
- [ ] `CommandBuilder::set_controlling_tty(true)` (the default; session leader)
- [ ] Three std threads (reader, writer, waiter) feeding tokio mpsc/oneshot
- [ ] `TtyHandle.stdout` is the reader mpsc wrapped as `Pin<Box<dyn Stream<Item = Bytes> + Send>>`
- [ ] `TtyHandle.stdin` is the writer mpsc wrapped as `Box<dyn AsyncWrite + Send + Unpin>`
- [ ] `TtyHandle.stderr` is `None` (PTY merges stdout/stderr)
- [ ] `TtyHandle.exit_code` is `LocalExitFuture` (oneshot + kill guard), `BoxFuture<Result<i32, TtyError>>`
- [ ] `TtyHandle.control` is `Some(TtyControlHandle::new(Arc::new(PtyControl)))`
- [ ] `PtyControl` implements `alknet_tty::TtyControl` (resize, signal)
- [ ] `PtyControl::signal` uses `kill(-pgid, sig)` with `kill(pid, sig)` fallback (Unix); `ChildKiller::kill` (non-Unix)
- [ ] `PtyControl::signal` uses `alknet_tty::signal_from_name` for name→number
- [ ] Unknown signal names fall back to `ChildKiller::kill` (SIGHUP)
- [ ] `LocalExitFuture::Drop` calls `ChildKiller::kill(SIGHUP)` when dropped without resolving (ADR-056)
- [ ] `LocalExitFuture` disarms the guard on resolve (no-op Drop on happy path)
- [ ] Reader thread sends zero-length `Bytes` sentinel on EOF
- [ ] Writer thread drops the writer on `StdinCmd::Eof`
- [ ] Integration tests: happy path, stdin round-trip, resize, signal (process group), cancel cleanup, unknown signal
- [ ] `cargo test -p alknet-tty-local` succeeds
- [ ] `cargo clippy -p alknet-tty-local` succeeds with no warnings
## References
- docs/architecture/crates/tty/tty-local.md — §"PTY Mode", §"REQ-TTY-02", §"Cancel-Cleanup (ADR-056)"
- docs/architecture/crates/tty/tty-backend.md — `TtyHandle`, `TtyControl` (the types this produces)
- docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md — ADR-053 (REQ-TTY-01)
- docs/architecture/decisions/056-backend-cleanup-on-session-cancel.md — ADR-056 (kill-on-Drop)
- /workspace/alknet-tty-poc/src/local_pty.rs — the reference implementation (port + add kill guard)
- /workspace/alknet-tty-poc/tests/signal.rs — the SIGINT-forwarding integration test (validates REQ-TTY-02)
## Notes
> This is the highest-risk task in the tty-local crate: the three-thread
> bridge, the process-group signal targeting, and the ADR-056 kill guard are
> all subtle. The POC is the reference but its `exit_code` was a bare
> `oneshot::Receiver<i32>` — the kill guard is NEW and required. Do not copy
> the POC's `exit_code` shape without the guard. The `AsyncWrite` wrapper over
> the mpsc sender is also new (the POC exposed the sender directly); the
> `TtyHandle.stdin` field requires `Box<dyn AsyncWrite + Send + Unpin>`.
## Summary
> To be filled on completion
+128
View File
@@ -0,0 +1,128 @@
---
id: tty-local/review-tty-local
name: Review alknet-tty-local for REQ-TTY-01/02 and ADR-056 contract conformance
status: pending
depends_on: [tty-local/backend-impl]
scope: moderate
risk: low
impact: phase
level: review
---
## Description
Review the `alknet-tty-local` implementation for spec conformance, with
particular attention to the three load-bearing requirements: REQ-TTY-01
(backends need not be natively async), REQ-TTY-02 (signal forwarding to the
process group), and ADR-056 (kill-on-Drop cancel-cleanup). This is the
quality checkpoint at the end of the local backend phase, before the
integration tests and the feature re-export wire the two crates together.
### Review Checklist
1. **PTY mode conformance** (tty-local.md §"PTY Mode"):
- `allocate_pty` uses `portable_pty::native_pty_system().openpty()`
- `CommandBuilder::set_controlling_tty(true)` (the default; session leader)
- Three std threads (reader, writer, waiter) feeding tokio mpsc/oneshot
- `TtyHandle.stdout` is the reader mpsc as `Pin<Box<dyn Stream<Item = Bytes> + Send>>`
- `TtyHandle.stdin` is the writer mpsc as `Box<dyn AsyncWrite + Send + Unpin>`
- `TtyHandle.stderr` is `None` (PTY merges stdout/stderr)
- `TtyHandle.exit_code` is `LocalExitFuture` (oneshot + kill guard)
- `TtyHandle.control` is `Some(TtyControlHandle::new(Arc::new(PtyControl)))`
- Reader thread sends zero-length `Bytes` sentinel on EOF
- Writer thread drops the writer on `StdinCmd::Eof`
- `AsyncWrite` wrapper over the mpsc sender (poll_write, poll_flush, poll_close)
2. **REQ-TTY-02: Signal forwarding to the process group** (tty-local.md §"REQ-TTY-02"):
- `PtyControl::signal` uses `kill(-pgid, sig)` (negative pid = process group)
- Fallback to `kill(pid, sig)` if the group signal fails
- `portable_pty` child is a session leader (`controlling_tty = true`)
- `signal_from_name` from `alknet-tty` for the 9 supported names
- Unknown names fall back to `ChildKiller::kill` (SIGHUP)
- Contract is "best-effort delivery to the foreground process group"
- Integration test validates process-group targeting (a child of the shell receives the signal)
3. **Pipe mode conformance** (tty-local.md §"Pipe Mode"):
- `allocate_pipe` uses `tokio::process::Command` with `Stdio::piped()`
- `TtyHandle.stdin` is `ChildStdin` as `Box<dyn AsyncWrite + Send + Unpin>`
- `TtyHandle.stdout`/`stderr` wrapped as `Pin<Box<dyn Stream<Item = Bytes> + Send>>`
- `TtyHandle.stderr` is `Some` (separate streams)
- `TtyHandle.exit_code` is `PipeExitFuture` (wait + kill guard)
- `TtyHandle.control` is `Some(TtyControlHandle::new(Arc::new(PipeControl)))`
- `PipeControl::resize` is a no-op
- `PipeControl::signal` uses `kill(pid, sig)` (NOT process group; documented limitation)
- Unknown signal names fall back to `start_kill()` (SIGKILL)
4. **ADR-056: kill-on-Drop cancel-cleanup** (tty-local.md §"Cancel-Cleanup"):
- `LocalExitFuture::Drop` calls `ChildKiller::kill(SIGHUP)` when dropped without resolving
- `LocalExitFuture` disarms the guard on resolve (no-op Drop on happy path)
- `PipeExitFuture::Drop` calls `Child::start_kill()` when dropped without resolving
- `PipeExitFuture` disarms the guard on resolve
- Neither future is a bare `oneshot::Receiver` / bare `Child::wait()` (the POC's shape — MUST have the guard)
- Integration test: drop the `TtyHandle` mid-session, assert the child is killed (no orphan)
5. **LocalTtyBackend conformance** (tty-local.md, tty-backend.md):
- `LocalTtyBackend::new()` takes no deps
- `allocate()` branches on `params.terminal` (Some → PTY, None → pipe)
- `resource_id()` returns `None`
- Empty `cmd` → `TtyError::AllocFailed`
- `backend_params` ignored (no backend-specific selector fields)
6. **Dependency constraints**:
- `portable_pty` and `libc` deps present (the heavy deps, here not in alknet-tty)
- `alknet-tty` dependency (workspace path) for the trait and types
- No `alknet-core` direct dependency (accessed via alknet-tty if needed)
- No `bollard`/`russh` (those are future backend crates)
7. **Pattern consistency**:
- `TtyControl` impls use `alknet_tty::TtyControl` trait
- `TtyControlHandle::new(Arc::new(...))` wrapping (OQ-43 pattern)
- `TtyError` for errors (not `anyhow` — the POC used `anyhow`; the crate uses `TtyError`)
- `tracing` for structured logging
- `#[cfg(unix)]` gates on `libc::kill` paths
8. **Test coverage**:
- PTY: happy path, stdin round-trip, resize, signal (process group), cancel cleanup, unknown signal
- Pipe: happy path, stdin round-trip, separate stderr, signal, cancel cleanup, resize no-op, unknown signal
- Backend: PTY dispatch, pipe dispatch, empty cmd, resource_id
## Acceptance Criteria
- [ ] PTY mode matches tty-local.md §"PTY Mode" (three-thread bridge, PtyControl)
- [ ] REQ-TTY-02 satisfied: `kill(-pgid, sig)` with fallback, session leader, process-group test passes
- [ ] Pipe mode matches tty-local.md §"Pipe Mode" (tokio::process, PipeControl, stderr Some)
- [ ] ADR-056 satisfied: both `LocalExitFuture` and `PipeExitFuture` have kill-on-Drop guards
- [ ] Neither exit future is a bare `oneshot::Receiver` / bare `Child::wait()` (the POC's shape)
- [ ] `LocalTtyBackend` branches on `terminal`, `resource_id` returns `None`
- [ ] `portable_pty`/`libc` deps present; no `bollard`/`russh`/`alknet-core` direct dep
- [ ] `TtyControl` impls use the `alknet_tty::TtyControl` trait + `TtyControlHandle::new` wrapping
- [ ] `#[cfg(unix)]` gates on `libc::kill` paths
- [ ] `cargo fmt --check -p alknet-tty-local` passes
- [ ] `cargo clippy -p alknet-tty-local` passes with no warnings
- [ ] All tests pass (PTY + pipe + backend)
- [ ] Cancel-cleanup integration tests confirm no orphaned processes
## References
- docs/architecture/crates/tty/tty-local.md — the authoritative spec
- docs/architecture/crates/tty/tty-backend.md — the trait this implements
- docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md — ADR-053 (REQ-TTY-01)
- docs/architecture/decisions/054-local-tty-backend-sibling-crate.md — ADR-054
- docs/architecture/decisions/056-backend-cleanup-on-session-cancel.md — ADR-056
- /workspace/alknet-tty-poc/src/local_pty.rs — the reference (note: POC lacks the kill guard)
- /workspace/alknet-tty-poc/tests/signal.rs — the SIGINT-forwarding test (REQ-TTY-02)
## Notes
> This review focuses on the three load-bearing requirements. The ADR-056
> kill guard is the most likely deviation — the POC's `exit_code` was a bare
> `oneshot::Receiver<i32>` without a guard, and an implementer who copies the
> POC's shape verbatim violates the contract. Verify both `LocalExitFuture`
> and `PipeExitFuture` have the guard and the cancel-cleanup tests pass (no
> orphaned processes). REQ-TTY-02's process-group targeting is the other
> subtle requirement — verify the integration test spawns a shell with a
> child and confirms the child receives the signal.
## Summary
> To be filled on completion