Skip to content

Commit e541be9

Browse files
fix(update): retry sanity-exec on ETXTBSY — deflake coverage and harden self-update against the fork/exec fd race (#140)
* test(update): regression test for the ETXTBSY sanity-exec race (RED) Reproduces the coverage-job flake deterministically: a write fd held open on the staged binary while sanity_exec runs — the shape a sibling thread's fork() leaves behind via fd inheritance — makes the exec fail with "Text file busy" (Linux enforces ETXTBSY; the test is linux-gated). Also tightens the existing strictness test to assert each rejection's REASON instead of bare is_err(), which previously let an ETXTBSY spawn failure masquerade as the expected rejection one line before the flake surfaced. This commit is intentionally pushed without the fix so CI demonstrates the failure; the follow-up commit makes it pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(update): retry sanity-exec on ETXTBSY (fork/exec fd-inheritance race) Between a sibling thread's fork() and its exec(), the child briefly inherits every open fd — including a write fd on the binary staged moments ago — and exec'ing the file during that window fails with "Text file busy". Retry the spawn on ErrorKind::ExecutableFileBusy (10 attempts, 25 ms linear backoff, <=1.4 s worst case) instead of failing a fully SHA-verified download; all other spawn errors still fail immediately and the 10 s hang timeout applies per attempt. Same dance Go's os/exec and cargo do. Turns the previous commit's RED regression test green and deflakes the coverage job (first bitten on PR #139: llvm-cov widens the race window, which is why `test`/`test-release` never caught it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 570339d commit e541be9

1 file changed

Lines changed: 77 additions & 22 deletions

File tree

crates/socket-patch-core/src/update/download.rs

Lines changed: 77 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -259,24 +259,45 @@ async fn sanity_exec(
259259
expected: &semver::Version,
260260
strict: bool,
261261
) -> Result<Option<String>, UpdateError> {
262-
let mut cmd = tokio::process::Command::new(staged);
263-
cmd.arg("--version")
264-
.stdin(std::process::Stdio::null())
265-
.stdout(std::process::Stdio::piped())
266-
.stderr(std::process::Stdio::null())
267-
.kill_on_drop(true);
268-
let output = tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output())
269-
.await
270-
.map_err(|_| {
271-
UpdateError::VerifyFailed(
272-
"downloaded binary hung during its --version self-check".to_string(),
273-
)
274-
})?
275-
.map_err(|e| {
276-
UpdateError::VerifyFailed(format!(
277-
"downloaded binary failed to execute (wrong architecture?): {e}"
278-
))
279-
})?;
262+
// ETXTBSY retry: between a sibling thread's fork() and its exec(), the
263+
// child briefly inherits every open fd — including a write fd on the
264+
// binary staged moments ago — and exec'ing the file during that window
265+
// fails with "Text file busy". The window is real for any multi-threaded
266+
// process (and bites the parallel test binary under coverage), so ride
267+
// it out with short sleeps instead of failing a fully verified download
268+
// — the same dance Go's os/exec and cargo do.
269+
const ETXTBSY_ATTEMPTS: u64 = 10;
270+
let mut attempt = 0u64;
271+
let output = loop {
272+
let mut cmd = tokio::process::Command::new(staged);
273+
cmd.arg("--version")
274+
.stdin(std::process::Stdio::null())
275+
.stdout(std::process::Stdio::piped())
276+
.stderr(std::process::Stdio::null())
277+
.kill_on_drop(true);
278+
let result = tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output())
279+
.await
280+
.map_err(|_| {
281+
UpdateError::VerifyFailed(
282+
"downloaded binary hung during its --version self-check".to_string(),
283+
)
284+
})?;
285+
match result {
286+
Ok(output) => break output,
287+
Err(e)
288+
if e.kind() == std::io::ErrorKind::ExecutableFileBusy
289+
&& attempt < ETXTBSY_ATTEMPTS =>
290+
{
291+
attempt += 1;
292+
tokio::time::sleep(std::time::Duration::from_millis(25 * attempt)).await;
293+
}
294+
Err(e) => {
295+
return Err(UpdateError::VerifyFailed(format!(
296+
"downloaded binary failed to execute (wrong architecture?): {e}"
297+
)));
298+
}
299+
}
300+
};
280301
if !output.status.success() {
281302
return Err(UpdateError::VerifyFailed(format!(
282303
"downloaded binary's --version self-check exited with {}",
@@ -537,17 +558,23 @@ mod tests {
537558
path
538559
};
539560

561+
// Each rejection asserts the REASON, not just is_err(): an unrelated
562+
// spawn failure (e.g. the ETXTBSY race covered by the test below)
563+
// must not masquerade as the expected rejection.
540564
// Wrong program name: hard error in both modes.
541565
let imposter = write_script("imposter", "#!/bin/sh\necho other-tool 9.9.9\n");
542-
assert!(sanity_exec(&imposter, &expected, false).await.is_err());
566+
let err = sanity_exec(&imposter, &expected, false).await.unwrap_err();
567+
assert!(err.to_string().contains("identifies as"), "got: {err}");
543568

544569
// Non-zero exit: hard error.
545570
let failing = write_script("failing", "#!/bin/sh\necho socket-patch 9.9.9\nexit 3\n");
546-
assert!(sanity_exec(&failing, &expected, true).await.is_err());
571+
let err = sanity_exec(&failing, &expected, true).await.unwrap_err();
572+
assert!(err.to_string().contains("exited with"), "got: {err}");
547573

548574
// Version mismatch: fatal in strict mode, warning otherwise.
549575
let mismatched = write_script("mismatch", "#!/bin/sh\necho socket-patch 1.0.0\n");
550-
assert!(sanity_exec(&mismatched, &expected, true).await.is_err());
576+
let err = sanity_exec(&mismatched, &expected, true).await.unwrap_err();
577+
assert!(err.to_string().contains("instead of version"), "got: {err}");
551578
let warning = sanity_exec(&mismatched, &expected, false).await.unwrap();
552579
assert!(warning.unwrap().contains("1.0.0"));
553580

@@ -558,6 +585,34 @@ mod tests {
558585
// Exec-format failure (not executable at all): hard error.
559586
let garbage = tmp.path().join("garbage");
560587
std::fs::write(&garbage, b"\x00\x01\x02").unwrap();
561-
assert!(sanity_exec(&garbage, &expected, false).await.is_err());
588+
let err = sanity_exec(&garbage, &expected, false).await.unwrap_err();
589+
assert!(err.to_string().contains("failed to execute"), "got: {err}");
590+
}
591+
592+
// Regression test for the coverage-job flake: between a sibling thread's
593+
// fork() and its exec(), the child inherits every open fd — including a
594+
// write fd on the just-staged binary — and exec'ing the binary during
595+
// that window fails with ETXTBSY ("Text file busy"). Simulate the
596+
// inherited fd with a write handle held open briefly on another thread;
597+
// sanity_exec must ride it out instead of failing a verified download.
598+
// Linux-only: other platforms don't reliably enforce ETXTBSY.
599+
#[cfg(target_os = "linux")]
600+
#[tokio::test]
601+
async fn sanity_exec_retries_when_binary_briefly_text_busy() {
602+
use std::os::unix::fs::PermissionsExt;
603+
let tmp = tempfile::tempdir().unwrap();
604+
let path = tmp.path().join("busy");
605+
std::fs::write(&path, "#!/bin/sh\necho socket-patch 9.9.9\n").unwrap();
606+
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
607+
608+
let held = std::fs::OpenOptions::new().append(true).open(&path).unwrap();
609+
let dropper = std::thread::spawn(move || {
610+
std::thread::sleep(std::time::Duration::from_millis(150));
611+
drop(held);
612+
});
613+
614+
let result = sanity_exec(&path, &semver::Version::new(9, 9, 9), true).await;
615+
dropper.join().unwrap();
616+
assert_eq!(result.unwrap(), None);
562617
}
563618
}

0 commit comments

Comments
 (0)