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

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
tty-local/crate-init
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 (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)

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