use super::{
MAX_TOOL_OUTPUT_BYTES, STREAMING_CHANNEL_CAPACITY, ToolExecError, finish_tool_output_sanitized,
sanitize_transcript,
};
use choreo_sanitize::{ByteBudget, TRUNCATION_SUFFIX};
use crossbeam_channel;
use std::{
io::Read,
path::{Path, PathBuf},
process::{Command, Output},
sync::mpsc,
time::Duration,
};
#[cfg(unix)]
use std::os::fd::{AsFd, AsRawFd, OwnedFd};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
#[cfg(windows)]
use std::os::windows::io::AsRawHandle;
#[cfg(windows)]
use std::sync::Arc;
use tracing::{debug, warn};
#[cfg(target_os = "linux")]
use tracing::trace;
#[cfg(unix)]
const DRAIN_POLL_INTERVAL: Duration = Duration::from_millis(100);
const DRAIN_COMPLETION_GRACE: Duration = Duration::from_secs(1);
const DRAIN_DETACH_GRACE: Duration = Duration::from_secs(5);
fn executable_candidate_names(name: &str, pathext: Option<&str>, windows: bool) -> Vec<String> {
let mut candidates = vec![name.to_string()];
if windows {
let raw = pathext.unwrap_or(".COM;.EXE;.BAT;.CMD");
for ext in raw.split(';') {
let ext = ext.trim();
if ext.is_empty() {
continue;
}
let ext = ext.to_ascii_lowercase();
let candidate = format!("{name}{ext}");
if !name.to_ascii_lowercase().ends_with(&ext) {
candidates.push(candidate);
}
}
}
candidates
}
pub(crate) fn binary_exists(name: &str) -> bool {
let windows = cfg!(windows);
let pathext = std::env::var("PATHEXT").ok();
let candidates = executable_candidate_names(name, pathext.as_deref(), windows);
std::env::var_os("PATH")
.map(|path| {
std::env::split_paths(&path).any(|dir| {
candidates
.iter()
.any(|candidate| dir.join(candidate).is_file())
})
})
.unwrap_or(false)
}
pub(crate) fn resolve_workdir(workdir: Option<&str>, working_dir: Option<&Path>) -> PathBuf {
super::resolve_path(workdir.unwrap_or("."), working_dir)
}
pub(crate) fn sanitize_env(cmd: &mut Command) {
for var in &[
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"LD_AUDIT",
"LD_DEBUG",
"PYTHONPATH",
"PERL5LIB",
"RUBYLIB",
"DYLD_INSERT_LIBRARIES",
] {
cmd.env_remove(var);
}
}
fn setup_child(cmd: &mut Command) {
sanitize_env(cmd);
#[cfg(unix)]
cmd.process_group(0);
}
#[cfg(unix)]
#[cfg(target_os = "linux")]
fn open_pidfd(pid: u32) -> Option<OwnedFd> {
let pid = rustix::process::Pid::from_raw(pid as i32)?;
match rustix::process::pidfd_open(pid, rustix::process::PidfdFlags::empty()) {
Ok(fd) => Some(fd),
Err(e) => {
trace!(pid = pid.as_raw_pid(), error = %e, "pidfd_open unavailable; timeout kills fall back to PID-based killpg");
None
}
}
}
#[cfg(unix)]
#[cfg(not(target_os = "linux"))]
fn open_pidfd(_pid: u32) -> Option<OwnedFd> {
None
}
#[cfg(unix)]
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))]
fn kill_child_tree(pid: u32, pidfd: Option<&OwnedFd>) -> bool {
let Some(pid) = rustix::process::Pid::from_raw(pid as i32) else {
debug!(raw_pid = pid, "refusing to signal pid 0");
return false;
};
#[cfg(target_os = "linux")]
if let Some(fd) = pidfd {
match rustix::process::pidfd_send_signal(fd, rustix::process::Signal::KILL) {
Ok(()) => {
let _ = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
return true;
}
Err(rustix::io::Errno::SRCH) => {
debug!(
pid = pid.as_raw_pid(),
"pidfd_send_signal: child already exited on its own"
);
return false;
}
Err(e) => {
warn!(
pid = pid.as_raw_pid(),
error = %e,
"pidfd_send_signal failed; falling back to leader-checked killpg"
);
}
}
}
let is_group_leader = rustix::process::getpgid(Some(pid))
.map(|pgid| pgid == pid)
.unwrap_or(false);
if is_group_leader
&& rustix::process::kill_process_group(pid, rustix::process::Signal::KILL).is_ok()
{
return true;
}
rustix::process::kill_process(pid, rustix::process::Signal::KILL).is_ok()
}
#[cfg(windows)]
struct ChildJob(windows_sys::Win32::Foundation::HANDLE);
#[cfg(windows)]
unsafe impl Send for ChildJob {}
#[cfg(windows)]
unsafe impl Sync for ChildJob {}
#[cfg(windows)]
impl ChildJob {
fn assign(child: &std::process::Child) -> std::io::Result<Self> {
unsafe {
let job = windows_sys::Win32::System::JobObjects::CreateJobObjectW(
std::ptr::null(),
std::ptr::null(),
);
if job.is_null() {
return Err(std::io::Error::last_os_error());
}
let mut info: windows_sys::Win32::System::JobObjects::JOBOBJECT_EXTENDED_LIMIT_INFORMATION =
Default::default();
info.BasicLimitInformation.LimitFlags =
windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if windows_sys::Win32::System::JobObjects::SetInformationJobObject(
job,
windows_sys::Win32::System::JobObjects::JobObjectExtendedLimitInformation,
&info as *const _ as *const core::ffi::c_void,
std::mem::size_of::<
windows_sys::Win32::System::JobObjects::JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
>() as u32,
) == 0
{
let err = std::io::Error::last_os_error();
windows_sys::Win32::Foundation::CloseHandle(job);
return Err(err);
}
if windows_sys::Win32::System::JobObjects::AssignProcessToJobObject(
job,
child.as_raw_handle(),
) == 0
{
let err = std::io::Error::last_os_error();
windows_sys::Win32::Foundation::CloseHandle(job);
return Err(err);
}
Ok(ChildJob(job))
}
}
fn terminate(&self) -> bool {
unsafe { windows_sys::Win32::System::JobObjects::TerminateJobObject(self.0, 1) != 0 }
}
}
#[cfg(windows)]
impl Drop for ChildJob {
fn drop(&mut self) {
unsafe {
windows_sys::Win32::Foundation::CloseHandle(self.0);
}
}
}
#[cfg(windows)]
struct ProcessIsAlive(windows_sys::Win32::Foundation::HANDLE);
#[cfg(windows)]
unsafe impl Send for ProcessIsAlive {}
#[cfg(windows)]
impl ProcessIsAlive {
fn from_child(child: &std::process::Child) -> Self {
Self(child.as_raw_handle())
}
fn is_running(&self) -> bool {
unsafe {
windows_sys::Win32::System::Threading::WaitForSingleObject(self.0, 0)
== windows_sys::Win32::Foundation::WAIT_TIMEOUT
}
}
}
#[cfg(windows)]
fn collect_capped_drain(
thread: Option<(std::thread::JoinHandle<()>, mpsc::Receiver<Vec<u8>>)>,
job: &ChildJob,
) -> Option<Vec<u8>> {
let (handle, rx) = thread?;
let buf = match rx.recv_timeout(DRAIN_COMPLETION_GRACE) {
Ok(buf) => buf,
Err(mpsc::RecvTimeoutError::Timeout) => {
warn!("shell tool drain wedged past the child's exit; terminating job to force EOF");
job.terminate();
match rx.recv_timeout(DRAIN_DETACH_GRACE) {
Ok(buf) => buf,
Err(_) => {
warn!("shell tool drain still blocked after job termination; detaching it");
drop(handle); return None;
}
}
}
Err(_) => Vec::new(),
};
let _ = handle.join();
Some(buf)
}
#[cfg(unix)]
fn collect_capped_drain(
thread: Option<(std::thread::JoinHandle<()>, mpsc::Receiver<Vec<u8>>)>,
grace: Duration,
) -> Option<Vec<u8>> {
let (handle, rx) = thread?;
let buf = match rx.recv_timeout(grace) {
Ok(buf) => buf,
Err(mpsc::RecvTimeoutError::Timeout) => {
warn!("shell tool drain did not deliver within {grace:?}; detaching it");
drop(handle); return None;
}
Err(_) => Vec::new(),
};
let _ = handle.join();
Some(buf)
}
#[cfg(windows)]
fn collect_line_drains(
stdout: (std::thread::JoinHandle<()>, mpsc::Receiver<()>),
stderr: (std::thread::JoinHandle<()>, mpsc::Receiver<()>),
job: &ChildJob,
) {
for (handle, rx, name) in [
(stdout.0, stdout.1, "stdout"),
(stderr.0, stderr.1, "stderr"),
] {
match rx.recv_timeout(DRAIN_COMPLETION_GRACE) {
Ok(()) => {
let _ = handle.join();
}
Err(mpsc::RecvTimeoutError::Timeout) => {
warn!(
"shell tool {name} line drain wedged past the child's exit; terminating job to force EOF"
);
job.terminate();
match rx.recv_timeout(DRAIN_DETACH_GRACE) {
Ok(()) => {
let _ = handle.join();
}
Err(_) => {
warn!(
"shell tool {name} line drain still blocked after job termination; detaching it"
);
drop(handle);
}
}
}
Err(_) => {
let _ = handle.join();
}
}
}
}
#[cfg(unix)]
fn collect_line_drains(
stdout: (std::thread::JoinHandle<()>, mpsc::Receiver<()>),
stderr: (std::thread::JoinHandle<()>, mpsc::Receiver<()>),
grace: Duration,
) {
for (handle, rx, name) in [
(stdout.0, stdout.1, "stdout"),
(stderr.0, stderr.1, "stderr"),
] {
match rx.recv_timeout(grace) {
Ok(()) => {
let _ = handle.join();
}
Err(mpsc::RecvTimeoutError::Timeout) => {
warn!(
"shell tool {name} line drain did not deliver within {grace:?}; detaching it"
);
drop(handle);
}
Err(_) => {
let _ = handle.join();
}
}
}
}
fn collect_merger_body(
handle: std::thread::JoinHandle<()>,
rx: mpsc::Receiver<Vec<u8>>,
) -> Vec<u8> {
match rx.recv_timeout(DRAIN_DETACH_GRACE) {
Ok(body) => {
let _ = handle.join();
body
}
Err(mpsc::RecvTimeoutError::Timeout) => {
warn!("stream merger did not finish within {DRAIN_DETACH_GRACE:?}; detaching it");
drop(handle);
Vec::new()
}
Err(_) => {
let _ = handle.join();
Vec::new()
}
}
}
fn spawn_watchdog(
timeout_ms: u64,
pid: u32,
kill_tree: impl FnOnce() -> bool + Send + 'static,
done_rx: mpsc::Receiver<()>,
killed_tx: mpsc::Sender<()>,
abort_tx: Option<crossbeam_channel::Sender<()>>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
if done_rx
.recv_timeout(Duration::from_millis(timeout_ms))
.is_err()
&& kill_tree()
{
warn!(
pid,
timeout_ms, "shell tool timed out; killed child process tree"
);
let _ = killed_tx.send(());
if let Some(abort_tx) = abort_tx {
let _ = abort_tx.send(());
}
}
})
}
#[cfg(unix)]
fn poll_readable(
fd: rustix::fd::BorrowedFd<'_>,
stop_rx: &mpsc::Receiver<()>,
poll: Duration,
) -> bool {
let mut pfds = [rustix::event::PollFd::new(
&fd,
rustix::event::PollFlags::IN,
)];
let timeout = rustix::event::Timespec {
tv_sec: poll.as_secs() as i64,
tv_nsec: poll.subsec_nanos() as i64,
};
loop {
match rustix::event::poll(&mut pfds, Some(&timeout)) {
Ok(0) => {
if stop_rx.try_recv().is_ok() {
return false;
}
continue;
}
Ok(_) => return true,
Err(rustix::io::Errno::INTR) => continue,
Err(e) => {
debug!(error = %e, "poll on child pipe failed; stopping drain");
return false;
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DrainAccumulate {
None,
Capped(usize),
}
fn accumulate_chunk(budget: &mut Option<ByteBudget>, full: &mut Vec<u8>, chunk: &[u8]) {
match budget.as_mut() {
Some(budget) => {
let take = budget.fit(chunk.len());
full.extend_from_slice(chunk.get(..take).unwrap_or(chunk));
}
None => full.extend_from_slice(chunk),
}
}
#[cfg(unix)]
fn drain_fd<R: Read + AsFd>(
mut reader: R,
stop_rx: mpsc::Receiver<()>,
poll: Duration,
on_data: &mut dyn FnMut(&[u8]),
accumulate: DrainAccumulate,
) -> Vec<u8> {
let fd = reader.as_fd().as_raw_fd();
let nonblocking = set_nonblocking(reader.as_fd());
if !nonblocking {
debug!(
fd,
"could not set child pipe non-blocking; draining one chunk per poll"
);
}
let mut full: Vec<u8> = Vec::new();
let mut budget = match accumulate {
DrainAccumulate::None => None,
DrainAccumulate::Capped(cap) => Some(ByteBudget::new(cap)),
};
let mut buf = [0u8; 8192];
loop {
if !poll_readable(reader.as_fd(), &stop_rx, poll) {
break;
}
loop {
match reader.read(&mut buf) {
Ok(0) => return full, Ok(n) => {
let filled = buf.get(..n).unwrap_or(&buf);
on_data(filled);
accumulate_chunk(&mut budget, &mut full, filled);
if !nonblocking {
break; }
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break, Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {
if !nonblocking {
break; }
continue;
}
Err(e) => {
debug!(error = %e, "read from child pipe failed; stopping drain");
return full;
}
}
}
}
full
}
#[cfg(unix)]
fn set_nonblocking(fd: rustix::fd::BorrowedFd<'_>) -> bool {
match rustix::fs::fcntl_getfl(fd) {
Ok(flags) => rustix::fs::fcntl_setfl(fd, flags | rustix::fs::OFlags::NONBLOCK).is_ok(),
Err(_) => false,
}
}
#[cfg(windows)]
fn drain_reader<R: Read + Send>(
mut reader: R,
on_data: &mut dyn FnMut(&[u8]),
accumulate: DrainAccumulate,
) -> Vec<u8> {
let mut full: Vec<u8> = Vec::new();
let mut budget = match accumulate {
DrainAccumulate::None => None,
DrainAccumulate::Capped(cap) => Some(ByteBudget::new(cap)),
};
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => return full, Ok(n) => {
on_data(&buf[..n]);
accumulate_chunk(&mut budget, &mut full, &buf[..n]);
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => {
debug!(error = %e, "read from child pipe failed; stopping drain");
return full;
}
}
}
}
const MAX_PENDING_LINE_BYTES: usize = 16 * 1024;
fn partial_tail_len(pending: &[u8]) -> usize {
let mut i = pending.len();
let cr = usize::from(i > 0 && pending.last() == Some(&b'\r'));
i -= cr;
let mut continuations = 0usize;
while i > 0 && (0x80..=0xBF).contains(pending.get(i - 1).unwrap_or(&0)) {
i -= 1;
continuations += 1;
}
if i > 0 {
let lead = pending.get(i - 1).copied().unwrap_or(0);
let expected = match lead {
0xC0..=0xDF => 1,
0xE0..=0xEF => 2,
0xF0..=0xF7 => 3,
_ => 0,
};
if expected > continuations {
return continuations + 1 + cr;
}
}
cr
}
fn flush_partial_line(pending: &mut Vec<u8>, on_line: &mut dyn FnMut(Vec<u8>)) {
let split = pending.len() - partial_tail_len(pending);
let line: Vec<u8> = pending.drain(..split).collect();
on_line(line);
}
fn forward_complete_lines(chunk: &[u8], pending: &mut Vec<u8>, on_line: &mut dyn FnMut(Vec<u8>)) {
for &b in chunk {
if b == b'\n' && pending.last() == Some(&b'\r') {
pending.pop();
}
pending.push(b);
if b == b'\n' {
let line = std::mem::take(pending);
on_line(line);
} else if pending.len() >= MAX_PENDING_LINE_BYTES {
flush_partial_line(pending, on_line);
}
}
}
#[cfg(unix)]
fn spawn_capped_drain<R: Read + AsFd + Send + 'static>(
reader: R,
stop_rx: mpsc::Receiver<()>,
) -> (std::thread::JoinHandle<()>, mpsc::Receiver<Vec<u8>>) {
let (done_tx, done_rx) = mpsc::channel::<Vec<u8>>();
let handle = std::thread::spawn(move || {
let buf = drain_fd(
reader,
stop_rx,
DRAIN_POLL_INTERVAL,
&mut |_| {},
DrainAccumulate::Capped(MAX_TOOL_OUTPUT_BYTES),
);
let _ = done_tx.send(buf);
});
(handle, done_rx)
}
#[cfg(windows)]
fn spawn_capped_drain<R: Read + Send + 'static>(
reader: R,
_stop_rx: mpsc::Receiver<()>, ) -> (std::thread::JoinHandle<()>, mpsc::Receiver<Vec<u8>>) {
let (done_tx, done_rx) = mpsc::channel::<Vec<u8>>();
let handle = std::thread::spawn(move || {
let buf = drain_reader(
reader,
&mut |_| {},
DrainAccumulate::Capped(MAX_TOOL_OUTPUT_BYTES),
);
let _ = done_tx.send(buf);
});
(handle, done_rx)
}
pub(crate) fn spawn_with_watchdog(
cmd: &mut Command,
timeout_ms: u64,
) -> Result<(Output, bool), ToolExecError> {
setup_child(cmd);
let mut child = cmd.spawn()?;
let pid = child.id();
#[cfg(unix)]
let pidfd = open_pidfd(pid);
#[cfg(windows)]
let job = Arc::new(ChildJob::assign(&child).map_err(|e| {
let _ = child.kill();
let _ = child.wait();
e
})?);
#[cfg(windows)]
let watchdog_proc = ProcessIsAlive::from_child(&child);
let (done_tx, done_rx) = mpsc::channel::<()>();
let (killed_tx, killed_rx) = mpsc::channel::<()>();
#[cfg(unix)]
let watchdog = spawn_watchdog(
timeout_ms,
pid,
move || kill_child_tree(pid, pidfd.as_ref()),
done_rx,
killed_tx,
None,
);
#[cfg(windows)]
let watchdog = {
let watchdog_job = Arc::clone(&job);
spawn_watchdog(
timeout_ms,
pid,
move || watchdog_proc.is_running() && watchdog_job.terminate(),
done_rx,
killed_tx,
None,
)
};
let (out_stop_tx, out_stop_rx) = mpsc::channel::<()>();
let (err_stop_tx, err_stop_rx) = mpsc::channel::<()>();
let stdout_thread = child
.stdout
.take()
.map(|s| spawn_capped_drain(s, out_stop_rx));
let stderr_thread = child
.stderr
.take()
.map(|s| spawn_capped_drain(s, err_stop_rx));
let status = child.wait()?;
let _ = done_tx.send(());
let _ = out_stop_tx.send(());
let _ = err_stop_tx.send(());
if let Err(e) = watchdog.join() {
warn!("watchdog thread panicked: {:?}", e);
}
#[cfg(unix)]
let stdout = collect_capped_drain(stdout_thread, DRAIN_COMPLETION_GRACE).unwrap_or_default();
#[cfg(unix)]
let stderr = collect_capped_drain(stderr_thread, DRAIN_COMPLETION_GRACE).unwrap_or_default();
#[cfg(windows)]
let stdout = collect_capped_drain(stdout_thread, &job).unwrap_or_default();
#[cfg(windows)]
let stderr = collect_capped_drain(stderr_thread, &job).unwrap_or_default();
let was_killed = killed_rx.try_recv().is_ok();
Ok((
Output {
stdout,
stderr,
status,
},
was_killed,
))
}
struct StreamByteCap {
budget: ByteBudget,
tx: crossbeam_channel::Sender<Vec<u8>>,
abort_rx: crossbeam_channel::Receiver<()>,
}
impl StreamByteCap {
fn new(
limit: usize,
tx: crossbeam_channel::Sender<Vec<u8>>,
abort_rx: crossbeam_channel::Receiver<()>,
) -> Self {
Self {
budget: ByteBudget::new(limit),
tx,
abort_rx,
}
}
fn push(&mut self, chunk: &[u8], out: &mut Vec<u8>) -> bool {
let n = self.budget.fit(chunk.len());
let fitted = chunk.get(..n).unwrap_or(chunk);
if n > 0 && !self.forward(fitted) {
return false;
}
if n > 0 {
out.extend_from_slice(fitted);
}
if let Some(marker) = self.budget.take_marker() {
if !self.forward(marker.as_bytes()) {
return false;
}
out.extend_from_slice(marker.as_bytes());
}
true
}
fn forward(&self, bytes: &[u8]) -> bool {
match self.tx.try_send(bytes.to_vec()) {
Ok(()) => true,
Err(_) => crossbeam_channel::select! {
send(self.tx, bytes.to_vec()) -> res => res.is_ok(),
recv(self.abort_rx) -> abort => match abort {
Ok(()) => false,
Err(_) => self.tx.send(bytes.to_vec()).is_ok(),
},
},
}
}
}
#[cfg(unix)]
fn spawn_line_drain<R>(
reader: R,
stop_rx: mpsc::Receiver<()>,
merge_tx: crossbeam_channel::Sender<Vec<u8>>,
) -> (std::thread::JoinHandle<()>, mpsc::Receiver<()>)
where
R: Read + AsFd + Send + 'static,
{
let (done_tx, done_rx) = mpsc::channel::<()>();
let handle = std::thread::spawn(move || {
let mut pending: Vec<u8> = Vec::new();
drain_fd(
reader,
stop_rx,
DRAIN_POLL_INTERVAL,
&mut |chunk: &[u8]| {
forward_complete_lines(chunk, &mut pending, &mut |line| {
let _ = merge_tx.send(line);
});
},
DrainAccumulate::None,
);
if !pending.is_empty() {
let _ = merge_tx.send(pending);
}
let _ = done_tx.send(());
});
(handle, done_rx)
}
#[cfg(windows)]
fn spawn_line_drain<R>(
reader: R,
_stop_rx: mpsc::Receiver<()>,
merge_tx: crossbeam_channel::Sender<Vec<u8>>,
) -> (std::thread::JoinHandle<()>, mpsc::Receiver<()>)
where
R: Read + Send + 'static,
{
let (done_tx, done_rx) = mpsc::channel::<()>();
let handle = std::thread::spawn(move || {
let mut pending: Vec<u8> = Vec::new();
drain_reader(
reader,
&mut |chunk: &[u8]| {
forward_complete_lines(chunk, &mut pending, &mut |line| {
let _ = merge_tx.send(line);
});
},
DrainAccumulate::None,
);
if !pending.is_empty() {
let _ = merge_tx.send(pending);
}
let _ = done_tx.send(());
});
(handle, done_rx)
}
#[derive(Debug, Clone, Copy)]
pub struct RecordFraming(usize);
impl RecordFraming {
pub const fn none() -> Self {
Self(0)
}
pub fn shell(display_cmd: &str) -> Self {
Self(shell_output_framing_reservation(display_cmd))
}
fn bytes(self) -> usize {
self.0
}
}
pub fn spawn_with_streaming(
cmd: &mut Command,
timeout_ms: u64,
framing: RecordFraming,
output_tx: crossbeam_channel::Sender<Vec<u8>>,
) -> Result<(Output, bool), ToolExecError> {
setup_child(cmd);
let mut child = cmd.spawn()?;
let pid = child.id();
#[cfg(unix)]
let pidfd = open_pidfd(pid);
#[cfg(windows)]
let job = Arc::new(ChildJob::assign(&child).map_err(|e| {
let _ = child.kill();
let _ = child.wait();
e
})?);
#[cfg(windows)]
let watchdog_proc = ProcessIsAlive::from_child(&child);
let stdout = child
.stdout
.take()
.ok_or_else(|| std::io::Error::other("stdout not piped"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| std::io::Error::other("stderr not piped"))?;
let (out_stop_tx, out_stop_rx) = mpsc::channel::<()>();
let (err_stop_tx, err_stop_rx) = mpsc::channel::<()>();
let (merge_tx, merge_rx) = crossbeam_channel::bounded::<Vec<u8>>(STREAMING_CHANNEL_CAPACITY);
let stdout_thread = spawn_line_drain(stdout, out_stop_rx, merge_tx.clone());
let stderr_thread = spawn_line_drain(stderr, err_stop_rx, merge_tx.clone());
drop(merge_tx);
let (abort_tx, abort_rx) = crossbeam_channel::bounded::<()>(1);
let (merger_done_tx, merger_done_rx) = mpsc::channel::<Vec<u8>>();
let merger_thread = std::thread::spawn(move || {
let mut stream_cap = StreamByteCap::new(
MAX_TOOL_OUTPUT_BYTES.saturating_sub(framing.bytes()),
output_tx,
abort_rx,
);
let mut full: Vec<u8> = Vec::new();
while let Ok(line) = merge_rx.recv() {
let lossy = String::from_utf8_lossy(&line);
let escaped = sanitize_transcript(&lossy);
if !stream_cap.push(escaped.as_bytes(), &mut full) {
break;
}
}
let _ = merger_done_tx.send(full);
});
let (done_tx, done_rx) = mpsc::channel::<()>();
let (killed_tx, killed_rx) = mpsc::channel::<()>();
#[cfg(unix)]
let watchdog = spawn_watchdog(
timeout_ms,
pid,
move || kill_child_tree(pid, pidfd.as_ref()),
done_rx,
killed_tx,
Some(abort_tx),
);
#[cfg(windows)]
let watchdog = {
let watchdog_job = Arc::clone(&job);
spawn_watchdog(
timeout_ms,
pid,
move || watchdog_proc.is_running() && watchdog_job.terminate(),
done_rx,
killed_tx,
Some(abort_tx),
)
};
let status = match child.wait() {
Ok(status) => status,
Err(e) => {
#[cfg(unix)]
{
let _ = out_stop_tx.send(());
let _ = err_stop_tx.send(());
collect_line_drains(stdout_thread, stderr_thread, DRAIN_COMPLETION_GRACE);
}
#[cfg(windows)]
{
job.terminate();
collect_line_drains(stdout_thread, stderr_thread, &job);
}
let _ = collect_merger_body(merger_thread, merger_done_rx);
if let Err(e) = watchdog.join() {
warn!("watchdog thread panicked: {:?}", e);
}
return Err(e.into());
}
};
let _ = done_tx.send(());
let _ = out_stop_tx.send(());
let _ = err_stop_tx.send(());
#[cfg(unix)]
collect_line_drains(stdout_thread, stderr_thread, DRAIN_COMPLETION_GRACE);
#[cfg(windows)]
collect_line_drains(stdout_thread, stderr_thread, &job);
let body = collect_merger_body(merger_thread, merger_done_rx);
if let Err(e) = watchdog.join() {
warn!("watchdog thread panicked: {:?}", e);
}
let was_killed = killed_rx.try_recv().is_ok();
Ok((
Output {
stdout: body,
stderr: Vec::new(),
status,
},
was_killed,
))
}
fn shell_output_framing_reservation(display_cmd: &str) -> usize {
let escaped_header_len = sanitize_transcript(&format!("$ {display_cmd}\n")).len();
const WORST_CASE_FOOTER_LEN: usize = "\n\nExit code: -2147483648".len();
escaped_header_len + WORST_CASE_FOOTER_LEN + 2 * TRUNCATION_SUFFIX.len()
}
pub fn run_shell_streaming(
cmd: &mut Command,
display_cmd: &str,
timeout_ms: u64,
output_tx: crossbeam_channel::Sender<Vec<u8>>,
) -> Result<String, ToolExecError> {
let (output, was_killed) = spawn_with_streaming(
cmd,
timeout_ms,
RecordFraming::shell(display_cmd),
output_tx,
)?;
Ok(format_shell_output(
display_cmd,
&output,
timeout_ms,
was_killed,
))
}
pub(crate) fn format_shell_output(
display_cmd: &str,
output: &Output,
timeout_ms: u64,
was_killed: bool,
) -> String {
if was_killed {
return finish_tool_output_sanitized(
&format!("$ {display_cmd}"),
Some(format!(
"\n[command timed out after {timeout_ms}ms]\n\nExit code: -1"
)),
);
}
let combined_str = if output.stderr.is_empty() {
String::from_utf8_lossy(&output.stdout)
} else {
let mut combined = output.stdout.clone();
combined.extend_from_slice(&output.stderr);
std::borrow::Cow::Owned(String::from_utf8_lossy(&combined).into_owned())
};
let exit_code = output.status.code().unwrap_or(-1);
finish_tool_output_sanitized(
&format!("$ {display_cmd}\n{combined_str}"),
Some(format!("\nExit code: {exit_code}")),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::process::ExitStatusExt;
use std::process::Stdio;
struct ReapOnDrop(std::process::Child);
impl Drop for ReapOnDrop {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn setup_child_places_child_in_its_own_process_group() {
let mut cmd = std::process::Command::new("sh");
cmd.args(["-c", "echo $$; while :; do :; done"])
.stdout(Stdio::piped());
setup_child(&mut cmd);
let mut child = cmd.spawn().expect("spawn child");
let stdout = child.stdout.take().expect("take stdout");
let _reap = ReapOnDrop(child);
let mut reader = BufReader::new(stdout);
let mut pid_line = String::new();
reader.read_line(&mut pid_line).expect("read child pid");
let child_pid = pid_line.trim().parse::<i32>().expect("parse child pid");
let pgid = rustix::process::getpgid(Some(
rustix::process::Pid::from_raw(child_pid).expect("parsed child pid is nonzero"),
))
.expect("getpgid on live child");
assert_eq!(
pgid.as_raw_pid(),
child_pid,
"child must be leader of its own process group"
);
}
#[test]
fn kill_child_tree_falls_back_to_direct_kill_when_not_group_leader() {
let mut cmd = std::process::Command::new("sh");
cmd.args(["-c", "exec sleep 30"]);
let child = cmd.spawn().expect("spawn child");
let pid = child.id();
let mut _reap = ReapOnDrop(child);
assert!(
kill_child_tree(pid, None),
"direct-kill fallback must reap a non-leader child"
);
assert!(!_reap.0.wait().expect("wait on killed child").success());
}
#[test]
#[cfg(target_os = "linux")]
fn kill_child_tree_kills_group_through_pinned_pidfd() {
let mut cmd = std::process::Command::new("sh");
cmd.args(["-c", "sleep 30"]).stdout(Stdio::piped());
setup_child(&mut cmd);
let child = cmd.spawn().expect("spawn child");
let pid = child.id();
let mut _reap = ReapOnDrop(child);
let Some(pidfd) = open_pidfd(pid) else {
eprintln!("pidfd_open unavailable; skipping pinned-pidfd test");
return;
};
assert!(
kill_child_tree(pid, Some(&pidfd)),
"pinned pidfd kill must reap a leader child"
);
assert!(!_reap.0.wait().expect("wait on killed child").success());
}
#[test]
fn drain_fd_reads_to_eof_without_stop_signal() {
let (reader, mut writer) = std::io::pipe().expect("pipe");
writer.write_all(b"line1\nline2\n").expect("write");
drop(writer);
let (_stop_tx, stop_rx) = mpsc::channel::<()>();
let got = drain_fd(
reader,
stop_rx,
Duration::ZERO,
&mut |_| {},
DrainAccumulate::None,
);
assert_eq!(got, b"line1\nline2\n");
}
#[test]
fn drain_fd_captures_output_larger_than_one_chunk() {
let (reader, mut writer) = std::io::pipe().expect("pipe");
let payload = vec![b'x'; 20 * 1024];
writer.write_all(&payload).expect("write");
drop(writer);
let (_stop_tx, stop_rx) = mpsc::channel::<()>();
let got = drain_fd(
reader,
stop_rx,
Duration::ZERO,
&mut |_| {},
DrainAccumulate::None,
);
assert_eq!(got, payload);
}
#[test]
fn drain_fd_stops_when_signalled_even_with_open_writer() {
let (reader, mut writer) = std::io::pipe().expect("pipe");
writer.write_all(b"hello").expect("write");
let (stop_tx, stop_rx) = mpsc::channel::<()>();
stop_tx.send(()).expect("signal stop");
let got = drain_fd(
reader,
stop_rx,
Duration::ZERO,
&mut |_| {},
DrainAccumulate::None,
);
assert_eq!(
got, b"hello",
"buffered data must be drained before stopping"
);
drop(writer);
}
#[test]
fn drain_fd_caps_accumulation_at_requested_limit() {
let (reader, mut writer) = std::io::pipe().expect("pipe");
let payload = vec![b'x'; 12 * 1024];
writer.write_all(&payload).expect("write");
drop(writer);
let (_stop_tx, stop_rx) = mpsc::channel::<()>();
let mut seen = 0usize;
let got = drain_fd(
reader,
stop_rx,
Duration::ZERO,
&mut |chunk: &[u8]| seen += chunk.len(),
DrainAccumulate::Capped(8 * 1024),
);
assert_eq!(got.len(), 8 * 1024, "accumulation must stop at the cap");
assert_eq!(
seen,
payload.len(),
"on_data must still observe every byte past the cap"
);
}
#[test]
fn forward_complete_lines_splits_lines_and_folds_crlf() {
let mut pending: Vec<u8> = Vec::new();
let mut lines: Vec<Vec<u8>> = Vec::new();
forward_complete_lines(b"a\r\nb", &mut pending, &mut |l| lines.push(l));
assert_eq!(lines, vec![b"a\n".to_vec()]);
assert_eq!(pending, b"b");
forward_complete_lines(b"\nc", &mut pending, &mut |l| lines.push(l));
assert_eq!(lines, vec![b"a\n".to_vec(), b"b\n".to_vec()]);
assert_eq!(pending, b"c");
if !pending.is_empty() {
lines.push(std::mem::take(&mut pending));
}
assert_eq!(lines, vec![b"a\n".to_vec(), b"b\n".to_vec(), b"c".to_vec()]);
}
#[test]
fn forward_complete_lines_flushes_oversized_unterminated_lines() {
let mut pending: Vec<u8> = Vec::new();
let mut parts: Vec<Vec<u8>> = Vec::new();
forward_complete_lines(
&vec![b'x'; MAX_PENDING_LINE_BYTES * 3],
&mut pending,
&mut |l| parts.push(l),
);
let mut merged: Vec<u8> = parts.iter().flatten().copied().collect();
merged.extend_from_slice(&pending);
assert_eq!(
merged.len(),
MAX_PENDING_LINE_BYTES * 3,
"no bytes lost across the partial flushes"
);
assert!(
parts.iter().all(|p| p.len() <= MAX_PENDING_LINE_BYTES),
"no partial chunk may exceed the threshold"
);
assert!(pending.is_empty(), "pure 'x' input leaves nothing pending");
let mut pending: Vec<u8> = vec![b'x'; MAX_PENDING_LINE_BYTES - 1];
let mut parts = Vec::new();
forward_complete_lines(b"\r", &mut pending, &mut |l| parts.push(l));
assert_eq!(parts, vec![vec![b'x'; MAX_PENDING_LINE_BYTES - 1]]);
assert_eq!(pending, b"\r", "trailing CR held back for CRLF folding");
forward_complete_lines(b"\n", &mut pending, &mut |l| parts.push(l));
assert_eq!(parts[1], b"\n", "the held-back CR folds with the LF");
assert!(pending.is_empty());
}
#[test]
fn forward_complete_lines_holds_back_partial_utf8_at_flush() {
let mut pending: Vec<u8> = vec![b'x'; MAX_PENDING_LINE_BYTES - 2];
let mut parts: Vec<Vec<u8>> = Vec::new();
forward_complete_lines(b"\xe2\x82", &mut pending, &mut |l| parts.push(l));
assert_eq!(
parts,
vec![vec![b'x'; MAX_PENDING_LINE_BYTES - 2]],
"the complete prefix is flushed; nothing past it"
);
assert_eq!(pending, b"\xe2\x82", "partial char held back whole");
forward_complete_lines(b"\xac", &mut pending, &mut |l| parts.push(l));
assert!(
!pending.is_empty(),
"the char is still pending (no newline)"
);
let joined: Vec<u8> = parts.concat();
assert_eq!(
joined.len(),
MAX_PENDING_LINE_BYTES - 2,
"no bytes lost across the flush"
);
let stream: Vec<u8> = joined
.into_iter()
.chain(std::mem::take(&mut pending))
.collect();
assert_eq!(
String::from_utf8_lossy(&stream),
format!("{}€", "x".repeat(MAX_PENDING_LINE_BYTES - 2)),
"chunks must join back into the original valid UTF-8"
);
}
#[test]
fn executable_candidate_names_unix_is_exact() {
assert_eq!(executable_candidate_names("nu", None, false), vec!["nu"]);
assert_eq!(
executable_candidate_names("nu", Some(".EXE;.CMD"), false),
vec!["nu"]
);
}
#[test]
fn executable_candidate_names_windows_probes_pathext() {
assert_eq!(
executable_candidate_names("pwsh", None, true),
vec!["pwsh", "pwsh.com", "pwsh.exe", "pwsh.bat", "pwsh.cmd"]
);
assert_eq!(
executable_candidate_names("nu", Some(".exe;.MSI;"), true),
vec!["nu", "nu.exe", "nu.msi"]
);
assert_eq!(
executable_candidate_names("powershell.exe", None, true),
vec![
"powershell.exe",
"powershell.exe.com",
"powershell.exe.bat",
"powershell.exe.cmd"
]
);
}
#[test]
fn format_shell_output_was_killed_shows_timeout() {
let output = Output {
stdout: b"some output".to_vec(),
stderr: b"".to_vec(),
status: std::process::ExitStatus::from_raw(0),
};
let result = format_shell_output("sleep 10", &output, 5000, true);
assert!(result.contains("timed out after 5000ms"));
assert!(result.contains("Exit code: -1"));
}
#[test]
fn format_shell_output_not_killed_shows_exit_code() {
let output = Output {
stdout: b"hello\nworld".to_vec(),
stderr: b"".to_vec(),
status: std::process::ExitStatus::from_raw(0),
};
let result = format_shell_output("echo hello", &output, 5000, false);
assert!(!result.contains("timed out"));
assert!(result.contains("hello"));
assert!(result.contains("world"));
assert!(result.contains("Exit code: 0"));
}
#[test]
fn format_shell_output_includes_stderr() {
let output = Output {
stdout: b"stdout".to_vec(),
stderr: b"stderr".to_vec(),
status: std::process::ExitStatus::from_raw(1 << 8),
};
let result = format_shell_output("cmd", &output, 1000, false);
assert!(result.contains("stdout"));
assert!(result.contains("stderr"));
assert!(result.contains("Exit code: 1"));
}
#[test]
fn stream_byte_cap_accumulates_what_it_forwards() {
let (tx, rx) = crossbeam_channel::unbounded::<Vec<u8>>();
let (_abort_tx, abort_rx) = crossbeam_channel::bounded::<()>(1);
let mut cap = StreamByteCap::new(10, tx, abort_rx);
let mut out = Vec::new();
assert!(cap.push(b"abcd", &mut out));
assert!(cap.push(b"0123456789", &mut out));
assert!(cap.push(b"xyz", &mut out));
let streamed: Vec<u8> = rx.try_iter().flatten().collect();
assert_eq!(
out, streamed,
"recorded body must equal the forwarded stream"
);
assert_eq!(out, b"abcd012345\n...[truncated]");
}
#[test]
fn stream_byte_cap_abort_interrupts_a_blocked_send() {
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(1);
let (abort_tx, abort_rx) = crossbeam_channel::bounded::<()>(1);
let mut cap = StreamByteCap::new(100, tx.clone(), abort_rx);
let mut out = Vec::new();
tx.try_send(b"full".to_vec()).expect("fill channel");
abort_tx.send(()).expect("queue abort");
assert!(
!cap.push(b"blocked", &mut out),
"abort must interrupt a blocked send"
);
assert_eq!(rx.try_recv().expect("queued chunk"), b"full");
assert!(rx.try_recv().is_err(), "aborted send must not deliver");
assert!(out.is_empty(), "aborted send must not be accumulated");
}
#[test]
fn stream_byte_cap_abort_disconnect_does_not_stop_streaming() {
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(1);
let (abort_tx, abort_rx) = crossbeam_channel::bounded::<()>(1);
let mut cap = StreamByteCap::new(100, tx.clone(), abort_rx);
let mut out = Vec::new();
drop(abort_tx);
tx.try_send(b"full".to_vec()).expect("fill channel");
let consumer = std::thread::spawn(move || {
let first = rx.recv().unwrap();
let second = rx.recv().unwrap();
(first, second)
});
assert!(
cap.push(b"more", &mut out),
"a disconnected abort channel must not stop the stream"
);
let (first, second) = consumer.join().expect("consumer");
assert_eq!(first, b"full");
assert_eq!(second, b"more");
assert_eq!(out, b"more", "the delayed chunk must still be accumulated");
}
#[test]
fn framing_reservation_covers_prefix_footer_and_markers() {
let prefix = "$ echo\n".len();
let worst_footer = "\n\nExit code: -2147483648".len();
assert_eq!(
shell_output_framing_reservation("echo"),
prefix + worst_footer + 2 * TRUNCATION_SUFFIX.len()
);
assert!(shell_output_framing_reservation("echo") < MAX_TOOL_OUTPUT_BYTES / 100);
assert!(
shell_output_framing_reservation("echo\u{200b}")
> shell_output_framing_reservation("echo"),
"Cf chars in the display command must be reserved at escaped size"
);
}
#[test]
fn merged_streams_preserve_each_streams_line_order() {
let (out_r, mut out_w) = std::io::pipe().expect("pipe");
let (err_r, mut err_w) = std::io::pipe().expect("pipe");
out_w.write_all(b"o1\no2\no3\n").expect("write stdout");
err_w.write_all(b"e1\ne2\n").expect("write stderr");
drop(out_w);
drop(err_w);
let (merge_tx, merge_rx) = crossbeam_channel::unbounded::<Vec<u8>>();
let (_out_stop_tx, out_stop_rx) = mpsc::channel::<()>();
let (_err_stop_tx, err_stop_rx) = mpsc::channel::<()>();
let t1 = spawn_line_drain(out_r, out_stop_rx, merge_tx.clone());
let t2 = spawn_line_drain(err_r, err_stop_rx, merge_tx.clone());
t1.1.recv().expect("stdout drain completion");
t2.1.recv().expect("stderr drain completion");
t1.0.join().expect("stdout drain");
t2.0.join().expect("stderr drain");
drop(merge_tx);
let merged: Vec<String> = merge_rx
.try_iter()
.map(|l| String::from_utf8_lossy(&l).into_owned())
.collect();
let outs: Vec<String> = merged
.iter()
.filter(|l| l.starts_with('o'))
.cloned()
.collect();
let errs: Vec<String> = merged
.iter()
.filter(|l| l.starts_with('e'))
.cloned()
.collect();
assert_eq!(
outs,
vec!["o1\n", "o2\n", "o3\n"],
"stdout lines keep their relative order"
);
assert_eq!(errs, vec!["e1\n", "e2\n"], "stderr lines keep their order");
assert_eq!(merged.len(), 5, "every line from both streams is merged");
}
#[test]
fn collect_capped_drain_detaches_when_drain_never_delivers() {
let (done_tx, done_rx) = mpsc::channel::<Vec<u8>>();
let (release_tx, release_rx) = mpsc::channel::<()>();
let handle = std::thread::spawn(move || {
let _done_tx = done_tx;
let _ = release_rx.recv();
});
let got = collect_capped_drain(Some((handle, done_rx)), Duration::ZERO);
assert!(
got.is_none(),
"a never-delivering drain must detach, not hang"
);
release_tx.send(()).expect("release wedged drain");
}
#[test]
fn collect_line_drains_detaches_when_drain_never_delivers() {
let (done_tx1, done_rx1) = mpsc::channel::<()>();
let (rel1_tx, rel1_rx) = mpsc::channel::<()>();
let h1 = std::thread::spawn(move || {
let _done_tx1 = done_tx1;
let _ = rel1_rx.recv();
});
let (done_tx2, done_rx2) = mpsc::channel::<()>();
let (rel2_tx, rel2_rx) = mpsc::channel::<()>();
let h2 = std::thread::spawn(move || {
let _done_tx2 = done_tx2;
let _ = rel2_rx.recv();
});
collect_line_drains((h1, done_rx1), (h2, done_rx2), Duration::ZERO);
rel1_tx.send(()).expect("release stdout drain");
rel2_tx.send(()).expect("release stderr drain");
}
}