From 3a33cf8dfc828afeb283d276448496e138440d64 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Tue, 4 Aug 2026 16:30:38 -0700 Subject: [PATCH 1/2] fix(auth): retry ExecProvider spawn on transient ETXTBSY Linux can report ETXTBSY for a just-written, freshly chmod'd provider script when another thread in the process forks at the same moment, even though nothing holds the file open for writing. This surfaced as an intermittent panic in exec_provider_sends_request_to_stdin_and_parses_credential under concurrent test execution, and is a real risk for any consumer spawning provider scripts under load. Co-Authored-By: Claude Sonnet 5 --- cli-engine/src/auth/exec.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/cli-engine/src/auth/exec.rs b/cli-engine/src/auth/exec.rs index 76ff7ef..1a6b2bf 100644 --- a/cli-engine/src/auth/exec.rs +++ b/cli-engine/src/auth/exec.rs @@ -107,7 +107,7 @@ impl ExecProvider { .stdout(Stdio::piped()) .stderr(Stdio::piped()); - let mut child = command.spawn().map_err(|err| self.exec_error(err, ""))?; + let mut child = self.spawn_retrying_text_busy(&mut command).await?; let Some(mut stdin) = child.stdin.take() else { return Err(CliCoreError::message("auth: provider stdin unavailable")); }; @@ -145,6 +145,33 @@ impl ExecProvider { ))) } + /// Spawns `command`, retrying on `ETXTBSY`. + /// + /// Linux can transiently report `ETXTBSY` for a just-written, freshly + /// chmod'd script when another thread in this process forks at the same + /// moment, even though nothing holds the file open for writing. The + /// retry with backoff clears once the kernel releases the transient + /// hold; a real "busy" (e.g. another process genuinely writing the + /// file) will still exhaust the attempts and surface as an error. + async fn spawn_retrying_text_busy( + &self, + command: &mut Command, + ) -> Result { + const MAX_ATTEMPTS: u32 = 5; + let mut delay = Duration::from_millis(1); + for _ in 1..MAX_ATTEMPTS { + match command.spawn() { + Ok(child) => return Ok(child), + Err(err) if err.kind() == ErrorKind::ExecutableFileBusy => { + time::sleep(delay).await; + delay *= 2; + } + Err(err) => return Err(self.exec_error(err, "")), + } + } + command.spawn().map_err(|err| self.exec_error(err, "")) + } + fn request(&self, action: &str, env: &str, command: &str, tier: &str) -> AuthnRequest { AuthnRequest { action: action.to_owned(), From 0c0642f8a54917a97541a6dec1a93de02c2aeb39 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Tue, 4 Aug 2026 16:35:55 -0700 Subject: [PATCH 2/2] fix(auth): retry loop now actually performs 5 retries with 16ms backoff The prior loop spawned 5 times but only slept 4 times (1/2/4/8ms), so the computed 16ms delay was never used, contradicting the documented backoff. Restructure as 5 retries (6 attempts total) so the 16ms delay is actually slept before the final attempt. Co-Authored-By: Claude Sonnet 5 --- cli-engine/src/auth/exec.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli-engine/src/auth/exec.rs b/cli-engine/src/auth/exec.rs index 1a6b2bf..a11ce5c 100644 --- a/cli-engine/src/auth/exec.rs +++ b/cli-engine/src/auth/exec.rs @@ -157,19 +157,19 @@ impl ExecProvider { &self, command: &mut Command, ) -> Result { - const MAX_ATTEMPTS: u32 = 5; + const MAX_RETRIES: u32 = 5; let mut delay = Duration::from_millis(1); - for _ in 1..MAX_ATTEMPTS { + for retry in 0..=MAX_RETRIES { match command.spawn() { Ok(child) => return Ok(child), - Err(err) if err.kind() == ErrorKind::ExecutableFileBusy => { + Err(err) if err.kind() == ErrorKind::ExecutableFileBusy && retry < MAX_RETRIES => { time::sleep(delay).await; delay *= 2; } Err(err) => return Err(self.exec_error(err, "")), } } - command.spawn().map_err(|err| self.exec_error(err, "")) + unreachable!("the loop above always returns before its range is exhausted") } fn request(&self, action: &str, env: &str, command: &str, tier: &str) -> AuthnRequest {