8.1 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| tty-local/pipe-mode | Implement pipe mode (tokio::process, PipeControl, ADR-056 kill guard) | completed |
|
moderate | medium | component | 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
pub fn allocate_pipe(
cmd: Vec<String>,
cwd: Option<PathBuf>,
env: HashMap<String, String>,
) -> Result<TtyHandle, TtyError>;
TtyHandle.stdin—Child::stdin(ChildStdinimplementsAsyncWrite), boxed asBox<dyn AsyncWrite + Send + Unpin>.TtyHandle.stdout—Child::stdoutwrapped as aStream<Item = Bytes>. Usetokio_util::io::ReaderStreamor a manualAsyncRead→Streamadapter to convertChildStdout(AsyncRead) intoPin<Box<dyn Stream<Item = Bytes> + Send>>.TtyHandle.stderr—Some, same wrapping as stdout.TtyHandle.exit_code— aFuturewrappingChild::wait()PLUS a kill guard (ADR-056, see below).TtyHandle.control—Some(TtyControlHandle::new(Arc::new(PipeControl))).
PipeControl (implements TtyControl)
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:
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 helloin pipe mode, read stdout stream to EOF, await exit_code → 0, assert stderr is empty. - Stdin round-trip: spawn
cat, write viaAsyncWritestdin, 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, sendsignal("TERM"), assert the child exits with signal-terminated status. - Cancel cleanup (ADR-056): spawn
sleep 60, drop theTtyHandlewithout 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 tostart_kill()(SIGKILL).
Acceptance Criteria
allocate_pipefunction spawns viatokio::process::CommandwithStdio::piped()TtyHandle.stdinisChildStdinboxed asBox<dyn AsyncWrite + Send + Unpin>TtyHandle.stdoutisChildStdoutwrapped asPin<Box<dyn Stream<Item = Bytes> + Send>>TtyHandle.stderrisSome(same wrapping as stdout)TtyHandle.exit_codeisPipeExitFuture(wait + kill guard),BoxFuture<Result<i32, TtyError>>TtyHandle.controlisSome(TtyControlHandle::new(Arc::new(PipeControl)))PipeControlimplementsalknet_tty::TtyControlPipeControl::resizeis a no-opPipeControl::signaluseslibc::kill(pid, sig)(Unix) /start_kill()(non-Unix); NOT process-group targetingPipeControl::signalusesalknet_tty::signal_from_name; unknown names fall back tostart_kill()PipeExitFuture::DropcallsChild::start_kill()when dropped without resolving (ADR-056)PipeExitFuturedisarms the guard on resolve (no-op Drop on happy path)- Doc comment on
PipeControl::signalnotes 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-localsucceedscargo clippy -p alknet-tty-localsucceeds 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
Childis natively async, no std-thread bridge). The two subtleties: (1) theAsyncRead→Stream<Item=Bytes>conversion for stdout/stderr (usetokio_util::io::ReaderStreamor a manual adapter), and (2) the ADR-056 kill guard on theexit_codefuture. Do NOT rely ontokio::process::Child::kill_on_drop(true)alone — the explicit guard in the future'sDropis 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