#![cfg(unix)]
use crate::bounded_push::{run_bounded_push, STOPPED_EXIT_CODE};
use crate::child_watch::{output_watched, WatchTerms};
use crate::test_support::{fixture, git, git_text, remove, repository, scratch_environment};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
const HOLD_DEADLINE: Duration = Duration::from_millis(2500);
const SHORT_GRACE: Duration = Duration::from_secs(2);
const HELD_REF: &str = "refs/remotes/origin/main";
#[derive(Clone, Copy, Debug)]
enum Holder {
Responsive,
Unresponsive,
}
struct Stage {
root: PathBuf,
repo: PathBuf,
ready: PathBuf,
}
fn stage(name: &str) -> Stage {
let root: PathBuf = fixture(name);
let repo: PathBuf = repository(root.as_path(), "repo");
let remote: PathBuf = root.join("remote.git");
git(root.as_path(), &["init", "--bare", "--quiet", remote.to_str().expect("utf-8")]);
git(repo.as_path(), &["remote", "add", "origin", remote.to_str().expect("utf-8")]);
git(repo.as_path(), &["push", "--quiet", "origin", "main"]);
let behind: String = git_text(repo.as_path(), &["rev-parse", "HEAD"]);
git(repo.as_path(), &["commit", "--quiet", "--allow-empty", "--message", "second"]);
git(repo.as_path(), &["push", "--quiet", "origin", "main"]);
git(repo.as_path(), &["update-ref", HELD_REF, behind.as_str()]);
let ready: PathBuf = root.join("ready");
return Stage { root, repo, ready };
}
fn holder(stage: &Stage, kind: Holder) -> Command {
let lock: PathBuf = stage.repo.join(".git").join(format!("{HELD_REF}.lock"));
let target: String = git_text(stage.repo.as_path(), &["rev-parse", "HEAD"]);
let script: &str = "( printf 'start\\nupdate %s %s\\nprepare\\n' \"$HELD_REF\" \"$TARGET\"; \
while [ ! -e \"$LOCK\" ]; do sleep 0.05; done; : > \"$READY\"; sleep 600 ) \
| /usr/bin/git update-ref --stdin";
let mut command: Command = Command::new("sh");
command
.arg("-c")
.arg(script)
.current_dir(stage.repo.as_path())
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/nonexistent-global-config")
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "core.lockfilePid")
.env("GIT_CONFIG_VALUE_0", "true")
.env("HELD_REF", HELD_REF)
.env("TARGET", target)
.env("LOCK", lock.as_os_str())
.env("READY", stage.ready.as_os_str());
if matches!(kind, Holder::Unresponsive) {
unsafe {
command.pre_exec(|| {
let mut set: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut set);
libc::sigaddset(&mut set, libc::SIGTERM);
libc::sigprocmask(libc::SIG_BLOCK, &set, std::ptr::null_mut());
return Ok(());
});
}
}
return command;
}
fn lock_files(directory: &Path) -> Vec<PathBuf> {
let mut found: Vec<PathBuf> = Vec::new();
let mut pending: Vec<PathBuf> = vec![directory.to_path_buf()];
while let Some(next) = pending.pop() {
for entry in std::fs::read_dir(next.as_path()).expect("read the directory").flatten() {
let path: PathBuf = entry.path();
if entry.file_type().expect("file type").is_dir() {
pending.push(path);
} else if path.to_string_lossy().ends_with(".lock") {
found.push(path);
}
}
}
found.sort();
return found;
}
const SWEEP_WAIT: Duration = Duration::from_secs(20);
fn describe_left(files: &[PathBuf]) -> String {
let mut lines: Vec<String> = Vec::new();
for file in files {
let meta = std::fs::symlink_metadata(file);
let size: String = meta.as_ref().map_or(String::from("gone"), |metadata| { return metadata.len().to_string(); });
let born: String = meta.as_ref().ok().and_then(|metadata| { return metadata.created().ok(); }).map_or(String::from("unknown"), |time| { return format!("{time:?}"); });
let name: String = file.to_string_lossy().into_owned();
let sibling: String = format!("{}~pid.lock", name.trim_end_matches(".lock"));
lines.push(format!("{name} size={size} born={born} pid_file_beside={}", Path::new(sibling.as_str()).exists()));
}
return lines.join("; ");
}
fn assert_clean_after_stop(stage: &Stage, kind: Holder, how: &str) {
assert!(stage.ready.exists(), "{how} {kind:?}: the holder took the lock before it was stopped");
let waited: Instant = Instant::now();
let mut left: Vec<PathBuf> = lock_files(stage.repo.join(".git").as_path());
while !left.is_empty() && waited.elapsed() < SWEEP_WAIT {
std::thread::sleep(Duration::from_millis(50));
left = lock_files(stage.repo.join(".git").as_path());
}
assert!(left.is_empty(), "{how} {kind:?}: a stopped Git child left lock files: {}", describe_left(left.as_slice()));
let fetched = std::process::Command::new("/usr/bin/git")
.current_dir(stage.repo.as_path())
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/nonexistent-global-config")
.args(["fetch", "--quiet", "origin", "main"])
.output()
.expect("start git fetch");
assert!(fetched.status.success(), "{how} {kind:?}: the following fetch failed: {}", String::from_utf8_lossy(&fetched.stderr));
}
fn bounded_run_leaves_no_lock(kind: Holder, name: &str) {
let stage: Stage = stage(name);
let attempt = run_bounded_push(holder(&stage, kind), HOLD_DEADLINE, SHORT_GRACE, scratch_environment().as_slice()).expect("runs");
assert_eq!(attempt.exit_code, STOPPED_EXIT_CODE, "the holder is stopped at its deadline");
assert_clean_after_stop(&stage, kind, "bounded run");
remove(stage.root.as_path());
}
fn watched_step_leaves_no_lock(kind: Holder, name: &str) {
let stage: Stage = stage(name);
let mut command: Command = holder(&stage, kind);
let terms: WatchTerms = WatchTerms {
limit: HOLD_DEADLINE,
cadence: crate::wait_progress::Cadence { grace: HOLD_DEADLINE, interval: HOLD_DEADLINE },
notices: Box::new(std::io::sink()),
};
let ended = output_watched(&mut command, "git update-ref --stdin", terms);
assert_eq!(ended.expect_err("the step is stopped at its bound").kind(), std::io::ErrorKind::TimedOut);
assert_clean_after_stop(&stage, kind, "watched step");
remove(stage.root.as_path());
}
#[test]
fn a_push_stop_of_a_responsive_holder_leaves_no_lock() {
bounded_run_leaves_no_lock(Holder::Responsive, "stop-lock-push-responsive");
}
#[test]
fn a_push_stop_of_an_unresponsive_holder_leaves_no_lock() {
bounded_run_leaves_no_lock(Holder::Unresponsive, "stop-lock-push-unresponsive");
}
#[test]
fn a_watched_stop_of_a_responsive_holder_leaves_no_lock() {
watched_step_leaves_no_lock(Holder::Responsive, "stop-lock-watched-responsive");
}
#[test]
fn a_watched_stop_of_an_unresponsive_holder_leaves_no_lock() {
watched_step_leaves_no_lock(Holder::Unresponsive, "stop-lock-watched-unresponsive");
}
#[test]
fn a_watched_stop_gives_a_catchable_signal_first() {
let root: PathBuf = fixture("stop-lock-watched-catchable");
let marker: PathBuf = root.join("trapped");
let mut command: Command = Command::new("sh");
command
.arg("-c")
.arg("trap ': > \"$MARKER\"; exit 0' TERM; while :; do sleep 0.05; done")
.env("MARKER", marker.as_os_str());
let terms: WatchTerms = WatchTerms {
limit: Duration::from_millis(500),
cadence: crate::wait_progress::Cadence { grace: Duration::from_millis(500), interval: Duration::from_millis(500) },
notices: Box::new(std::io::sink()),
};
let ended = output_watched(&mut command, "a trapping shell", terms);
assert_eq!(ended.expect_err("stopped at the bound").kind(), std::io::ErrorKind::TimedOut);
assert!(marker.exists(), "the stop reached the TERM trap before any kill");
remove(root.as_path());
}
#[test]
fn a_stop_without_grace_sweeps_the_locks_on_a_thread_of_its_own() {
let stage: Stage = stage("stop-lock-push-no-grace");
let attempt = run_bounded_push(holder(&stage, Holder::Unresponsive), HOLD_DEADLINE, Duration::ZERO, scratch_environment().as_slice()).expect("runs");
assert_eq!(attempt.exit_code, STOPPED_EXIT_CODE, "the holder is stopped at its deadline");
assert_clean_after_stop(&stage, Holder::Unresponsive, "bounded run without grace");
remove(stage.root.as_path());
}