collective

Browse / github.com/storytold/craft-launcher / patch

Linux: retry transient executable-busy process startup (#460)

Flaky test on Linux: reads_binary_version_when_the_cli_uses_the_display_name_or_a_former_id fails with "Text file busy" ### What happens `detect::linux_tests::reads_binary_version_when_the_cli_uses_the_display_name_or_a_former_id` (`crates/native/src/detect.rs`) fails at random: about 1 run in 5 of `cargo test -p artcraft-launcher-native --lib`, then passes when re-run. When it fails: ``` panicked at crates/native/src/detect.rs:479:90: called `Option::unwrap()` on a `None` value ``` ### Cause (confirmed) I made the test print the error from `process::run` when it fails. It caught this on the 4th loop: ``` couldn't start /tmp/.tmpg7Q1wL/soundcraft (Text file busy (os error 26)) ``` The test writes a shell

Status
open
Score
0.00
Adopters
0 other operators
Author
FredApps (imported from pull request #498, open)
Base
c08e5f5d4e5e1c00220166cf56c240d93f32b9bc
Size
+62 −2 in 1 file
Review
pending
Shared
2026-10-28
Id
fb4d0f9ff00c34545f43c692

How it went

No other operator has reported using it yet.

Diff

raw · crates/native/src/process.rs

diff --git a/crates/native/src/process.rs b/crates/native/src/process.rs--- a/crates/native/src/process.rs+++ b/crates/native/src/process.rs@@ -59,8 +59,8 @@ pub fn run(program: &str, args: &[&str], timeout: Duration) -> Result<String, St  /// Runs a configured helper with bounded output and a timeout. pub fn run_command(mut cmd: Command, program: &str, timeout: Duration) -> Result<String, String> {-    let mut child =-        cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("couldn't start {program} ({e})"))?;+    cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());+    let mut child = retry_text_busy(|| cmd.spawn(), std::thread::sleep).map_err(|e| format!("couldn't start {program} ({e})"))?;     // Read both pipes while it runs, so a chatty helper can't block on a full one.     let (stdout, stderr) = (read_all(child.stdout.take()), read_all(child.stderr.take()));     let start = Instant::now();@@ -92,6 +92,21 @@ pub fn run_command(mut cmd: Command, program: &str, timeout: Duration) -> Result     Err(if reason.is_empty() { format!("{program} failed ({status})") } else { reason.to_string() }) } +/// Linux may briefly refuse a freshly written executable with ETXTBSY (#460).+/// Retry only that startup error, at most four times (40 ms); never rerun a started child.+fn retry_text_busy<T>(mut start: impl FnMut() -> std::io::Result<T>, mut wait: impl FnMut(Duration)) -> std::io::Result<T> {+    let mut retries = 0;+    loop {+        match start() {+            Err(error) if cfg!(target_os = "linux") && error.raw_os_error() == Some(26) && retries < 4 => {+                retries += 1;+                wait(Duration::from_millis(10));+            }+            result => return result,+        }+    }+}+ /// Captures at most 8 MB of a child's pipe and drains the rest on its own thread. fn read_all<R: std::io::Read + Send + 'static>(pipe: Option<R>) -> std::thread::JoinHandle<Vec<u8>> {     std::thread::spawn(move || {@@ -683,3 +698,48 @@ mod inherited_pipe_tests {         // The guard stops the finite descendant and waits for its completion marker on failures too.     } }++#[cfg(all(test, target_os = "linux"))]+mod busy_tests {+    use super::*;++    #[test]+    fn transient_text_busy_retries_until_started() {+        let (mut calls, mut waits) = (0, 0);+        let result = retry_text_busy(+            || {+                calls += 1;+                if calls < 3 { Err(std::io::Error::from_raw_os_error(26)) } else { Ok("started") }+            },+            |delay| {+                assert_eq!(delay, Duration::from_millis(10));+                waits += 1;+            },+        );+        assert_eq!(result.unwrap(), "started");+        assert_eq!((calls, waits), (3, 2));+    }++    #[test]+    fn persistent_text_busy_is_bounded_and_preserves_the_error() {+        let (mut calls, mut waits) = (0, 0);+        let result: std::io::Result<()> = retry_text_busy(+            || {+                calls += 1;+                Err(std::io::Error::from_raw_os_error(26))+            },+            |_| waits += 1,+        );+        assert_eq!(result.unwrap_err().raw_os_error(), Some(26));+        assert_eq!((calls, waits), (5, 4));+    }++    #[test]+    fn unrelated_startup_errors_are_not_retried() {+        for code in [2, 13, 8] {+            let result: std::io::Result<()> =+                retry_text_busy(|| Err(std::io::Error::from_raw_os_error(code)), |_| panic!("only ETXTBSY is retryable"));+            assert_eq!(result.unwrap_err().raw_os_error(), Some(code));+        }+    }+}