use super::*;
use crate::test_support::{executable, fixture, isolated_overlay, remove};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus};
use std::time::{Duration, Instant};
const MODE_VAR: &str = "CLI_GIT_SIGNAL_GUARD_TEST_MODE";
const DIR_VAR: &str = "CLI_GIT_SIGNAL_GUARD_TEST_DIR";
const STEP_TIMEOUT: Duration = Duration::from_secs(20);
#[test]
fn helper_entry() {
let Some(mode) = std::env::var_os(MODE_VAR) else {
return;
};
let directory: PathBuf = PathBuf::from(
std::env::var_os(DIR_VAR).expect("helper directory is set with the mode"),
);
let guard: SignalGuard = SignalGuard::install().expect("guard installs");
std::fs::write(directory.join("installed"), b"").expect("installed marker");
let mut command: Command = helper_command(mode.to_string_lossy().as_ref(), directory.as_path());
let outcome: ChildOutcome = guard.run_child(&mut command).expect("guarded child runs");
std::fs::write(directory.join("outcome"), describe(outcome)).expect("outcome record");
drop(guard);
std::fs::write(directory.join("cleanup"), b"").expect("cleanup marker");
}
fn helper_command(mode: &str, directory: &Path) -> Command {
let mut command: Command = match mode {
"early" => {
let mut built: Command = Command::new("sleep");
built.arg("30");
built
}
"relay" => {
let mut built: Command = Command::new("/bin/sh");
built.arg("-c").arg(format!(
"touch '{}/started'; exec sleep 30",
directory.display()
));
built
}
"terminal" => {
let mut built: Command = Command::new("/bin/sh");
built.arg("-c").arg(format!(
"trap 'echo interrupt >> \"{dir}/interrupts\"' INT; touch '{dir}/started'; while :; do sleep 0.05; done",
dir = directory.display()
));
built
}
"commit" => {
let mut built: Command = Command::new(crate::test_support::REAL_GIT);
built
.arg("commit")
.arg("--allow-empty")
.arg("--message")
.arg("held")
.current_dir(directory.join("repo"));
built
}
other => panic!("unknown helper mode {other}"),
};
if mode != "commit" {
command.current_dir(directory);
}
for (name, value) in isolated_overlay() {
command.env(name, value);
}
return command;
}
fn describe(outcome: ChildOutcome) -> String {
match outcome {
ChildOutcome::Exited(code) => return format!("exited {code}"),
ChildOutcome::Signaled(signal) => return format!("signaled {signal}"),
}
}
fn wait_for(path: &Path) {
let started: Instant = Instant::now();
while !path.exists() {
assert!(started.elapsed() < STEP_TIMEOUT, "{} never appeared", path.display());
std::thread::sleep(Duration::from_millis(10));
}
}
fn spawn_helper(mode: &str, directory: &Path) -> Child {
let executable_path: PathBuf = std::env::current_exe().expect("test executable");
return Command::new(executable_path)
.args(["--exact", "signal_relay::tests::helper_entry", "--nocapture", "--test-threads=1"])
.env(MODE_VAR, mode)
.env(DIR_VAR, directory)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("helper starts");
}
fn send(pid: u32, signal: libc::c_int) {
let target: libc::pid_t = libc::pid_t::try_from(pid).expect("helper pid fits");
let status: libc::c_int = unsafe { libc::kill(target, signal) };
assert_eq!(status, 0, "kill delivered");
}
fn finish(mut helper: Child) -> ExitStatus {
let started: Instant = Instant::now();
loop {
if let Some(status) = helper.try_wait().expect("helper status") {
return status;
}
assert!(started.elapsed() < STEP_TIMEOUT, "helper did not exit");
std::thread::sleep(Duration::from_millis(10));
}
}
#[cfg(unix)]
#[test]
fn only_process_senders_are_relayed() {
assert!(is_process_sender(libc::SI_USER));
assert!(is_process_sender(libc::SI_QUEUE));
assert!(!is_process_sender(libc::SI_KERNEL));
}
#[cfg(target_os = "linux")]
#[test]
fn thread_directed_kill_is_a_process_sender_on_linux() {
assert!(is_process_sender(libc::SI_TKILL));
}
#[test]
fn guard_catches_the_four_signals() {
assert_eq!(
RELAYED_SIGNALS,
[libc::SIGINT, libc::SIGTERM, libc::SIGHUP, libc::SIGQUIT]
);
}
#[cfg(unix)]
#[test]
fn handler_marks_a_signal_only_for_a_process_sender() {
let _statics: std::sync::MutexGuard<'_, ()> = RELAY_STATICS.lock().expect("relay statics lock");
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
let header: *mut SignalHeader = (&mut info as *mut libc::siginfo_t).cast::<SignalHeader>();
unsafe { (*header).code = libc::SI_KERNEL };
relay_handler(libc::SIGINT, &mut info, std::ptr::null_mut());
assert_eq!(
PENDING[0].load(std::sync::atomic::Ordering::SeqCst),
0,
"a terminal sender is not marked for relay"
);
unsafe { (*header).code = libc::SI_USER };
relay_handler(libc::SIGINT, &mut info, std::ptr::null_mut());
assert_eq!(
PENDING[0].load(std::sync::atomic::Ordering::SeqCst),
libc::SIGINT,
"a process sender is marked for relay"
);
PENDING[0].store(0, std::sync::atomic::Ordering::SeqCst);
}
#[cfg(unix)]
static RELAY_STATICS: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(unix)]
#[test]
fn a_signal_pending_before_spawn_is_relayed_when_the_pid_is_published() {
let _statics: std::sync::MutexGuard<'_, ()> = RELAY_STATICS.lock().expect("relay statics lock");
PENDING[1].store(libc::SIGTERM, std::sync::atomic::Ordering::SeqCst);
let mut command: Command = Command::new("sleep");
command.arg("30");
let started: Instant = Instant::now();
let outcome: ChildOutcome = run_relayed(&mut command).expect("child runs");
assert_eq!(
outcome,
ChildOutcome::Signaled(libc::SIGTERM),
"the pending SIGTERM reaches the child"
);
assert!(
started.elapsed() < Duration::from_secs(10),
"the child was signaled at once, not after its sleep"
);
PENDING[1].store(0, std::sync::atomic::Ordering::SeqCst);
}
#[test]
fn a_second_guard_is_refused_until_the_first_is_dropped() {
let first: SignalGuard = SignalGuard::install().expect("first guard");
assert!(SignalGuard::install().is_err());
drop(first);
let again: SignalGuard = SignalGuard::install().expect("guard after drop");
drop(again);
}
#[cfg(unix)]
#[test]
fn process_signal_is_relayed_and_the_child_outcome_is_reported() {
let root: PathBuf = fixture("signal-relay");
let helper: Child = spawn_helper("relay", root.as_path());
let pid: u32 = helper.id();
wait_for(root.join("started").as_path());
send(pid, libc::SIGTERM);
let status: ExitStatus = finish(helper);
assert!(status.success(), "the wrapper survived the signal: {status:?}");
assert_eq!(
std::fs::read_to_string(root.join("outcome")).expect("outcome"),
format!("signaled {}", libc::SIGTERM)
);
assert!(root.join("cleanup").exists(), "the wrapper continued after the child");
remove(root.as_path());
}
#[cfg(unix)]
#[test]
fn signal_during_start_is_relayed_after_the_child_starts() {
let root: PathBuf = fixture("signal-early");
let helper: Child = spawn_helper("early", root.as_path());
let pid: u32 = helper.id();
wait_for(root.join("installed").as_path());
send(pid, libc::SIGINT);
let status: ExitStatus = finish(helper);
assert!(status.success(), "the wrapper survived the signal: {status:?}");
assert_eq!(
std::fs::read_to_string(root.join("outcome")).expect("outcome"),
format!("signaled {}", libc::SIGINT)
);
remove(root.as_path());
}
#[cfg(target_os = "linux")]
#[test]
fn terminal_interrupt_is_not_relayed_a_second_time() {
let root: PathBuf = fixture("signal-terminal");
let (mut master, helper): (std::fs::File, Child) = spawn_terminal_helper(root.as_path());
wait_for(root.join("started").as_path());
std::io::Write::write_all(&mut master, b"\x03").expect("interrupt character written");
wait_for(root.join("interrupts").as_path());
std::thread::sleep(Duration::from_millis(300));
assert_eq!(
std::fs::read_to_string(root.join("interrupts")).expect("interrupt log").lines().count(),
1,
"the interrupt reached the child exactly once"
);
send(helper.id(), libc::SIGTERM);
let status: ExitStatus = finish(helper);
assert!(status.success(), "the wrapper survived: {status:?}");
assert_eq!(
std::fs::read_to_string(root.join("outcome")).expect("outcome"),
format!("signaled {}", libc::SIGTERM)
);
remove(root.as_path());
}
#[cfg(target_os = "linux")]
fn spawn_terminal_helper(directory: &Path) -> (std::fs::File, Child) {
use std::os::fd::{FromRawFd, OwnedFd};
use std::os::unix::process::CommandExt;
let mut master: libc::c_int = -1;
let mut slave: libc::c_int = -1;
let opened: libc::c_int = unsafe {
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
assert_eq!(opened, 0, "pseudo-terminal opens");
let master_file: std::fs::File = unsafe { std::fs::File::from_raw_fd(master) };
let slave_owned: OwnedFd = unsafe { OwnedFd::from_raw_fd(slave) };
let executable_path: PathBuf = std::env::current_exe().expect("test executable");
let mut command: Command = Command::new(executable_path);
command
.args(["--exact", "signal_relay::tests::helper_entry", "--nocapture", "--test-threads=1"])
.env(MODE_VAR, "terminal")
.env(DIR_VAR, directory)
.stdin(std::process::Stdio::from(slave_owned.try_clone().expect("slave clone")))
.stdout(std::process::Stdio::from(slave_owned.try_clone().expect("slave clone")))
.stderr(std::process::Stdio::from(slave_owned));
unsafe {
command.pre_exec(|| {
libc::setsid();
libc::ioctl(0, libc::TIOCSCTTY, 0);
return Ok(());
});
}
let helper: Child = command.spawn().expect("terminal helper starts");
return (master_file, helper);
}
#[cfg(unix)]
#[test]
fn term_during_a_held_pre_commit_ends_git_and_reports_143() {
let root: PathBuf = fixture("signal-commit");
let repo: PathBuf = crate::test_support::repository(root.as_path(), "repo");
executable(
repo.join(".git").join("hooks").join("pre-commit").as_path(),
format!("#!/bin/sh\ntouch '{}/started'\nsleep 5\n", root.display()).as_bytes(),
);
let helper: Child = spawn_helper("commit", root.as_path());
let pid: u32 = helper.id();
wait_for(root.join("started").as_path());
send(pid, libc::SIGTERM);
let status: ExitStatus = finish(helper);
assert!(status.success(), "the wrapper survived: {status:?}");
assert_eq!(
std::fs::read_to_string(root.join("outcome")).expect("outcome"),
format!("signaled {}", libc::SIGTERM)
);
assert_eq!(128 + libc::SIGTERM, 143);
assert!(root.join("cleanup").exists());
remove(root.as_path());
}