use std::io;
use std::time::Duration;
#[cfg(unix)]
use std::sync::Arc;
#[cfg(unix)]
use super::pid_gate::PidGate;
use tokio::time::{Instant, sleep};
const POLL_INTERVAL: Duration = Duration::from_millis(20);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SoftDelivery {
Sent,
#[cfg_attr(not(windows), allow(dead_code))]
Unsupported,
Failed,
}
#[derive(Debug, Clone, Copy)]
#[cfg_attr(not(feature = "process-control"), allow(dead_code))]
pub(crate) struct GracefulOutcome {
pub soft: SoftDelivery,
pub members_before: Option<usize>,
pub members_after: Option<usize>,
pub drained: bool,
pub escalated: bool,
pub elapsed: Duration,
}
pub(crate) trait GracefulTarget {
fn signal_all(&self, signal: i32) -> SoftDelivery;
fn is_drained(&self) -> bool;
fn alive_count(&self) -> Option<usize>;
fn hard_kill(&self) -> io::Result<()>;
}
pub(crate) async fn run(
target: &impl GracefulTarget,
skip_drop_kill: &super::SkipDropKill,
signal: i32,
timeout: Duration,
escalate: bool,
) -> io::Result<GracefulOutcome> {
let started = Instant::now();
let members_before = target.alive_count();
let epoch = skip_drop_kill.begin_shutdown();
let soft = target.signal_all(signal);
#[cfg(feature = "tracing")]
tracing::debug!(
target: "processkit",
phase = "soft_signal",
signal,
delivery = ?soft,
members_before = ?members_before,
escalate,
"graceful teardown: soft signal issued"
);
let deadline = started + timeout.min(crate::MAX_DEADLINE);
#[cfg(feature = "tracing")]
tracing::debug!(
target: "processkit",
phase = "grace_started",
grace_ms = timeout.min(crate::MAX_DEADLINE).as_millis() as u64,
"graceful teardown: grace window opened"
);
while !target.is_drained() {
let now = Instant::now();
if now >= deadline {
break;
}
sleep(POLL_INTERVAL.min(deadline - now)).await;
}
let drained = target.is_drained();
let escalated = escalate && !drained;
let kill_result = if escalated {
target.hard_kill()
} else {
if !escalate {
skip_drop_kill.request(epoch);
}
Ok(())
};
let members_after = target.alive_count();
let elapsed = started.elapsed();
#[cfg(any(feature = "tracing", feature = "metrics"))]
let phase = if escalated {
"escalated"
} else if drained {
"drained"
} else {
"spared"
};
#[cfg(feature = "tracing")]
tracing::debug!(
target: "processkit",
phase,
drained,
escalated,
members_after = ?members_after,
elapsed_ms = elapsed.as_millis() as u64,
"graceful teardown: grace window closed"
);
#[cfg(feature = "metrics")]
{
crate::metrics::record_teardown(phase);
crate::metrics::record_teardown_duration(phase, elapsed);
}
kill_result.map(|()| GracefulOutcome {
soft,
members_before,
members_after,
drained,
escalated,
elapsed,
})
}
#[cfg(unix)]
pub(crate) trait PidTarget {
fn signal(&self, signal: i32);
fn is_alive(&self) -> bool;
fn hard_kill(&self);
}
#[cfg(unix)]
pub(crate) async fn run_pid(target: &impl PidTarget, signal: i32, grace: Duration) {
target.signal(signal);
#[cfg(feature = "tracing")]
tracing::debug!(
target: "processkit",
phase = "soft_signal",
signal,
"graceful child teardown: soft signal issued"
);
let deadline = Instant::now() + grace.min(crate::MAX_DEADLINE);
#[cfg(feature = "tracing")]
tracing::debug!(
target: "processkit",
phase = "grace_started",
grace_ms = grace.min(crate::MAX_DEADLINE).as_millis() as u64,
"graceful child teardown: grace window opened"
);
loop {
let now = Instant::now();
if now >= deadline {
break; }
if !target.is_alive() {
#[cfg(feature = "metrics")]
crate::metrics::record_teardown("drained");
#[cfg(feature = "tracing")]
tracing::debug!(
target: "processkit",
phase = "drained",
"graceful child teardown: child exited within grace"
);
return;
}
sleep(POLL_INTERVAL.min(deadline - now)).await;
}
#[cfg(feature = "metrics")]
crate::metrics::record_teardown("escalated");
#[cfg(feature = "tracing")]
tracing::debug!(
target: "processkit",
phase = "escalated",
"graceful child teardown: grace elapsed, hard kill"
);
target.hard_kill();
}
#[cfg(unix)]
pub(crate) struct UnixChild {
gate: Arc<PidGate>,
}
#[cfg(unix)]
impl UnixChild {
pub(crate) fn new(gate: Arc<PidGate>) -> Self {
Self { gate }
}
}
#[cfg(unix)]
impl PidTarget for UnixChild {
fn signal(&self, signal: i32) {
self.gate.with_live_pid((), |pid| {
unsafe {
libc::kill(pid as i32, signal);
}
});
}
fn is_alive(&self) -> bool {
self.gate.with_live_pid(false, |pid| {
let rc = unsafe { libc::kill(pid as i32, 0) };
rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
})
}
fn hard_kill(&self) {
self.gate.with_live_pid((), |pid| {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
struct FakeTarget {
signals: AtomicUsize,
hard_kills: AtomicUsize,
alive_polls: AtomicUsize,
fail_hard_kill: bool,
}
impl FakeTarget {
fn new(alive_polls: usize) -> Self {
Self {
signals: AtomicUsize::new(0),
hard_kills: AtomicUsize::new(0),
alive_polls: AtomicUsize::new(alive_polls),
fail_hard_kill: false,
}
}
}
impl GracefulTarget for FakeTarget {
fn signal_all(&self, _signal: i32) -> SoftDelivery {
self.signals.fetch_add(1, Ordering::Relaxed);
SoftDelivery::Sent
}
fn is_drained(&self) -> bool {
let remaining = self.alive_polls.load(Ordering::Relaxed);
if remaining == 0 {
return true;
}
self.alive_polls.store(remaining - 1, Ordering::Relaxed);
false
}
fn alive_count(&self) -> Option<usize> {
Some(self.alive_polls.load(Ordering::Relaxed))
}
fn hard_kill(&self) -> io::Result<()> {
self.hard_kills.fetch_add(1, Ordering::Relaxed);
if self.fail_hard_kill {
Err(io::Error::other("hard_kill failed"))
} else {
Ok(())
}
}
}
#[tokio::test]
async fn drained_before_deadline_does_not_escalate() {
let target = FakeTarget::new(0); let skip = crate::sys::SkipDropKill::new();
run(&target, &skip, 15, Duration::from_secs(10), true)
.await
.expect("graceful run");
assert_eq!(target.signals.load(Ordering::Relaxed), 1, "signalled once");
assert_eq!(
target.hard_kills.load(Ordering::Relaxed),
0,
"no escalation"
);
assert!(!skip.is_set(), "escalate path leaves skip clear");
}
#[tokio::test(start_paused = true)]
async fn drains_mid_poll_does_not_escalate() {
let target = FakeTarget::new(3);
let skip = crate::sys::SkipDropKill::new();
run(&target, &skip, 15, Duration::from_secs(10), true)
.await
.expect("graceful run");
assert_eq!(
target.hard_kills.load(Ordering::Relaxed),
0,
"drained in time"
);
assert!(!skip.is_set());
}
#[tokio::test(start_paused = true)]
async fn deadline_elapses_after_polling_then_escalates() {
let target = FakeTarget::new(usize::MAX);
let skip = crate::sys::SkipDropKill::new();
run(&target, &skip, 15, Duration::from_millis(50), true)
.await
.expect("graceful run");
assert_eq!(
target.hard_kills.load(Ordering::Relaxed),
1,
"escalated after the deadline elapsed"
);
assert!(!skip.is_set());
}
#[tokio::test]
async fn not_drained_by_deadline_escalates_when_asked() {
let target = FakeTarget::new(usize::MAX);
let skip = crate::sys::SkipDropKill::new();
run(&target, &skip, 15, Duration::ZERO, true)
.await
.expect("graceful run");
assert_eq!(
target.hard_kills.load(Ordering::Relaxed),
1,
"escalated once"
);
assert!(!skip.is_set(), "escalation does not set skip");
}
#[tokio::test]
async fn not_drained_without_escalation_sets_skip_and_spares_survivors() {
let target = FakeTarget::new(usize::MAX);
let skip = crate::sys::SkipDropKill::new();
run(&target, &skip, 15, Duration::ZERO, false)
.await
.expect("graceful run");
assert_eq!(target.hard_kills.load(Ordering::Relaxed), 0, "no hard kill");
assert!(skip.is_set(), "skip set so Drop spares survivors");
}
#[tokio::test(start_paused = true)]
async fn a_concurrent_rearm_wins_over_a_stale_non_escalating_request() {
struct RacingRearm<'a> {
latch: &'a crate::sys::SkipDropKill,
polls: AtomicUsize,
}
impl GracefulTarget for RacingRearm<'_> {
fn signal_all(&self, _signal: i32) -> SoftDelivery {
SoftDelivery::Sent
}
fn is_drained(&self) -> bool {
if self.polls.fetch_add(1, Ordering::Relaxed) == 1 {
self.latch.clear();
}
false
}
fn alive_count(&self) -> Option<usize> {
None
}
fn hard_kill(&self) -> io::Result<()> {
Ok(())
}
}
let skip = crate::sys::SkipDropKill::new();
skip.clear();
let target = RacingRearm {
latch: &skip,
polls: AtomicUsize::new(0),
};
run(&target, &skip, 15, Duration::from_millis(100), false)
.await
.expect("graceful run");
assert!(
!skip.is_set(),
"a spawn/adopt that re-armed mid-shutdown must not be re-spared by the \
shutdown's stale request — the fresh child keeps its Drop-kill backstop"
);
}
#[tokio::test(start_paused = true)]
async fn a_non_escalating_shutdown_without_a_race_still_spares() {
let target = FakeTarget::new(3); let skip = crate::sys::SkipDropKill::new();
skip.clear(); run(&target, &skip, 15, Duration::from_secs(10), false)
.await
.expect("graceful run");
assert!(
skip.is_set(),
"an unraced non-escalating shutdown spares its survivors on Drop"
);
}
#[tokio::test]
async fn hard_kill_error_propagates() {
let mut target = FakeTarget::new(usize::MAX);
target.fail_hard_kill = true;
let skip = crate::sys::SkipDropKill::new();
let err = run(&target, &skip, 15, Duration::ZERO, true)
.await
.expect_err("hard_kill failure surfaces");
assert_eq!(err.kind(), io::ErrorKind::Other);
assert!(!skip.is_set());
}
#[tokio::test]
async fn saturating_timeout_does_not_panic() {
let target = FakeTarget::new(0); let skip = crate::sys::SkipDropKill::new();
run(&target, &skip, 15, Duration::MAX, true)
.await
.expect("graceful run with saturating timeout");
}
#[tokio::test(start_paused = true)]
async fn outcome_reports_an_early_drain_without_spending_the_whole_grace() {
let target = FakeTarget::new(3); let skip = crate::sys::SkipDropKill::new();
let outcome = run(&target, &skip, 15, Duration::from_secs(30), true)
.await
.expect("graceful run");
assert_eq!(
outcome.soft,
SoftDelivery::Sent,
"the soft signal was issued"
);
assert!(outcome.drained, "the tree drained within the grace");
assert!(!outcome.escalated, "an in-time drain needs no hard kill");
assert_eq!(
outcome.members_before,
Some(3),
"three members before the signal"
);
assert_eq!(outcome.members_after, Some(0), "none left after the drain");
assert!(
outcome.elapsed < Duration::from_secs(30),
"an early drain must not spend the whole grace window (took {:?})",
outcome.elapsed
);
}
#[tokio::test]
async fn outcome_reports_escalation_when_the_tree_does_not_drain() {
let target = FakeTarget::new(usize::MAX); let skip = crate::sys::SkipDropKill::new();
let outcome = run(&target, &skip, 15, Duration::ZERO, true)
.await
.expect("graceful run");
assert!(!outcome.drained, "the tree never drained");
assert!(outcome.escalated, "escalation to the hard kill is reported");
assert_eq!(
target.hard_kills.load(Ordering::Relaxed),
1,
"the hard kill actually fired"
);
assert!(outcome.members_before.is_some(), "a member count was read");
}
#[tokio::test]
async fn outcome_reports_spared_survivors_without_escalating() {
let target = FakeTarget::new(usize::MAX); let skip = crate::sys::SkipDropKill::new();
let outcome = run(&target, &skip, 15, Duration::ZERO, false)
.await
.expect("graceful run");
assert!(!outcome.drained, "the tree was left running, not drained");
assert!(
!outcome.escalated,
"a non-escalating shutdown never hard-kills"
);
assert_eq!(target.hard_kills.load(Ordering::Relaxed), 0, "no hard kill");
assert!(skip.is_set(), "survivors are spared on Drop");
}
#[cfg(unix)]
struct FakePid {
signals: AtomicUsize,
last_signal: std::sync::atomic::AtomicI32,
hard_kills: AtomicUsize,
alive_polls: AtomicUsize,
}
#[cfg(unix)]
impl FakePid {
fn new(alive_polls: usize) -> Self {
Self {
signals: AtomicUsize::new(0),
last_signal: std::sync::atomic::AtomicI32::new(0),
hard_kills: AtomicUsize::new(0),
alive_polls: AtomicUsize::new(alive_polls),
}
}
}
#[cfg(unix)]
impl PidTarget for FakePid {
fn signal(&self, signal: i32) {
self.signals.fetch_add(1, Ordering::Relaxed);
self.last_signal.store(signal, Ordering::Relaxed);
}
fn is_alive(&self) -> bool {
let remaining = self.alive_polls.load(Ordering::Relaxed);
if remaining == 0 {
return false;
}
self.alive_polls.store(remaining - 1, Ordering::Relaxed);
true
}
fn hard_kill(&self) {
self.hard_kills.fetch_add(1, Ordering::Relaxed);
}
}
#[cfg(unix)]
#[tokio::test(start_paused = true)]
async fn pid_child_that_catches_the_signal_is_still_hard_killed() {
let target = FakePid::new(usize::MAX);
run_pid(&target, 15, Duration::from_millis(100)).await;
assert_eq!(target.signals.load(Ordering::Relaxed), 1, "signalled once");
assert_eq!(
target.last_signal.load(Ordering::Relaxed),
15,
"the configured graceful signal is delivered, not a hard-coded one"
);
assert_eq!(
target.hard_kills.load(Ordering::Relaxed),
1,
"a survivor that rode out the grace is force-killed"
);
}
#[cfg(unix)]
#[tokio::test(start_paused = true)]
async fn pid_child_that_exits_within_grace_skips_the_hard_kill() {
let target = FakePid::new(2);
run_pid(&target, 15, Duration::from_secs(10)).await;
assert_eq!(
target.hard_kills.load(Ordering::Relaxed),
0,
"a child gone within the grace is not force-killed (no recycled-pid SIGKILL)"
);
}
#[cfg(unix)]
#[tokio::test]
async fn pid_saturating_grace_does_not_panic() {
let target = FakePid::new(0); run_pid(&target, 15, Duration::MAX).await;
assert_eq!(
target.hard_kills.load(Ordering::Relaxed),
0,
"an already-gone child is not force-killed"
);
}
#[cfg(unix)]
#[test]
fn a_retired_unix_child_reports_gone_even_for_a_live_pid() {
let gate = std::sync::Arc::new(PidGate::new(Some(std::process::id())));
gate.retire();
let child = UnixChild::new(gate);
assert!(
!child.is_alive(),
"a retired pid must report gone, not probe the recycled pid alive"
);
let live_gate = std::sync::Arc::new(PidGate::new(Some(std::process::id())));
let live_child = UnixChild::new(live_gate);
assert!(
live_child.is_alive(),
"a live, un-retired pid still probes alive"
);
}
#[cfg(unix)]
#[tokio::test(start_paused = true)]
async fn run_pid_leaves_a_retired_live_pid_untouched() {
let gate = std::sync::Arc::new(PidGate::new(Some(std::process::id())));
gate.retire();
let target = UnixChild::new(gate);
let start = Instant::now();
run_pid(&target, 15, Duration::from_secs(10)).await;
assert!(
start.elapsed() < Duration::from_secs(1),
"a retired target ends the grace immediately, before any hard kill"
);
}
#[cfg(unix)]
#[tokio::test(start_paused = true)]
async fn a_reap_landing_mid_grace_stands_the_detached_kill_down() {
struct RetireAfterFirstPoll {
inner: UnixChild,
gate: std::sync::Arc<PidGate>,
signals: AtomicUsize,
polls: AtomicUsize,
hard_kills: AtomicUsize,
}
impl PidTarget for RetireAfterFirstPoll {
fn signal(&self, _signal: i32) {
self.signals.fetch_add(1, Ordering::SeqCst);
}
fn is_alive(&self) -> bool {
let alive = self.inner.is_alive();
if self.polls.fetch_add(1, Ordering::SeqCst) == 0 {
self.gate.retire();
}
alive
}
fn hard_kill(&self) {
self.hard_kills.fetch_add(1, Ordering::SeqCst);
self.inner.hard_kill();
}
}
let gate = std::sync::Arc::new(PidGate::new(Some(std::process::id())));
let target = RetireAfterFirstPoll {
inner: UnixChild::new(gate.clone()),
gate,
signals: AtomicUsize::new(0),
polls: AtomicUsize::new(0),
hard_kills: AtomicUsize::new(0),
};
let start = Instant::now();
run_pid(&target, 15, Duration::from_secs(10)).await;
assert_eq!(
target.signals.load(Ordering::SeqCst),
1,
"the driver issues the graceful signal once, up front, before it polls"
);
assert_eq!(
target.hard_kills.load(Ordering::SeqCst),
0,
"a reap landing mid-grace must suppress the final SIGKILL (no recycled-pid kill)"
);
assert!(
start.elapsed() < Duration::from_secs(1),
"the driver stands down on the next poll, not after riding out the grace"
);
}
#[cfg(feature = "tracing")]
#[derive(Clone, Default)]
struct PhaseCapture {
phases: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
}
#[cfg(feature = "tracing")]
static INSTALL_PHASE_CAPTURE_SUBSCRIBER: std::sync::Once = std::sync::Once::new();
#[cfg(feature = "tracing")]
std::thread_local! {
static ACTIVE_PHASE_CAPTURE: std::cell::RefCell<Option<PhaseCapture>> = const {
std::cell::RefCell::new(None)
};
}
#[cfg(feature = "tracing")]
impl PhaseCapture {
fn phases(&self) -> Vec<String> {
self.phases.lock().expect("phase capture lock").clone()
}
}
#[cfg(feature = "tracing")]
struct PhaseVisitor<'a>(&'a mut Option<String>);
#[cfg(feature = "tracing")]
impl tracing::field::Visit for PhaseVisitor<'_> {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "phase" {
*self.0 = Some(value.to_owned());
}
}
fn record_debug(&mut self, _field: &tracing::field::Field, _value: &dyn std::fmt::Debug) {}
}
#[cfg(feature = "tracing")]
struct PhaseCaptureSubscriber;
#[cfg(feature = "tracing")]
impl tracing::Subscriber for PhaseCaptureSubscriber {
fn register_callsite(
&self,
_metadata: &tracing::Metadata<'_>,
) -> tracing::subscriber::Interest {
tracing::subscriber::Interest::sometimes()
}
fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
let mut phase = None;
event.record(&mut PhaseVisitor(&mut phase));
if let Some(phase) = phase {
ACTIVE_PHASE_CAPTURE.with(|active| {
if let Some(capture) = active.borrow().as_ref() {
capture
.phases
.lock()
.expect("phase capture lock")
.push(phase);
}
});
}
}
fn enter(&self, _span: &tracing::span::Id) {}
fn exit(&self, _span: &tracing::span::Id) {}
}
#[cfg(feature = "tracing")]
struct ActivePhaseCapture;
#[cfg(feature = "tracing")]
impl ActivePhaseCapture {
fn install(capture: PhaseCapture) -> Self {
ACTIVE_PHASE_CAPTURE.with(|active| {
assert!(
active.borrow().is_none(),
"phase capture scopes must not nest on one thread"
);
active.replace(Some(capture));
});
Self
}
}
#[cfg(feature = "tracing")]
impl Drop for ActivePhaseCapture {
fn drop(&mut self) {
ACTIVE_PHASE_CAPTURE.with(|active| {
active.replace(None);
});
}
}
#[cfg(feature = "tracing")]
fn capture_teardown_phases<F, Fut>(body: F) -> Vec<String>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = ()>,
{
INSTALL_PHASE_CAPTURE_SUBSCRIBER.call_once(|| {
tracing::subscriber::set_global_default(PhaseCaptureSubscriber)
.expect("install phase capture subscriber");
});
let capture = PhaseCapture::default();
let _active_capture = ActivePhaseCapture::install(capture.clone());
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.expect("current-thread runtime");
rt.block_on(body());
capture.phases()
}
#[cfg(feature = "tracing")]
#[test]
fn teardown_narrates_the_full_transition_sequence_on_a_clean_drain() {
let phases = capture_teardown_phases(|| async {
let target = FakeTarget::new(0); let skip = crate::sys::SkipDropKill::new();
run(&target, &skip, 15, Duration::from_secs(10), true)
.await
.expect("graceful run");
});
assert_eq!(
phases,
vec!["soft_signal", "grace_started", "drained"],
"a clean graceful teardown narrates soft signal → grace → drain, in order"
);
}
#[cfg(feature = "tracing")]
#[test]
fn teardown_narrates_the_escalation_branch() {
let phases = capture_teardown_phases(|| async {
let target = FakeTarget::new(usize::MAX); let skip = crate::sys::SkipDropKill::new();
run(&target, &skip, 15, Duration::ZERO, true)
.await
.expect("graceful run");
});
assert_eq!(
phases,
vec!["soft_signal", "grace_started", "escalated"],
"a tree that rides out the grace narrates the escalation to the hard kill"
);
}
#[cfg(feature = "tracing")]
#[test]
fn teardown_narrates_survivors_spared_by_a_non_escalating_stop() {
let phases = capture_teardown_phases(|| async {
let target = FakeTarget::new(usize::MAX); let skip = crate::sys::SkipDropKill::new();
run(&target, &skip, 15, Duration::ZERO, false)
.await
.expect("graceful run");
});
assert_eq!(
phases,
vec!["soft_signal", "grace_started", "spared"],
"a non-escalating stop that leaves survivors narrates them as spared"
);
}
#[cfg(all(unix, feature = "tracing"))]
#[test]
fn pid_teardown_narrates_a_clean_exit_within_grace() {
let phases = capture_teardown_phases(|| async {
let target = FakePid::new(0); run_pid(&target, 15, Duration::from_secs(10)).await;
});
assert_eq!(
phases,
vec!["soft_signal", "grace_started", "drained"],
"a child gone within the grace narrates the same drain transition"
);
}
#[cfg(all(unix, feature = "tracing"))]
#[test]
fn pid_teardown_narrates_the_hard_kill_branch() {
let phases = capture_teardown_phases(|| async {
let target = FakePid::new(usize::MAX); run_pid(&target, 15, Duration::ZERO).await;
});
assert_eq!(
phases,
vec!["soft_signal", "grace_started", "escalated"],
"a child that survives the grace narrates the escalation to the hard kill"
);
}
}