From 599d86ccbf05010ef3bbd40dbbbcfdc44aa4d87f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Wed, 4 Nov 2026 02:50:35 +0100 Subject: [PATCH] Kill the child processes at once on a second interrupt during the grace period --- src/lib.rs | 2 +- src/process_tree.rs | 13 +++++++++---- src/signals.rs | 9 +++++++++ tests/e2e/forwarding.rs | 42 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b8ea26c..f065262 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -114,6 +114,6 @@ pub fn run(config: SandboxConfig) -> Result { // bwrap ignores them: its death would SIGKILL the entrypoint through --die-with-parent. let bwrap_process = bwrap.spawn(signal_forwarder.ignore_forwarded_signals())?; let status = signal_forwarder.forward_until_exit(&bwrap_process)?; - process_tree::terminate_and_reap_all(CLEANUP_GRACE_PERIOD)?; + process_tree::terminate_and_reap_all(CLEANUP_GRACE_PERIOD, signals::has_pending_interrupt)?; Ok(status) } diff --git a/src/process_tree.rs b/src/process_tree.rs index ebf68f9..74843f5 100644 --- a/src/process_tree.rs +++ b/src/process_tree.rs @@ -16,10 +16,13 @@ pub fn become_subreaper() -> io::Result<()> { Ok(prctl::set_child_subreaper(true)?) } -pub fn terminate_and_reap_all(grace_period: Duration) -> io::Result<()> { +pub fn terminate_and_reap_all( + grace_period: Duration, + should_stop_waiting: impl Fn() -> bool, +) -> io::Result<()> { signal_all_children(&[Signal::TERM, Signal::CONT]); - if reap_until(Instant::now() + grace_period)? { + if reap_until(Instant::now() + grace_period, should_stop_waiting)? { return Ok(()); } @@ -100,11 +103,13 @@ fn send_signals(pidfd: &OwnedFd, signals: &[Signal]) { } } -fn reap_until(deadline: Instant) -> io::Result { +fn reap_until(deadline: Instant, should_stop_waiting: impl Fn() -> bool) -> io::Result { loop { match process::wait(WaitOptions::NOHANG) { Ok(Some(_)) => {} - Ok(None) if Instant::now() < deadline => thread::sleep(REAP_POLL_INTERVAL), + Ok(None) if Instant::now() < deadline && !should_stop_waiting() => { + thread::sleep(REAP_POLL_INTERVAL) + } Ok(None) => return Ok(false), Err(Errno::CHILD) => return Ok(true), Err(e) => return Err(e.into()), diff --git a/src/signals.rs b/src/signals.rs index dea3e13..735d4fa 100644 --- a/src/signals.rs +++ b/src/signals.rs @@ -177,6 +177,15 @@ fn reap_until_exit_of(child: Pid) -> io::Result> { } } +pub fn has_pending_interrupt() -> bool { + let interrupt = SigSet::from(Signal::SIGINT); + let no_wait = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + unsafe { libc::sigtimedwait(interrupt.as_ref(), ptr::null_mut(), &no_wait) == libc::SIGINT } +} + pub fn exit_with_status(status: ExitStatus) -> ! { match status.signal() { Some(number) => kill_self_with(number), diff --git a/tests/e2e/forwarding.rs b/tests/e2e/forwarding.rs index d97ed19..289be44 100644 --- a/tests/e2e/forwarding.rs +++ b/tests/e2e/forwarding.rs @@ -1,6 +1,8 @@ use std::os::unix::process::{CommandExt, ExitStatusExt}; use std::path::Path; use std::process::{Child, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; use tempfile::TempDir; @@ -113,6 +115,46 @@ fn entrypoint_inherits_no_signalfd() { ); } +#[test] +fn single_interrupt_keeps_grace_period_for_child_processes() { + let dir = TempDir::new().expect("failed to create temp dir"); + let script = "setsid -f sh -c 'trap \"\" INT; trap \"sleep 0.5; touch cleaned; exit\" TERM; touch ready; sleep 60 & wait' /dev/null 2>&1 + until [ -e ready ]; do sleep 0.01; done + : > entrypoint-ready + sleep 60"; + + let (_sandbox, mut child) = spawn_sandbox(&["--blacklist"], dir.path(), script); + wait_for_file(&dir.path().join("entrypoint-ready")); + send_signal(&child, libc::SIGINT); + child.wait().unwrap(); + + assert!( + dir.path().join("cleaned").exists(), + "child process did not get its grace period" + ); +} + +#[test] +fn interrupt_during_grace_period_kills_child_processes_at_once() { + let dir = TempDir::new().expect("failed to create temp dir"); + let script = + "setsid -f sh -c 'trap \"\" TERM; touch ready; sleep 60' /dev/null 2>&1 + until [ -e ready ]; do sleep 0.01; done"; + + let (_sandbox, mut child) = spawn_sandbox(&["--blacklist"], dir.path(), script); + wait_for_file(&dir.path().join("ready")); + thread::sleep(Duration::from_millis(300)); + let interrupted_at = Instant::now(); + send_signal(&child, libc::SIGINT); + child.wait().unwrap(); + + assert!( + interrupted_at.elapsed() < Duration::from_secs(2), + "agent-sandbox waited {:?} after the interrupt", + interrupted_at.elapsed() + ); +} + fn spawn_sandbox(args: &[&str], dir: &Path, script: &str) -> (Sandbox, Child) { let mut sandbox = Sandbox::new(args); let child = sandbox