Skip to content

Commit bd740fd

Browse files
authored
feat(executor): forward signals to sandboxed process group to prevent orphans (#39)
Fixes issue #37: when sx receives SIGINT/SIGTERM/SIGHUP it now delivers the signal to the entire sandboxed process group via kill(-pgid, ...), followed by a 2s grace period and SIGKILL escalation. A RAII PgidKillGuard ensures the subtree is cleaned up even on unexpected exit paths. - Add signal-hook and libc dependencies - Spawn child in its own process group (process_group(0)) - Add spawn_with_signal_forwarding with SIGTERM→SIGKILL escalation - Add PgidKillGuard for panic-safe cleanup - Add unit test for process group isolation - Add integration tests (tests/signal_test.rs) covering SIGTERM propagation and document the known SIGKILL limitation
1 parent 4ed0e18 commit bd740fd

4 files changed

Lines changed: 349 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ thiserror = "2"
3030
anyhow = "1"
3131
tempfile = "3"
3232

33+
# Signal handling and syscalls
34+
signal-hook = "0.4"
35+
libc = "0.2"
36+
3337
[dev-dependencies]
3438
assert_cmd = "2"
3539
predicates = "3"

src/sandbox/executor.rs

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
// sandbox-exec invocation
22
use crate::sandbox::seatbelt::{generate_seatbelt_profile, SandboxParams, SeatbeltError};
33
use crate::sandbox::trace::TraceSession;
4+
use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM};
5+
use signal_hook::iterator::Signals;
46
use std::fs;
57
use std::io;
8+
use std::os::unix::process::CommandExt;
69
use std::path::Path;
710
use std::process::{Command, ExitStatus, Stdio};
11+
use std::time::Duration;
812
use tempfile::{NamedTempFile, TempDir};
913

