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
@@ -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));+ }+ }+}