Watch
0
0
Fork
You've already forked hyperhive
0

meta lock waiters keep their queue place; deadlines cover pin_agent git and kill the whole process group

This commit is contained in:
atlas 2026-10-20 00:23:39 +02:00 • committed by mara
commit 161dd8c44b
4 changed files with 138 additions and 51 deletions

View file

@ -358,12 +358,15 @@ Every public `meta.rs` operation takes the module's internal `META_LOCK`
mutex, so concurrent job-queue nodes never mutex, so concurrent job-queue nodes never
race on the repo's `.git/index.lock`. race on the repo's `.git/index.lock`.
Each git or nix child `meta.rs` runs under `META_LOCK` has a deadline, so a hung Each git or nix child under `META_LOCK` has a deadline, so a hung forge fetch
forge fetch fails the operation instead of holding the lock: 10 minutes for fails the operation instead of holding the lock: 10 minutes for
`nix flake update`/`lock` and forge fetch/push, 2 minutes for local git. The error `nix flake update`/`lock` and forge fetch/push, 2 minutes for local git, including
names the command and the deadline, and the child dies with it. A caller waiting on the `rev-parse` and `show` reads `pin_agents` makes. The error names the command
`META_LOCK` for over a minute logs a `meta_lock: still waiting` warning every and the deadline. Each child leads its own process group and the timeout
minute, naming its own operation and the holder's. `SIGKILL`s the whole group, so a `git` or credential helper spawned by a hung
`nix` dies too. A caller waiting on `META_LOCK` for over a minute logs a
`meta_lock: still waiting` warning every minute, naming its own operation and
the holder's. Waiters keep their place in the lock's FIFO queue across warnings.
--- ---

View file

