From 5744d054e5659a8400366bac6e14d3d593212253 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Sun, 4 Oct 2026 02:43:42 +0800 Subject: [PATCH] test: run freshly written stand-in executables once before relying on them Three tests write a shell script and run it at once: git_pending's fake git, git_pending_fast_path's hanging git and cli_ui's stand-in browser. When another thread of the same test binary forks while the script is still open for writing, the child keeps the inherited descriptor until it execs, and running the script meanwhile fails with ETXTBSY. Main's Coverage job on 81bd7c7 failed that way: `git` read the failed start as a failed git and conversion_risk returned None. The hanging-git test could pass the same way without its timeout ever running. Each helper now starts the script once, retrying while the error is ExecutableFileBusy. Once a start succeeds no writer is left, so the test's own runs cannot hit it. The hanging git uses `exec sleep 30`, so stopping it leaves no orphan, and the test checks that the timeout actually elapsed. --- crates/codegraph-cli/tests/cli_ui.rs | 34 ++++++++++++++++ crates/codegraph-watch/src/git_pending.rs | 31 ++++++++++++++ .../tests/git_pending_fast_path.rs | 40 ++++++++++++++++++- 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/crates/codegraph-cli/tests/cli_ui.rs b/crates/codegraph-cli/tests/cli_ui.rs index 17c66e29..933d499e 100644 --- a/crates/codegraph-cli/tests/cli_ui.rs +++ b/crates/codegraph-cli/tests/cli_ui.rs @@ -333,11 +333,45 @@ fn opener(dir: &TestDir) -> (PathBuf, PathBuf) { ) .unwrap(); std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + wait_until_executable(&script); + // The warm-up run wrote an empty marker; the tests read only what `codegraph ui` writes. + let _ = std::fs::remove_file(&marker); script }; (script, marker) } +/// Run a just-written executable once, retrying while the kernel reports it busy. +/// +/// Another thread of this test binary may fork while the script is still open for writing; the +/// child holds the inherited descriptor until it execs, and running the script meanwhile fails with +/// ETXTBSY, so `codegraph ui` could not start the stand-in browser. Once one run succeeds no writer +/// is left. +#[cfg(not(windows))] +fn wait_until_executable(script: &Path) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match std::process::Command::new(script) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(mut child) => { + let _ = child.wait(); + return; + } + Err(error) + if error.kind() == std::io::ErrorKind::ExecutableFileBusy + && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(2)); + } + Err(error) => panic!("cannot run {}: {error}", script.display()), + } + } +} + fn wait_for_file(path: &Path, timeout: Duration) -> Option { let deadline = Instant::now() + timeout; loop { diff --git a/crates/codegraph-watch/src/git_pending.rs b/crates/codegraph-watch/src/git_pending.rs index 76580fef..46ba2276 100644 --- a/crates/codegraph-watch/src/git_pending.rs +++ b/crates/codegraph-watch/src/git_pending.rs @@ -755,9 +755,40 @@ mod tests { ) .unwrap(); fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + wait_until_executable(&script); script } + /// Run a just-written executable once, retrying while the kernel reports it busy. + /// + /// Another thread of this test binary may fork while the script is still open for writing; + /// the child holds the inherited descriptor until it execs, and running the script meanwhile + /// fails with ETXTBSY, which `git` reports as a failed git (main's Coverage job on 81bd7c7). + /// Once one run succeeds no writer is left, so the test's own runs cannot hit it. + fn wait_until_executable(script: &Path) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match std::process::Command::new(script) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(mut child) => { + let _ = child.wait(); + return; + } + Err(error) + if error.kind() == std::io::ErrorKind::ExecutableFileBusy + && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(2)); + } + Err(error) => panic!("cannot run {}: {error}", script.display()), + } + } + } + #[test] fn a_system_attributes_file_is_a_conversion_risk() { let dir = std::env::temp_dir().join(format!("cg_fake_git_{}", std::process::id())); diff --git a/crates/codegraph-watch/tests/git_pending_fast_path.rs b/crates/codegraph-watch/tests/git_pending_fast_path.rs index f0079b68..f35db6f2 100644 --- a/crates/codegraph-watch/tests/git_pending_fast_path.rs +++ b/crates/codegraph-watch/tests/git_pending_fast_path.rs @@ -615,6 +615,38 @@ fn symlinks_decline() { fs::remove_dir_all(&outside).ok(); } +/// Start a just-written executable once, retrying while the kernel reports it busy, then stop it. +/// +/// Another thread of this test binary may fork while the file is still open for writing; the +/// child holds the inherited descriptor until it execs, and running the file meanwhile fails with +/// ETXTBSY. Here that failure looked like a declined git, so the test passed without the timeout +/// ever running. Once one start succeeds no writer is left. +#[cfg(unix)] +fn wait_until_executable(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match std::process::Command::new(path) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(mut child) => { + let _ = child.kill(); + let _ = child.wait(); + return; + } + Err(error) + if error.kind() == std::io::ErrorKind::ExecutableFileBusy + && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(2)); + } + Err(error) => panic!("cannot run {}: {error}", path.display()), + } + } +} + #[cfg(unix)] #[test] fn a_hanging_git_declines_within_the_timeout() { @@ -625,8 +657,10 @@ fn a_hanging_git_declines_within_the_timeout() { return; }; let fake = repo.top.with_extension("fake-git"); - fs::write(&fake, "#!/bin/sh\nsleep 30\n").unwrap(); + // `exec`, so killing the stand-in kills the sleep too and leaves no orphan behind. + fs::write(&fake, "#!/bin/sh\nexec sleep 30\n").unwrap(); fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap(); + wait_until_executable(&fake); git_pending_hooks::set_git_program(Some(fake.clone())); git_pending_hooks::set_git_timeout(Some(Duration::from_millis(300))); let started = Instant::now(); @@ -634,6 +668,10 @@ fn a_hanging_git_declines_within_the_timeout() { git_pending_hooks::set_git_program(None); git_pending_hooks::set_git_timeout(None); assert_eq!(declined.1, PendingSource::FullInventory); + assert!( + started.elapsed() >= Duration::from_millis(300), + "the stand-in git ran until the timeout, rather than failing to start" + ); assert!( started.elapsed() < Duration::from_secs(20), "the timeout bounds the wait"