use command_stream::signal::{signal_exit_code, signal_number};
#[cfg(unix)]
use command_stream::{OutputChunk, ProcessRunner, RunOptions, StdinOption, StreamingRunner};
#[cfg(unix)]
use std::time::Duration;
#[cfg(unix)]
fn graceful_child(marker: &std::path::Path) -> String {
format!(
"trap 'echo handled >> {marker}; exit 0' TERM INT; \
echo ready; \
while true; do sleep 0.05; done",
marker = marker.display()
)
}
#[cfg(unix)]
fn stubborn_child() -> String {
"trap '' TERM INT; echo ready; while true; do sleep 0.05; done".to_string()
}
#[cfg(unix)]
fn stubborn_heartbeat_child(heartbeat: &std::path::Path) -> String {
format!(
"trap '' TERM INT; \
echo ready; \
while true; do echo tick >> {heartbeat}; sleep 0.05; done",
heartbeat = heartbeat.display()
)
}
#[cfg(unix)]
fn grandchild_heartbeat_command(heartbeat: &std::path::Path) -> String {
format!(
"sh -c 'while true; do echo tick >> {beat}; sleep 0.05; done' & \
echo ready; \
wait",
beat = heartbeat.display()
)
}
#[cfg(unix)]
fn orphaned_grandchild_heartbeat_command(heartbeat: &std::path::Path) -> String {
format!(
"sh -c 'while true; do echo tick >> {beat}; sleep 0.05; done' & \
echo ready",
beat = heartbeat.display()
)
}
#[cfg(unix)]
fn heartbeat_len(path: &std::path::Path) -> u64 {
std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0)
}
#[cfg(unix)]
const GRACEFUL_KILL_GRACE_MS: u64 = 2000;
#[cfg(unix)]
fn handler_ran(marker: &std::path::Path) -> bool {
std::fs::read_to_string(marker)
.map(|text| text.contains("handled"))
.unwrap_or(false)
}
#[test]
fn signal_numbers_follow_the_posix_names() {
assert_eq!(signal_number("SIGHUP"), 1);
assert_eq!(signal_number("SIGINT"), 2);
assert_eq!(signal_number("SIGQUIT"), 3);
assert_eq!(signal_number("SIGKILL"), 9);
assert_eq!(signal_number("SIGUSR1"), 10);
assert_eq!(signal_number("SIGUSR2"), 12);
assert_eq!(signal_number("SIGTERM"), 15);
}
#[test]
fn unknown_signal_names_fall_back_to_sigterm() {
assert_eq!(signal_number("NOT-A-SIGNAL"), signal_number("SIGTERM"));
assert_eq!(signal_exit_code("NOT-A-SIGNAL"), 143);
}
#[test]
fn exit_codes_follow_the_128_plus_signal_convention() {
assert_eq!(signal_exit_code("SIGHUP"), 129);
assert_eq!(signal_exit_code("SIGINT"), 130); assert_eq!(signal_exit_code("SIGQUIT"), 131);
assert_eq!(signal_exit_code("SIGKILL"), 137);
assert_eq!(signal_exit_code("SIGUSR1"), 138);
assert_eq!(signal_exit_code("SIGUSR2"), 140);
assert_eq!(signal_exit_code("SIGTERM"), 143);
}
#[cfg(unix)]
#[tokio::test]
async fn process_runner_kill_lets_the_child_handle_sigterm() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("marker");
let mut runner = ProcessRunner::new(
graceful_child(&marker),
RunOptions {
mirror: false,
kill_grace_ms: GRACEFUL_KILL_GRACE_MS,
..Default::default()
},
);
runner.start().await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
runner.kill().unwrap();
tokio::time::sleep(Duration::from_millis(400)).await;
assert!(
handler_ran(&marker),
"the child's SIGTERM handler never ran: kill() destroyed it before it could clean up"
);
}
#[cfg(unix)]
#[tokio::test]
async fn process_runner_kill_with_sends_the_requested_signal() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("marker");
let mut runner = ProcessRunner::new(
graceful_child(&marker),
RunOptions {
mirror: false,
kill_grace_ms: GRACEFUL_KILL_GRACE_MS,
..Default::default()
},
);
runner.start().await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
runner.kill_with("SIGINT").unwrap();
tokio::time::sleep(Duration::from_millis(400)).await;
assert!(handler_ran(&marker), "the child's SIGINT handler never ran");
}
#[cfg(unix)]
#[tokio::test]
async fn process_runner_honors_the_configured_kill_signal() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("marker");
let mut runner = ProcessRunner::new(
format!(
"trap 'echo handled >> {marker}; exit 0' INT; echo ready; while true; do sleep 0.05; done",
marker = marker.display()
),
RunOptions {
mirror: false,
kill_signal: "SIGINT".to_string(),
kill_grace_ms: GRACEFUL_KILL_GRACE_MS,
..Default::default()
},
);
runner.start().await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
runner.kill().unwrap();
tokio::time::sleep(Duration::from_millis(400)).await;
assert!(
handler_ran(&marker),
"kill() did not deliver the configured SIGINT"
);
}
#[cfg(unix)]
#[tokio::test]
async fn process_runner_escalates_to_sigkill_when_the_signal_is_ignored() {
let dir = tempfile::tempdir().unwrap();
let heartbeat = dir.path().join("heartbeat");
let mut runner = ProcessRunner::new(
stubborn_heartbeat_child(&heartbeat),
RunOptions {
mirror: false,
kill_grace_ms: 50,
..Default::default()
},
);
runner.start().await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
let while_running = heartbeat_len(&heartbeat);
assert!(
while_running > 0,
"the child never started: no heartbeat was written"
);
runner.kill().unwrap();
tokio::time::sleep(Duration::from_millis(500)).await;
let after_kill = heartbeat_len(&heartbeat);
tokio::time::sleep(Duration::from_millis(500)).await;
assert_eq!(
heartbeat_len(&heartbeat),
after_kill,
"a process ignoring SIGTERM kept running: it was never escalated to SIGKILL"
);
}
#[cfg(unix)]
#[tokio::test]
async fn process_runner_kill_reaches_grandchildren() {
let dir = tempfile::tempdir().unwrap();
let heartbeat = dir.path().join("heartbeat");
let mut runner = ProcessRunner::new(
grandchild_heartbeat_command(&heartbeat),
RunOptions {
mirror: false,
kill_grace_ms: 50,
stdin: StdinOption::Null,
..Default::default()
},
);
runner.start().await.unwrap();
tokio::time::sleep(Duration::from_millis(400)).await;
assert!(
heartbeat_len(&heartbeat) > 0,
"the grandchild never started: no heartbeat was written"
);
runner.kill().unwrap();
tokio::time::sleep(Duration::from_millis(400)).await;
let after_kill = heartbeat_len(&heartbeat);
tokio::time::sleep(Duration::from_millis(400)).await;
assert_eq!(
heartbeat_len(&heartbeat),
after_kill,
"the grandchild survived the kill and kept writing its heartbeat"
);
}
#[cfg(unix)]
#[tokio::test]
async fn process_runner_kill_reaches_a_grandchild_whose_parent_already_exited() {
let dir = tempfile::tempdir().unwrap();
let heartbeat = dir.path().join("heartbeat");
let mut runner = ProcessRunner::new(
orphaned_grandchild_heartbeat_command(&heartbeat),
RunOptions {
mirror: false,
kill_grace_ms: 50,
stdin: StdinOption::Null,
..Default::default()
},
);
runner.start().await.unwrap();
tokio::time::sleep(Duration::from_millis(400)).await;
assert!(
heartbeat_len(&heartbeat) > 0,
"the grandchild never started: no heartbeat was written"
);
runner.kill().unwrap();
tokio::time::sleep(Duration::from_millis(400)).await;
let after_kill = heartbeat_len(&heartbeat);
tokio::time::sleep(Duration::from_millis(400)).await;
assert_eq!(
heartbeat_len(&heartbeat),
after_kill,
"the orphaned grandchild survived the kill and kept writing its heartbeat"
);
}
#[cfg(unix)]
#[tokio::test]
async fn stream_kill_lets_the_child_handle_the_signal() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("marker");
let mut stream = StreamingRunner::new(graceful_child(&marker))
.kill_grace_ms(GRACEFUL_KILL_GRACE_MS)
.stream();
let mut exit_code = None;
let mut killed = false;
while let Some(chunk) = stream.next().await {
match chunk {
OutputChunk::Stdout(_) if !killed => {
killed = true;
stream.kill();
}
OutputChunk::Exit(code) => exit_code = Some(code),
_ => {}
}
}
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(exit_code, Some(143), "expected the SIGTERM exit code");
assert!(
handler_ran(&marker),
"the child's SIGTERM handler never ran"
);
}
#[cfg(unix)]
#[tokio::test]
async fn stream_zero_grace_escalates_immediately() {
let mut stream = StreamingRunner::new(stubborn_child())
.kill_grace_ms(0)
.stream();
let mut exit_code = None;
let mut killed = false;
while let Some(chunk) = stream.next().await {
match chunk {
OutputChunk::Stdout(_) if !killed => {
killed = true;
stream.kill();
}
OutputChunk::Exit(code) => exit_code = Some(code),
_ => {}
}
}
assert_eq!(exit_code, Some(143));
}
#[cfg(unix)]
const ZERO_GRACE_ATTEMPTS: usize = 10;
#[cfg(unix)]
#[tokio::test]
async fn stream_zero_grace_leaves_no_room_for_the_handler() {
for attempt in 0..ZERO_GRACE_ATTEMPTS {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("marker");
let mut stream = StreamingRunner::new(graceful_child(&marker))
.kill_grace_ms(0)
.stream();
let mut killed = false;
while let Some(chunk) = stream.next().await {
if matches!(chunk, OutputChunk::Stdout(_)) && !killed {
killed = true;
stream.kill();
}
}
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(
!handler_ran(&marker),
"attempt {attempt}: kill_grace_ms(0) still left the child time to run its SIGTERM handler"
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn process_runner_zero_grace_leaves_no_room_for_the_handler() {
for attempt in 0..ZERO_GRACE_ATTEMPTS {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("marker");
let mut runner = ProcessRunner::new(
graceful_child(&marker),
RunOptions {
mirror: false,
kill_grace_ms: 0,
..Default::default()
},
);
runner.start().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
runner.kill().unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(
!handler_ran(&marker),
"attempt {attempt}: kill_grace_ms: 0 still left the child time to run its SIGTERM handler"
);
}
}