11 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| tty-local/pty-mode | Implement PTY mode (three-thread bridge, PtyControl, REQ-TTY-02, ADR-056 kill guard) | completed |
|
broad | high | component | 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:
- Reader thread — blocking reads from
MasterPty::try_clone_reader()→mpsc::Sender<Bytes>. Reads into an 8 KiB buffer, copies each chunk toBytes,blocking_sends to the mpsc. On EOF (master reader returns EOF when the slave closes — child exited, PTY buffer drained), sends a zero-lengthBytessentinel and exits. The async-facingTtyHandle.stdoutis thempsc::Receiver<Bytes>wrapped asPin<Box<dyn Stream<Item = Bytes> + Send>>. - Writer thread — drains an
mpsc::Receiver<StdinCmd>→ blocking writes toMasterPty::take_writer().StdinCmd::Bytes(bytes)writes and flushes;StdinCmd::Eofdrops the writer (EOF to slave stdin) and exits. The async-facingTtyHandle.stdinis thempsc::Sender<StdinCmd>wrapped asBox<dyn AsyncWrite + Send + Unpin>(anAsyncWriteimpl that wraps eachwriteas aStdinCmd::Bytesandflushas a no-op). - Waiter thread — blocking
Child::wait()→oneshot::Sender<i32>with the exit code. The async-facingTtyHandle.exit_codeis aFuturewrapping thisoneshot::Receiver<i32>PLUS a kill guard holding theportable_pty::ChildKiller(see ADR-056 below).
TtyHandle.stderr is None (PTY backends merge stdout/stderr).
StdinCmd
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)
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 callsMasterPty::resize(PtySize)— non-blocking (issues anioctl).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"):
- 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. - The local backend MUST spawn the child as a session leader with a
controlling tty (
CommandBuilder::set_controlling_tty(true)— the default). - The
TtyControl::signalcontract is "best-effort delivery to the foreground process group." Unknown signal names fall back to the backend's default kill (portable_pty'sChildKiller::killsends SIGHUP); known names map tolibcsignal numbers viaalknet_tty::signal_from_nameand 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:
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
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 hellointo a PTY, read stdout until the zero-length sentinel, await exit_code → 0. - Stdin round-trip: spawn
catinto a PTY, write bytes via theAsyncWritestdin, read them back via stdout, sendEof, await exit 0. - Resize: call
PtyControl::resize, assert no error (the ioctl succeeds). - Signal (Unix): spawn
sleep 60, sendsignal("INT"), assert the child exits with a signal-terminated status (negative exit code or 130 for SIGINT). Test process-group targeting: spawnbash -c "sleep 60", send INT, assert thesleepchild also receives it (the group signal). - Cancel cleanup (ADR-056): spawn
sleep 60, drop theTtyHandle(and thus theexit_codefuture) 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 toChildKiller::kill(SIGHUP); the child exits.
Acceptance Criteria
allocate_ptyfunction spawns viaportable_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.stdoutis the reader mpsc wrapped asPin<Box<dyn Stream<Item = Bytes> + Send>>TtyHandle.stdinis the writer mpsc wrapped asBox<dyn AsyncWrite + Send + Unpin>TtyHandle.stderrisNone(PTY merges stdout/stderr)TtyHandle.exit_codeisLocalExitFuture(oneshot + kill guard),BoxFuture<Result<i32, TtyError>>TtyHandle.controlisSome(TtyControlHandle::new(Arc::new(PtyControl)))PtyControlimplementsalknet_tty::TtyControl(resize, signal)PtyControl::signaluseskill(-pgid, sig)withkill(pid, sig)fallback (Unix);ChildKiller::kill(non-Unix)PtyControl::signalusesalknet_tty::signal_from_namefor name→number- Unknown signal names fall back to
ChildKiller::kill(SIGHUP) LocalExitFuture::DropcallsChildKiller::kill(SIGHUP)when dropped without resolving (ADR-056)LocalExitFuturedisarms the guard on resolve (no-op Drop on happy path)- Reader thread sends zero-length
Bytessentinel 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-localsucceedscargo clippy -p alknet-tty-localsucceeds 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_codewas a bareoneshot::Receiver<i32>— the kill guard is NEW and required. Do not copy the POC'sexit_codeshape without the guard. TheAsyncWritewrapper over the mpsc sender is also new (the POC exposed the sender directly); theTtyHandle.stdinfield requiresBox<dyn AsyncWrite + Send + Unpin>.
Summary
To be filled on completion