use std::time::{Duration, Instant};
use processkit::{Command, Finished, Outcome, ProcessGroup};
#[tokio::test]
#[ignore = "spawns a real subprocess and shuts it down gracefully"]
async fn shutdown_lets_a_term_handling_child_end_the_grace_early() {
let group = ProcessGroup::with_options(
processkit::ProcessGroupOptions::default().shutdown_timeout(Duration::from_secs(10)),
)
.expect("create group");
let mut run = group
.start(
&Command::new("sh")
.args(["-c", "trap 'exit 0' TERM; echo ready; read line"])
.keep_stdin_open(),
)
.await
.expect("start");
run.wait_for_line(|l| l.contains("ready"), Duration::from_secs(10))
.await
.expect("trap installed");
let waiter = tokio::spawn(run.wait());
let start = Instant::now();
tokio::time::timeout(Duration::from_secs(20), group.shutdown())
.await
.expect("shutdown bounded")
.expect("shutdown ok");
assert!(
start.elapsed() < Duration::from_secs(8),
"a TERM-handling child must end the 10s grace early (took {:?})",
start.elapsed()
);
let outcome = waiter.await.expect("join").expect("wait");
assert_eq!(
outcome,
Outcome::Exited(0),
"the child exited via its TERM trap"
);
}
#[tokio::test]
#[ignore = "spawns a TERM-ignoring subprocess and escalates to SIGKILL"]
async fn shutdown_escalates_to_kill_after_the_grace_window() {
let group = ProcessGroup::with_options(
processkit::ProcessGroupOptions::default()
.shutdown_timeout(Duration::from_millis(500))
.escalate_to_kill(true),
)
.expect("create group");
let mut run = group
.start(&Command::new("sh").args(["-c", "trap '' TERM; echo ready; while :; do :; done"]))
.await
.expect("start");
run.wait_for_line(|l| l.contains("ready"), Duration::from_secs(10))
.await
.expect("trap installed");
let waiter = tokio::spawn(run.wait());
let start = Instant::now();
tokio::time::timeout(Duration::from_secs(15), group.shutdown())
.await
.expect("escalation keeps shutdown bounded")
.expect("shutdown ok");
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_millis(300),
"the grace window must be waited out before escalating ({elapsed:?})"
);
let outcome = waiter.await.expect("join").expect("wait");
assert!(
matches!(outcome, Outcome::Signalled(_)),
"SIGKILL surfaces as a signal kill, got {outcome:?}"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess and times it out gracefully"]
async fn graceful_timeout_lets_a_term_handling_child_end_the_grace_early() {
let start = Instant::now();
let result = Command::new("sh")
.args(["-c", "trap 'exit 0' TERM; while :; do :; done"])
.timeout(Duration::from_millis(500))
.timeout_grace(Duration::from_secs(10))
.output_string()
.await
.expect("run completes");
assert!(result.timed_out(), "the deadline fired");
assert!(
start.elapsed() < Duration::from_secs(5),
"a TERM-handling child must end the 10s grace early (took {:?})",
start.elapsed()
);
}
#[tokio::test]
#[ignore = "spawns a TERM-ignoring subprocess; escalates to SIGKILL after the grace"]
async fn graceful_timeout_escalates_to_kill_after_the_grace() {
let start = Instant::now();
let result = Command::new("sh")
.args(["-c", "trap '' TERM; while :; do :; done"])
.timeout(Duration::from_millis(500))
.timeout_grace(Duration::from_millis(500))
.output_string()
.await
.expect("run completes");
assert!(result.timed_out());
assert!(
start.elapsed() >= Duration::from_millis(900),
"must wait the deadline + grace before SIGKILL (took {:?})",
start.elapsed()
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess and times out a streamed run gracefully"]
async fn graceful_timeout_on_a_streamed_run_signals_and_ends_the_stream() {
use tokio_stream::StreamExt;
let mut run = Command::new("sh")
.args([
"-c",
"trap 'echo bye 1>&2; exit 0' TERM; echo ready; while :; do :; done",
])
.timeout(Duration::from_millis(500))
.timeout_grace(Duration::from_secs(10))
.start()
.await
.expect("start");
let start = Instant::now();
let mut lines = run.stdout_lines().unwrap();
let first = tokio::time::timeout(Duration::from_secs(10), lines.next())
.await
.expect("the ready banner arrives before the deadline");
assert_eq!(first.as_deref(), Some("ready"), "trap installed");
let ended = tokio::time::timeout(Duration::from_secs(5), async {
while lines.next().await.is_some() {}
})
.await;
assert!(
ended.is_ok(),
"the graceful-timeout signal must end the stream well within the grace"
);
let Finished {
outcome, stderr, ..
} = run.finish().await.expect("finish");
assert_eq!(
outcome,
Outcome::TimedOut,
"a streamed run whose deadline fired must report TimedOut (consistent with the bulk path)"
);
assert!(
stderr.contains("bye"),
"the graceful SIGTERM must have run the trap (stderr: {stderr:?})"
);
assert!(
start.elapsed() < Duration::from_secs(8),
"a TERM-handling streamed child must end the 10s grace early (took {:?})",
start.elapsed()
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess and times out an output_events run gracefully"]
async fn graceful_timeout_on_an_events_run_reports_timed_out() {
use processkit::OutputEvent;
use tokio_stream::StreamExt;
let mut run = Command::new("sh")
.args(["-c", "trap 'exit 0' TERM; echo ready; while :; do :; done"])
.timeout(Duration::from_millis(500))
.timeout_grace(Duration::from_secs(10))
.start()
.await
.expect("start");
let start = Instant::now();
let mut events = run.output_events().unwrap();
let mut saw_ready = false;
let drained = tokio::time::timeout(Duration::from_secs(8), async {
while let Some(ev) = events.next().await {
if let OutputEvent::Stdout(l) = ev {
saw_ready |= l.text().contains("ready");
}
}
})
.await;
assert!(
drained.is_ok(),
"the events stream must end within the grace"
);
assert!(
saw_ready,
"the ready banner must arrive before the deadline"
);
let outcome = run.finish().await.expect("finish").outcome;
assert_eq!(
outcome,
Outcome::TimedOut,
"an events run whose deadline fired must report TimedOut (parity with finish)"
);
assert!(
start.elapsed() < Duration::from_secs(8),
"a TERM-handling events child must end the 10s grace early (took {:?})",
start.elapsed()
);
}
#[tokio::test]
#[ignore = "spawns a TERM-ignoring child in a SHARED group; escalates to SIGKILL"]
async fn graceful_timeout_in_a_shared_group_escalates_to_kill() {
let group = ProcessGroup::new().expect("create group");
let start = Instant::now();
let result = group
.start(
&Command::new("sh")
.args(["-c", "trap '' TERM; echo ready; while :; do :; done"])
.timeout(Duration::from_millis(500))
.timeout_grace(Duration::from_millis(500)),
)
.await
.expect("start")
.output_string()
.await
.expect("run completes");
assert!(result.timed_out(), "the deadline fired");
assert!(
start.elapsed() >= Duration::from_millis(900),
"must wait deadline + grace before SIGKILL in a shared group (took {:?})",
start.elapsed()
);
}
#[cfg(feature = "process-control")]
#[tokio::test]
#[ignore = "spawns a real subprocess; verifies the configurable timeout signal"]
async fn graceful_timeout_uses_the_configured_signal() {
use processkit::Signal;
let start = Instant::now();
let result = Command::new("sh")
.args(["-c", "trap 'exit 0' INT; trap '' TERM; while :; do :; done"])
.timeout(Duration::from_millis(500))
.timeout_grace(Duration::from_secs(10))
.timeout_signal(Signal::Int)
.output_string()
.await
.expect("run completes");
assert!(result.timed_out());
assert!(
start.elapsed() < Duration::from_secs(5),
"the INT trap must end it early — the signal is configurable (took {:?})",
start.elapsed()
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess; D4 graceful shutdown of an own-group handle"]
async fn shutdown_gracefully_stops_a_term_handling_child() {
let mut run = Command::new("sh")
.args(["-c", "trap 'exit 0' TERM; echo ready; while :; do :; done"])
.start()
.await
.expect("start");
run.wait_for_line(|l| l == "ready", Duration::from_secs(10))
.await
.expect("trap installed");
let start = Instant::now();
let outcome = tokio::time::timeout(
Duration::from_secs(10),
run.shutdown(Duration::from_secs(5)),
)
.await
.expect("shutdown finished in time")
.expect("shutdown ok");
assert_eq!(
outcome,
Outcome::Exited(0),
"the child caught SIGTERM and exited cleanly within the grace"
);
assert!(
start.elapsed() < Duration::from_secs(4),
"graceful shutdown must end as soon as the child exits, not ride the grace (took {:?})",
start.elapsed()
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess; M2 shutdown with an already-elapsed timeout"]
async fn shutdown_reports_timed_out_when_the_deadline_already_elapsed() {
let mut run = Command::new("sh")
.args([
"-c",
"trap 'exit 0' TERM; echo ready; while :; do sleep 1; done",
])
.timeout(Duration::from_millis(100))
.start()
.await
.expect("start");
run.wait_for_line(|l| l == "ready", Duration::from_secs(10))
.await
.expect("trap installed");
tokio::time::sleep(Duration::from_millis(300)).await;
let start = Instant::now();
let outcome = tokio::time::timeout(
Duration::from_secs(10),
run.shutdown(Duration::from_secs(5)),
)
.await
.expect("shutdown finished in time")
.expect("shutdown ok");
assert_eq!(
outcome,
Outcome::TimedOut,
"an already-elapsed deadline classifies the shutdown as TimedOut"
);
assert!(
start.elapsed() < Duration::from_secs(4),
"a single teardown ends as soon as the child exits on TERM, not the full grace (took {:?})",
start.elapsed()
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess; D4 shutdown is unsupported on a shared-group handle"]
async fn shutdown_is_unsupported_on_a_shared_group_handle() {
let group = ProcessGroup::new().expect("group");
let run = group
.start(&Command::new("sh").args(["-c", "sleep 30"]))
.await
.expect("start");
let err = run
.shutdown(Duration::from_secs(1))
.await
.expect_err("a shared-group handle cannot be gracefully shut down");
assert!(
matches!(err, processkit::Error::Unsupported { .. }),
"expected Unsupported, got {err:?}"
);
group.shutdown().await.expect("teardown the group");
}