Files
alknet/tasks/tty-local/pipe-mode.md
T

185 lines
8.1 KiB
Markdown

---
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<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