mirror of
https://github.com/openai/codex.git
synced 2026-11-13 21:30:47 +00:00
## Why The daemon shares the standalone CLI package, coupling daemon updates to the visible CLI installation. A dedicated package lets daemon updates preserve the CLI selection and shell profile. ## What changed - Add dedicated daemon package selection and separate daemon and updater PID files, with diagnostics for the new files. - Make `codex app-server daemon update` migrate eligible legacy stable latest-channel installations to a dedicated package. Prepare and validate the release and its package-ownership support before switching; preserve settings and whether the daemon was running. Scheduled updates keep legacy installations in place. - Add daemon-only and deferred-selection modes to the shell and PowerShell installers, leaving the visible CLI installation unchanged. - Coordinate migration with installer locks, refresh package selection after acquiring lifecycle locks, and reject package changes during update downloads. ## Testing Add coverage for running and stopped migrations, incompatible installers and releases, package-selection races, daemon-only updates, and a capability probe that does not start an updater. Add a shell installer regression test verifying that daemon installation preserves the visible CLI and shell profile. GitOrigin-RevId: 1ec0f358f8f6ebde7daddb74e880cc8d2235d647
366 lines
12 KiB
Rust
366 lines
12 KiB
Rust
//! Reports app-server daemon state without starting or stopping the daemon.
|
|
//!
|
|
//! The background-server check is deliberately passive. It reads the daemon
|
|
//! state directory, PID files, settings file, and control socket path, then
|
|
//! attempts only a bounded initialize probe when a socket already exists. That
|
|
//! keeps doctor safe to run while the user is debugging startup or update-loop
|
|
//! issues.
|
|
|
|
use std::io::Read;
|
|
use std::num::NonZeroU32;
|
|
use std::path::Path;
|
|
|
|
use codex_core::config::Config;
|
|
use serde::Deserialize;
|
|
|
|
use super::CheckStatus;
|
|
use super::DoctorCheck;
|
|
|
|
const MAX_PROBE_ERROR_CHARS: usize = 120;
|
|
const STATE_DIR_NAME: &str = "app-server-daemon";
|
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
|
const PID_FILE_NAME: &str = "app-server.pid";
|
|
const UPDATE_PID_FILE_NAME: &str = "app-server-updater.pid";
|
|
const MAX_SETTINGS_BYTES: u64 = 16 * 1024;
|
|
|
|
#[derive(Deserialize)]
|
|
struct ConfiguredSettings {
|
|
updater: Option<ConfiguredUpdater>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ConfiguredUpdater {
|
|
auto_update_enabled: Option<bool>,
|
|
update_interval_minutes: Option<NonZeroU32>,
|
|
}
|
|
|
|
/// Builds the app-server status row from existing daemon state.
|
|
///
|
|
/// Missing files are expected for the ephemeral/not-running case and should not
|
|
/// be treated as failures. A stale socket is a warning because it can explain
|
|
/// client connection problems without proving the daemon itself is broken.
|
|
pub(super) async fn background_server_check(config: &Config) -> DoctorCheck {
|
|
let mut details = Vec::new();
|
|
let state_dir = config.codex_home.join(STATE_DIR_NAME);
|
|
details.push(format!("daemon state dir: {}", state_dir.display()));
|
|
push_file_detail(
|
|
&mut details,
|
|
"settings",
|
|
&state_dir.join(SETTINGS_FILE_NAME),
|
|
);
|
|
push_file_detail(&mut details, "pid file", &state_dir.join(PID_FILE_NAME));
|
|
push_file_detail(
|
|
&mut details,
|
|
"update-loop pid file",
|
|
&state_dir.join(UPDATE_PID_FILE_NAME),
|
|
);
|
|
push_file_detail(
|
|
&mut details,
|
|
"dedicated pid file",
|
|
&state_dir.join("daemon.pid"),
|
|
);
|
|
push_file_detail(
|
|
&mut details,
|
|
"dedicated update-loop pid file",
|
|
&state_dir.join("daemon-updater.pid"),
|
|
);
|
|
push_configured_updater(&mut details, &state_dir.join(SETTINGS_FILE_NAME));
|
|
|
|
let socket_path = match codex_app_server::app_server_control_socket_path(&config.codex_home) {
|
|
Ok(socket_path) => socket_path,
|
|
Err(err) => {
|
|
return DoctorCheck::new(
|
|
"app_server.status",
|
|
"app-server",
|
|
CheckStatus::Warning,
|
|
"background server socket path could not be resolved",
|
|
)
|
|
.details(details)
|
|
.detail(err.to_string());
|
|
}
|
|
};
|
|
|
|
details.push(format!("control socket: {}", socket_path.display()));
|
|
let status = socket_status(socket_path.as_path()).await;
|
|
details.push(format!("status: {}", status.detail_label()));
|
|
if let Some(version_detail) = status.app_server_version_detail() {
|
|
details.push(version_detail);
|
|
}
|
|
details.push(format!("mode: {}", server_mode(&state_dir)));
|
|
|
|
let mut check = DoctorCheck::new(
|
|
"app_server.status",
|
|
"app-server",
|
|
status.check_status(),
|
|
status.summary(),
|
|
)
|
|
.details(details);
|
|
if status.check_status() == CheckStatus::Warning {
|
|
check = check.remediation("Run codex app-server daemon version for more details.");
|
|
}
|
|
check
|
|
}
|
|
|
|
fn push_file_detail(details: &mut Vec<String>, label: &str, path: &Path) {
|
|
match std::fs::metadata(path) {
|
|
Ok(metadata) if metadata.is_file() => {
|
|
details.push(format!("{label}: {} (file)", path.display()));
|
|
}
|
|
Ok(_) => {
|
|
details.push(format!("{label}: {} (not a file)", path.display()));
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
details.push(format!("{label}: {} (missing)", path.display()));
|
|
}
|
|
Err(err) => details.push(format!("{label}: {} ({err})", path.display())),
|
|
}
|
|
}
|
|
|
|
fn push_configured_updater(details: &mut Vec<String>, path: &Path) {
|
|
if !path.is_file() {
|
|
return;
|
|
}
|
|
let mut contents = Vec::new();
|
|
let read = std::fs::File::open(path)
|
|
.and_then(|file| file.take(MAX_SETTINGS_BYTES + 1).read_to_end(&mut contents));
|
|
let settings = read
|
|
.ok()
|
|
.filter(|_| contents.len() as u64 <= MAX_SETTINGS_BYTES)
|
|
.and_then(|_| serde_json::from_slice::<ConfiguredSettings>(&contents).ok());
|
|
let Some(settings) = settings else {
|
|
details.push("configured updater settings: unreadable or invalid".into());
|
|
return;
|
|
};
|
|
if let Some(updater) = settings.updater {
|
|
if let Some(enabled) = updater.auto_update_enabled {
|
|
details.push(format!(
|
|
"automatic updates: {} (configured)",
|
|
if enabled { "enabled" } else { "disabled" }
|
|
));
|
|
}
|
|
if let Some(minutes) = updater.update_interval_minutes {
|
|
details.push(format!("update interval: {minutes} minutes (configured)"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn server_mode(state_dir: &Path) -> &'static str {
|
|
if state_dir.join(SETTINGS_FILE_NAME).is_file() {
|
|
"persistent"
|
|
} else {
|
|
"ephemeral"
|
|
}
|
|
}
|
|
|
|
enum SocketStatus {
|
|
NotRunning,
|
|
Running(String),
|
|
StaleOrUnreachable(String),
|
|
}
|
|
|
|
impl SocketStatus {
|
|
fn check_status(&self) -> CheckStatus {
|
|
match self {
|
|
Self::NotRunning | Self::Running(_) => CheckStatus::Ok,
|
|
Self::StaleOrUnreachable(_) => CheckStatus::Warning,
|
|
}
|
|
}
|
|
|
|
fn summary(&self) -> &'static str {
|
|
match self {
|
|
Self::NotRunning => "background server is not running",
|
|
Self::Running(_) => "background server is running",
|
|
Self::StaleOrUnreachable(_) => "background server socket is stale or unreachable",
|
|
}
|
|
}
|
|
|
|
fn detail_label(&self) -> &'static str {
|
|
match self {
|
|
Self::NotRunning => "not running",
|
|
Self::Running(_) => "running",
|
|
Self::StaleOrUnreachable(_) => "stale or unreachable",
|
|
}
|
|
}
|
|
|
|
fn app_server_version_detail(&self) -> Option<String> {
|
|
match self {
|
|
Self::NotRunning => None,
|
|
Self::Running(app_server_version) => {
|
|
Some(format!("app-server version: {app_server_version}"))
|
|
}
|
|
Self::StaleOrUnreachable(error) => {
|
|
Some(format!("app-server version: unavailable ({error})"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn socket_status(socket_path: &Path) -> SocketStatus {
|
|
if !socket_path.exists() {
|
|
return SocketStatus::NotRunning;
|
|
}
|
|
|
|
match codex_app_server_daemon::probe_app_server_version(socket_path).await {
|
|
Ok(app_server_version) => SocketStatus::Running(app_server_version),
|
|
Err(err) => SocketStatus::StaleOrUnreachable(concise_probe_error(&err, socket_path)),
|
|
}
|
|
}
|
|
|
|
fn concise_probe_error(err: &anyhow::Error, socket_path: &Path) -> String {
|
|
let socket_path = socket_path.display().to_string();
|
|
let message = err
|
|
.to_string()
|
|
.replace(&socket_path, "control socket")
|
|
.split_whitespace()
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
if message.is_empty() {
|
|
return "unknown error".to_string();
|
|
}
|
|
let mut chars = message.chars();
|
|
let truncated = chars
|
|
.by_ref()
|
|
.take(MAX_PROBE_ERROR_CHARS)
|
|
.collect::<String>();
|
|
if chars.next().is_some() {
|
|
format!("{truncated}...")
|
|
} else {
|
|
message
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::path::PathBuf;
|
|
|
|
use codex_core::config::ConfigBuilder;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
use super::*;
|
|
|
|
async fn test_config(codex_home: PathBuf) -> Config {
|
|
ConfigBuilder::default()
|
|
.codex_home(codex_home)
|
|
.build()
|
|
.await
|
|
.expect("config")
|
|
}
|
|
|
|
fn create_socket_placeholder(config: &Config) {
|
|
let socket_path = codex_app_server::app_server_control_socket_path(&config.codex_home)
|
|
.expect("socket path");
|
|
std::fs::create_dir_all(socket_path.parent().expect("socket parent"))
|
|
.expect("create socket dir");
|
|
std::fs::write(socket_path, "").expect("create socket placeholder");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn not_running_background_server_stays_ok_without_version() {
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let config = test_config(temp.path().to_path_buf()).await;
|
|
|
|
let check = background_server_check(&config).await;
|
|
|
|
assert_eq!(check.status, CheckStatus::Ok);
|
|
assert_eq!(check.summary, "background server is not running");
|
|
assert!(check.details.contains(&"status: not running".to_string()));
|
|
assert!(
|
|
!check
|
|
.details
|
|
.iter()
|
|
.any(|detail| detail.starts_with("app-server version:"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn running_background_server_reports_app_server_version() {
|
|
let status = SocketStatus::Running("1.2.3".to_string());
|
|
|
|
assert_eq!(status.check_status(), CheckStatus::Ok);
|
|
assert_eq!(status.summary(), "background server is running");
|
|
assert_eq!(status.detail_label(), "running");
|
|
assert_eq!(
|
|
status.app_server_version_detail(),
|
|
Some("app-server version: 1.2.3".to_string())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_version_probe_reports_unavailable() {
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let config = test_config(temp.path().to_path_buf()).await;
|
|
create_socket_placeholder(&config);
|
|
|
|
let check = background_server_check(&config).await;
|
|
|
|
assert_eq!(check.status, CheckStatus::Warning);
|
|
assert_eq!(
|
|
check.summary,
|
|
"background server socket is stale or unreachable"
|
|
);
|
|
assert!(
|
|
check
|
|
.details
|
|
.contains(&"status: stale or unreachable".to_string())
|
|
);
|
|
assert!(
|
|
check
|
|
.details
|
|
.iter()
|
|
.any(|detail| detail.starts_with("app-server version: unavailable ("))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn configured_updates_and_missing_updater_record_are_visible() {
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let state_dir = temp.path().join(STATE_DIR_NAME);
|
|
std::fs::create_dir(&state_dir).expect("state dir");
|
|
std::fs::write(
|
|
state_dir.join(SETTINGS_FILE_NAME),
|
|
r#"{"updater":{"autoUpdateEnabled":false,"updateIntervalMinutes":90}}"#,
|
|
)
|
|
.expect("settings");
|
|
let config = test_config(temp.path().to_path_buf()).await;
|
|
|
|
let check = background_server_check(&config).await;
|
|
let details = check
|
|
.details
|
|
.iter()
|
|
.filter(|detail| {
|
|
detail.starts_with("automatic updates:")
|
|
|| detail.starts_with("update interval:")
|
|
|| detail.starts_with("update-loop pid file:")
|
|
|| detail.starts_with("dedicated")
|
|
})
|
|
.map(|detail| {
|
|
detail
|
|
.replace(&temp.path().display().to_string(), "CODEX_HOME")
|
|
.replace('\\', "/")
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
insta::assert_snapshot!("configured_updater", details);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_settings_are_not_reported_as_effective() {
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let path = temp.path().join(SETTINGS_FILE_NAME);
|
|
for contents in [
|
|
r#"{"updater":{"autoUpdateEnabled":"no"}}"#,
|
|
r#"{"updater":{"updateIntervalMinutes":0}}"#,
|
|
] {
|
|
std::fs::write(&path, contents).expect("settings");
|
|
let mut details = Vec::new();
|
|
|
|
push_configured_updater(&mut details, &path);
|
|
|
|
insta::allow_duplicates! {
|
|
insta::assert_snapshot!(details.join("\n"), @"configured updater settings: unreadable or invalid");
|
|
}
|
|
}
|
|
}
|
|
}
|