--- id: tty-local/pipe-mode name: Implement pipe mode (tokio::process, PipeControl, ADR-056 kill guard) status: completed 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, cwd: Option, env: HashMap, ) -> Result; ``` - `TtyHandle.stdin` — `Child::stdin` (`ChildStdin` implements `AsyncWrite`), boxed as `Box`. - `TtyHandle.stdout` — `Child::stdout` wrapped as a `Stream`. Use `tokio_util::io::ReaderStream` or a manual `AsyncRead`→`Stream` adapter to convert `ChildStdout` (`AsyncRead`) into `Pin + 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, } 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> + Send>>, child: Option, // 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` - [ ] `TtyHandle.stdout` is `ChildStdout` wrapped as `Pin + Send>>` - [ ] `TtyHandle.stderr` is `Some` (same wrapping as stdout) - [ ] `TtyHandle.exit_code` is `PipeExitFuture` (wait + kill guard), `BoxFuture>` - [ ] `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` > 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