Merge feat/tty-local/pipe-mode: resolve Cargo.toml/Cargo.lock conflicts with pty-mode (both tokio-stream and tokio-util needed)

This commit is contained in:
glm-5.2 committed 2026-07-07 22:58:40 +00:00
commit 6dce987ed4
3 files changed
+464 -1

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ alknet-tty = { path = "../alknet-tty" }
portable-pty = "0.9"
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
tokio-util = { version = "0.7", features = ["io"] }
bytes = "1"
futures-core = "0.3"
tracing = "0.1"
+462 -1
View File
@@ -5,4 +5,465 @@
//! `PipeControl` (no-op resize, `libc::kill(pid, sig)` signal), and the
//! ADR-056 kill guard on the `exit_code` future.
// TODO: implement
use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::process::ExitStatus;
use std::sync::Arc;
use std::task::{Context, Poll};
use alknet_tty::backend::{BoxFuture, TtyControl, TtyControlHandle, TtyError, TtyHandle};
use alknet_tty::control::signal_from_name;
use bytes::Bytes;
use futures_core::Stream;
use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
use tokio_util::io::ReaderStream;
/// Allocate a pipe-mode session: spawn `cmd` with `Stdio::piped()` for
/// stdin/stdout/stderr and return a `TtyHandle` whose `stderr` is `Some`
/// (separate streams). The `exit_code` future carries the ADR-056 kill
/// guard (see [`PipeExitFuture`]). `control` is a [`PipeControl`]
/// (no-op resize, `libc::kill(pid, sig)` signal).
pub fn allocate_pipe(
cmd: Vec<String>,
cwd: Option<PathBuf>,
env: HashMap<String, String>,
) -> Result<TtyHandle, TtyError> {
if cmd.is_empty() {
return Err(TtyError::AllocFailed {
message: "empty command vector".to_string(),
});
}
let mut command = Command::new(&cmd[0]);
command.args(&cmd[1..]);
command.stdin(std::process::Stdio::piped());
command.stdout(std::process::Stdio::piped());
command.stderr(std::process::Stdio::piped());
command.kill_on_drop(true);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
for (k, v) in env {
command.env(k, v);
}
let mut child = command.spawn().map_err(|e| TtyError::AllocFailed {
message: format!("spawn failed: {e}"),
})?;
let pid = child.id();
let stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin> = child
.stdin
.take()
.map(|s: ChildStdin| Box::new(s) as Box<dyn tokio::io::AsyncWrite + Send + Unpin>)
.ok_or_else(|| TtyError::AllocFailed {
message: "child stdin not piped".to_string(),
})?;
let stdout: ChildStdout = child.stdout.take().ok_or_else(|| TtyError::AllocFailed {
message: "child stdout not piped".to_string(),
})?;
let stderr: ChildStderr = child.stderr.take().ok_or_else(|| TtyError::AllocFailed {
message: "child stderr not piped".to_string(),
})?;
let stdout: Pin<Box<dyn Stream<Item = Bytes> + Send>> =
Box::pin(BytesStream::wrap(ReaderStream::new(stdout)));
let stderr: Pin<Box<dyn Stream<Item = Bytes> + Send>> =
Box::pin(BytesStream::wrap(ReaderStream::new(stderr)));
let control = Some(TtyControlHandle::new(Arc::new(PipeControl::new(pid))));
let exit_code: BoxFuture<Result<i32, TtyError>> = Box::pin(PipeExitFuture::new(child));
Ok(TtyHandle {
stdin,
stdout,
stderr: Some(stderr),
exit_code,
control,
})
}
/// Adapter wrapping `tokio_util::io::ReaderStream<R>` (which yields
/// `Result<Bytes, io::Error>`) into a `Stream<Item = Bytes>`, dropping the
/// `io::Error` info by ending the stream on read error. The adapter pump
/// treats a stream end as EOF; a read error is indistinguishable from EOF
/// at the wire level (the `exit_code` future surfaces the failure if the
/// child died abnormally).
struct BytesStream<S> {
inner: S,
}
impl<S> BytesStream<S> {
fn wrap(inner: S) -> Self {
Self { inner }
}
}
impl<S, E> Stream for BytesStream<S>
where
S: Stream<Item = Result<Bytes, E>> + Unpin,
{
type Item = Bytes;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
match Pin::new(&mut self.inner).poll_next(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(None) => Poll::Ready(None),
Poll::Ready(Some(Ok(bytes))) => Poll::Ready(Some(bytes)),
// Read error: end the stream (EOF to the adapter).
Poll::Ready(Some(Err(_))) => Poll::Ready(None),
}
}
}
/// Control handle for pipe mode: no-op resize and `libc::kill(pid, sig)`
/// signal forwarding (the runner case — ADR-054).
///
/// # Signal scope — no process group (documented limitation)
///
/// Pipe mode has no session leader / controlling tty, so `signal()` targets
/// the direct child pid only via `libc::kill(pid, sig)`. If the child has
/// spawned its own children, they will NOT 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. See
/// `tty-local.md` §"Pipe Mode".
pub struct PipeControl {
pid: Option<u32>,
}
impl PipeControl {
fn new(pid: Option<u32>) -> Self {
Self { pid }
}
#[cfg(test)]
pub(crate) fn pid(&self) -> Option<u32> {
self.pid
}
}
impl TtyControl for PipeControl {
fn resize(&self, _cols: u16, _rows: u16, _pixel_width: u16, _pixel_height: u16) {
// No-op — no PTY in pipe mode.
}
fn signal(&self, name: &str) {
// Targets the direct child pid only (no process group — see the
// type-level doc comment on `PipeControl`).
#[cfg(unix)]
{
if let Some(pid) = self.pid {
let pid = pid as libc::pid_t;
match signal_from_name(name) {
Some(sig) => {
// SAFETY: libc::kill on a known pid with a valid
// signal number is the documented signal-forwarding
// path. Best-effort: ignore errors (child may be gone).
let _ = unsafe { libc::kill(pid, sig) };
}
None => {
// Unknown name → SIGKILL. On Unix, `libc::kill(pid,
// SIGKILL)` is exactly what
// `tokio::process::Child::start_kill()` does
// internally; the `Child` handle lives in the
// `PipeExitFuture` and isn't accessible here, so we
// kill by pid instead.
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
}
}
} else {
tracing::warn!(name = name, "PipeControl::signal: no pid; cannot signal");
}
}
#[cfg(not(unix))]
{
// Non-Unix: no `libc::kill` available, and the `Child` handle
// lives in the `PipeExitFuture` (not accessible from here).
// Dropping the `TtyHandle` triggers the ADR-056 kill guard.
let _ = name;
tracing::warn!(
name = name,
"PipeControl::signal: non-Unix; drop TtyHandle to kill"
);
}
}
}
/// The `exit_code` future for pipe mode: wraps `tokio::process::Child::wait`
/// plus the ADR-056 kill guard.
///
/// # ADR-056 — kill-on-Drop contract
///
/// On cancel (the future is dropped without being driven to completion),
/// `Drop` calls `Child::start_kill()` (SIGKILL); the child exits and the
/// inner `wait` future reaps it. On the happy path, `poll` resolves the
/// `wait` future, then **disarms** the guard by taking the `Child` out of
/// `self.child` — so the subsequent `Drop` is a no-op. The guard is the
/// spec-compliant mechanism; `kill_on_drop(true)` on the `Command` is a
/// defense-in-depth backstop, not the primary path (the `Child` is moved
/// into the future and the future's `Drop` is the cancel path, not the
/// `Child`'s).
///
/// # Implementation note
///
/// `tokio::process::Child::wait()` borrows `&mut self`, so the `wait` future
/// cannot be held alongside the `Child` in the same struct without a
/// self-referential borrow. Instead, the `Child` lives in an `Option<Child>`
/// and is polled in place: on each `poll`, we `as_mut()` the `Option`,
/// construct a fresh `Child::wait()` borrow-future, pin it locally, and drive
/// it. The borrow ends when the local future is dropped at the end of the
/// `poll` call, so the `Child` remains free to be `take`n (disarmed or
/// killed) outside the borrow. This is the same pattern the standard
/// library's `tokio::process` examples use for "wait with a kill guard."
struct PipeExitFuture {
child: Option<Child>,
}
impl PipeExitFuture {
fn new(child: Child) -> Self {
Self { child: Some(child) }
}
}
impl Future for PipeExitFuture {
type Output = Result<i32, TtyError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// Poll the wait future in an inner scope so the borrow of
// `self.child` ends before we `take()` the child to disarm/kill.
enum Step {
Pending,
Exited(ExitStatus),
Failed(String),
Taken,
}
let step = match self.child.as_mut() {
None => Step::Taken,
Some(child) => {
let wait = child.wait();
tokio::pin!(wait);
match Future::poll(Pin::new(&mut wait), cx) {
Poll::Pending => Step::Pending,
Poll::Ready(Ok(status)) => Step::Exited(status),
Poll::Ready(Err(e)) => Step::Failed(format!("wait failed: {e}")),
}
}
};
// The borrow of `self.child` ended with `step`; now we can `take()`.
match step {
Step::Pending => Poll::Pending,
Step::Exited(status) => {
self.child.take();
Poll::Ready(Ok(exit_code_from(status)))
}
Step::Failed(msg) => {
self.child.take();
Poll::Ready(Err(TtyError::WaitFailed { message: msg }))
}
Step::Taken => Poll::Ready(Err(TtyError::WaitFailed {
message: "child already taken".to_string(),
})),
}
}
}
impl Drop for PipeExitFuture {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
// Best-effort kill; the child may already be exiting.
let _ = child.start_kill();
}
}
}
/// Map an `ExitStatus` to the adapter's `i32` exit code: `code()` on normal
/// exit; on Unix, the negative signal number on signal-terminated exit
/// (matches `std` convention and ADR-055 §4). Falls back to `-1` (the
/// adapter's "could not determine" sentinel — ADR-055 §4).
fn exit_code_from(status: ExitStatus) -> i32 {
if let Some(code) = status.code() {
return code;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = status.signal() {
return -sig;
}
}
-1
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::AsyncWriteExt;
use tokio_stream::StreamExt;
fn sh_cmd(argv: &[&str]) -> Vec<String> {
argv.iter().map(|s| s.to_string()).collect()
}
/// Drain a `Stream<Item = Bytes>` into a `Vec<u8>`.
async fn drain(mut s: Pin<Box<dyn Stream<Item = Bytes> + Send>>) -> Vec<u8> {
let mut out = Vec::new();
while let Some(chunk) = s.next().await {
out.extend_from_slice(&chunk);
}
out
}
/// Swap a `Pin<Box<dyn Stream<Item = Bytes> + Send>>` out for an empty
/// stream, returning the original. (`TtyHandle.stdout` is not `Option`,
/// so `.take()` isn't available; this is the equivalent.)
fn swap_stdout(
slot: &mut Pin<Box<dyn Stream<Item = Bytes> + Send>>,
) -> Pin<Box<dyn Stream<Item = Bytes> + Send>> {
std::mem::replace(slot, Box::pin(tokio_stream::empty()))
}
#[tokio::test]
async fn happy_path_echo() {
let mut handle =
allocate_pipe(sh_cmd(&["echo", "hello"]), None, HashMap::new()).expect("allocate");
let stderr = handle.stderr.take().expect("stderr present");
let stdout = swap_stdout(&mut handle.stdout);
let out = drain(stdout).await;
let err = drain(stderr).await;
assert!(err.is_empty(), "stderr empty");
let code = handle.exit_code.await.expect("exit");
assert_eq!(code, 0);
assert_eq!(String::from_utf8_lossy(&out), "hello\n");
}
#[tokio::test]
async fn stdin_round_trip_cat() {
let mut handle = allocate_pipe(sh_cmd(&["cat"]), None, HashMap::new()).expect("allocate");
handle
.stdin
.write_all(b"round-trip\n")
.await
.expect("write stdin");
// Close stdin so `cat` sees EOF and exits. Replace with a sink
// (`TtyHandle.stdin` is not `Option`; this drops the child stdin).
handle.stdin = Box::new(tokio::io::sink());
let stdout = swap_stdout(&mut handle.stdout);
let out = drain(stdout).await;
let code = handle.exit_code.await.expect("exit");
assert_eq!(code, 0);
assert_eq!(String::from_utf8_lossy(&out), "round-trip\n");
}
#[tokio::test]
async fn separate_stderr() {
let mut handle = allocate_pipe(sh_cmd(&["sh", "-c", "echo err >&2"]), None, HashMap::new())
.expect("allocate");
let stderr = handle.stderr.take().expect("stderr present");
let stdout = swap_stdout(&mut handle.stdout);
let out = drain(stdout).await;
assert!(out.is_empty(), "stdout empty");
let err = drain(stderr).await;
let code = handle.exit_code.await.expect("exit");
assert_eq!(code, 0);
assert_eq!(String::from_utf8_lossy(&err), "err\n");
}
#[cfg(unix)]
#[tokio::test]
async fn signal_term_kills_child() {
let handle =
allocate_pipe(sh_cmd(&["sleep", "60"]), None, HashMap::new()).expect("allocate");
let control = handle.control.clone().expect("control present");
control.signal("TERM");
let code = handle.exit_code.await.expect("exit");
assert_eq!(code, -15, "SIGTERM = -15");
}
#[cfg(unix)]
#[tokio::test]
async fn cancel_cleanup_drops_kill_child() {
// Use a temp file to capture the child's pid, then drop the handle
// (which drops exit_code → the ADR-056 guard sends SIGKILL) and
// assert the process no longer exists via `kill(pid, 0)`.
let pid_file = std::env::temp_dir().join(format!(
"alknet_pipe_cancel_pid_{}_{}.txt",
std::process::id(),
rand_seed()
));
let cmd = sh_cmd(&[
"sh",
"-c",
&format!("echo $$ > '{}'; exec sleep 60", pid_file.display()),
]);
let handle = allocate_pipe(cmd, None, HashMap::new()).expect("allocate");
// Wait for the shell to write its pid.
for _ in 0..100 {
if pid_file.exists() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let pid_str = std::fs::read_to_string(&pid_file).expect("pid file written");
let pid: i32 = pid_str.trim().parse().expect("pid parses");
let _ = std::fs::remove_file(&pid_file);
// Drop the handle without awaiting exit_code — the ADR-056 guard
// must kill the child.
drop(handle);
// Give the kernel a moment to deliver SIGKILL and reap.
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// kill(pid, 0) returns ESRCH (no such process) when the child is gone.
let alive = unsafe { libc::kill(pid, 0) } == 0;
assert!(!alive, "child (pid={pid}) should be killed after drop");
}
#[tokio::test]
async fn resize_is_noop() {
let handle = allocate_pipe(sh_cmd(&["true"]), None, HashMap::new()).expect("allocate");
let control = handle.control.as_ref().expect("control present");
control.resize(120, 40, 800, 600);
control.resize(0, 0, 0, 0);
}
#[cfg(unix)]
#[tokio::test]
async fn unknown_signal_falls_back_to_sigkill() {
let handle =
allocate_pipe(sh_cmd(&["sleep", "60"]), None, HashMap::new()).expect("allocate");
let control = handle.control.clone().expect("control present");
// Unknown name → SIGKILL fallback (equivalent to start_kill()).
control.signal("NOSUCH");
let code = handle.exit_code.await.expect("exit");
assert_eq!(code, -9, "SIGKILL = -9");
}
#[cfg(unix)]
#[tokio::test]
async fn pipe_control_pid_recorded() {
let ctrl = PipeControl::new(Some(42));
assert_eq!(ctrl.pid(), Some(42));
let ctrl_none = PipeControl::new(None);
assert_eq!(ctrl_none.pid(), None);
}
#[test]
fn empty_command_returns_alloc_failed() {
let result = allocate_pipe(vec![], None, HashMap::new());
assert!(
matches!(result, Err(TtyError::AllocFailed { .. })),
"expected AllocFailed"
);
}
fn rand_seed() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
}