diff --git a/codex-rs/cli/tests/app_server_daemon.rs b/codex-rs/cli/tests/app_server_daemon.rs index a2626b04b2..9c3c820d49 100644 --- a/codex-rs/cli/tests/app_server_daemon.rs +++ b/codex-rs/cli/tests/app_server_daemon.rs @@ -1,5 +1,8 @@ #![cfg(unix)] +#[path = "support/executable.rs"] +mod executable; + use std::path::PathBuf; use std::process::Child; use std::process::Command; @@ -10,6 +13,7 @@ use std::time::Instant; use anyhow::Context; use anyhow::Result; use anyhow::ensure; +use executable::copy_executable; use pretty_assertions::assert_eq; use serde_json::Value; use tempfile::TempDir; @@ -40,7 +44,7 @@ impl TestDaemon { // Preserve the installed path without invalidating the shared CLI's Rosetta cache. #[cfg(all(target_os = "macos", target_arch = "x86_64"))] { - std::fs::copy(&codex_source, &managed)?; + copy_executable(&codex_source, &managed)?; // Translate the fixture before timed daemon capability and readiness checks. ensure!( Command::new(&managed) @@ -53,8 +57,9 @@ impl TestDaemon { ); } #[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] - std::fs::hard_link(&codex_source, &managed) - .or_else(|_| std::fs::copy(&codex_source, managed).map(|_| ()))?; + if std::fs::hard_link(&codex_source, &managed).is_err() { + copy_executable(&codex_source, &managed)?; + } std::fs::write(standalone.join("auto-update-version"), &release_name)?; std::os::unix::fs::symlink( PathBuf::from("releases").join(release_name), @@ -499,7 +504,7 @@ fn packaged_daemon_launch(action: &str, initial: InitialDaemon) -> Result<()> { for directory in ["bin", "codex-path", "codex-resources"] { std::fs::create_dir_all(package.join(directory))?; } - std::fs::copy(&daemon.codex, package.join("bin/codex"))?; + copy_executable(&daemon.codex, &package.join("bin/codex"))?; daemon.codex = package.join("bin/codex"); for helper in [ "bin/codex-code-mode-host", @@ -540,22 +545,11 @@ fn packaged_daemon_launch(action: &str, initial: InitialDaemon) -> Result<()> { br#"{"shutdownGraceSeconds":0}"#, )?; let cli_before = daemon.codex.canonicalize()?; - let mut command = daemon.command(); - command.args(["app-server", "daemon", action]); - // A freshly copied executable can briefly remain busy on Linux CI workers. - let mut retries = 0; - let result = loop { - let result = command.output(); - if !result - .as_ref() - .is_err_and(|error| error.kind() == std::io::ErrorKind::ExecutableFileBusy) - || retries == 2 - { - break result?; - } - retries += 1; - std::thread::sleep(Duration::from_millis(/*millis*/ 10)); - }; + let result = daemon + .command() + .args(["app-server", "daemon", action]) + .output() + .context("failed to launch packaged daemon fixture")?; ensure!( result.status.success(), "{}", diff --git a/codex-rs/cli/tests/doctor_path_safety.rs b/codex-rs/cli/tests/doctor_path_safety.rs index db2df64a82..d67b7fd2dd 100644 --- a/codex-rs/cli/tests/doctor_path_safety.rs +++ b/codex-rs/cli/tests/doctor_path_safety.rs @@ -1,5 +1,8 @@ //! Black-box coverage for safe diagnostic execution and config error reporting. +#[path = "support/executable.rs"] +mod executable; + use std::ffi::OsString; use std::path::PathBuf; use std::time::Duration; @@ -12,6 +15,7 @@ use codex_app_server_protocol::RequestId; use codex_config::loader::project_trust_key; use codex_state::SqliteConfig; use codex_utils_absolute_path::AbsolutePathBuf; +use executable::copy_executable; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -45,7 +49,7 @@ impl Fixture { // Hard-link setup and teardown invalidate other tests' Rosetta translations. #[cfg(all(target_os = "macos", target_arch = "x86_64"))] { - std::fs::copy(&source, &program)?; + copy_executable(&source, &program)?; // Translate the fixture before the timed diagnostic command. anyhow::ensure!( std::process::Command::new(&program) @@ -59,7 +63,7 @@ impl Fixture { } #[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] if std::fs::hard_link(&source, &program).is_err() { - std::fs::copy(&source, &program)?; + copy_executable(&source, &program)?; } let workspace = root.path().join("workspace"); let bin = workspace.join("node_modules/.bin"); diff --git a/codex-rs/cli/tests/exec_server.rs b/codex-rs/cli/tests/exec_server.rs index d16e9d95a8..9d018b8423 100644 --- a/codex-rs/cli/tests/exec_server.rs +++ b/codex-rs/cli/tests/exec_server.rs @@ -1,3 +1,6 @@ +#[path = "support/executable.rs"] +mod executable; + #[cfg(target_os = "linux")] #[path = "exec_server/pid_namespace_tests.rs"] mod pid_namespace_tests; @@ -33,6 +36,7 @@ use codex_exec_server::NoiseRendezvousConnectBundle; use codex_exec_server::ProcessId; use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; +use executable::copy_executable; use futures::SinkExt; use futures::StreamExt; use predicates::prelude::PredicateBooleanExt; @@ -185,7 +189,7 @@ metrics_exporter = {{ otlp-http = {{ endpoint = "{collector_url}/v1/metrics", pr let bin_dir = package.path().join("bin"); std::fs::create_dir(&bin_dir)?; let executable = bin_dir.join(format!("codex{}", std::env::consts::EXE_SUFFIX)); - std::fs::copy(codex_utils_cargo_bin::cargo_bin("codex")?, &executable)?; + copy_executable(&codex_utils_cargo_bin::cargo_bin("codex")?, &executable)?; let manifest = package.path().join("codex-package.json"); std::fs::write(&manifest, r#"{"version":"1.2.3-alpha.4"}"#)?; diff --git a/codex-rs/cli/tests/support/executable.rs b/codex-rs/cli/tests/support/executable.rs new file mode 100644 index 0000000000..2ee51da727 --- /dev/null +++ b/codex-rs/cli/tests/support/executable.rs @@ -0,0 +1,39 @@ +//! Copies executable fixtures without exposing writable descriptors to sibling test spawns. +//! Linux copies finish in a separate process before the fixture can be launched. + +use std::path::Path; +#[cfg(target_os = "linux")] +use std::process::Command; + +#[cfg(target_os = "linux")] +use anyhow::Context; +use anyhow::Result; +#[cfg(target_os = "linux")] +use anyhow::ensure; + +pub(super) fn copy_executable(source: &Path, destination: &Path) -> Result<()> { + #[cfg(target_os = "linux")] + { + // Sibling tests can spawn children while a copy holds the executable open + // for writing. Keep that descriptor in a separate process and wait for it + // to exit, so inherited descriptors cannot cause ETXTBSY during launch. + let output = Command::new("/bin/cp") + .arg("--") + .arg(source) + .arg(destination) + .output() + .context("failed to start fixture executable copy")?; + ensure!( + output.status.success(), + "failed to copy fixture executable {} to {}: {}", + source.display(), + destination.display(), + String::from_utf8_lossy(&output.stderr) + ); + // Match fs::copy even when the test process has a restrictive umask. + std::fs::set_permissions(destination, std::fs::metadata(source)?.permissions())?; + } + #[cfg(not(target_os = "linux"))] + std::fs::copy(source, destination)?; + Ok(()) +}