use std::collections::HashMap;
use std::fs::{File, OpenOptions};
#[cfg(unix)]
use std::os::fd::FromRawFd;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use crate::util::UnwrapPoison;
#[cfg(unix)]
const WATCHER_SCRIPT: &str = "trap '' TERM; cat >/dev/null; kill -s KILL 0";
const LAUNCH_PROBE: Duration = Duration::from_millis(250);
const POLL_INTERVAL: Duration = Duration::from_millis(25);
const WAITER_POLL: Duration = Duration::from_millis(50);
const STOP_GRACE: Duration = Duration::from_secs(5);
const STOP_ANNOTATION_WAIT: Duration = Duration::from_secs(2);
const MAX_OUTPUT_NAME_ATTEMPTS: usize = 16;
const FAILURE_OUTPUT_PREFIX_BYTES: usize = 400;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StopResult {
Stopped,
AlreadyFinished,
}
pub(crate) struct BackgroundSessions {
inner: std::sync::Mutex<HashMap<PathBuf, SessionEntry>>,
}
impl Default for BackgroundSessions {
fn default() -> Self {
Self {
inner: std::sync::Mutex::new(HashMap::new()),
}
}
}
struct SessionEntry {
command: Arc<std::sync::Mutex<tokio::process::Child>>,
#[cfg(unix)]
watcher: Option<tokio::process::Child>,
#[cfg(unix)]
write_end: Option<std::os::fd::OwnedFd>,
#[cfg(unix)]
pgid: u32,
early_status: Option<std::process::ExitStatus>,
finished: Arc<AtomicBool>,
}
impl BackgroundSessions {
pub(crate) async fn launch(
self: &Arc<Self>,
command: &str,
workspace_root: &Path,
) -> Result<PathBuf, String> {
let (output_path, out_file) = create_bg_output_file()
.map_err(|e| format!("Failed to create background output file: {e}"))?;
super::record_spill_owner(output_path.clone());
let mut cmd = super::build_shell_command(command, workspace_root);
let stdout_file = out_file.try_clone().map_err(|e| {
let _ = std::fs::remove_file(&output_path);
format!("Failed to set up background output: {e}")
})?;
let stderr_file = out_file.try_clone().map_err(|e| {
let _ = std::fs::remove_file(&output_path);
format!("Failed to set up background output: {e}")
})?;
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::from(stdout_file));
cmd.stderr(Stdio::from(stderr_file));
#[cfg(unix)]
let watchdog = match WatchdogSetup::spawn(&mut cmd, &output_path) {
Ok(w) => w,
Err(e) => {
let _ = std::fs::remove_file(&output_path);
return Err(e);
}
};
let mut cmd_child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
#[cfg(unix)]
watchdog.abort().await;
let _ = std::fs::remove_file(&output_path);
return Err(format!("Failed to start background command: {e}"));
}
};
let early_status = match probe_command(&mut cmd_child).await {
Ok(Some(status)) if matches!(status.code(), Some(126 | 127)) => {
let prefix = read_output_prefix(&output_path, FAILURE_OUTPUT_PREFIX_BYTES);
#[cfg(unix)]
watchdog.abort().await;
let _ = std::fs::remove_file(&output_path);
let prefix_msg = prefix
.filter(|p| !p.is_empty())
.map(|p| format!("\noutput: {p}"))
.unwrap_or_default();
return Err(format!(
"Failed to start background command.\n\
command: {command}\n\
reason: command not found or not executable (exit status {}).{prefix_msg}",
status.code().unwrap_or(-1)
));
}
Ok(Some(status)) => Some(status),
Ok(None) => None,
Err(e) => {
#[cfg(unix)]
watchdog.abort().await;
let _ = std::fs::remove_file(&output_path);
return Err(e);
}
};
let entry = SessionEntry {
command: Arc::new(std::sync::Mutex::new(cmd_child)),
#[cfg(unix)]
watcher: Some(watchdog.watcher_child),
#[cfg(unix)]
write_end: Some(watchdog.write_end),
#[cfg(unix)]
pgid: watchdog.pgid,
early_status,
finished: Arc::new(AtomicBool::new(false)),
};
self.inner
.lock()
.unwrap_poison()
.insert(output_path.clone(), entry);
self.spawn_waiter(&output_path);
Ok(output_path)
}
pub(crate) async fn stop(self: &Arc<Self>, output_path: &Path) -> Result<StopResult, String> {
#[cfg(unix)]
{
let (pgid, finished) = {
let guard = self.inner.lock().unwrap_poison();
let entry = guard.get(output_path).ok_or_else(|| {
format!(
"No background session found for output file: {}",
output_path.display()
)
})?;
(entry.pgid, entry.finished.clone())
};
if finished.load(Ordering::SeqCst) {
return Ok(StopResult::AlreadyFinished);
}
super::kill_process_group(pgid, libc::SIGTERM);
tokio::time::sleep(stop_grace()).await;
if !finished.load(Ordering::SeqCst) {
super::kill_process_group(pgid, libc::SIGKILL);
}
wait_for_finished(&finished, stop_annotation_wait()).await;
Ok(StopResult::Stopped)
}
#[cfg(not(unix))]
{
let (command, finished) = {
let guard = self.inner.lock().unwrap_poison();
let entry = guard.get(output_path).ok_or_else(|| {
format!(
"No background session found for output file: {}",
output_path.display()
)
})?;
(entry.command.clone(), entry.finished.clone())
};
if finished.load(Ordering::SeqCst) {
return Ok(StopResult::AlreadyFinished);
}
let mut g = command.lock().unwrap_poison();
let _ = g.start_kill();
drop(g);
wait_for_finished(&finished, stop_annotation_wait()).await;
Ok(StopResult::Stopped)
}
}
pub(crate) fn terminate_all(&self) {
let targets: Vec<_> = {
let mut guard = self.inner.lock().unwrap_poison();
guard
.iter_mut()
.filter(|(_, e)| !e.finished.load(Ordering::SeqCst))
.map(|(_, e)| {
#[cfg(unix)]
{
(e.pgid, e.write_end.take())
}
#[cfg(not(unix))]
{
(e.command.clone(),)
}
})
.collect()
};
for target in targets {
#[cfg(unix)]
{
super::kill_process_group(target.0, libc::SIGKILL);
drop(target.1);
}
#[cfg(not(unix))]
{
let mut g = target.0.lock().unwrap_poison();
let _ = g.start_kill();
}
}
}
#[cfg(test)]
pub(crate) fn contains(&self, path: &Path) -> bool {
self.inner.lock().unwrap_poison().contains_key(path)
}
#[cfg(test)]
pub(crate) fn is_finished(&self, path: &Path) -> bool {
self.inner
.lock()
.unwrap_poison()
.get(path)
.is_some_and(|e| e.finished.load(Ordering::SeqCst))
}
fn spawn_waiter(self: &Arc<Self>, output_path: &Path) {
let sessions = self.clone();
let output_path = output_path.to_path_buf();
let (command, finished, early_status) = {
let guard = self.inner.lock().unwrap_poison();
let entry = guard.get(&output_path).expect("session just registered");
(
entry.command.clone(),
entry.finished.clone(),
entry.early_status,
)
};
tokio::spawn(async move {
let mut status = early_status;
if status.is_none() {
loop {
let exited = {
let mut g = command.lock().unwrap_poison();
g.try_wait().ok().flatten()
};
if let Some(s) = exited {
status = Some(s);
break;
}
tokio::time::sleep(WAITER_POLL).await;
}
}
let status = status.expect("background command status determined");
append_exit_annotation(&output_path, status);
finished.store(true, Ordering::SeqCst);
#[cfg(unix)]
{
let (watcher, write_end) = {
let mut guard = sessions.inner.lock().unwrap_poison();
let entry = guard
.get_mut(&output_path)
.expect("session still registered");
(entry.watcher.take(), entry.write_end.take())
};
drop(write_end); if let Some(mut w) = watcher {
let _ = w.wait().await;
}
}
});
}
}
#[cfg(unix)]
struct WatchdogSetup {
watcher_child: tokio::process::Child,
write_end: std::os::fd::OwnedFd,
pgid: u32,
}
#[cfg(unix)]
impl WatchdogSetup {
fn spawn(cmd: &mut tokio::process::Command, output_path: &Path) -> Result<Self, String> {
let (read_end, write_end) = make_lifeline_pipe().map_err(|e| {
let _ = std::fs::remove_file(output_path);
format!("Failed to create background lifeline pipe: {e}")
})?;
let mut watcher_cmd = tokio::process::Command::new("sh");
watcher_cmd
.arg("-c")
.arg(WATCHER_SCRIPT)
.process_group(0)
.stdin(Stdio::from(read_end))
.stdout(Stdio::null())
.stderr(Stdio::null());
let watcher_child = match watcher_cmd.spawn() {
Ok(c) => c,
Err(e) => {
let _ = std::fs::remove_file(output_path);
return Err(format!("Failed to spawn background watchdog: {e}"));
}
};
let pgid = watcher_child
.id()
.expect("watcher PID available after spawn");
let pgid_signed: libc::pid_t = pgid.try_into().expect("PGID fits in pid_t");
cmd.process_group(pgid_signed);
Ok(Self {
watcher_child,
write_end,
pgid,
})
}
async fn abort(self) {
super::kill_process_group(self.pgid, libc::SIGKILL);
drop(self.write_end);
let mut w = self.watcher_child;
let _ = w.wait().await;
}
}
#[cfg(unix)]
fn make_lifeline_pipe() -> std::io::Result<(std::os::fd::OwnedFd, std::os::fd::OwnedFd)> {
let mut fds = [0i32; 2];
let ret = unsafe { libc::pipe(fds.as_mut_ptr()) };
if ret != 0 {
return Err(std::io::Error::last_os_error());
}
for fd in fds {
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
if flags < 0 {
return Err(std::io::Error::last_os_error());
}
let ret = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) };
if ret != 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(unsafe {
(
std::os::fd::OwnedFd::from_raw_fd(fds[0]),
std::os::fd::OwnedFd::from_raw_fd(fds[1]),
)
})
}
fn stop_grace() -> Duration {
crate::util::env_duration_secs("MAHBOT_BG_STOP_GRACE_SECS", STOP_GRACE.as_secs())
}
fn stop_annotation_wait() -> Duration {
crate::util::env_duration_secs(
"MAHBOT_BG_ANNOTATION_WAIT_SECS",
STOP_ANNOTATION_WAIT.as_secs(),
)
}
fn create_bg_output_file() -> std::io::Result<(PathBuf, File)> {
let dir = super::agent_temp_dir().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "agent temp dir unavailable")
})?;
for _ in 0..MAX_OUTPUT_NAME_ATTEMPTS {
let path = dir.join(format!("bg_{:04x}.out", rand::random::<u16>()));
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(file) => return Ok((path, file)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"could not allocate a unique background output file name",
))
}
async fn probe_command(
cmd_child: &mut tokio::process::Child,
) -> Result<Option<std::process::ExitStatus>, String> {
let deadline = Instant::now() + LAUNCH_PROBE;
loop {
match cmd_child.try_wait() {
Ok(Some(status)) => return Ok(Some(status)),
Ok(None) => {
if Instant::now() >= deadline {
return Ok(None);
}
tokio::time::sleep(POLL_INTERVAL).await;
}
Err(e) => {
return Err(format!("Failed to probe background command: {e}"));
}
}
}
}
fn append_exit_annotation(output_path: &Path, status: std::process::ExitStatus) {
let note = match status.code() {
Some(c) => format!("[exit status: {c}]"),
None => "[exit status: terminated by signal]".to_string(),
};
match OpenOptions::new().append(true).open(output_path) {
Ok(mut f) => {
use std::io::Write;
let _ = writeln!(f, "\n{note}");
}
Err(e) => {
tracing::warn!(
path = %output_path.display(),
err = %e,
"Failed to append background exit annotation"
);
}
}
}
fn read_output_prefix(path: &Path, max_bytes: usize) -> Option<String> {
use std::io::Read;
let mut f = File::open(path).ok()?;
let mut buf = Vec::new();
let mut chunk = [0u8; 256];
while buf.len() < max_bytes {
let n = f.read(&mut chunk).ok()?;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
}
buf.truncate(max_bytes);
Some(crate::util::scrub_credentials(&String::from_utf8_lossy(
&buf,
)))
}
async fn wait_for_finished(finished: &AtomicBool, bound: Duration) {
let deadline = Instant::now() + bound;
while !finished.load(Ordering::SeqCst) && Instant::now() < deadline {
tokio::time::sleep(POLL_INTERVAL).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::test::set_env_var;
use crate::workspace::test_ws;
use tempfile::TempDir;
async fn wait_finished(sessions: &BackgroundSessions, path: &Path, bound: Duration) -> bool {
let deadline = Instant::now() + bound;
loop {
let f = sessions
.inner
.lock()
.unwrap_poison()
.get(path)
.is_none_or(|e| e.finished.load(Ordering::SeqCst));
if f || Instant::now() >= deadline {
return f;
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
fn read_file(path: &Path) -> String {
std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
}
#[tokio::test]
async fn launch_quick_exit_appends_annotation() {
let dir = TempDir::new().expect("tempdir");
let ws = test_ws(dir.path());
let sessions = Arc::new(BackgroundSessions::default());
let path = sessions
.launch("echo hello-bg", ws.as_path())
.await
.expect("launch succeeds");
assert!(
path.parent()
.and_then(|p| p.file_name())
.is_some_and(|n| n == ".agent"),
"bg output must be flat in the .agent temp dir: {}",
path.display()
);
assert!(
path.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with("bg_")),
"bg output must use the bg_* name shape: {}",
path.display()
);
assert!(
wait_finished(&sessions, &path, Duration::from_secs(10)).await,
"quick command should finish"
);
let out = read_file(&path);
assert!(out.contains("hello-bg"), "output: {out}");
assert!(out.contains("[exit status: 0]"), "output: {out}");
}
#[tokio::test]
async fn launch_running_has_no_annotation_until_exit() {
let dir = TempDir::new().expect("tempdir");
let ws = test_ws(dir.path());
let sessions = Arc::new(BackgroundSessions::default());
let path = sessions
.launch("sleep 1", ws.as_path())
.await
.expect("launch succeeds");
tokio::time::sleep(Duration::from_millis(150)).await;
let out = read_file(&path);
assert!(!out.contains("[exit status:"), "output before exit: {out}");
assert!(
!sessions
.inner
.lock()
.unwrap_poison()
.get(&path)
.expect("session registered")
.finished
.load(Ordering::SeqCst),
"should not be finished while running"
);
assert!(
wait_finished(&sessions, &path, Duration::from_secs(10)).await,
"sleep should finish"
);
let out = read_file(&path);
assert!(out.contains("[exit status: 0]"), "output: {out}");
}
#[cfg(unix)]
#[tokio::test]
async fn launch_failure_command_not_found_errors() {
let dir = TempDir::new().expect("tempdir");
let ws = test_ws(dir.path());
let sessions = Arc::new(BackgroundSessions::default());
let err = sessions
.launch("definitely_not_a_command_xyz_123", ws.as_path())
.await
.expect_err("unknown command must be a synchronous launch error");
assert!(
err.contains("not found or not executable"),
"error message: {err}"
);
assert!(err.contains("127"), "error should mention exit 127: {err}");
assert!(
sessions.inner.lock().unwrap_poison().is_empty(),
"no session should be registered after a launch failure"
);
}
#[cfg(unix)]
#[tokio::test]
async fn launch_failure_not_executable_errors() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().expect("tempdir");
let ws = test_ws(dir.path());
let sessions = Arc::new(BackgroundSessions::default());
let script = dir.path().join("not-exec.sh");
std::fs::write(&script, "#!/bin/sh\necho hi\n").expect("write script");
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o644)).expect("chmod");
let err = sessions
.launch("./not-exec.sh", ws.as_path())
.await
.expect_err("non-executable script must be a synchronous launch error");
assert!(
err.contains("not found or not executable"),
"error message: {err}"
);
assert!(err.contains("126"), "error should mention exit 126: {err}");
assert!(sessions.inner.lock().unwrap_poison().is_empty());
}
#[tokio::test]
async fn launch_legit_nonzero_early_exit_is_successful_launch() {
let dir = TempDir::new().expect("tempdir");
let ws = test_ws(dir.path());
let sessions = Arc::new(BackgroundSessions::default());
let path = sessions
.launch("exit 3", ws.as_path())
.await
.expect("exit 3 is a successful launch");
assert!(
wait_finished(&sessions, &path, Duration::from_secs(10)).await,
"should finish"
);
let out = read_file(&path);
assert!(out.contains("[exit status: 3]"), "output: {out}");
}
#[tokio::test]
async fn stop_kills_running_session() {
let _env = set_env_var("MAHBOT_BG_STOP_GRACE_SECS", Some("0"));
let dir = TempDir::new().expect("tempdir");
let ws = test_ws(dir.path());
let sessions = Arc::new(BackgroundSessions::default());
let path = sessions
.launch("sleep 30", ws.as_path())
.await
.expect("launch succeeds");
assert!(
!sessions
.inner
.lock()
.unwrap_poison()
.get(&path)
.expect("session")
.finished
.load(Ordering::SeqCst)
);
let result = sessions.stop(&path).await.expect("stop succeeds");
assert_eq!(result, StopResult::Stopped);
assert!(
wait_finished(&sessions, &path, Duration::from_secs(10)).await,
"stopped session should finish"
);
let out = read_file(&path);
assert!(
out.contains("[exit status: terminated by signal]"),
"output: {out}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn stop_grace_skips_sigkill_after_early_exit() {
let _env = set_env_var("MAHBOT_BG_STOP_GRACE_SECS", Some("1"));
let dir = TempDir::new().expect("tempdir");
let ws = test_ws(dir.path());
let sessions = Arc::new(BackgroundSessions::default());
let path = sessions
.launch("trap '' TERM; sleep 0.5; exit 0", ws.as_path())
.await
.expect("launch succeeds");
let result = sessions.stop(&path).await.expect("stop succeeds");
assert_eq!(result, StopResult::Stopped);
assert!(
wait_finished(&sessions, &path, Duration::from_secs(10)).await,
"TERM-trapping command should exit during the grace"
);
let out = read_file(&path);
assert!(
out.contains("[exit status: 0]"),
"the command must be allowed to exit 0 during the grace, not SIGKILLed: {out}"
);
assert!(
!out.contains("[exit status: terminated by signal]"),
"the post-grace SIGKILL must be skipped once the waiter finished: {out}"
);
}
#[tokio::test]
async fn stop_already_finished_is_noop() {
let dir = TempDir::new().expect("tempdir");
let ws = test_ws(dir.path());
let sessions = Arc::new(BackgroundSessions::default());
let path = sessions
.launch("echo quick", ws.as_path())
.await
.expect("launch succeeds");
assert!(
wait_finished(&sessions, &path, Duration::from_secs(10)).await,
"should finish"
);
let result = sessions.stop(&path).await.expect("stop is a no-op");
assert_eq!(result, StopResult::AlreadyFinished);
}
#[tokio::test]
async fn stop_unknown_path_errors() {
let dir = TempDir::new().expect("tempdir");
let sessions = Arc::new(BackgroundSessions::default());
let err = sessions
.stop(&dir.path().join(".agent/bg_0000.out"))
.await
.expect_err("unknown session must error");
assert!(
err.contains("No background session found"),
"error message: {err}"
);
}
#[tokio::test]
async fn terminate_all_kills_running_sessions() {
let dir = TempDir::new().expect("tempdir");
let ws = test_ws(dir.path());
let sessions = Arc::new(BackgroundSessions::default());
let p1 = sessions
.launch("sleep 30", ws.as_path())
.await
.expect("launch 1");
let p2 = sessions
.launch("sleep 30", ws.as_path())
.await
.expect("launch 2");
assert_eq!(sessions.inner.lock().unwrap_poison().len(), 2);
sessions.terminate_all();
assert!(
wait_finished(&sessions, &p1, Duration::from_secs(10)).await,
"session 1 killed by teardown"
);
assert!(
wait_finished(&sessions, &p2, Duration::from_secs(10)).await,
"session 2 killed by teardown"
);
let o1 = read_file(&p1);
assert!(
o1.contains("[exit status: terminated by signal]"),
"output 1: {o1}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn watchdog_kills_group_when_write_end_closes() {
let dir = TempDir::new().expect("tempdir");
let ws = test_ws(dir.path());
let sessions = Arc::new(BackgroundSessions::default());
let path = sessions
.launch("sleep 30", ws.as_path())
.await
.expect("launch succeeds");
let write_end = sessions
.inner
.lock()
.unwrap_poison()
.get_mut(&path)
.expect("session registered")
.write_end
.take()
.expect("write end present while running");
drop(write_end);
assert!(
wait_finished(&sessions, &path, Duration::from_secs(10)).await,
"watchdog should kill the command when the daemon side dies"
);
let out = read_file(&path);
assert!(
out.contains("[exit status: terminated by signal]"),
"output: {out}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn stray_grandchild_killed_when_command_exits() {
let dir = TempDir::new().expect("tempdir");
let ws = test_ws(dir.path());
let sessions = Arc::new(BackgroundSessions::default());
let pid_file = dir.path().join("stray.pid");
let cmd = format!("sleep 5 & echo $! > {}", pid_file.display());
let path = sessions
.launch(&cmd, ws.as_path())
.await
.expect("launch succeeds — sh exits 0 even with a stray");
assert!(
wait_finished(&sessions, &path, Duration::from_secs(10)).await,
"session finishes when the launched command exits"
);
let out = read_file(&path);
assert!(out.contains("[exit status: 0]"), "output: {out}");
let pid: i32 = std::fs::read_to_string(&pid_file)
.expect("stray pid file")
.trim()
.parse()
.expect("valid pid");
let deadline = Instant::now() + Duration::from_secs(3);
let mut alive = true;
while Instant::now() < deadline {
if unsafe { libc::kill(pid, 0) } != 0 {
alive = false;
break;
}
tokio::time::sleep(POLL_INTERVAL).await;
}
assert!(
!alive,
"stray grandchild (pid={pid}) must die with the session"
);
}
}