//! 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, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ConfiguredUpdater { auto_update_enabled: Option, update_interval_minutes: Option, } /// 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, 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, 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::(&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 { 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::>() .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::(); 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::>() .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"); } } } }