mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Isolate daemon package tests to reduce disk contention (#53897)
## Why Daemon package tests copy full debug CLI packages. Running these I/O-heavy cases alongside other tests can increase shared-disk writeback pressure. ## What changed - Move package installation and replacement coverage into the `app_server_daemon_package` test binary, sharing daemon fixtures with the lifecycle tests. - Reserve all nextest test threads for each package test and add an exclusive Bazel target that runs the cases serially on Linux and macOS. - Hard-link the prepared fixture executable during package setup, while retaining real independent package copies in the installer. GitOrigin-RevId: 255ac8caf75d9eb61bafe68f1f9bcfa99ee72484
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Child>,
|
||||
}
|
||||
|
||||
impl TestDaemon {
|
||||
fn new() -> Result<Self> {
|
||||
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<Value> {
|
||||
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<u32> {
|
||||
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::<Value>(&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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<Child>,
|
||||
}
|
||||
|
||||
impl TestDaemon {
|
||||
pub(super) fn new() -> Result<Self> {
|
||||
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<Value> {
|
||||
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<u32> {
|
||||
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::<Value>(&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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user