use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, OnceLock};
use crate::backend::SandboxBackend;
use super::ledger::Ledger;
static WATCHDOG_CHILD: OnceLock<std::process::Child> = OnceLock::new();
#[cfg(test)]
pub(crate) static SPAWN_CALL_COUNT: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
pub(crate) fn spawn(cache_dir: &Path, backend: &Arc<dyn SandboxBackend>, ledger: &Ledger) {
let Ok(script_path) = write_script(cache_dir) else {
return;
};
let sandbox_kill = tab_join(&backend.watchdog_kill_command());
let network_kill = tab_join(&backend.watchdog_network_kill_command());
let mut command = build_command(&script_path);
command
.arg(ledger.sandboxes_path())
.arg(ledger.networks_path())
.arg(ledger.record_path())
.arg(&sandbox_kill)
.arg(&network_kill)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null());
let Ok(child) = command.spawn() else {
return;
};
#[cfg(test)]
SPAWN_CALL_COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if WATCHDOG_CHILD.set(child).is_err() {
}
}
#[cfg(unix)]
fn build_command(script_path: &Path) -> Command {
let mut c = Command::new("sh");
c.arg(script_path);
use std::os::unix::process::CommandExt;
c.process_group(0);
c
}
#[cfg(windows)]
fn build_command(script_path: &Path) -> Command {
let mut c = Command::new("powershell");
c.args(["-NoProfile", "-NonInteractive", "-File"]);
c.arg(script_path);
c
}
fn tab_join(words: &[String]) -> String {
words.join("\t")
}
fn script_path(cache_dir: &Path) -> PathBuf {
let ext = if cfg!(windows) { "ps1" } else { "sh" };
let name = format!(
"watchdog-{:012x}.{ext}",
content_fingerprint(script_contents())
);
cache_dir.join("reaper").join(name)
}
fn content_fingerprint(content: &str) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in content.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash & 0xffff_ffff_ffff
}
fn write_script(cache_dir: &Path) -> std::io::Result<PathBuf> {
let path = script_path(cache_dir);
if path.exists() {
return Ok(path);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
std::fs::write(&tmp, script_contents())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&tmp)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&tmp, perms)?;
}
if let Err(err) = std::fs::rename(&tmp, &path) {
let _ = std::fs::remove_file(&tmp);
if !path.exists() {
return Err(err);
}
}
Ok(path)
}
#[cfg(unix)]
fn script_contents() -> &'static str {
POSIX_SCRIPT
}
#[cfg(windows)]
fn script_contents() -> &'static str {
POWERSHELL_SCRIPT
}
#[cfg(unix)]
const POSIX_SCRIPT: &str = r#"#!/bin/sh
# rightsize orphan-reaper watchdog (POSIX). Blocks reading stdin; on EOF (the owning
# rightsize process exited, cleanly or via SIGKILL) removes that run's sandboxes and
# networks via the backend-CLI commands passed as argv, then deletes the run's ledger
# files. See docs/reaping.md.
sandboxes_file="$1"
networks_file="$2"
record_json="$3"
sandbox_kill="$4"
network_kill="$5"
# Block until stdin reaches EOF -- see crate::reaper::watchdog's doc for the
# non-inheritable pipe contract this depends on.
cat >/dev/null
tab="$(printf '\t')"
reap_lines() {
file="$1"
kill_cmd="$2"
[ -n "$kill_cmd" ] || return 0
[ -f "$file" ] || return 0
while IFS= read -r item || [ -n "$item" ]; do
[ -z "$item" ] && continue
IFS="$tab"
set -- $kill_cmd "$item"
unset IFS
"$@" >/dev/null 2>&1
done < "$file"
}
reap_lines "$sandboxes_file" "$sandbox_kill"
reap_lines "$networks_file" "$network_kill"
rm -f "$sandboxes_file" "$networks_file" "$record_json"
"#;
#[cfg(windows)]
const POWERSHELL_SCRIPT: &str = r#"param(
[string]$SandboxesFile,
[string]$NetworksFile,
[string]$RecordJson,
[string]$SandboxKill,
[string]$NetworkKill
)
# Block until stdin reaches EOF -- see crate::reaper::watchdog's doc for the
# non-inheritable pipe contract this depends on.
[Console]::In.ReadToEnd() | Out-Null
function Reap-Lines([string]$File, [string]$KillCmd) {
if ([string]::IsNullOrEmpty($KillCmd)) { return }
if (-not (Test-Path $File)) { return }
$words = $KillCmd -split "`t"
Get-Content $File | ForEach-Object {
$item = $_.Trim()
if ($item -ne "") {
$argv = $words[1..($words.Length - 1)] + $item
try { & $words[0] @argv | Out-Null } catch {}
}
}
}
Reap-Lines -File $SandboxesFile -KillCmd $SandboxKill
Reap-Lines -File $NetworksFile -KillCmd $NetworkKill
Remove-Item -ErrorAction SilentlyContinue $SandboxesFile, $NetworksFile, $RecordJson
"#;
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn script_name_derives_from_content() {
let name = script_path(Path::new("/tmp"))
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
assert!(
name.starts_with("watchdog-")
&& name.ends_with(".sh")
&& name.len() == "watchdog-.sh".len() + 12,
"unexpected script name shape: {name}"
);
assert_ne!(content_fingerprint("a"), content_fingerprint("b"));
assert_eq!(
content_fingerprint(POSIX_SCRIPT),
content_fingerprint(POSIX_SCRIPT)
);
}
fn temp_cache_dir(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"rz-watchdog-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn posix_script_invokes_the_kill_command_per_line_and_deletes_ledger_files_on_stdin_eof() {
let cache = temp_cache_dir("e2e");
let script = write_script(&cache).expect("write watchdog script");
let runs_dir = cache.join("runs");
std::fs::create_dir_all(&runs_dir).unwrap();
let sandboxes = runs_dir.join("r1.sandboxes");
let networks = runs_dir.join("r1.networks");
let record = runs_dir.join("r1.json");
std::fs::write(&sandboxes, "rz-a-0\nrz-a-1\n").unwrap();
std::fs::write(&networks, "net-1\n").unwrap();
std::fs::write(&record, "{}").unwrap();
let log = cache.join("invocations.log");
let stub = cache.join("stub-kill.sh");
std::fs::write(
&stub,
format!(
"#!/bin/sh\necho \"$@\" >> {}\n",
shell_quote(&log.display().to_string())
),
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&stub).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&stub, perms).unwrap();
}
let kill_cmd = stub.display().to_string();
let mut child = Command::new("sh")
.arg(&script)
.arg(&sandboxes)
.arg(&networks)
.arg(&record)
.arg(&kill_cmd)
.arg(&kill_cmd)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn watchdog script");
drop(child.stdin.take());
let status = child.wait().expect("watchdog script must exit");
assert!(status.success(), "watchdog script exited with {status:?}");
let log_contents = std::fs::read_to_string(&log).unwrap_or_default();
assert!(log_contents.contains("rz-a-0"), "{log_contents}");
assert!(log_contents.contains("rz-a-1"), "{log_contents}");
assert!(log_contents.contains("net-1"), "{log_contents}");
assert!(!sandboxes.exists(), "sandboxes ledger file must be deleted");
assert!(!networks.exists(), "networks ledger file must be deleted");
assert!(!record.exists(), "record json must be deleted");
}
#[test]
fn posix_script_with_empty_ledger_files_just_deletes_the_record_files() {
let cache = temp_cache_dir("empty");
let script = write_script(&cache).expect("write watchdog script");
let runs_dir = cache.join("runs");
std::fs::create_dir_all(&runs_dir).unwrap();
let sandboxes = runs_dir.join("r2.sandboxes");
let networks = runs_dir.join("r2.networks");
let record = runs_dir.join("r2.json");
std::fs::write(&record, "{}").unwrap();
let mut child = Command::new("sh")
.arg(&script)
.arg(&sandboxes)
.arg(&networks)
.arg(&record)
.arg("true") .arg("")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn watchdog script");
drop(child.stdin.take());
let status = child.wait().unwrap();
assert!(status.success());
assert!(!record.exists());
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
#[test]
fn tab_join_preserves_a_word_containing_spaces() {
let words = vec!["sh".to_string(), "-c".to_string(), "a b c".to_string()];
let joined = tab_join(&words);
let split: Vec<&str> = joined.split('\t').collect();
assert_eq!(split, vec!["sh", "-c", "a b c"]);
}
}