Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions hew-core/src/bd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use serde::{Deserialize, Serialize};
use tracing::debug;

use crate::error::{HewError, Result};
use crate::process::spawn_with_etxtbsy_retry;

/// Soft default timeout for any single `bd` invocation.
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
Expand Down Expand Up @@ -133,7 +134,7 @@ impl RealBd {
let mut cmd = Command::new(&self.path);
cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());

let mut child = cmd.spawn()?;
let mut child = spawn_with_etxtbsy_retry(&mut cmd)?;

let status = match child.wait_timeout(self.timeout)? {
Some(s) => s,
Expand Down Expand Up @@ -200,7 +201,7 @@ impl RealBd {
let mut cmd = Command::new(&self.path);
cmd.args(args).stdin(Stdio::null()).stdout(Stdio::from(file)).stderr(Stdio::piped());

let mut child = cmd.spawn()?;
let mut child = spawn_with_etxtbsy_retry(&mut cmd)?;

let status = match child.wait_timeout(self.timeout)? {
Some(s) => s,
Expand Down
30 changes: 2 additions & 28 deletions hew-core/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,17 @@

use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::process::{Command, Stdio};
use std::time::Duration;

use tracing::debug;
use wait_timeout::ChildExt;

use crate::error::{HewError, Result};
use crate::process::spawn_with_etxtbsy_retry;

const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);

/// Spawn a `Command`, retrying on `ExecutableFileBusy` (Linux `ETXTBSY`).
///
/// Background: when parallel threads each write+exec their own stub
/// binary (the test harness pattern), one thread's writable fd to its
/// stub temp file leaks into another thread's child via `fork()` even
/// when the fd is `O_CLOEXEC` — `O_CLOEXEC` only fires on `exec`, not
/// `fork`. The kernel then sees an outstanding writer on the inode and
/// the child's `exec` trips `ETXTBSY` (errno 26). Same race can happen
/// in production any time `hew init` rewrites bundled artifacts during
/// concurrent reads. Exponential backoff up to ~150ms total handles the
/// transient window without callers needing to care.
fn spawn_with_etxtbsy_retry(cmd: &mut Command) -> std::io::Result<Child> {
use std::io::ErrorKind;
let mut delay_ms = 5u64;
for _ in 0..5 {
match cmd.spawn() {
Ok(c) => return Ok(c),
Err(e) if e.kind() == ErrorKind::ExecutableFileBusy => {
std::thread::sleep(Duration::from_millis(delay_ms));
delay_ms *= 2;
}
Err(e) => return Err(e),
}
}
cmd.spawn()
}

#[derive(Debug, Clone)]
pub struct GitOutput {
pub stdout: String,
Expand Down
1 change: 1 addition & 0 deletions hew-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub mod memories;
pub mod notify;
pub mod os;
pub mod prime;
pub(crate) mod process;
pub mod review;
pub mod skills;
pub mod slash;
Expand Down
3 changes: 2 additions & 1 deletion hew-core/src/os.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use tracing::debug;
use wait_timeout::ChildExt;

use crate::error::{HewError, Result};
use crate::process::spawn_with_etxtbsy_retry;

const BREW_INSTALL_TIMEOUT: Duration = Duration::from_secs(600);

Expand Down Expand Up @@ -137,7 +138,7 @@ fn run_brew_install_git(brew: &std::path::Path) -> Result<()> {
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let mut child = spawn_with_etxtbsy_retry(&mut cmd)?;
let status = match child.wait_timeout(BREW_INSTALL_TIMEOUT)? {
Some(s) => s,
None => {
Expand Down
48 changes: 48 additions & 0 deletions hew-core/src/process.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//! Subprocess spawn helpers shared across hew-core.
//!
//! The single helper here, [`spawn_with_etxtbsy_retry`], wraps
//! [`std::process::Command::spawn`] with a brief retry loop on
//! `ETXTBSY` (Linux's "Text file busy" — `ErrorKind::ExecutableFileBusy`).
//!
//! ## Why this exists
//!
//! When parallel threads each write+exec their own stub binary (the
//! test harness pattern used in `hew_core::testing::install_executable_stub`),
//! one thread's writable fd to its stub temp file leaks into another
//! thread's child via `fork()` even when the fd is `O_CLOEXEC` —
//! `O_CLOEXEC` only fires on `exec`, not `fork`. The kernel then sees
//! an outstanding writer on the inode and the child's `exec` trips
//! `ETXTBSY` (errno 26).
//!
//! Same race can happen in production any time `hew init` rewrites
//! bundled artifacts during concurrent reads. Exponential backoff up to
//! ~150ms total handles the transient window without callers needing
//! to care.
//!
//! Defense in depth: every spawn path in hew-core (git, bd, os
//! installer probes) should go through this helper. The cost on the
//! happy path is one extra `match`; the cost on the failing path is a
//! handful of millisecond-scale sleeps that resolve a race the caller
//! would otherwise surface as a confusing test flake.

use std::process::{Child, Command};
use std::time::Duration;

/// Spawn `cmd`, retrying briefly on `ETXTBSY` (Linux "Text file busy").
/// Returns the spawned `Child` on success or the underlying io error
/// on the final attempt.
pub(crate) fn spawn_with_etxtbsy_retry(cmd: &mut Command) -> std::io::Result<Child> {
use std::io::ErrorKind;
let mut delay_ms = 5u64;
for _ in 0..5 {
match cmd.spawn() {
Ok(c) => return Ok(c),
Err(e) if e.kind() == ErrorKind::ExecutableFileBusy => {
std::thread::sleep(Duration::from_millis(delay_ms));
delay_ms *= 2;
}
Err(e) => return Err(e),
}
}
cmd.spawn()
}
Loading