diff --git a/codex-rs/.config/nextest.toml b/codex-rs/.config/nextest.toml index f370a0aa36..0dd671758e 100644 --- a/codex-rs/.config/nextest.toml +++ b/codex-rs/.config/nextest.toml @@ -12,13 +12,15 @@ slow-timeout = { period = "1m", terminate-after = 2 } [[profile.default.overrides]] # This case validates and copies both the initial and replacement debug package. -filter = 'package(codex-cli) & binary(app_server_daemon) & test(=packaged_daemon_start_and_explicit_replacement)' +filter = 'package(codex-cli) & binary(=app_server_daemon_package) & test(=packaged_daemon_start_and_explicit_replacement)' slow-timeout = { period = "1m", terminate-after = 3 } +threads-required = "num-test-threads" [[profile.default.overrides]] -# These cases copy a full debug CLI package and launch its new executable. -filter = 'package(codex-cli) & binary(app_server_daemon) & test(packaged_daemon_)' +# These copies must not compete with other tests on the same runner. +filter = 'package(codex-cli) & binary(=app_server_daemon_package)' slow-timeout = { period = "1m", terminate-after = 2 } +threads-required = "num-test-threads" [profile.default.junit] path = "junit.xml" diff --git a/codex-rs/cli/BUILD.bazel b/codex-rs/cli/BUILD.bazel index d9673f989f..ec69a56834 100644 --- a/codex-rs/cli/BUILD.bazel +++ b/codex-rs/cli/BUILD.bazel @@ -1,3 +1,6 @@ +load("@crates//:data.bzl", "DEP_DATA") +load("@crates//:defs.bzl", "all_crate_deps") +load("@rules_rust//rust:defs.bzl", "rust_test") load("//:defs.bzl", "MACOS_WEBRTC_RUSTC_LINK_FLAGS", "codex_rust_crate") load("//bazel/platforms:release_binaries.bzl", "multiplatform_binaries") load("//bazel/rules:e2e_benchmark.bzl", "codex_e2e_benchmark") @@ -14,6 +17,35 @@ codex_rust_crate( test_data_extra = glob(["src/**/snapshots/**"]), ) +# Real installation copies a full debug package. Run after other Bazel tests +# on the CI runner so remote executors do not amplify shared-disk writeback. +rust_test( + name = "cli-app_server_daemon_package-test", + timeout = "long", + srcs = [ + "tests/app_server_daemon/package.rs", + "tests/app_server_daemon/support.rs", + ], + args = ["--test-threads=1"], + crate_name = "app_server_daemon_package", + crate_root = "tests/app_server_daemon/package.rs", + data = [":codex"], + env = {"CARGO_BIN_EXE_codex": "$(rlocationpath :codex)"}, + rustc_env = {"BAZEL_PACKAGE": "codex-rs/cli"}, + rustc_flags = MACOS_WEBRTC_RUSTC_LINK_FLAGS, + tags = ["exclusive"], + target_compatible_with = select({ + "@platforms//os:linux": [], + "@platforms//os:macos": [], + "//conditions:default": ["@platforms//:incompatible"], + }), + version = DEP_DATA["codex-rs/cli"]["version"], + deps = all_crate_deps( + normal = True, + normal_dev = True, + ), +) + release_binary( binary = ":codex", ) diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 608921c370..9443824741 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -14,6 +14,10 @@ path = "src/main.rs" name = "logs_client" path = "src/bin/logs_client.rs" +[[test]] +name = "app_server_daemon_package" +path = "tests/app_server_daemon/package.rs" + [lib] name = "codex_cli" path = "src/lib.rs" diff --git a/codex-rs/cli/tests/app_server_daemon.rs b/codex-rs/cli/tests/app_server_daemon.rs index 7129f9f34b..72b10fa51b 100644 --- a/codex-rs/cli/tests/app_server_daemon.rs +++ b/codex-rs/cli/tests/app_server_daemon.rs @@ -1,7 +1,13 @@ +//! Managed daemon lifecycle through the public CLI, using isolated standalone fixtures. #![cfg(unix)] -use std::path::PathBuf; -use std::process::Child; +#[path = "app_server_daemon/support.rs"] +mod support; + +use support::TestDaemon; +use support::signal; +use support::wait_for_exit; + use std::process::Command; use std::process::Stdio; use std::time::Duration; @@ -10,109 +16,8 @@ use std::time::Instant; use anyhow::Context; use anyhow::Result; use anyhow::ensure; -use codex_utils_cargo_bin::copy_executable; use pretty_assertions::assert_eq; use serde_json::Value; -use tempfile::TempDir; - -struct TestDaemon { - home: TempDir, - codex: PathBuf, - unmanaged: Option, -} - -impl TestDaemon { - fn new() -> Result { - let home = tempfile::Builder::new().tempdir_in("/tmp")?; - let codex = codex_utils_cargo_bin::cargo_bin("codex")?; - let codex_source = std::fs::canonicalize(&codex)?; - let target = if cfg!(target_os = "macos") { - format!("{}-apple-darwin", std::env::consts::ARCH) - } else { - format!("{}-unknown-linux-musl", std::env::consts::ARCH) - }; - let standalone = home.path().join("packages/standalone"); - let release_name = format!("0.0.0-{target}"); - let managed = standalone - .join("releases") - .join(&release_name) - .join("bin/codex"); - std::fs::create_dir_all(managed.parent().context("managed bin parent")?)?; - // Preserve the installed path without invalidating the shared CLI's Rosetta cache. - #[cfg(all(target_os = "macos", target_arch = "x86_64"))] - { - copy_executable(&codex_source, &managed)?; - // Translate the fixture before timed daemon capability and readiness checks. - ensure!( - Command::new(&managed) - .env("CODEX_HOME", home.path()) - .arg("--version") - .output()? - .status - .success(), - "failed to prepare managed test executable" - ); - } - #[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] - 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), - standalone.join("current"), - )?; - // Model a daemon that was previously launched and is currently stopped. - let state = home.path().join("app-server-daemon"); - std::fs::create_dir(&state)?; - std::fs::write(state.join("app-server.stderr.log"), b"")?; - Ok(Self { - home, - codex, - unmanaged: None, - }) - } - - fn command(&self) -> Command { - let mut command = Command::new(&self.codex); - command.env("CODEX_HOME", self.home.path()); - command - } - - fn lifecycle(&self, action: &str) -> Result { - let output = self - .command() - .args(["app-server", "daemon", action]) - .output()?; - ensure!( - output.status.success(), - "daemon {action} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - Ok(serde_json::from_slice(&output.stdout)?) - } - - fn pid(&self, name: &str) -> Result { - let record = std::fs::read(self.home.path().join("app-server-daemon").join(name)) - .with_context(|| format!("failed to read {name}"))?; - Ok(serde_json::from_slice::(&record)?["pid"] - .as_u64() - .context("pid missing")? as u32) - } -} - -impl Drop for TestDaemon { - fn drop(&mut self) { - if let Some(mut child) = self.unmanaged.take() { - let _ = child.kill(); - let _ = child.wait(); - } - let _ = self.lifecycle("stop"); - if let Ok(pid) = self.pid("app-server-updater.pid") { - let _ = signal(pid, libc::SIGTERM); - } - } -} #[test] fn managed_daemon_restarts_from_deleted_working_directory() -> Result<()> { @@ -303,32 +208,6 @@ fn explicit_restart_applies_feature_overrides_and_preserves_saved_settings() -> Ok(()) } -fn signal(pid: u32, signal: libc::c_int) -> Result<()> { - let raw_pid = libc::pid_t::try_from(pid).context("pid out of range")?; - ensure!( - unsafe { libc::kill(raw_pid, signal) } == 0, - "failed to signal pid {pid}: {}", - std::io::Error::last_os_error() - ); - Ok(()) -} - -fn wait_for_exit(pid: u32) -> Result<()> { - let deadline = Instant::now() + Duration::from_secs(30); - loop { - let output = Command::new("/bin/ps") - .args(["-p", &pid.to_string(), "-o", "stat="]) - .output() - .context("failed to invoke ps")?; - let state = String::from_utf8_lossy(&output.stdout); - if !output.status.success() || state.trim().starts_with('Z') { - return Ok(()); - } - ensure!(Instant::now() < deadline, "pid {pid} did not exit: {state}"); - std::thread::sleep(Duration::from_millis(50)); - } -} - #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn managed_identity_survives_locale_and_timezone_changes() -> Result<()> { @@ -671,203 +550,3 @@ fn manual_update_rejects_an_unowned_installation() -> Result<()> { assert!(daemon.pid("app-server-updater.pid").is_err()); Ok(()) } - -#[derive(Clone, Copy, PartialEq, Eq)] -enum InitialDaemon { - Missing, - Legacy, -} - -fn packaged_daemon_launch(action: &str, initial: InitialDaemon) -> Result<()> { - let mut daemon = TestDaemon::new()?; - let standalone = daemon.home.path().join("packages/standalone"); - let package = if action == "start" && initial == InitialDaemon::Missing { - standalone.join("releases/caller") - } else { - daemon.home.path().join("cli-package") - }; - for directory in ["bin", "codex-path", "codex-resources"] { - std::fs::create_dir_all(package.join(directory))?; - } - copy_executable(&daemon.codex, &package.join("bin/codex"))?; - daemon.codex = package.join("bin/codex"); - for helper in [ - "bin/codex-code-mode-host", - "codex-path/rg", - "codex-resources/bwrap", - ] { - codex_utils_cargo_bin::write_executable(&package.join(helper), "runtime fixture")?; - } - let target = format!( - "{}-{}", - std::env::consts::ARCH, - if cfg!(target_os = "macos") { - "apple-darwin" - } else if cfg!(target_env = "gnu") { - "unknown-linux-gnu" - } else { - "unknown-linux-musl" - } - ); - std::fs::write( - package.join("codex-package.json"), - serde_json::to_vec(&serde_json::json!({ - "version": env!("CARGO_PKG_VERSION"), "target": target, "entrypoint": "bin/codex" - }))?, - )?; - if action == "start" && initial == InitialDaemon::Missing { - std::fs::remove_file(standalone.join("current"))?; - std::os::unix::fs::symlink(&package, standalone.join("current"))?; - } - let cli_selection = standalone.join("current").canonicalize()?; - let state = daemon.home.path().join("app-server-daemon"); - if initial == InitialDaemon::Missing { - std::fs::remove_file(state.join("app-server.stderr.log"))?; - } - std::fs::write( - state.join("settings.json"), - br#"{"shutdownGraceSeconds":0}"#, - )?; - let cli_before = daemon.codex.canonicalize()?; - let result = daemon - .command() - .args(["app-server", "daemon", action]) - .output() - .context("failed to launch packaged daemon fixture")?; - ensure!( - result.status.success(), - "{}", - String::from_utf8_lossy(&result.stderr) - ); - assert_eq!( - String::from_utf8_lossy(&result.stderr).contains("Installing daemon from CLI version"), - initial == InitialDaemon::Missing - ); - let output: Value = serde_json::from_slice(&result.stdout)?; - let dedicated = daemon - .home - .path() - .canonicalize()? - .join("packages/app-server-daemon"); - let (initial_root, initial_pid_file) = match initial { - InitialDaemon::Missing => (&dedicated, "daemon.pid"), - InitialDaemon::Legacy => (&standalone, "app-server.pid"), - }; - assert_eq!( - output["managedCodexPath"], - initial_root - .canonicalize()? - .join("current/bin/codex") - .to_str() - .context("managed path is not UTF-8")? - ); - assert_eq!(daemon.codex.canonicalize()?, cli_before); - assert_eq!(standalone.join("current").canonicalize()?, cli_selection); - assert!(state.join(initial_pid_file).exists()); - let legacy_updater = match initial { - InitialDaemon::Missing => { - assert!(!state.join("app-server-updater.pid").exists()); - None - } - InitialDaemon::Legacy => Some(daemon.pid("app-server-updater.pid")?), - }; - if action == "start" { - let initial_current = initial_root.join("current"); - let current = dedicated.join("current"); - let original = initial_current.canonicalize()?; - let original_pid = daemon.pid(initial_pid_file)?; - let settings_before = std::fs::read(state.join("settings.json"))?; - let refused = daemon - .command() - .args(["app-server", "daemon", "update", "--from-cli"]) - .output()?; - assert!(!refused.status.success()); - assert!(String::from_utf8_lossy(&refused.stderr).contains("rerun with --yes")); - assert_eq!(daemon.pid(initial_pid_file)?, original_pid); - assert_eq!(initial_current.canonicalize()?, original); - if initial == InitialDaemon::Legacy { - assert!(!current.exists()); - } - - std::fs::remove_file(package.join("bin/codex-code-mode-host"))?; - let invalid = daemon - .command() - .args(["app-server", "daemon", "update", "--from-cli", "--yes"]) - .output()?; - assert!(!invalid.status.success()); - assert_eq!(daemon.pid(initial_pid_file)?, original_pid); - assert_eq!(initial_current.canonicalize()?, original); - if initial == InitialDaemon::Legacy { - assert!(!current.exists()); - } - codex_utils_cargo_bin::write_executable( - &package.join("bin/codex-code-mode-host"), - "replacement helper", - )?; - let replaced = daemon - .command() - .args(["app-server", "daemon", "update", "--from-cli", "--yes"]) - .output()?; - ensure!( - replaced.status.success(), - "{}", - String::from_utf8_lossy(&replaced.stderr) - ); - let output: Value = serde_json::from_slice(&replaced.stdout)?; - assert_eq!(output["status"], "updated"); - assert_ne!(daemon.pid("daemon.pid")?, original_pid); - wait_for_exit(original_pid)?; - if let Some(pid) = legacy_updater { - wait_for_exit(pid)?; - } - assert!(!state.join("app-server.pid").exists()); - assert!(!state.join("app-server-updater.pid").exists()); - assert_eq!( - output["managedCodexPath"], - current - .join("bin/codex") - .to_str() - .context("managed path is not UTF-8")? - ); - assert_ne!(current.canonicalize()?, original); - assert!(!dedicated.join("auto-update-version").exists()); - assert_eq!(std::fs::read(state.join("settings.json"))?, settings_before); - assert_eq!( - std::fs::read(current.join("bin/codex-code-mode-host"))?, - b"replacement helper" - ); - if initial == InitialDaemon::Missing { - assert_eq!( - std::fs::read(original.join("bin/codex-code-mode-host"))?, - b"runtime fixture" - ); - } - assert_eq!(daemon.lifecycle("start")?["status"], "alreadyRunning"); - assert_eq!(daemon.lifecycle("stop")?["status"], "stopped"); - assert_eq!(standalone.join("current").canonicalize()?, cli_selection); - } - if action == "bootstrap" { - assert_eq!(output["autoUpdateEnabled"], false); - } - Ok(()) -} - -#[test] -fn packaged_daemon_start_and_explicit_replacement() -> Result<()> { - packaged_daemon_launch("start", InitialDaemon::Missing) -} - -#[test] -fn packaged_daemon_restart_seeds_local_package() -> Result<()> { - packaged_daemon_launch("restart", InitialDaemon::Missing) -} - -#[test] -fn packaged_daemon_bootstrap_seeds_local_package() -> Result<()> { - packaged_daemon_launch("bootstrap", InitialDaemon::Missing) -} - -#[test] -fn packaged_daemon_explicit_replacement_migrates_running_legacy() -> Result<()> { - packaged_daemon_launch("start", InitialDaemon::Legacy) -} diff --git a/codex-rs/cli/tests/app_server_daemon/package.rs b/codex-rs/cli/tests/app_server_daemon/package.rs new file mode 100644 index 0000000000..6c010c59d4 --- /dev/null +++ b/codex-rs/cli/tests/app_server_daemon/package.rs @@ -0,0 +1,219 @@ +//! Real package installation and replacement through the daemon CLI. +//! Keep these I/O-heavy cases separate from ordinary lifecycle coverage. +#![cfg(unix)] + +mod support; + +use anyhow::Context; +use anyhow::Result; +use anyhow::ensure; +use pretty_assertions::assert_eq; +use serde_json::Value; +use support::TestDaemon; +use support::wait_for_exit; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum InitialDaemon { + Missing, + Legacy, +} + +fn packaged_daemon_launch(action: &str, initial: InitialDaemon) -> Result<()> { + let mut daemon = TestDaemon::new()?; + let standalone = daemon.home.path().join("packages/standalone"); + let package = if action == "start" && initial == InitialDaemon::Missing { + standalone.join("releases/caller") + } else { + daemon.home.path().join("cli-package") + }; + for directory in ["bin", "codex-path", "codex-resources"] { + std::fs::create_dir_all(package.join(directory))?; + } + // Both paths are in this test's temp home and therefore on the same filesystem. + // Reuse its prepared (and on Intel macOS, translated) executable. The installer + // still makes a real independent package copy; only fixture setup shares this inode. + std::fs::hard_link( + standalone.join("current/bin/codex"), + package.join("bin/codex"), + )?; + daemon.codex = package.join("bin/codex"); + for helper in [ + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/bwrap", + ] { + codex_utils_cargo_bin::write_executable(&package.join(helper), "runtime fixture")?; + } + let target = format!( + "{}-{}", + std::env::consts::ARCH, + if cfg!(target_os = "macos") { + "apple-darwin" + } else if cfg!(target_env = "gnu") { + "unknown-linux-gnu" + } else { + "unknown-linux-musl" + } + ); + std::fs::write( + package.join("codex-package.json"), + serde_json::to_vec(&serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), "target": target, "entrypoint": "bin/codex" + }))?, + )?; + if action == "start" && initial == InitialDaemon::Missing { + std::fs::remove_file(standalone.join("current"))?; + std::os::unix::fs::symlink(&package, standalone.join("current"))?; + } + let cli_selection = standalone.join("current").canonicalize()?; + let state = daemon.home.path().join("app-server-daemon"); + if initial == InitialDaemon::Missing { + std::fs::remove_file(state.join("app-server.stderr.log"))?; + } + std::fs::write( + state.join("settings.json"), + br#"{"shutdownGraceSeconds":0}"#, + )?; + let cli_before = daemon.codex.canonicalize()?; + let result = daemon + .command() + .args(["app-server", "daemon", action]) + .output() + .context("failed to launch packaged daemon fixture")?; + ensure!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&result.stderr).contains("Installing daemon from CLI version"), + initial == InitialDaemon::Missing + ); + let output: Value = serde_json::from_slice(&result.stdout)?; + let dedicated = daemon + .home + .path() + .canonicalize()? + .join("packages/app-server-daemon"); + let (initial_root, initial_pid_file) = match initial { + InitialDaemon::Missing => (&dedicated, "daemon.pid"), + InitialDaemon::Legacy => (&standalone, "app-server.pid"), + }; + assert_eq!( + output["managedCodexPath"], + initial_root + .canonicalize()? + .join("current/bin/codex") + .to_str() + .context("managed path is not UTF-8")? + ); + assert_eq!(daemon.codex.canonicalize()?, cli_before); + assert_eq!(standalone.join("current").canonicalize()?, cli_selection); + assert!(state.join(initial_pid_file).exists()); + let legacy_updater = match initial { + InitialDaemon::Missing => { + assert!(!state.join("app-server-updater.pid").exists()); + None + } + InitialDaemon::Legacy => Some(daemon.pid("app-server-updater.pid")?), + }; + if action == "start" { + let initial_current = initial_root.join("current"); + let current = dedicated.join("current"); + let original = initial_current.canonicalize()?; + let original_pid = daemon.pid(initial_pid_file)?; + let settings_before = std::fs::read(state.join("settings.json"))?; + let refused = daemon + .command() + .args(["app-server", "daemon", "update", "--from-cli"]) + .output()?; + assert!(!refused.status.success()); + assert!(String::from_utf8_lossy(&refused.stderr).contains("rerun with --yes")); + assert_eq!(daemon.pid(initial_pid_file)?, original_pid); + assert_eq!(initial_current.canonicalize()?, original); + if initial == InitialDaemon::Legacy { + assert!(!current.exists()); + } + + std::fs::remove_file(package.join("bin/codex-code-mode-host"))?; + let invalid = daemon + .command() + .args(["app-server", "daemon", "update", "--from-cli", "--yes"]) + .output()?; + assert!(!invalid.status.success()); + assert_eq!(daemon.pid(initial_pid_file)?, original_pid); + assert_eq!(initial_current.canonicalize()?, original); + if initial == InitialDaemon::Legacy { + assert!(!current.exists()); + } + codex_utils_cargo_bin::write_executable( + &package.join("bin/codex-code-mode-host"), + "replacement helper", + )?; + let replaced = daemon + .command() + .args(["app-server", "daemon", "update", "--from-cli", "--yes"]) + .output()?; + ensure!( + replaced.status.success(), + "{}", + String::from_utf8_lossy(&replaced.stderr) + ); + let output: Value = serde_json::from_slice(&replaced.stdout)?; + assert_eq!(output["status"], "updated"); + assert_ne!(daemon.pid("daemon.pid")?, original_pid); + wait_for_exit(original_pid)?; + if let Some(pid) = legacy_updater { + wait_for_exit(pid)?; + } + assert!(!state.join("app-server.pid").exists()); + assert!(!state.join("app-server-updater.pid").exists()); + assert_eq!( + output["managedCodexPath"], + current + .join("bin/codex") + .to_str() + .context("managed path is not UTF-8")? + ); + assert_ne!(current.canonicalize()?, original); + assert!(!dedicated.join("auto-update-version").exists()); + assert_eq!(std::fs::read(state.join("settings.json"))?, settings_before); + assert_eq!( + std::fs::read(current.join("bin/codex-code-mode-host"))?, + b"replacement helper" + ); + if initial == InitialDaemon::Missing { + assert_eq!( + std::fs::read(original.join("bin/codex-code-mode-host"))?, + b"runtime fixture" + ); + } + assert_eq!(daemon.lifecycle("start")?["status"], "alreadyRunning"); + assert_eq!(daemon.lifecycle("stop")?["status"], "stopped"); + assert_eq!(standalone.join("current").canonicalize()?, cli_selection); + } + if action == "bootstrap" { + assert_eq!(output["autoUpdateEnabled"], false); + } + Ok(()) +} + +#[test] +fn packaged_daemon_start_and_explicit_replacement() -> Result<()> { + packaged_daemon_launch("start", InitialDaemon::Missing) +} + +#[test] +fn packaged_daemon_restart_seeds_local_package() -> Result<()> { + packaged_daemon_launch("restart", InitialDaemon::Missing) +} + +#[test] +fn packaged_daemon_bootstrap_seeds_local_package() -> Result<()> { + packaged_daemon_launch("bootstrap", InitialDaemon::Missing) +} + +#[test] +fn packaged_daemon_explicit_replacement_migrates_running_legacy() -> Result<()> { + packaged_daemon_launch("start", InitialDaemon::Legacy) +} diff --git a/codex-rs/cli/tests/app_server_daemon/support.rs b/codex-rs/cli/tests/app_server_daemon/support.rs new file mode 100644 index 0000000000..5a2a0d5170 --- /dev/null +++ b/codex-rs/cli/tests/app_server_daemon/support.rs @@ -0,0 +1,140 @@ +//! Isolated installed-CLI fixtures and PID cleanup for Unix daemon integration tests. +//! Every test owns its home and CLI selection; only immutable executable contents are shared. + +use std::path::PathBuf; +use std::process::Child; +use std::process::Command; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use anyhow::Result; +use anyhow::ensure; +use codex_utils_cargo_bin::copy_executable; +use serde_json::Value; +use tempfile::TempDir; + +pub(super) struct TestDaemon { + pub(super) home: TempDir, + pub(super) codex: PathBuf, + pub(super) unmanaged: Option, +} + +impl TestDaemon { + pub(super) fn new() -> Result { + let home = tempfile::Builder::new().tempdir_in("/tmp")?; + let codex = codex_utils_cargo_bin::cargo_bin("codex")?; + let codex_source = std::fs::canonicalize(&codex)?; + let target = if cfg!(target_os = "macos") { + format!("{}-apple-darwin", std::env::consts::ARCH) + } else { + format!("{}-unknown-linux-musl", std::env::consts::ARCH) + }; + let standalone = home.path().join("packages/standalone"); + let release_name = format!("0.0.0-{target}"); + let managed = standalone + .join("releases") + .join(&release_name) + .join("bin/codex"); + std::fs::create_dir_all(managed.parent().context("managed bin parent")?)?; + // Preserve the installed path without invalidating the shared CLI's Rosetta cache. + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + { + copy_executable(&codex_source, &managed)?; + // Translate the fixture before timed daemon capability and readiness checks. + ensure!( + Command::new(&managed) + .env("CODEX_HOME", home.path()) + .arg("--version") + .output()? + .status + .success(), + "failed to prepare managed test executable" + ); + } + #[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] + 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), + standalone.join("current"), + )?; + // Model a daemon that was previously launched and is currently stopped. + let state = home.path().join("app-server-daemon"); + std::fs::create_dir(&state)?; + std::fs::write(state.join("app-server.stderr.log"), b"")?; + Ok(Self { + home, + codex, + unmanaged: None, + }) + } + + pub(super) fn command(&self) -> Command { + let mut command = Command::new(&self.codex); + command.env("CODEX_HOME", self.home.path()); + command + } + + pub(super) fn lifecycle(&self, action: &str) -> Result { + let output = self + .command() + .args(["app-server", "daemon", action]) + .output()?; + ensure!( + output.status.success(), + "daemon {action} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(serde_json::from_slice(&output.stdout)?) + } + + pub(super) fn pid(&self, name: &str) -> Result { + let record = std::fs::read(self.home.path().join("app-server-daemon").join(name)) + .with_context(|| format!("failed to read {name}"))?; + Ok(serde_json::from_slice::(&record)?["pid"] + .as_u64() + .context("pid missing")? as u32) + } +} + +impl Drop for TestDaemon { + fn drop(&mut self) { + if let Some(mut child) = self.unmanaged.take() { + let _ = child.kill(); + let _ = child.wait(); + } + let _ = self.lifecycle("stop"); + if let Ok(pid) = self.pid("app-server-updater.pid") { + let _ = signal(pid, libc::SIGTERM); + } + } +} + +pub(super) fn signal(pid: u32, signal: libc::c_int) -> Result<()> { + let raw_pid = libc::pid_t::try_from(pid).context("pid out of range")?; + ensure!( + unsafe { libc::kill(raw_pid, signal) } == 0, + "failed to signal pid {pid}: {}", + std::io::Error::last_os_error() + ); + Ok(()) +} + +pub(super) fn wait_for_exit(pid: u32) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let output = Command::new("/bin/ps") + .args(["-p", &pid.to_string(), "-o", "stat="]) + .output() + .context("failed to invoke ps")?; + let state = String::from_utf8_lossy(&output.stdout); + if !output.status.success() || state.trim().starts_with('Z') { + return Ok(()); + } + ensure!(Instant::now() < deadline, "pid {pid} did not exit: {state}"); + std::thread::sleep(Duration::from_millis(50)); + } +}