use std::ffi::OsString;
use std::io::{Read, Seek, SeekFrom, Write};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tempfile::NamedTempFile;
pub type AbortFlag = Arc<AtomicBool>;
pub fn new_abort_flag() -> AbortFlag {
Arc::new(AtomicBool::new(false))
}
const STDERR_TAIL_CAP: u64 = 64 * 1024;
const DEFAULT_TIMEOUT_SECS: u64 = 600;
const TIMEOUT_ENV_VAR: &str = "DROIDSAW_MCP_SUBPROCESS_TIMEOUT_SEC";
pub fn subprocess_timeout_secs() -> u64 {
std::env::var(TIMEOUT_ENV_VAR)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_TIMEOUT_SECS)
}
#[derive(Debug)]
pub struct SubprocessOutput {
pub status: std::process::ExitStatus,
pub stdout: Vec<u8>,
pub stderr_tail: Vec<u8>,
pub stderr_overflowed: bool,
}
#[derive(Debug)]
pub enum McpSubprocessError {
Timeout {
tool: String,
observed_secs: u64,
},
Aborted { tool: String },
Io(std::io::Error),
}
impl McpSubprocessError {
pub fn into_mcp_error(self) -> rmcp::ErrorData {
match self {
McpSubprocessError::Timeout { tool, observed_secs } => rmcp::ErrorData::new(
rmcp::model::ErrorCode(-32000),
format!(
"subprocess for tool {tool:?} timed out after {observed_secs}s. \
SIGTERM sent; caller may retry with a larger \
DROIDSAW_MCP_SUBPROCESS_TIMEOUT_SEC value."
),
Some(serde_json::json!({
"type": "SubprocessTimeout",
"tool": tool,
"observed_secs": observed_secs,
})),
),
McpSubprocessError::Aborted { tool } => rmcp::ErrorData::new(
rmcp::model::ErrorCode(-32000),
format!(
"subprocess for tool {tool:?} aborted: client disconnected. \
SIGTERM sent; the abort signal fired before the wall-clock \
timeout was reached."
),
Some(serde_json::json!({
"type": "SubprocessAborted",
"tool": tool,
})),
),
McpSubprocessError::Io(e) => rmcp::ErrorData::new(rmcp::model::ErrorCode::INTERNAL_ERROR,
format!("subprocess I/O error: {e}"),
None,
),
}
}
}
pub fn run_with_timeout(
program: &str,
args: &[OsString],
tool: &str,
timeout_secs: u64,
abort: Option<&AtomicBool>,
) -> Result<SubprocessOutput, McpSubprocessError> {
let mut stderr_file =
NamedTempFile::new().map_err(McpSubprocessError::Io)?;
let stderr_for_child = stderr_file
.as_file()
.try_clone()
.map_err(McpSubprocessError::Io)?;
let mut child = Command::new(program)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::from(stderr_for_child))
.spawn()
.map_err(McpSubprocessError::Io)?;
let deadline = Instant::now()
.checked_add(Duration::from_secs(timeout_secs))
.unwrap_or_else(|| {
#[allow(
clippy::arithmetic_side_effects,
reason = "1h fallback under impossible-in-practice Instant overflow"
)]
{ Instant::now() + Duration::from_secs(60 * 60) }
});
let poll_interval = Duration::from_millis(250);
let status = loop {
match child.try_wait().map_err(McpSubprocessError::Io)? {
Some(s) => break s,
None => {
let aborted = abort.is_some_and(|f| f.load(Ordering::Relaxed));
if Instant::now() >= deadline || aborted {
terminate_child(&mut child);
let kill_deadline = Instant::now()
.checked_add(Duration::from_secs(5))
.unwrap_or_else(|| {
#[allow(
clippy::arithmetic_side_effects,
reason = "60s fallback under impossible-in-practice Instant overflow"
)]
{ Instant::now() + Duration::from_secs(60) }
});
loop {
match child.try_wait().map_err(McpSubprocessError::Io)? {
Some(_) => break,
None => {
if Instant::now() >= kill_deadline {
drop(child.kill());
drop(child.wait());
break;
}
std::thread::sleep(Duration::from_millis(100));
}
}
}
if aborted {
return Err(McpSubprocessError::Aborted {
tool: tool.to_owned(),
});
}
return Err(McpSubprocessError::Timeout {
tool: tool.to_owned(),
observed_secs: timeout_secs,
});
}
std::thread::sleep(poll_interval);
}
}
};
let stdout = match child.stdout.take() {
Some(mut s) => {
let mut buf = Vec::new();
s.read_to_end(&mut buf).map_err(McpSubprocessError::Io)?;
buf
}
None => Vec::new(),
};
drop(stderr_file.flush());
let (stderr_tail, stderr_overflowed) = read_tail(&mut stderr_file, STDERR_TAIL_CAP)
.unwrap_or((Vec::new(), false));
Ok(SubprocessOutput {
status,
stdout,
stderr_tail,
stderr_overflowed,
})
}
fn read_tail(file: &mut NamedTempFile, cap: u64) -> std::io::Result<(Vec<u8>, bool)> {
let len = file.as_file().metadata()?.len();
let overflowed = len > cap;
let start = if overflowed { len.saturating_sub(cap) } else { 0 };
file.seek(SeekFrom::Start(start))?;
#[allow(
clippy::as_conversions,
clippy::cast_possible_truncation,
reason = "PROOF: read_len = min(len, cap) where cap is the caller-provided stderr cap (default 64 KiB at call site `run_with_timeout`, hard-bounded by `DROIDSAW_MCP_SUBPROCESS_STDERR_CAP`). Since cap fits in u64 and is always set to ≤ usize::MAX on supported 64-bit targets, the (u64 -> usize) widen is lossless. The variable `read_len` is then passed to Vec::with_capacity (a hint), so even if a hypothetical 32-bit target truncates, the subsequent read_to_end would re-grow correctly."
)]
let read_len = (len.saturating_sub(start)) as usize;
let mut buf = Vec::with_capacity(read_len);
file.read_to_end(&mut buf)?;
Ok((buf, overflowed))
}
fn terminate_child(child: &mut std::process::Child) {
#[cfg(unix)]
{
#[allow(
clippy::as_conversions,
clippy::cast_possible_wrap,
reason = "PROOF: u32 -> i32 reinterpret of a Unix PID for libc::kill(pid_t, sig). pid_t is defined as i32 on every Unix std::process supports. Linux+macOS PIDs are constrained to [1, PID_MAX_LIMIT] (Linux PID_MAX_LIMIT=4_194_304; macOS pid_t max=99_999), well below i32::MAX, so the bit pattern is identical."
)]
let pid = child.id() as i32;
unsafe {
libc::kill(pid, libc::SIGTERM);
}
}
#[cfg(not(unix))]
{
let _ = child.kill();
}
}
pub fn run_command_with_timeout(
program: &str,
args: &[OsString],
tool: &str,
timeout_secs: u64,
abort: Option<&AtomicBool>,
) -> Result<std::process::Output, McpSubprocessError> {
let out = run_with_timeout(program, args, tool, timeout_secs, abort)?;
Ok(std::process::Output {
status: out.status,
stdout: out.stdout,
stderr: out.stderr_tail,
})
}