@ -3,10 +3,11 @@
//! fetch proposal commits into the applied repo. //! fetch proposal commits into the applied repo.
use std::path::Path; use std::path::Path;
use std::process::Output; use std::process::{Output, Stdio};
use std::time::Duration; use std::time::Duration;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::process::Command; use tokio::process::Command;
/// Deadline for a child that talks to a remote (forge fetch/push, `nix flake /// Deadline for a child that talks to a remote (forge fetch/push, `nix flake
@ -18,19 +19,54 @@ pub const FETCH_DEADLINE: Duration = Duration::from_mins(10);
pub const GIT_LOCAL_DEADLINE: Duration = Duration::from_mins(2); pub const GIT_LOCAL_DEADLINE: Duration = Duration::from_mins(2);
/// Run `cmd` to completion and capture its output, failing once `deadline` /// Run `cmd` to completion and capture its output, failing once `deadline`
/// passes. `what` names the command in the error. The child is killed when the /// passes. `what` names the command in the error. The child leads its own
/// deadline drops its future, so a hung remote cannot leave an orphan behind. /// process group and the whole group is `SIGKILL`ed on timeout, so a
/// grandchild (a `git` or credential helper under `nix`) dies with it instead
/// of outliving the lock.
pub async fn run_with_deadline( pub async fn run_with_deadline(
cmd: &mut Command, cmd: &mut Command,
deadline: Duration, deadline: Duration,
what: &str, what: &str,
) -> Result<Output> { ) -> Result<Output> {
// `kill_on_drop` is what turns the timeout's drop into a kill. // `kill_on_drop` covers the caller dropping this future; `process_group(0)`
cmd.kill_on_drop(true); // makes the child's pid the group id the timeout path kills.
tokio::time::timeout(deadline, cmd.output()) let mut child = cmd
.await .stdin(Stdio::null())
.map_err(|_| anyhow::anyhow!("{what} timed out after {}s", deadline.as_secs_f64()))? .stdout(Stdio::piped())
.with_context(|| what.to_owned()) .stderr(Stdio::piped())
.kill_on_drop(true)
.process_group(0)
.spawn()
.with_context(|| what.to_owned())?;
// Read before waiting: a reaped child has no id.
let pgid = child.id().and_then(|pid| i32::try_from(pid).ok());
let (stdout, stderr) = (child.stdout.take(), child.stderr.take());
let io = async { tokio::join!(child.wait(), drain(stdout), drain(stderr)) };
if let Ok((status, stdout, stderr)) = tokio::time::timeout(deadline, io).await {
return Ok(Output {
status: status.with_context(|| what.to_owned())?,
stdout: stdout.with_context(|| what.to_owned())?,
stderr: stderr.with_context(|| what.to_owned())?,
});
}
if let Some(pgid) = pgid {
// SAFETY: plain `kill(2)`; `-pgid` targets the child's own process
// group, which `process_group(0)` made it lead.
unsafe {
libc::kill(-pgid, libc::SIGKILL);
}
}
child.wait().await.with_context(|| what.to_owned())?;
bail!("{what} timed out after {}s", deadline.as_secs_f64())
}
/// Everything `pipe` yields until EOF.
async fn drain<R: AsyncRead + Unpin>(pipe: Option<R>) -> std::io::Result<Vec<u8>> {
let mut buf = Vec::new();
if let Some(mut p) = pipe {
p.read_to_end(&mut buf).await?;
}
Ok(buf)
} }
const GIT_NAME: &str = "c0re"; const GIT_NAME: &str = "c0re";
@ -135,12 +171,12 @@ pub async fn git_authed(dir: &Path, args: &[&str], auth_header: &str) -> Result<
/// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha. /// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha.
pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result<String> { pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result<String> {
let out = git_command() let out = run_with_deadline(
.current_dir(dir) git_command().current_dir(dir).args(["rev-parse", refname]),
.args(["rev-parse", refname]) GIT_LOCAL_DEADLINE,
.output() &format!("git rev-parse {refname} in {}", dir.display()),
.await )
.with_context(|| format!("git rev-parse {refname} in {}", dir.display()))?; .await?;
if !out.status.success() { if !out.status.success() {
bail!( bail!(
"git rev-parse {refname} failed ({}): {}", "git rev-parse {refname} failed ({}): {}",
@ -155,12 +191,14 @@ pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result<String> {
/// such file. Reads the object database, not the working tree. /// such file. Reads the object database, not the working tree.
pub async fn git_show_file(dir: &Path, rev: &str, path: &str) -> Result<Option<String>> { pub async fn git_show_file(dir: &Path, rev: &str, path: &str) -> Result<Option<String>> {
let spec = format!("{rev}:{path}"); let spec = format!("{rev}:{path}");
let listed = git_command() let listed = run_with_deadline(
git_command()
.current_dir(dir) .current_dir(dir)
.args(["ls-tree", "--name-only", rev, "--", path]) .args(["ls-tree", "--name-only", rev, "--", path]),
.output() GIT_LOCAL_DEADLINE,
.await &format!("git ls-tree {spec} in {}", dir.display()),
.with_context(|| format!("git ls-tree {spec} in {}", dir.display()))?; )
.await?;
if !listed.status.success() { if !listed.status.success() {
bail!( bail!(
"git ls-tree {spec} failed ({}): {}", "git ls-tree {spec} failed ({}): {}",
@ -171,12 +209,12 @@ pub async fn git_show_file(dir: &Path, rev: &str, path: &str) -> Result<Option<S
if listed.stdout.iter().all(u8::is_ascii_whitespace) { if listed.stdout.iter().all(u8::is_ascii_whitespace) {
return Ok(None); return Ok(None);
} }
let shown = git_command() let shown = run_with_deadline(
.current_dir(dir) git_command().current_dir(dir).args(["show", &spec]),
.args(["show", &spec]) GIT_LOCAL_DEADLINE,
.output() &format!("git show {spec} in {}", dir.display()),
.await )
.with_context(|| format!("git show {spec} in {}", dir.display()))?; .await?;
if !shown.status.success() { if !shown.status.success() {
bail!( bail!(
"git show {spec} failed ({}): {}", "git show {spec} failed ({}): {}",

View file

@ -475,14 +475,18 @@ async fn a_systemctl_answer_inside_the_bound_passes_through() {
} }
/// A child that outlives the deadline yields an error naming the command and /// A child that outlives the deadline yields an error naming the command and
/// the deadline, and is killed rather than orphaned. /// the deadline, and the child and its grandchild are both killed rather than
/// orphaned.
#[tokio::test] #[tokio::test]
async fn run_with_deadline_times_out_and_kills_the_child() { async fn run_with_deadline_times_out_and_kills_the_process_group() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let pidfile = dir.path().join("pid"); let (shpid, sleeppid) = (dir.path().join("sh.pid"), dir.path().join("sleep.pid"));
let mut cmd = tokio::process::Command::new("sh"); let mut cmd = tokio::process::Command::new("sh");
cmd.arg("-c") cmd.arg("-c").arg(format!(
.arg(format!("echo $$ > {}; exec sleep 30", pidfile.display())); "echo $$ > {}; sleep 300 & echo $! > {}; wait",
shpid.display(),
sleeppid.display()
));
let err = run_with_deadline( let err = run_with_deadline(
&mut cmd, &mut cmd,
std::time::Duration::from_millis(500), std::time::Duration::from_millis(500),
@ -493,18 +497,23 @@ async fn run_with_deadline_times_out_and_kills_the_child() {
let msg = format!("{err:#}"); let msg = format!("{err:#}");
assert!(msg.contains("fake fetch timed out after 0.5s"), "{msg}"); assert!(msg.contains("fake fetch timed out after 0.5s"), "{msg}");
let pid = std::fs::read_to_string(&pidfile) for file in [&shpid, &sleeppid] {
.expect("child wrote its pid before the deadline") // The pid file proves the process existed before the kill.
let pid = std::fs::read_to_string(file)
.expect("process wrote its pid before the deadline")
.trim() .trim()
.to_owned(); .to_owned();
let proc = std::path::PathBuf::from("/proc").join(&pid); let proc = std::path::PathBuf::from("/proc").join(&pid);
let mut gone = false;
for _ in 0..100 { for _ in 0..100 {
if !proc.exists() { if !proc.exists() {
return; gone = true;
break;
} }
tokio::time::sleep(std::time::Duration::from_millis(50)).await; tokio::time::sleep(std::time::Duration::from_millis(50)).await;
} }
panic!("child {pid} still running after the deadline dropped its future"); assert!(gone, "pid {pid} ({}) still running", file.display());
}
} }
/// Presence control for the timeout test: a child that finishes in time /// Presence control for the timeout test: a child that finishes in time

View file

@ -81,10 +81,14 @@ async fn lock_notifying<'a>(
mut on_stall: impl FnMut(&'static str, Duration), mut on_stall: impl FnMut(&'static str, Duration),
) -> MetaGuard<'a> { ) -> MetaGuard<'a> {
let start = Instant::now(); let start = Instant::now();
// One acquire future for the whole wait: dropping it loses the place in
// the mutex's FIFO queue, so the stall timer runs beside it, not around it.
let acquire = lock.lock();
tokio::pin!(acquire);
let guard = loop { let guard = loop {
match tokio::time::timeout(interval, lock.lock()).await { tokio::select! {
Ok(guard) => break guard, guard = &mut acquire => break guard,
Err(_) => on_stall( () = tokio::time::sleep(interval) => on_stall(
*holder.lock().unwrap_or_else(PoisonError::into_inner), *holder.lock().unwrap_or_else(PoisonError::into_inner),
start.elapsed(), start.elapsed(),
), ),
@ -2202,6 +2206,39 @@ mod lock_wait_tests {
drop(second); drop(second);
assert_eq!(*stalls.borrow(), vec![("first", 60), ("first", 120)]); assert_eq!(*stalls.borrow(), vec![("first", 60), ("first", 120)]);
} }
/// Waiters get the lock in arrival order even when one of them stalls past
/// an interval: A queues at 0s, B at 30s, the holder releases at 70s. A
/// re-armed acquire would put A (stalled at 60s) behind B.
#[tokio::test(start_paused = true)]
async fn waiters_acquire_in_arrival_order_across_a_stall() {
use std::sync::Arc;
static LOCK: Mutex<()> = Mutex::const_new(());
static HOLDER: StdMutex<&'static str> = StdMutex::new("");
let order = Arc::new(StdMutex::new(Vec::new()));
let first = lock_notifying(&LOCK, &HOLDER, "first", Duration::from_mins(1), |_, _| {
panic!("an uncontended lock must not report a stall")
})
.await;
let spawn_waiter = |op: &'static str| {
let order = Arc::clone(&order);
tokio::spawn(async move {
let guard =
lock_notifying(&LOCK, &HOLDER, op, Duration::from_mins(1), |_, _| {}).await;
order.lock().unwrap().push(op);
drop(guard);
})
};
let a = spawn_waiter("A");
tokio::time::sleep(Duration::from_secs(30)).await;
let b = spawn_waiter("B");
tokio::time::sleep(Duration::from_secs(40)).await;
drop(first);
a.await.expect("A");
b.await.expect("B");
assert_eq!(*order.lock().unwrap(), vec!["A", "B"]);
}
/// A hung nix child comes back as a timeout error naming the command. The /// A hung nix child comes back as a timeout error naming the command. The
/// deadline and program are injected; the production const is untouched. /// deadline and program are injected; the production const is untouched.
#[tokio::test] #[tokio::test]