use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use crate::runner::execution::process::{test_ctrlc_state, wait_for_child};
fn slow_exit_command(exit_delay_ms: u64, exit_code: i32, ready_file: &std::path::Path) -> Command {
let script = format!(
r#"import pathlib
import signal
import sys
import time
ready_file = pathlib.Path(sys.argv[1])
def handle(_signum, _frame):
time.sleep({delay_seconds:.3})
raise SystemExit({exit_code})
signal.signal(signal.SIGINT, handle)
ready_file.write_text("ready", encoding="utf-8")
while True:
time.sleep(1)
"#,
delay_seconds = exit_delay_ms as f64 / 1000.0,
exit_code = exit_code,
);
let mut cmd = Command::new("python3");
cmd.arg("-c")
.arg(script)
.arg(ready_file)
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd
}
fn ignore_sigint_command(ready_file: &std::path::Path) -> Command {
let mut cmd = Command::new("python3");
cmd.arg("-c")
.arg(
r#"import pathlib
import signal
import sys
import time
ready_file = pathlib.Path(sys.argv[1])
signal.signal(signal.SIGINT, signal.SIG_IGN)
ready_file.write_text("ready", encoding="utf-8")
while True:
time.sleep(1)
"#,
)
.arg(ready_file)
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd
}
fn make_ready_file() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("create temp dir for process readiness");
let ready_file = dir.path().join("ready");
(dir, ready_file)
}
fn wait_for_ready_file(child: &mut std::process::Child, ready_file: &std::path::Path) {
let start = std::time::Instant::now();
while !ready_file.exists() {
if let Some(status) = child
.try_wait()
.expect("poll child while waiting for ready")
{
panic!("test child exited before becoming ready: {status}");
}
if start.elapsed() > Duration::from_secs(5) {
let _ = child.kill();
let _ = child.wait();
panic!("timed out waiting for test child readiness file");
}
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn test_process_exits_cleanly_after_timeout_interrupt() {
let (_ready_dir, ready_file) = make_ready_file();
let mut cmd = slow_exit_command(100, 0, &ready_file);
#[cfg(unix)]
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(|| {
let _ = libc::setpgid(0, 0);
Ok(())
});
}
let mut child = cmd.spawn().expect("Failed to spawn test process");
wait_for_ready_file(&mut child, &ready_file);
let ctrlc = test_ctrlc_state();
#[cfg(unix)]
{
let mut guard = ctrlc.active_pgid.lock().unwrap();
*guard = Some(child.id() as i32);
}
let timeout = Some(Duration::from_millis(200));
let result = wait_for_child(&mut child, &ctrlc, timeout);
assert!(
result.is_ok(),
"Process should exit successfully after timeout interrupt"
);
let status = result.unwrap();
assert!(status.success(), "Process should have exit code 0");
}
#[test]
fn test_process_times_out_and_is_killed() {
let (_ready_dir, ready_file) = make_ready_file();
let mut cmd = ignore_sigint_command(&ready_file);
#[cfg(unix)]
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(|| {
let _ = libc::setpgid(0, 0);
Ok(())
});
}
let mut child = cmd.spawn().expect("Failed to spawn test process");
wait_for_ready_file(&mut child, &ready_file);
let ctrlc = test_ctrlc_state();
#[cfg(unix)]
{
let mut guard = ctrlc.active_pgid.lock().unwrap();
*guard = Some(child.id() as i32);
}
let timeout = Some(Duration::from_millis(50));
let start = std::time::Instant::now();
let result = wait_for_child(&mut child, &ctrlc, timeout);
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_secs(2),
"Should wait at least 2 seconds for grace period before kill"
);
assert!(
result.is_err(),
"wait_for_child should return Timeout error when process is killed"
);
match result {
Err(crate::runner::RunnerError::Timeout) => {}
Err(other) => panic!("Expected Timeout error, got {:?}", other),
Ok(status) => panic!(
"Expected Timeout error, got Ok with exit code {:?}",
status.code()
),
}
}
#[test]
fn test_process_exits_nonzero_after_timeout() {
let (_ready_dir, ready_file) = make_ready_file();
let mut cmd = slow_exit_command(100, 42, &ready_file);
#[cfg(unix)]
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(|| {
let _ = libc::setpgid(0, 0);
Ok(())
});
}
let mut child = cmd.spawn().expect("Failed to spawn test process");
wait_for_ready_file(&mut child, &ready_file);
let ctrlc = test_ctrlc_state();
#[cfg(unix)]
{
let mut guard = ctrlc.active_pgid.lock().unwrap();
*guard = Some(child.id() as i32);
}
let timeout = Some(Duration::from_millis(50));
let result = wait_for_child(&mut child, &ctrlc, timeout);
assert!(
result.is_err(),
"Should return Timeout error for process that exits non-zero after interrupt"
);
match result {
Err(crate::runner::RunnerError::Timeout) => {}
Err(other) => panic!("Expected Timeout error, got {:?}", other),
Ok(status) => panic!(
"Expected Timeout error, got Ok with exit code {:?}",
status.code()
),
}
}
#[test]
fn test_ctrl_c_interrupt_handling() {
let (_ready_dir, ready_file) = make_ready_file();
let mut cmd = slow_exit_command(100, 0, &ready_file);
#[cfg(unix)]
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(|| {
let _ = libc::setpgid(0, 0);
Ok(())
});
}
let mut child = cmd.spawn().expect("Failed to spawn test process");
wait_for_ready_file(&mut child, &ready_file);
let ctrlc = test_ctrlc_state();
#[cfg(unix)]
{
let mut guard = ctrlc.active_pgid.lock().unwrap();
*guard = Some(child.id() as i32);
}
let ctrlc_clone = Arc::clone(&ctrlc);
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
ready_rx.recv().expect("signal wait_for_child start");
std::thread::park_timeout(Duration::from_millis(100));
ctrlc_clone.interrupted.store(true, Ordering::SeqCst);
});
ready_tx.send(()).expect("notify interrupt thread");
let result = wait_for_child(&mut child, &ctrlc, None);
assert!(
result.is_ok(),
"Process should exit successfully after Ctrl-C interrupt"
);
let status = result.unwrap();
assert!(status.success(), "Process should have exit code 0");
}
#[test]
fn test_no_timeout_no_interrupt_process_completes_normally() {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("exit 0");
#[cfg(unix)]
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(|| {
let _ = libc::setpgid(0, 0);
Ok(())
});
}
let mut child = cmd.spawn().expect("Failed to spawn test process");
let ctrlc = test_ctrlc_state();
#[cfg(unix)]
{
let mut guard = ctrlc.active_pgid.lock().unwrap();
*guard = Some(child.id() as i32);
}
let result = wait_for_child(&mut child, &ctrlc, None);
assert!(result.is_ok());
assert!(result.unwrap().success());
}
#[test]
fn test_wait_for_child_leaves_active_pgid_for_caller_cleanup() {
let (_ready_dir, ready_file) = make_ready_file();
let mut cmd = ignore_sigint_command(&ready_file);
#[cfg(unix)]
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(|| {
let _ = libc::setpgid(0, 0);
Ok(())
});
}
let mut child = cmd.spawn().expect("Failed to spawn test process");
wait_for_ready_file(&mut child, &ready_file);
let ctrlc = test_ctrlc_state();
#[cfg(unix)]
{
let mut guard = ctrlc.active_pgid.lock().unwrap();
*guard = Some(child.id() as i32);
}
let timeout = Some(Duration::from_millis(50));
let _ = wait_for_child(&mut child, &ctrlc, timeout);
#[cfg(unix)]
{
let pgid = ctrlc.active_pgid.lock().unwrap();
assert!(
pgid.is_some(),
"wait_for_child doesn't clear pgid - that's caller's responsibility"
);
}
}
#[test]
fn test_pre_run_interrupt_returns_immediately() {
let ctrlc = test_ctrlc_state();
ctrlc.interrupted.store(true, Ordering::SeqCst);
let should_abort = ctrlc.interrupted.load(Ordering::SeqCst);
assert!(should_abort, "Should detect pre-run interrupt");
assert!(
ctrlc.interrupted.load(Ordering::SeqCst),
"Interrupted flag should remain set after detecting pre-run interrupt"
);
let pgid = ctrlc.active_pgid.lock().unwrap();
assert!(
pgid.is_none(),
"active_pgid should remain None when aborting before spawn"
);
}
#[test]
fn test_ctrl_c_during_timeout_grace_period() {
let (_ready_dir, ready_file) = make_ready_file();
let mut cmd = slow_exit_command(500, 0, &ready_file);
#[cfg(unix)]
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(|| {
let _ = libc::setpgid(0, 0);
Ok(())
});
}
let mut child = cmd.spawn().expect("Failed to spawn test process");
wait_for_ready_file(&mut child, &ready_file);
let ctrlc = test_ctrlc_state();
#[cfg(unix)]
{
let mut guard = ctrlc.active_pgid.lock().unwrap();
*guard = Some(child.id() as i32);
}
let ctrlc_clone = Arc::clone(&ctrlc);
std::thread::spawn(move || {
std::thread::park_timeout(Duration::from_millis(200));
ctrlc_clone.interrupted.store(true, Ordering::SeqCst);
});
let timeout = Some(Duration::from_millis(50));
let result = wait_for_child(&mut child, &ctrlc, timeout);
assert!(
result.is_ok(),
"Process should exit successfully (code 0) after timeout interrupt, even with Ctrl-C during grace"
);
let status = result.unwrap();
assert!(status.success(), "Process should have exit code 0");
}
#[test]
fn test_ctrlc_state_isolation() {
let ctrlc1 = test_ctrlc_state();
let ctrlc2 = test_ctrlc_state();
ctrlc1.interrupted.store(true, Ordering::SeqCst);
assert!(
!ctrlc2.interrupted.load(Ordering::SeqCst),
"Isolated CtrlCState should not be affected by other state changes"
);
#[cfg(unix)]
{
let mut guard = ctrlc1.active_pgid.lock().unwrap();
*guard = Some(12345);
}
let pgid2 = ctrlc2.active_pgid.lock().unwrap();
assert!(
pgid2.is_none(),
"Isolated CtrlCState pgid should remain None"
);
}