From 79de6d11f065a63a0206372f70b9484694f562cc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 15:36:52 +0000 Subject: [PATCH 1/2] fix(test): stop executable fixtures racing ETXTBSY under parallel tests `provers::chuffed::tests::version_rejects_failed_empty_or_stderr_only_help` failed in CI with "Failed to execute fzn-chuffed --help: Text file busy (os error 26)". The fixtures wrote a script and executed it at once. A fork on another test thread, taken while the write descriptor was open, keeps a copy until that child execs, and the kernel refuses to execute a file with a writer. New provers::test_fixture::write_executable creates the script with mode 0700, closes it, then runs it with a sentinel argument until the kernel accepts the exec. After one successful exec no writer remains and no later fork can inherit one. The chuffed, faial, gpuverify and version_probe fixtures use it; each fixture exits 64 on an unexpected argument, so the probe runs no fixture body. Also carries the rustfmt fix to proof_search.rs from #465, identical to it, so `cargo fmt --check` is green here. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019a95kk88TZ1iegsBK3kox3 --- src/rust/proof_search.rs | 25 +++++++-------- src/rust/provers/chuffed.rs | 10 ++---- src/rust/provers/faial.rs | 10 ++---- src/rust/provers/gpuverify.rs | 10 ++---- src/rust/provers/mod.rs | 2 ++ src/rust/provers/test_fixture.rs | 52 +++++++++++++++++++++++++++++++ src/rust/provers/version_probe.rs | 8 ++--- 7 files changed, 77 insertions(+), 40 deletions(-) create mode 100644 src/rust/provers/test_fixture.rs diff --git a/src/rust/proof_search.rs b/src/rust/proof_search.rs index 3c90d3bf..949b63c9 100644 --- a/src/rust/proof_search.rs +++ b/src/rust/proof_search.rs @@ -384,7 +384,8 @@ mod chapel_ffi { let start = start.clone(); std::thread::spawn(move || { start.wait(); - let strategy = ChapelParallelSearch::new().expect("real Chapel runtime"); + let strategy = + ChapelParallelSearch::new().expect("real Chapel runtime"); assert_eq!(ChapelParallelSearch::available_prover_count(), 0); for _ in 0..2 { assert!(strategy.available()); @@ -414,10 +415,8 @@ mod chapel_ffi { // No global environment mutation races with other Rust tests, and // no external prover can be dispatched by this allocation/free probe. let empty_path = tempfile::tempdir().expect("empty prover search directory"); - let mut command = real_probe_command( - "real_search_without_provers_fails_and_frees_result", - CHILD, - ); + let mut command = + real_probe_command("real_search_without_provers_fails_and_frees_result", CHILD); command.env("PATH", empty_path.path()); run_real_probe( &mut command, @@ -478,7 +477,10 @@ mod chapel_ffi { matches!(stopped_before_cleanup, Ok(true)), "deadline returned with a live or unobservable prover: {stopped_before_cleanup:?}" ); - assert!(elapsed < Duration::from_secs(3), "deadline took {elapsed:?}"); + assert!( + elapsed < Duration::from_secs(3), + "deadline took {elapsed:?}" + ); let result = result.expect("native timeout returns a failed search result"); assert!(!result.success); assert_eq!( @@ -500,17 +502,12 @@ mod chapel_ffi { .expect("write finite prover"); std::fs::set_permissions(&prover, std::fs::Permissions::from_mode(0o700)) .expect("make finite prover executable"); - let mut command = real_probe_command( - "real_search_deadline_stops_the_owned_prover", - CHILD, - ); + let mut command = + real_probe_command("real_search_deadline_stops_the_owned_prover", CHILD); command .env("PATH", fixture.path()) .env(PID_FILE, fixture.path().join("prover.pid")); - run_real_probe( - &mut command, - "ECHIDNA_REAL_CHAPEL_DEADLINE_PROBE_COMPLETED", - ); + run_real_probe(&mut command, "ECHIDNA_REAL_CHAPEL_DEADLINE_PROBE_COMPLETED"); } } } diff --git a/src/rust/provers/chuffed.rs b/src/rust/provers/chuffed.rs index 879e33ba..35d30d3a 100644 --- a/src/rust/provers/chuffed.rs +++ b/src/rust/provers/chuffed.rs @@ -236,16 +236,12 @@ mod tests { #[cfg(unix)] fn version_fixture(script: &str, timeout: u64) -> (tempfile::TempDir, ChuffedBackend) { - use std::os::unix::fs::PermissionsExt; - let directory = tempfile::tempdir().unwrap(); let executable = directory.path().join("version-probe"); - std::fs::write( + crate::provers::test_fixture::write_executable( &executable, - format!("#!/bin/sh\n[ \"$#\" -eq 1 ] && [ \"$1\" = '--help' ] || exit 64\n{script}\n"), - ) - .unwrap(); - std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + &format!("#!/bin/sh\n[ \"$#\" -eq 1 ] && [ \"$1\" = '--help' ] || exit 64\n{script}\n"), + ); let backend = ChuffedBackend::new(ProverConfig { executable, timeout, diff --git a/src/rust/provers/faial.rs b/src/rust/provers/faial.rs index 1983cdf6..d4e8cb41 100644 --- a/src/rust/provers/faial.rs +++ b/src/rust/provers/faial.rs @@ -355,18 +355,14 @@ mod tests { #[cfg(unix)] fn version_fixture(script: &str, timeout: u64) -> (tempfile::TempDir, FaialBackend) { - use std::os::unix::fs::PermissionsExt; - let directory = tempfile::tempdir().unwrap(); let executable = directory.path().join("version-probe"); - std::fs::write( + crate::provers::test_fixture::write_executable( &executable, - format!( + &format!( "#!/bin/sh\n[ \"$#\" -eq 1 ] && [ \"$1\" = '--version' ] || exit 64\n{script}\n" ), - ) - .unwrap(); - std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + ); let backend = FaialBackend::new(ProverConfig { executable, timeout, diff --git a/src/rust/provers/gpuverify.rs b/src/rust/provers/gpuverify.rs index f994ce82..2a8c4bf0 100644 --- a/src/rust/provers/gpuverify.rs +++ b/src/rust/provers/gpuverify.rs @@ -389,18 +389,14 @@ mod tests { #[cfg(unix)] fn version_fixture(script: &str, timeout: u64) -> (tempfile::TempDir, GpuVerifyBackend) { - use std::os::unix::fs::PermissionsExt; - let directory = tempfile::tempdir().unwrap(); let executable = directory.path().join("version-probe"); - std::fs::write( + crate::provers::test_fixture::write_executable( &executable, - format!( + &format!( "#!/bin/sh\n[ \"$#\" -eq 1 ] && [ \"$1\" = '--version' ] || exit 64\n{script}\n" ), - ) - .unwrap(); - std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + ); let backend = GpuVerifyBackend::new(ProverConfig { executable, timeout, diff --git a/src/rust/provers/mod.rs b/src/rust/provers/mod.rs index 51a527c0..48668a2e 100644 --- a/src/rust/provers/mod.rs +++ b/src/rust/provers/mod.rs @@ -14,6 +14,8 @@ use std::path::{Path, PathBuf}; use crate::core::{ProofState, Tactic, TacticResult}; pub mod outcome; +#[cfg(all(test, unix))] +pub(crate) mod test_fixture; pub use outcome::{classify_anyhow_error, ProverOutcome}; pub mod io; diff --git a/src/rust/provers/test_fixture.rs b/src/rust/provers/test_fixture.rs new file mode 100644 index 00000000..d69db34c --- /dev/null +++ b/src/rust/provers/test_fixture.rs @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Test-only helper for writing an executable fixture script. +//! +//! Writing a script and executing it straight away races with every other +//! test thread that spawns a process: a `fork` taken while the write +//! descriptor is still open copies it into the child, and until that child +//! reaches `exec` (the descriptor is close-on-exec) the kernel refuses to +//! execute the file with `ETXTBSY` ("Text file busy"). That failure showed up +//! in CI as `Failed to execute fzn-chuffed --help: Text file busy`. +//! +//! [`write_executable`] closes its own descriptor and then runs the script +//! once with a sentinel argument until the kernel accepts it. After one +//! successful `exec` no writer is left and no later `fork` can inherit one, +//! so the test's own spawn cannot see `ETXTBSY`. The fixture scripts exit at +//! once on any argument they do not expect, so the probe runs no fixture body. + +use std::io::{ErrorKind, Write}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::time::Duration; + +/// Write `contents` to `path` as an owner-executable script and wait until the +/// kernel will execute it. The script must exit without side effects when +/// called with a single unexpected argument. +pub(crate) fn write_executable(path: &Path, contents: &str) { + { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o700) + .open(path) + .expect("create fixture executable"); + file.write_all(contents.as_bytes()) + .expect("write fixture executable"); + } + for _ in 0..200 { + match std::process::Command::new(path) + .arg("--echidna-fixture-ready") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + { + Err(error) if error.kind() == ErrorKind::ExecutableFileBusy => { + std::thread::sleep(Duration::from_millis(10)); + }, + Err(error) => panic!("fixture executable cannot run: {error}"), + Ok(_) => return, + } + } + panic!("fixture executable stayed busy (ETXTBSY) for 2 s"); +} diff --git a/src/rust/provers/version_probe.rs b/src/rust/provers/version_probe.rs index 081e88dd..0410de7c 100644 --- a/src/rust/provers/version_probe.rs +++ b/src/rust/provers/version_probe.rs @@ -54,14 +54,12 @@ mod tests { fn fixture(script: &str, timeout: u64) -> (tempfile::TempDir, ProverConfig) { let directory = tempfile::tempdir().unwrap(); let executable = directory.path().join("version-probe"); - std::fs::write( + crate::provers::test_fixture::write_executable( &executable, - format!( + &format!( "#!/bin/sh\n[ \"$#\" -eq 1 ] && [ \"$1\" = '--version' ] || exit 64\n{script}\n" ), - ) - .unwrap(); - std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + ); let config = ProverConfig { executable, timeout, From 9a05a780dc878dc7eae90a9c19dcc31de27b0697 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 16:10:24 +0000 Subject: [PATCH 2/2] fix(test): set the fixture mode explicitly, independent of the umask Review on #487: OpenOptions::mode is filtered by the process umask, so a restrictive umask could leave the fixture non-executable. Restore the explicit set_permissions(0o700) after closing the writer and before the readiness probe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019a95kk88TZ1iegsBK3kox3 --- src/rust/provers/test_fixture.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/rust/provers/test_fixture.rs b/src/rust/provers/test_fixture.rs index d69db34c..c148a3bf 100644 --- a/src/rust/provers/test_fixture.rs +++ b/src/rust/provers/test_fixture.rs @@ -15,7 +15,7 @@ //! once on any argument they do not expect, so the probe runs no fixture body. use std::io::{ErrorKind, Write}; -use std::os::unix::fs::OpenOptionsExt; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::Path; use std::time::Duration; @@ -33,6 +33,10 @@ pub(crate) fn write_executable(path: &Path, contents: &str) { file.write_all(contents.as_bytes()) .expect("write fixture executable"); } + // The creation mode is filtered by the umask; set it explicitly so the + // fixture is executable whatever umask the test runner inherited. + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .expect("make fixture executable"); for _ in 0..200 { match std::process::Command::new(path) .arg("--echidna-fixture-ready")