From 65e8a60e2f79ea66d4dea2e53713b16bb0c6bd25 Mon Sep 17 00:00:00 2001 From: Ben Wisecup Date: Fri, 31 Jul 2026 11:53:07 -0400 Subject: [PATCH] fix(auth): retry spawn on ETXTBSY in the auth-status check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A freshly written executable can transiently report ETXTBSY ("text file busy") when a concurrent fork in another thread still holds a writable fd to it across the fork->exec window. The runner spawn paths already route through spawn_with_etxtbsy_retry; the auth-status check did not, so it kept spawning the target binary directly. That gap showed up as an intermittent 'failed to spawn claude: Text file busy (os error 26)' panic in test_check_auth_async_success under the instrumented coverage build, which is slow enough to lose the race the plain test job usually wins. A single panic there aborts lcov.info generation, so the coverage gate fails with a missing-report error rather than a real per-file miss. Route both auth spawn sites through the existing retry helper, sharing one spawn_auth_child function so the two call sites no longer duplicate the spawn and its error mapping. This mirrors probe_claude_auth_async, which runs the same 'claude auth status --json' command and was already covered. Generated by the operator's software factory. City: factory-main · Agent: local-core.builder-1 On behalf of: @benw5483 Co-Authored-By: --- src/cli/commands/auth.rs | 44 +++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/cli/commands/auth.rs b/src/cli/commands/auth.rs index bdaaa1dc..1410c8d5 100644 --- a/src/cli/commands/auth.rs +++ b/src/cli/commands/auth.rs @@ -53,6 +53,28 @@ fn wait_io_error(e: std::io::Error) -> ActualError { } } +/// Spawn the configured `claude` command, retrying briefly on `ETXTBSY`. +/// +/// A freshly written executable can transiently report `ETXTBSY` ("text file +/// busy") when another thread still holds a writable fd to it across the +/// fork→exec window. That happens in parallel test runs, where a fixture script +/// is written and immediately spawned, and in production right after the real +/// `claude` binary is installed or updated. Routing through +/// [`spawn_with_etxtbsy_retry`] makes the exec deterministic; the runner spawn +/// paths already do the same. +/// +/// [`spawn_with_etxtbsy_retry`]: crate::runner::util::spawn_with_etxtbsy_retry +async fn spawn_auth_child( + cmd: &mut tokio::process::Command, +) -> Result { + crate::runner::util::spawn_with_etxtbsy_retry(|| cmd.spawn()) + .await + .map_err(|e| ActualError::RunnerFailed { + message: format!("failed to spawn claude: {e}"), + stderr: String::new(), + }) +} + /// Build a single-threaded tokio runtime. fn build_tokio_runtime() -> Result { tokio::runtime::Builder::new_current_thread() @@ -74,15 +96,10 @@ async fn check_auth_async( // When the timeout future is dropped, the Child is dropped. With // kill_on_drop(true) tokio sends SIGKILL, preventing orphaned processes. cmd.kill_on_drop(true); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); - let child = cmd - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .map_err(|e| ActualError::RunnerFailed { - message: format!("failed to spawn claude: {e}"), - stderr: String::new(), - })?; + let child = spawn_auth_child(&mut cmd).await?; let output = tokio::time::timeout(timeout, child.wait_with_output()) .await @@ -142,15 +159,10 @@ async fn check_auth_async_no_json( cmd.args(["auth", "status"]); cmd.stdin(std::process::Stdio::null()); cmd.kill_on_drop(true); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); - let child = cmd - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .map_err(|e| ActualError::RunnerFailed { - message: format!("failed to spawn claude: {e}"), - stderr: String::new(), - })?; + let child = spawn_auth_child(&mut cmd).await?; let output = tokio::time::timeout(timeout, child.wait_with_output()) .await