#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::fmt;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
#[cfg(windows)]
use process_wrap::std::JobObject;
#[cfg(unix)]
use process_wrap::std::ProcessGroup;
use process_wrap::std::{ChildWrapper, CommandWrap};
use super::{MAX_OUTPUT_BYTES, TIMEOUT_SECS, ToolDefinition, ToolOutput, ToolUseRequest, path};
use crate::app::ToolStatus;
use crate::tools::registry::{ToolContext, ToolError, ToolExecution};
use crate::utils;
use thndrs_agent::CancelToken;
const MAX_OUTPUT_LINES: usize = 200;
pub const NAME: &str = "run_shell";
type OwnedChild = Box<dyn ChildWrapper>;
enum WaitOutcome {
Exited(i32),
Timeout,
Cancelled,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum ProcessStatus {
Running,
Ok,
Failed,
Timeout,
Cancelled,
}
impl ProcessStatus {
pub fn label(&self) -> &'static str {
match self {
ProcessStatus::Running => "running",
ProcessStatus::Ok => "ok",
ProcessStatus::Failed => "failed",
ProcessStatus::Timeout => "timeout",
ProcessStatus::Cancelled => "cancelled",
}
}
pub const fn to_tool_status(self) -> ToolStatus {
match self {
ProcessStatus::Running => ToolStatus::Running,
ProcessStatus::Ok => ToolStatus::Ok,
ProcessStatus::Failed | ProcessStatus::Timeout => ToolStatus::Failed,
ProcessStatus::Cancelled => ToolStatus::Cancelled,
}
}
}
impl From<ProcessStatus> for ToolStatus {
fn from(status: ProcessStatus) -> Self {
status.to_tool_status()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum ProcessKind {
OneShot,
Background,
}
impl ProcessKind {
pub fn label(&self) -> &'static str {
match self {
ProcessKind::OneShot => "one-shot",
ProcessKind::Background => "background",
}
}
}
impl fmt::Display for ProcessKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProcessResult {
pub process_id: Option<u64>,
pub command: Vec<String>,
pub cwd: PathBuf,
pub status: ProcessStatus,
pub exit_code: Option<i32>,
pub stdout: Vec<String>,
pub stderr: Vec<String>,
pub elapsed: Duration,
pub kind: ProcessKind,
}
impl ProcessResult {
pub fn summary(&self) -> String {
let argv = self.command.join(" ");
let elapsed_ms = self.elapsed.as_millis();
match self.status {
ProcessStatus::Running => format!("$ {argv} [{}]", self.kind.label()),
other => format!("$ {argv} [{} {} {}ms]", self.kind.label(), other.label(), elapsed_ms),
}
}
pub fn to_output_lines(&self) -> Vec<String> {
let mut lines = vec![redact_secrets(&self.summary())];
if !self.stdout.is_empty() {
lines.push(String::from("── stdout ──"));
lines.extend(self.stdout.iter().cloned());
}
if !self.stderr.is_empty() {
lines.push(String::from("── stderr ──"));
lines.extend(self.stderr.iter().cloned());
}
lines
}
pub fn to_failed_output(&self) -> ToolOutput {
let err = match self.status {
ProcessStatus::Timeout => {
format!("command timed out after {}ms", self.elapsed.as_millis())
}
ProcessStatus::Cancelled => String::from("command cancelled"),
_ => {
let code = self.exit_code.map(|c| c.to_string()).unwrap_or_else(|| "?".to_string());
format!("command failed (exit {code})")
}
};
ToolOutput::failed("run_shell", err)
}
pub fn to_tool_output(&self) -> ToolOutput {
match ToolStatus::from(self.status) {
ToolStatus::Running | ToolStatus::Ok => ToolOutput::ok(NAME, self.to_output_lines()),
_ => {
let mut output = self.to_failed_output();
let lines = self.to_output_lines();
output.display.lines = lines.clone();
output.model.lines = lines;
output
}
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ProcessOutput {
pub stdout: Vec<String>,
pub stderr: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct ActiveProcess {
pub id: u64,
pub command: Vec<String>,
pub cwd: PathBuf,
pub kind: ProcessKind,
pub cancel: CancelToken,
pub started: Instant,
pub status: ProcessStatus,
pub output: ProcessOutput,
control: Option<Arc<ProcessControl>>,
}
impl ActiveProcess {
pub fn elapsed(&self) -> Duration {
self.started.elapsed()
}
pub fn cancel(&self) {
if let Some(control) = &self.control {
control.cancel();
} else {
self.cancel.cancel();
}
}
}
#[derive(Clone, Debug, Default)]
pub struct ProcessRegistry {
inner: Arc<RegistryInner>,
}
#[derive(Debug, Default)]
struct RegistryInner {
state: Mutex<RegistryState>,
}
#[derive(Debug, Default)]
struct RegistryState {
next_id: u64,
active: HashMap<u64, TrackedProcess>,
}
#[derive(Debug)]
struct TrackedProcess {
id: u64,
command: Vec<String>,
cwd: PathBuf,
kind: ProcessKind,
cancel: CancelToken,
started: Instant,
control: Option<Arc<ProcessControl>>,
output: Arc<OutputCapture>,
result: Arc<Mutex<Option<ProcessResult>>>,
worker: Option<JoinHandle<()>>,
announced: bool,
}
impl TrackedProcess {
fn synthetic(id: u64, command: Vec<String>, cwd: PathBuf, kind: ProcessKind, cancel: CancelToken) -> Self {
Self {
id,
command,
cwd,
kind,
cancel,
started: Instant::now(),
control: None,
output: Arc::new(OutputCapture::default()),
result: Arc::new(Mutex::new(None)),
worker: None,
announced: true,
}
}
fn snapshot(&self) -> ActiveProcess {
let result = self.result.lock().ok().and_then(|result| result.clone());
let output = result.as_ref().map_or_else(
|| self.output.snapshot(),
|result| ProcessOutput { stdout: result.stdout.clone(), stderr: result.stderr.clone() },
);
ActiveProcess {
id: self.id,
command: self.command.clone(),
cwd: self.cwd.clone(),
kind: self.kind,
cancel: self.cancel.clone(),
started: self.started,
status: result.map_or(ProcessStatus::Running, |result| result.status),
output,
control: self.control.clone(),
}
}
}
#[derive(Debug)]
struct ProcessControl {
cancel: CancelToken,
child: Arc<Mutex<Option<OwnedChild>>>,
}
impl ProcessControl {
fn cancel(&self) {
self.cancel.cancel();
if let Ok(mut child) = self.child.lock()
&& let Some(child) = child.as_mut()
{
let _ = child.start_kill();
}
}
}
#[derive(Debug, Default)]
struct OutputCapture {
stdout: Mutex<Vec<u8>>,
stderr: Mutex<Vec<u8>>,
readers: AtomicUsize,
}
impl OutputCapture {
fn append(&self, stdout: bool, bytes: &[u8]) {
let target = if stdout { &self.stdout } else { &self.stderr };
let Ok(mut target) = target.lock() else {
return;
};
let remaining = MAX_OUTPUT_BYTES.saturating_sub(target.len());
target.extend_from_slice(&bytes[..bytes.len().min(remaining)]);
}
fn snapshot(&self) -> ProcessOutput {
let stdout = self.stdout.lock().map(|bytes| bytes.clone()).unwrap_or_default();
let stderr = self.stderr.lock().map(|bytes| bytes.clone()).unwrap_or_default();
ProcessOutput { stdout: split_and_cap(&stdout), stderr: split_and_cap(&stderr) }
}
}
struct BackgroundMonitor {
id: u64,
command: Vec<String>,
cwd: PathBuf,
timeout: Duration,
start: Instant,
cancel: CancelToken,
child: Arc<Mutex<Option<OwnedChild>>>,
output: Arc<OutputCapture>,
result_slot: Arc<Mutex<Option<ProcessResult>>>,
}
impl ProcessRegistry {
pub fn new() -> Self {
Self::default()
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.inner.state.lock().map(|state| state.active.len()).unwrap_or(0)
}
#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
#[cfg(test)]
pub fn background_count(&self) -> usize {
self.inner
.state
.lock()
.map(|state| {
state
.active
.values()
.filter(|process| {
process.kind == ProcessKind::Background && process.snapshot().status == ProcessStatus::Running
})
.count()
})
.unwrap_or(0)
}
#[cfg(test)]
pub fn one_shot_count(&self) -> usize {
self.inner
.state
.lock()
.map(|state| state.active.values().filter(|p| p.kind == ProcessKind::OneShot).count())
.unwrap_or(0)
}
pub fn register(&self, command: Vec<String>, cwd: PathBuf, kind: ProcessKind, cancel: CancelToken) -> u64 {
let mut state = recover_lock(&self.inner.state);
let id = state.next_id;
state.next_id += 1;
state
.active
.insert(id, TrackedProcess::synthetic(id, command, cwd, kind, cancel));
id
}
pub fn get(&self, id: u64) -> Option<ActiveProcess> {
let state = self.inner.state.lock().ok()?;
state.active.get(&id).map(TrackedProcess::snapshot)
}
pub fn cancel(&self, id: u64) -> bool {
let Some(process) = self.get(id) else {
return false;
};
if process.status != ProcessStatus::Running {
return false;
}
process.cancel();
true
}
pub fn remove(&self, id: u64) -> Option<ActiveProcess> {
let mut tracked = self.inner.state.lock().ok()?.active.remove(&id)?;
if let Some(control) = &tracked.control {
control.cancel();
}
join_worker(tracked.worker.take());
Some(tracked.snapshot())
}
pub fn cancel_all(&self) {
let state = recover_lock(&self.inner.state);
for process in state.active.values() {
if process.snapshot().status == ProcessStatus::Running {
if let Some(control) = &process.control {
control.cancel();
} else {
process.cancel.cancel();
}
}
}
}
#[cfg(test)]
pub fn ids(&self) -> impl Iterator<Item = u64> {
self.inner
.state
.lock()
.map(|state| state.active.keys().copied().collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
}
pub fn background_ids(&self) -> impl Iterator<Item = u64> {
self.inner
.state
.lock()
.map(|state| {
state
.active
.values()
.filter(|process| {
process.kind == ProcessKind::Background && process.snapshot().status == ProcessStatus::Running
})
.map(|process| process.id)
.collect::<Vec<_>>()
})
.unwrap_or_default()
.into_iter()
}
pub fn announce(&self, id: u64) -> bool {
let Ok(mut state) = self.inner.state.lock() else {
return false;
};
let Some(process) = state.active.get_mut(&id) else {
return false;
};
process.announced = true;
true
}
pub fn drain_completed(&self) -> Vec<ProcessResult> {
self.drain_completed_inner(false)
}
pub fn shutdown(&self) -> Vec<ProcessResult> {
let tracked = {
let mut state = recover_lock(&self.inner.state);
for process in state.active.values() {
if let Some(control) = &process.control {
control.cancel();
} else {
process.cancel.cancel();
}
}
state.active.drain().map(|(_, process)| process).collect::<Vec<_>>()
};
let mut results = Vec::new();
for process in tracked {
join_worker(process.worker);
if let Ok(mut result) = process.result.lock()
&& let Some(result) = result.take()
{
results.push(result);
}
}
results.sort_by_key(|result| result.process_id);
results
}
fn drain_completed_inner(&self, include_unannounced: bool) -> Vec<ProcessResult> {
let tracked = {
let mut state = recover_lock(&self.inner.state);
if include_unannounced {
for process in state.active.values() {
if let Some(control) = &process.control {
control.cancel();
} else {
process.cancel.cancel();
}
}
}
let mut ids = Vec::new();
for (id, process) in &state.active {
let completed = process.result.lock().ok().is_some_and(|result| result.is_some());
if (include_unannounced || process.announced) && completed {
ids.push(*id);
}
}
ids.into_iter()
.filter_map(|id| state.active.remove(&id))
.collect::<Vec<_>>()
};
let mut results = Vec::new();
for process in tracked {
join_worker(process.worker);
if let Ok(mut result) = process.result.lock()
&& let Some(result) = result.take()
{
results.push(result);
}
}
results.sort_by_key(|result| result.process_id);
results
}
pub(crate) fn spawn_background(
&self, args: &ShellArgs, cwd: PathBuf, child: OwnedChild, start: Instant, cancel: CancelToken,
) -> u64 {
let argv = args.argv();
let timeout = args.timeout.unwrap_or(Duration::from_secs(TIMEOUT_SECS));
let child = Arc::new(Mutex::new(Some(child)));
let control = Arc::new(ProcessControl { cancel: cancel.clone(), child: child.clone() });
let output = Arc::new(OutputCapture::default());
let result = Arc::new(Mutex::new(None));
if let Ok(mut child_guard) = child.lock()
&& let Some(child) = child_guard.as_mut()
{
if let Some(stdout) = child.stdout().take() {
spawn_output_reader(stdout, output.clone(), true);
}
if let Some(stderr) = child.stderr().take() {
spawn_output_reader(stderr, output.clone(), false);
}
}
let mut state = recover_lock(&self.inner.state);
let id = state.next_id;
state.next_id += 1;
let output_for_worker = output.clone();
let result_for_worker = result.clone();
let cancel_for_worker = cancel.clone();
let cwd_for_worker = cwd.clone();
let worker = std::thread::spawn(move || {
BackgroundMonitor {
id,
command: argv,
cwd: cwd_for_worker,
timeout,
start,
cancel: cancel_for_worker,
child,
output: output_for_worker,
result_slot: result_for_worker,
}
.run();
});
state.active.insert(
id,
TrackedProcess {
id,
command: args.argv(),
cwd,
kind: ProcessKind::Background,
cancel,
started: start,
control: Some(control),
output,
result,
worker: Some(worker),
announced: false,
},
);
id
}
}
impl Drop for ProcessRegistry {
fn drop(&mut self) {
if Arc::strong_count(&self.inner) == 1 {
let _ = self.shutdown();
}
}
}
#[derive(Clone, Debug)]
pub struct ShellArgs {
pub program: String,
pub args: Vec<String>,
pub cwd: Option<PathBuf>,
pub timeout: Option<Duration>,
pub kind: ProcessKind,
}
impl ShellArgs {
pub fn argv(&self) -> Vec<String> {
let mut v = vec![self.program.clone()];
v.extend(self.args.iter().cloned());
v
}
}
pub fn definition() -> ToolDefinition {
ToolDefinition::new(
NAME,
r#"run_shell
Run an argv command in the workspace and capture stdout, stderr, and exit status.
Prefer narrower tools when they fit. Use for build, test, format, and inspection.
Runs as thndrs with its permissions, not in a sandbox. Output is capped,
truncated, and redacted; timeouts are enforced. With background=true, the
interactive app owns the child, returns its registry id immediately, and
supports :bg listing and cancellation."#,
serde_json::json!({
"type": "object",
"properties": {
"argv": { "type": "array", "minItems": 1, "items": { "type": "string" }, "description": "Full argv: program followed by its arguments." },
"cwd": { "type": "string", "description": "Optional working directory relative to the workspace root." },
"timeout_ms": { "type": "integer", "minimum": 1, "description": "Optional timeout in milliseconds." },
"background": { "type": "boolean", "description": "If true, run as a long-lived background process." }
},
"required": ["argv"]
}),
)
}
pub fn parse_arguments(arguments: &str) -> Result<ShellArgs, ToolError> {
let args = serde_json::from_str::<serde_json::Value>(arguments)
.map_err(|error| ToolError::InvalidArguments(format!("invalid JSON: {error}")))?;
let (program, cmd_args) = parse_argv(&args)?;
let cwd = args.get("cwd").and_then(|value| value.as_str()).map(PathBuf::from);
let timeout = match optional_u64(&args, "timeout_ms")? {
Some(0) => {
return Err(ToolError::InvalidArguments(
"'timeout_ms' must be greater than zero".to_string(),
));
}
Some(milliseconds) => Some(Duration::from_millis(milliseconds)),
None => optional_u64(&args, "timeout_secs")?.map(Duration::from_secs),
};
let kind = if args
.get("background")
.and_then(|value| value.as_bool())
.unwrap_or(false)
{
ProcessKind::Background
} else {
ProcessKind::OneShot
};
Ok(ShellArgs { program, args: cmd_args, cwd, timeout, kind })
}
pub fn execute_request(request: &ToolUseRequest, ctx: &ToolContext<'_>) -> ToolExecution {
let cancel = CancelToken::new();
execute_request_with_cancel_and_registry(request, ctx.root, &cancel, ctx.process_registry.as_ref())
}
pub fn execute_request_with_cancel(request: &ToolUseRequest, root: &Path, cancel: &CancelToken) -> ToolExecution {
execute_request_with_cancel_and_registry(request, root, cancel, None)
}
pub fn execute_request_with_cancel_and_registry(
request: &ToolUseRequest, root: &Path, cancel: &CancelToken, registry: Option<&ProcessRegistry>,
) -> ToolExecution {
match parse_arguments(&request.arguments) {
Ok(args) => execute_args(&args, root, cancel, registry),
Err(error) => ToolExecution::output(ToolOutput::failed(NAME, error.to_string())),
}
}
pub fn run_command(args: &ShellArgs, root: &Path, cancel: &CancelToken) -> Result<ProcessResult, String> {
run_command_with_registry(args, root, cancel, None)
}
pub fn run_command_with_registry(
args: &ShellArgs, root: &Path, cancel: &CancelToken, registry: Option<&ProcessRegistry>,
) -> Result<ProcessResult, String> {
let cwd = resolve_cwd(root, &args.cwd)?;
let argv = args.argv();
if args.kind == ProcessKind::Background {
let registry = registry
.ok_or_else(|| String::from("background commands require an application-owned process registry"))?;
let mut cmd = Command::new(&args.program);
cmd.args(&args.args)
.current_dir(&cwd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null());
let start = Instant::now();
let child = spawn_owned_command(cmd).map_err(|error| format!("failed to spawn '{}': {error}", args.program))?;
let process_cancel = CancelToken::new();
let id = registry.spawn_background(args, cwd.clone(), child, start, process_cancel);
return Ok(ProcessResult {
process_id: Some(id),
command: argv,
cwd,
status: ProcessStatus::Running,
exit_code: None,
stdout: Vec::new(),
stderr: Vec::new(),
elapsed: start.elapsed(),
kind: ProcessKind::Background,
});
}
run_foreground_command(args, cwd, argv, cancel)
}
#[cfg(test)]
pub fn exec(args: &ShellArgs, root: &Path) -> ToolOutput {
let cancel = CancelToken::new();
match run_command(args, root, &cancel) {
Ok(result) => output_from_result(&result),
Err(e) => ToolOutput::failed(NAME, e),
}
}
pub fn redact_secrets(line: &str) -> String {
let mut result = line.to_string();
let sk_re = regex_lite::Regex::new(r"\bsk-[A-Za-z0-9_]{8,}").expect("valid regex");
result = sk_re.replace_all(&result, "sk-[REDACTED]").to_string();
let bearer_re = regex_lite::Regex::new(r"(?i)bearer\s+[A-Za-z0-9_\-\.]{10,}").expect("valid regex");
result = bearer_re.replace_all(&result, "Bearer [REDACTED]").to_string();
let assign_re = regex_lite::Regex::new(r"(?i)(password|passwd|api_key|apikey|access_token|secret)\s*[:=]\s*\S{4,}")
.expect("valid regex");
assign_re.replace_all(&result, "$1=[REDACTED]").to_string()
}
fn wait_with_timeout(
child: &mut dyn ChildWrapper, timeout: &Duration, cancel: &CancelToken, start: &Instant,
) -> WaitOutcome {
loop {
match child.try_wait() {
Ok(Some(status)) => {
let _ = child.start_kill();
return WaitOutcome::Exited(status.code().unwrap_or(-1));
}
Ok(None) => {
if cancel.is_cancelled() {
let _ = child.kill();
return WaitOutcome::Cancelled;
}
if start.elapsed() > *timeout {
let _ = child.kill();
return WaitOutcome::Timeout;
}
std::thread::sleep(Duration::from_millis(20));
}
Err(_) => {
let _ = child.kill();
return WaitOutcome::Cancelled;
}
}
}
}
fn resolve_cwd(root: &Path, cwd: &Option<PathBuf>) -> Result<PathBuf, String> {
match cwd {
None => Ok(root.to_path_buf()),
Some(rel) => {
let resolved = path::resolve_within_root(root, &rel.to_string_lossy()).map_err(|e| e.to_string())?;
if !resolved.is_dir() {
return Err(format!("working directory is not a directory: {}", resolved.display()));
}
Ok(resolved)
}
}
}
fn read_to_capped_vec<R: Read>(mut stream: R) -> Vec<u8> {
let max_bytes: usize = MAX_OUTPUT_BYTES;
let mut buf = Vec::with_capacity(4096);
let mut chunk = [0u8; 4096];
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
let remaining = max_bytes.saturating_sub(buf.len());
if remaining == 0 {
continue;
}
let take = n.min(remaining);
buf.extend_from_slice(&chunk[..take]);
}
Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
buf
}
fn split_and_cap(buf: &[u8]) -> Vec<String> {
let content = String::from_utf8_lossy(buf);
let mut lines: Vec<String> = content
.lines()
.map(redact_secrets)
.map(|line| utils::truncate_line(&line))
.take(MAX_OUTPUT_LINES)
.collect();
let total_lines = content.lines().count();
if total_lines > MAX_OUTPUT_LINES {
let extra = total_lines - MAX_OUTPUT_LINES;
lines.push(format!("…({extra} more lines)"));
}
lines
}
fn execute_args(
args: &ShellArgs, root: &Path, cancel: &CancelToken, registry: Option<&ProcessRegistry>,
) -> ToolExecution {
if args.program.is_empty() {
return ToolExecution::output(ToolOutput::failed(
NAME,
"missing command: provide non-empty 'argv', 'command', or 'program'".to_string(),
));
}
match run_command_with_registry(args, root, cancel, registry) {
Ok(result) => ToolExecution::full(output_from_result(&result), None, Some(result)),
Err(error) => ToolExecution::output(ToolOutput::failed(NAME, error)),
}
}
fn output_from_result(result: &ProcessResult) -> ToolOutput {
result.to_tool_output()
}
fn join_worker(worker: Option<JoinHandle<()>>) {
if let Some(worker) = worker {
let _ = worker.join();
}
}
fn recover_lock<T>(lock: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn spawn_output_reader<R: Read + Send + 'static>(mut reader: R, output: Arc<OutputCapture>, stdout: bool) {
output.readers.fetch_add(1, Ordering::SeqCst);
std::thread::spawn(move || {
let mut chunk = [0_u8; 4096];
loop {
match reader.read(&mut chunk) {
Ok(0) => break,
Ok(n) => output.append(stdout, &chunk[..n]),
Err(ref error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
output.readers.fetch_sub(1, Ordering::SeqCst);
});
}
impl BackgroundMonitor {
fn run(self) {
let Self { id, command, cwd, timeout, start, cancel, child, output, result_slot } = self;
let outcome = loop {
match try_wait_owned(&child) {
Ok(Some(_status)) if cancel.is_cancelled() => break WaitOutcome::Cancelled,
Ok(Some(status)) => break WaitOutcome::Exited(status.code().unwrap_or(-1)),
Ok(None) => {
if cancel.is_cancelled() {
kill_and_reap(&child);
break WaitOutcome::Cancelled;
}
if start.elapsed() > timeout {
kill_and_reap(&child);
break WaitOutcome::Timeout;
}
std::thread::sleep(Duration::from_millis(20));
}
Err(_) => {
kill_and_reap(&child);
break WaitOutcome::Cancelled;
}
}
};
let drain_deadline = Instant::now() + Duration::from_millis(100);
while output.readers.load(Ordering::SeqCst) > 0 && Instant::now() < drain_deadline {
std::thread::sleep(Duration::from_millis(1));
}
let (status, exit_code) = match outcome {
WaitOutcome::Exited(code) if code == 0 => (ProcessStatus::Ok, Some(code)),
WaitOutcome::Exited(code) => (ProcessStatus::Failed, Some(code)),
WaitOutcome::Timeout => (ProcessStatus::Timeout, None),
WaitOutcome::Cancelled => (ProcessStatus::Cancelled, None),
};
let captured = output.snapshot();
let result = ProcessResult {
process_id: Some(id),
command,
cwd,
status,
exit_code,
stdout: captured.stdout,
stderr: captured.stderr,
elapsed: start.elapsed(),
kind: ProcessKind::Background,
};
let mut slot = recover_lock(&result_slot);
*slot = Some(result);
}
}
fn try_wait_owned(child: &Arc<Mutex<Option<OwnedChild>>>) -> io::Result<Option<std::process::ExitStatus>> {
let mut guard = child
.lock()
.map_err(|_| io::Error::other("process child lock poisoned"))?;
let Some(child) = guard.as_mut() else {
return Ok(None);
};
match child.try_wait()? {
Some(status) => {
let _ = child.start_kill();
*guard = None;
Ok(Some(status))
}
None => Ok(None),
}
}
fn kill_and_reap(child: &Arc<Mutex<Option<OwnedChild>>>) {
let Ok(mut guard) = child.lock() else {
return;
};
if let Some(child) = guard.as_mut() {
let _ = child.kill();
}
*guard = None;
}
fn parse_argv(args: &serde_json::Value) -> Result<(String, Vec<String>), ToolError> {
if let Some((field, argv)) = args
.get("argv")
.map(|argv| ("argv", argv))
.or_else(|| args.get("command").map(|command| ("command", command)))
{
let argv = argv
.as_array()
.ok_or_else(|| ToolError::InvalidArguments(format!("'{field}' must be an array")))?;
let argv = argv
.iter()
.enumerate()
.map(|(index, value)| {
value
.as_str()
.map(str::to_string)
.ok_or_else(|| ToolError::InvalidArguments(format!("{field}[{index}] must be a string")))
})
.collect::<Result<Vec<_>, _>>()?;
let (program, command_args) = argv
.split_first()
.ok_or_else(|| ToolError::InvalidArguments(format!("'{field}' must contain a program")))?;
if program.is_empty() {
return Err(ToolError::InvalidArguments(format!("{field}[0] must not be empty")));
}
return Ok((program.clone(), command_args.to_vec()));
}
let program = args
.get("program")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string();
let command_args = args
.get("args")
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
Ok((program, command_args))
}
fn optional_u64(args: &serde_json::Value, field: &str) -> Result<Option<u64>, ToolError> {
match args.get(field) {
None => Ok(None),
Some(value) => value
.as_u64()
.map(Some)
.ok_or_else(|| ToolError::InvalidArguments(format!("'{field}' must be a non-negative integer"))),
}
}
fn spawn_owned_command(command: Command) -> io::Result<OwnedChild> {
let mut command = CommandWrap::from(command);
#[cfg(unix)]
command.wrap(ProcessGroup::leader());
#[cfg(windows)]
command.wrap(JobObject);
command.spawn()
}
fn run_foreground_command(
args: &ShellArgs, cwd: PathBuf, argv: Vec<String>, cancel: &CancelToken,
) -> Result<ProcessResult, String> {
let mut cmd = Command::new(&args.program);
cmd.args(&args.args)
.current_dir(&cwd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null());
let timeout = args.timeout.unwrap_or(Duration::from_secs(TIMEOUT_SECS));
let start = Instant::now();
let mut child = spawn_owned_command(cmd).map_err(|e| format!("failed to spawn '{}': {e}", args.program))?;
let stdout = child
.stdout()
.take()
.ok_or_else(|| String::from("failed to capture child stdout"))?;
let stderr = child
.stderr()
.take()
.ok_or_else(|| String::from("failed to capture child stderr"))?;
let stdout_handle = std::thread::spawn(move || read_to_capped_vec(stdout));
let stderr_handle = std::thread::spawn(move || read_to_capped_vec(stderr));
let final_status = wait_with_timeout(child.as_mut(), &timeout, cancel, &start);
let elapsed = start.elapsed();
let (status, exit_code) = match final_status {
WaitOutcome::Exited(code) => {
if code == 0 {
(ProcessStatus::Ok, Some(code))
} else {
(ProcessStatus::Failed, Some(code))
}
}
WaitOutcome::Timeout => (ProcessStatus::Timeout, None),
WaitOutcome::Cancelled => (ProcessStatus::Cancelled, None),
};
let stdout_buf = stdout_handle.join().unwrap_or_default();
let stderr_buf = stderr_handle.join().unwrap_or_default();
Ok(ProcessResult {
process_id: None,
command: argv,
cwd,
status,
exit_code,
stdout: split_and_cap(&stdout_buf),
stderr: split_and_cap(&stderr_buf),
elapsed,
kind: ProcessKind::OneShot,
})
}