14+
/// Grace period between SIGTERM and SIGKILL when forwarding shutdown signals
15+
/// to the sandboxed process group. Long enough for typical cleanup (closing
16+
/// IPC peers, flushing buffers) but short enough that orphaned processes do
17+
/// not linger after `sx` is signalled.
18+
const SIGTERM_TO_SIGKILL_GRACE: Duration = Duration::from_secs(2);
19+
1020
/// Exit codes for sandbox execution
1121
pub mod exit_codes {
1222
pub const SUCCESS: i32 = 0;
@@ -171,8 +181,11 @@ SAVEHIST=0
171181
.stdout(Stdio::inherit())
172182
.stderr(Stdio::inherit());
173183

174-
// Execute and wait
175-
let status = cmd.spawn()?.wait()?;
184+
// Execute and wait, forwarding shutdown signals to the entire sandboxed subtree.
185+
// Without this, sx exits without signalling its sandboxed descendants — IPC
186+
// children (e.g. Node `--useNodeIpc` workers) get reparented to launchd and
187+
// accumulate forever.
188+
let status = spawn_with_signal_forwarding(cmd)?;
176189

177190
// Stop trace session
178191
if let Some(ref mut session) = trace_session {
@@ -217,6 +230,74 @@ pub fn dry_run(params: &SandboxParams) -> Result<String, SeatbeltError> {
217230
generate_seatbelt_profile(params)
218231
}
219232

233+
/// RAII guard that SIGKILLs an entire process group on drop.
234+
/// Provides panic / early-return safety so the sandbox subtree is never
235+
/// orphaned if `sx` exits along an unexpected path.
236+
struct PgidKillGuard {
237+
pgid: Option<i32>,
238+
}
239+
240+
impl PgidKillGuard {
241+
fn new(pgid: i32) -> Self {
242+
Self { pgid: Some(pgid) }
243+
}
244+
245+
/// Disarm the guard after a clean exit so we do not signal a dead pgid.
246+
fn disarm(&mut self) {
247+
self.pgid = None;
248+
}
249+
}
250+
251+
impl Drop for PgidKillGuard {
252+
fn drop(&mut self) {
253+
if let Some(pgid) = self.pgid {
254+
// Best-effort: ignore errors (group may already be gone).
255+
unsafe {
256+
libc::kill(-pgid, libc::SIGKILL);
257+
}
258+
}
259+
}
260+
}
261+
262+
/// Send `sig` to the entire process group identified by `pgid`.
263+
fn signal_pgroup(pgid: i32, sig: libc::c_int) {
264+
unsafe {
265+
libc::kill(-pgid, sig);
266+
}
267+
}
268+
269+
/// Spawn `cmd` in its own process group and wait for it, forwarding
270+
/// SIGINT/SIGTERM/SIGHUP to the sandboxed subtree with a SIGTERM →
271+
/// grace-period → SIGKILL escalation.
272+
fn spawn_with_signal_forwarding(mut cmd: Command) -> io::Result<ExitStatus> {
273+
// Put the child in its own process group so kill(-pgid, ...) reaches every
274+
// descendant in one syscall and does not loop back to sx itself.
275+
cmd.process_group(0);
276+
277+
let mut child = cmd.spawn()?;
278+
let pgid = child.id() as i32;
279+
let mut kill_guard = PgidKillGuard::new(pgid);
280+
281+
let mut signals = Signals::new([SIGINT, SIGTERM, SIGHUP])?;
282+
let signal_handle = signals.handle();
283+
284+
let signal_thread = std::thread::spawn(move || {
285+
if signals.forever().next().is_some() {
286+
signal_pgroup(pgid, libc::SIGTERM);
287+
std::thread::sleep(SIGTERM_TO_SIGKILL_GRACE);
288+
signal_pgroup(pgid, libc::SIGKILL);
289+
}
290+
});
291+
292+
let status = child.wait()?;
293+
294+
kill_guard.disarm();
295+
signal_handle.close();
296+
let _ = signal_thread.join();
297+
298+
Ok(status)
299+
}
300+
220301
/// Check if an environment variable name matches any glob-like pattern.
221302
/// Supports `*` wildcards: `AWS_*` matches `AWS_SECRET_KEY`, `*_SECRET*` matches `MY_SECRET_VALUE`.
222303
fn matches_env_pattern(name: &str, patterns: &[String]) -> bool {
@@ -363,4 +444,41 @@ mod tests {
363444
&["DYLD_*".to_string()]
364445
));
365446
}
447+
448+
/// Verifies the foundational mechanism for issue #37: spawning a child via
449+
/// `Command::process_group(0)` isolates it into its own process group so we
450+
/// can deliver group-wide signals via `kill(-pgid, ...)`. This is a direct
451+
/// unit test of the kernel behavior `spawn_with_signal_forwarding` relies
452+
/// on; it does not require `sandbox-exec` and runs in any environment.
453+
#[test]
454+
fn test_process_group_isolation_for_signal_forwarding() {
455+
let mut cmd = Command::new("/bin/sh");
456+
cmd.args(["-c", "sleep 5"])
457+
.process_group(0)
458+
.stdin(Stdio::null())
459+
.stdout(Stdio::null())
460+
.stderr(Stdio::null());
461+
462+
let mut child = cmd.spawn().expect("spawn /bin/sh");
463+
let child_pid = child.id() as i32;
464+
465+
let child_pgid = unsafe { libc::getpgid(child_pid) };
466+
assert_eq!(
467+
child_pgid, child_pid,
468+
"child should lead its own process group (pgid == pid)"
469+
);
470+
471+
let test_pgid = unsafe { libc::getpgid(0) };
472+
assert_ne!(
473+
test_pgid, child_pgid,
474+
"child pgid must be isolated from test process pgid"
475+
);
476+
477+
// Cleanup: signal the entire group, mirroring what the production
478+
// signal handler does, then reap the child.
479+
unsafe {
480+
libc::kill(-child_pid, libc::SIGTERM);
481+
}
482+
let _ = child.wait();
483+
}
366484
}

0 commit comments

Comments
 (0)