From daca1fab840303152d7b488a4b403dee2f85b62f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 7 Sep 2026 19:00:03 +0000 Subject: [PATCH] Add an explicit app-server daemon update command (#43562) ## What changed Add `codex app-server daemon update` to check the latest stable release once, even when automatic updates are disabled. The command requires an installer-owned latest-channel standalone installation and reports `updated`, `noUpdate`, or `unsupported` as JSON, including installed and running versions. Route manual requests through the updater so scheduled and manual installs share one owner and accepted updates continue if the CLI exits. Restart a running managed daemon when its binary or version differs from the selected installation; this may interrupt active or queued work. Return installer failures as command errors and retry requests across updater replacement. ## Testing Add coverage for manual updates with automatic updates disabled, queued requests, same-version binary replacement, updater handoff retries, and unsupported installations or unmanaged servers. GitOrigin-RevId: 240b63c79bae3a64a80768131a30da70900a4d62 --- codex-rs/app-server-daemon/README.md | 13 +- codex-rs/app-server-daemon/src/backend/pid.rs | 2 - codex-rs/app-server-daemon/src/lib.rs | 131 +++--- .../app-server-daemon/src/manual_update.rs | 249 +++++++++++ codex-rs/app-server-daemon/src/update_loop.rs | 354 +++++++++++---- .../src/update_loop_tests.rs | 404 ++++++++++++++++-- codex-rs/cli/src/main.rs | 8 + codex-rs/cli/tests/app_server_daemon.rs | 29 ++ 8 files changed, 1005 insertions(+), 185 deletions(-) create mode 100644 codex-rs/app-server-daemon/src/manual_update.rs diff --git a/codex-rs/app-server-daemon/README.md b/codex-rs/app-server-daemon/README.md index 68ba3169cf..913e794cbf 100644 --- a/codex-rs/app-server-daemon/README.md +++ b/codex-rs/app-server-daemon/README.md @@ -32,6 +32,7 @@ initialize the connection, the TUI starts an embedded server instead. Explicit ```sh codex app-server daemon start codex app-server daemon restart +codex app-server daemon update codex app-server daemon enable-remote-control codex app-server daemon disable-remote-control codex app-server daemon stop @@ -53,7 +54,17 @@ default. Edit `CODEX_HOME/app-server-daemon/settings.json` to change this: ``` Positive minute intervals have no configured cap. `daemon restart` applies the -enabled state; the next updater wait reads a new interval. `codex update` is unaffected. +enabled state; the next updater wait reads a new interval. The preference does +not affect an explicit `codex update` command or `daemon update`. + +`daemon update` checks the latest stable release once, even with automatic +updates disabled. It requires a Codex installer-owned latest-channel standalone +install. JSON reports `updated`, `noUpdate`, or `unsupported`, with installed +and running versions. The updater owns scheduled and manual installs. A manual +update restarts a running managed daemon, so active or queued work may be +interrupted. +Accepted updates continue if the CLI exits. Installer errors return nonzero. +The updater uses saved network settings; CLI `-c` overrides do not reach it. ## Bootstrap flow diff --git a/codex-rs/app-server-daemon/src/backend/pid.rs b/codex-rs/app-server-daemon/src/backend/pid.rs index dbe93f6512..393b5c9798 100644 --- a/codex-rs/app-server-daemon/src/backend/pid.rs +++ b/codex-rs/app-server-daemon/src/backend/pid.rs @@ -78,8 +78,6 @@ enum PidCommandKind { } impl PidBackend { - // Used by the stacked manual-update command to compare the running image. - #[allow(dead_code)] pub(crate) async fn running_executable_identity(&self) -> Result> { match self.read_pid_file_state().await? { PidFileState::Running(record) if self.record_is_active(&record).await? => { diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs index 280786d98d..8d0cca3d9b 100644 --- a/codex-rs/app-server-daemon/src/lib.rs +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -105,6 +105,24 @@ pub struct BootstrapOutput { pub app_server_version: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum UpdateStatus { + Updated, + NoUpdate, + Unsupported, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateOutput { + pub status: UpdateStatus, + pub managed_codex_path: PathBuf, + pub installed_version: Option, + pub running_version: Option, + pub message: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(untagged)] pub enum RemoteControlStartOutput { @@ -174,16 +192,10 @@ pub(crate) enum RestartIfRunningOutcome { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RestartMode { IfVersionChanged, + IfBinaryOrVersionChanged, Always, } -#[cfg(any(unix, windows))] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum UpdaterRefreshMode { - None, - ReexecIfManagedBinaryChanged, -} - #[cfg(any(unix, windows))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RestartDecision { @@ -254,6 +266,13 @@ pub async fn run_pid_update_loop( update_loop::run(http_client_factory).await } +pub async fn update() -> Result { + ensure_supported_platform()?; + #[cfg(windows)] + backend::windows::ensure_not_elevated()?; + update_loop::request_manual_update(&Daemon::from_environment()?).await +} + #[cfg(any(unix, windows))] fn ensure_supported_platform() -> Result<()> { Ok(()) @@ -388,7 +407,6 @@ impl Daemon { pub(crate) async fn try_restart_if_running( &self, mode: RestartMode, - updater_refresh_mode: UpdaterRefreshMode, managed_codex_bin: &Path, ) -> Result { let operation_lock = self.open_operation_lock_file().await?; @@ -407,7 +425,7 @@ impl Daemon { // this lock or probes the running server. Never restart from a // release that is no longer the selected latest-channel binary. if !self.is_stable_standalone_release()? - || managed_install::resolved_managed_codex_bin(&self.managed_codex_bin) + || managed_install::resolved_managed_codex_bin(&self.current_managed_codex_bin()?) .await .ok() .as_deref() @@ -415,6 +433,18 @@ impl Daemon { { return Ok(RestartIfRunningOutcome::AlreadyCurrent); } + let mode = if mode == RestartMode::IfBinaryOrVersionChanged { + let managed_identity = + managed_install::executable_identity(managed_codex_bin).await?; + if backend.running_executable_identity().await?.as_ref() == Some(&managed_identity) + { + RestartMode::IfVersionChanged + } else { + RestartMode::Always + } + } else { + mode + }; match restart_decision(mode, info.as_ref(), managed_version.as_deref()) { RestartDecision::NotReady => return Ok(RestartIfRunningOutcome::NotReady), RestartDecision::AlreadyCurrent => RestartIfRunningOutcome::AlreadyCurrent, @@ -422,7 +452,9 @@ impl Daemon { #[cfg(windows)] backend::windows::ensure_detached_launch(managed_codex_bin)?; backend.stop().await?; - let _ = self.start_managed_backend(&settings).await?; + let _ = self + .start_managed_backend_with_bin(&settings, managed_codex_bin) + .await?; self.wait_until_ready().await?; RestartIfRunningOutcome::Restarted } @@ -436,7 +468,7 @@ impl Daemon { }; if !self.is_stable_standalone_release()? - || managed_install::resolved_managed_codex_bin(&self.managed_codex_bin) + || managed_install::resolved_managed_codex_bin(&self.current_managed_codex_bin()?) .await .ok() .as_deref() @@ -444,19 +476,6 @@ impl Daemon { { return Ok(RestartIfRunningOutcome::AlreadyCurrent); } - #[cfg(unix)] - if should_reexec_updater(updater_refresh_mode, outcome) { - crate::update_loop::reexec_managed_updater(managed_codex_bin)?; - } - #[cfg(windows)] - if should_reexec_updater(updater_refresh_mode, outcome) { - backend::pid_update_loop_backend( - self.backend_paths_with_bin(&settings, managed_codex_bin), - ) - .replace_current_updater() - .await?; - } - Ok(outcome) } @@ -767,10 +786,20 @@ impl Daemon { .context("daemon settings path has no Codex home")?; Ok(managed_install::is_stable_standalone_release( codex_home, - &self.managed_codex_bin, + &self.current_managed_codex_bin()?, )) } + fn current_managed_codex_bin(&self) -> Result { + // An installer can move a legacy binary into bin/ while this updater runs. + let home = self + .settings_file + .parent() + .and_then(Path::parent) + .context("daemon settings path has no Codex home")?; + Ok(managed_install::managed_codex_bin(home)) + } + fn has_latest_selection_marker(&self) -> bool { self.settings_file .parent() @@ -841,6 +870,11 @@ impl Daemon { } } + fn manual_update_socket_path(&self) -> PathBuf { + self.update_pid_file + .with_file_name("app-server-updater.sock") + } + async fn load_settings(&self) -> Result { DaemonSettings::load(&self.settings_file).await } @@ -948,6 +982,9 @@ fn restart_decision( managed_version: Option<&str>, ) -> RestartDecision { match (mode, info, managed_version) { + (RestartMode::IfBinaryOrVersionChanged, _, _) => { + unreachable!("binary comparison is resolved before restart decision") + } (RestartMode::IfVersionChanged, None, _) => RestartDecision::NotReady, (RestartMode::IfVersionChanged, Some(info), Some(managed_version)) if info.app_server_version == managed_version => @@ -958,15 +995,6 @@ fn restart_decision( } } -#[cfg(any(unix, windows))] -fn should_reexec_updater( - updater_refresh_mode: UpdaterRefreshMode, - outcome: RestartIfRunningOutcome, -) -> bool { - updater_refresh_mode == UpdaterRefreshMode::ReexecIfManagedBinaryChanged - && outcome == RestartIfRunningOutcome::Restarted -} - #[cfg(unix)] fn try_lock_file(file: &tokio::fs::File) -> Result { use std::os::fd::AsRawFd; @@ -1002,11 +1030,8 @@ mod tests { use super::RemoteControlStartOutput; use super::RemoteControlStatus; use super::RestartDecision; - use super::RestartIfRunningOutcome; use super::RestartMode; - use super::UpdaterRefreshMode; use super::restart_decision; - use super::should_reexec_updater; use crate::client::ProbeInfo; #[cfg(unix)] use crate::settings::DaemonSettings; @@ -1019,38 +1044,6 @@ mod tests { ); } - #[test] - fn updater_reexec_waits_for_validated_restart() { - assert_eq!( - [ - RestartIfRunningOutcome::Busy, - RestartIfRunningOutcome::NotReady, - RestartIfRunningOutcome::AlreadyCurrent, - RestartIfRunningOutcome::NotRunning, - RestartIfRunningOutcome::Restarted, - ] - .map(|outcome| { - should_reexec_updater(UpdaterRefreshMode::ReexecIfManagedBinaryChanged, outcome) - }), - [false, false, false, false, true] - ); - } - - #[test] - fn unchanged_updater_never_reexecs() { - assert_eq!( - [ - RestartIfRunningOutcome::Busy, - RestartIfRunningOutcome::NotReady, - RestartIfRunningOutcome::AlreadyCurrent, - RestartIfRunningOutcome::NotRunning, - RestartIfRunningOutcome::Restarted, - ] - .map(|outcome| should_reexec_updater(UpdaterRefreshMode::None, outcome)), - [false, false, false, false, false] - ); - } - #[test] fn restart_decision_preserves_forced_refreshes() { let current_info = ProbeInfo { diff --git a/codex-rs/app-server-daemon/src/manual_update.rs b/codex-rs/app-server-daemon/src/manual_update.rs new file mode 100644 index 0000000000..743b6e4e6f --- /dev/null +++ b/codex-rs/app-server-daemon/src/manual_update.rs @@ -0,0 +1,249 @@ +//! Forwards manual requests to the updater and reports its install and restart result. + +use anyhow::Context; +use anyhow::Result; +use codex_uds::UnixStream; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::time::Duration; +use tokio::time::sleep; +use tokio::time::timeout; + +use super::InstallerHttp; +use super::Signal; +use super::UpdateLoopControl; +use super::UpdateTrigger; +use super::selected_release; +use super::update_once; +use crate::Daemon; +use crate::RestartIfRunningOutcome; +use crate::UpdateOutput; +use crate::UpdateStatus; +use crate::client; +use crate::managed_install::executable_identity; +use crate::managed_install::managed_codex_version; + +pub(crate) async fn request(daemon: &Daemon) -> Result { + let socket_path = daemon.manual_update_socket_path(); + let mut replacement_deadline = None; + 'request: loop { + let mut connection = if let Some(deadline) = replacement_deadline { + loop { + if let Ok(connection) = UnixStream::connect(&socket_path).await { + break connection; + } + // A one-shot updater may exit after accepting the connection but + // before reading our request. Give a successor time to appear, + // then return to the normal startup path if none owns the PID. + if tokio::time::Instant::now() + Duration::from_secs(29) >= deadline { + let settings = daemon.load_settings().await?; + if !crate::backend::pid_update_loop_backend(daemon.backend_paths(&settings)) + .is_starting_or_running() + .await? + { + replacement_deadline = None; + continue 'request; + } + } + anyhow::ensure!( + tokio::time::Instant::now() < deadline, + "timed out waiting for the replacement daemon updater" + ); + sleep(Duration::from_millis(50)).await; + } + } else { + match UnixStream::connect(&socket_path).await { + Ok(connection) => connection, + Err(_) => { + let _operation_lock = daemon.acquire_operation_lock().await?; + if let Ok(connection) = UnixStream::connect(&socket_path).await { + connection + } else { + let settings = daemon.load_settings().await?; + if !daemon.is_stable_standalone_release()? + || (daemon.running_backend_instance(&settings).await?.is_none() + && client::probe(&daemon.socket_path).await.is_ok()) + { + return unsupported(daemon).await; + } + crate::backend::pid_update_loop_backend(daemon.backend_paths(&settings)) + .stop() + .await?; + let current_exe = std::env::current_exe()?; + let worker = crate::backend::pid_update_loop_backend( + daemon.backend_paths_with_bin(&settings, ¤t_exe), + ); + worker.start().await?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + if let Ok(connection) = UnixStream::connect(&socket_path).await { + break connection; + } + anyhow::ensure!( + tokio::time::Instant::now() < deadline, + "timed out waiting for the daemon updater to accept a manual request" + ); + sleep(Duration::from_millis(50)).await; + } + } + } + } + }; + let mut response = Vec::new(); + let transport = async { + #[cfg(windows)] + connection.ensure_non_elevated_peer()?; + connection.write_all(b"update\n").await?; + connection + .take(16 * 1024) + .read_to_end(&mut response) + .await?; + Ok::<_, std::io::Error>(()) + } + .await; + if response.is_empty() { + if transport + .as_ref() + .is_err_and(|err| err.kind() == std::io::ErrorKind::PermissionDenied) + { + transport?; + } + let deadline = replacement_deadline + .get_or_insert_with(|| tokio::time::Instant::now() + Duration::from_secs(30)); + anyhow::ensure!( + tokio::time::Instant::now() < *deadline, + "daemon updater disconnected before responding" + ); + sleep(Duration::from_millis(50)).await; + continue; + } + transport?; + let outcome: std::result::Result = + serde_json::from_slice(&response).context("invalid response from daemon updater")?; + return outcome.map_err(anyhow::Error::msg); + } +} + +const UNSUPPORTED_MESSAGE: &str = "This command requires a CLI-managed daemon and a stable latest-channel standalone install; update this installation with its owning installer."; + +async fn unsupported(daemon: &Daemon) -> Result { + let managed_codex_path = daemon.current_managed_codex_bin()?; + Ok(UpdateOutput { + status: UpdateStatus::Unsupported, + installed_version: managed_codex_version(&managed_codex_path).await.ok(), + running_version: client::probe(&daemon.socket_path) + .await + .ok() + .map(|info| info.app_server_version), + managed_codex_path, + message: UNSUPPORTED_MESSAGE.to_string(), + }) +} + +pub(super) async fn handle_request( + mut connection: UnixStream, + http: &impl InstallerHttp, + daemon: &Daemon, + running_updater_identity: &crate::managed_install::ExecutableIdentity, + terminate: &mut Signal, +) -> Result { + #[cfg(windows)] + if connection.ensure_non_elevated_peer().is_err() { + return Ok(RequestDisposition::Unchanged); + } + let mut request = [0; 7]; + if !matches!( + timeout(Duration::from_secs(5), connection.read_exact(&mut request)).await, + Ok(Ok(_)) + ) || &request != b"update\n" + { + return Ok(RequestDisposition::Unchanged); + } + let result = run(http, daemon, running_updater_identity, terminate).await; + let interrupted = result.as_ref().err().is_some_and(|err| { + err.downcast_ref::() + .is_some_and(|err| err.kind() == std::io::ErrorKind::Interrupted) + }); + let unsupported = result + .as_ref() + .is_ok_and(|output| output.status == UpdateStatus::Unsupported); + let outcome: std::result::Result = + result.map_err(|err| format!("{err:#}")); + let sent = connection.write_all(&serde_json::to_vec(&outcome)?).await; + if interrupted { + return Ok(RequestDisposition::Stop); + } + sent?; + Ok(if unsupported { + RequestDisposition::Unchanged + } else { + RequestDisposition::Continue + }) +} + +pub(super) enum RequestDisposition { + Continue, + Unchanged, + Stop, +} + +pub(super) async fn run( + http: &impl InstallerHttp, + daemon: &Daemon, + running_updater_identity: &crate::managed_install::ExecutableIdentity, + terminate: &mut Signal, +) -> Result { + let settings = daemon.load_settings().await?; + let managed_running = daemon.running_backend_instance(&settings).await?.is_some(); + if !daemon.is_stable_standalone_release()? + || (!managed_running && client::probe(&daemon.socket_path).await.is_ok()) + { + return unsupported(daemon).await; + } + + let managed_codex_path = daemon.current_managed_codex_bin()?; + let (_, previous_release, _) = selected_release(daemon)?; + let previous_identity = executable_identity(&managed_codex_path).await?; + let (control, restart) = update_once( + http, + daemon, + running_updater_identity, + terminate, + UpdateTrigger::Manual, + ) + .await?; + if matches!(control, UpdateLoopControl::Stop) { + return Err(std::io::Error::from(std::io::ErrorKind::Interrupted).into()); + } + let current_managed_codex_path = daemon.current_managed_codex_bin()?; + let installed_version = Some(managed_codex_version(¤t_managed_codex_path).await?); + let updated = previous_release != selected_release(daemon)?.1 + || previous_identity != executable_identity(¤t_managed_codex_path).await?; + let running_version = client::probe(&daemon.socket_path) + .await + .ok() + .map(|info| info.app_server_version); + let message = match restart { + Some(RestartIfRunningOutcome::Restarted) => { + "The managed installation is ready and the running daemon was restarted. Active or queued work may have been interrupted." + } + Some(RestartIfRunningOutcome::AlreadyCurrent) => { + "The managed installation and running daemon are already current; the daemon was left running." + } + Some(RestartIfRunningOutcome::NotRunning) => { + "The managed installation is ready; no daemon was running when the updater checked." + } + _ => unreachable!("successful manual update has a restart outcome"), + }; + Ok(UpdateOutput { + status: if updated { + UpdateStatus::Updated + } else { + UpdateStatus::NoUpdate + }, + managed_codex_path: current_managed_codex_path, + installed_version, + running_version, + message: message.to_string(), + }) +} diff --git a/codex-rs/app-server-daemon/src/update_loop.rs b/codex-rs/app-server-daemon/src/update_loop.rs index 74cf16c672..2fe7850df4 100644 --- a/codex-rs/app-server-daemon/src/update_loop.rs +++ b/codex-rs/app-server-daemon/src/update_loop.rs @@ -1,4 +1,4 @@ -//! Installs updates, validates the server restart, then transfers updater ownership. +//! Owns scheduled and manual installs, daemon restarts, and updater replacement. use std::path::Path; #[cfg(unix)] @@ -29,7 +29,6 @@ use tokio::time::sleep_until; use crate::Daemon; use crate::RestartIfRunningOutcome; use crate::RestartMode; -use crate::UpdaterRefreshMode; use crate::managed_install::ExecutableIdentity; use crate::managed_install::executable_identity; use crate::managed_install::resolved_managed_codex_bin; @@ -37,6 +36,10 @@ use crate::managed_install::resolved_managed_codex_bin; use crate::settings::DaemonSettings; use crate::settings::UpdaterSettings; +#[path = "manual_update.rs"] +mod manual_update; +pub(crate) use manual_update::request as request_manual_update; + const INITIAL_UPDATE_DELAY: Duration = Duration::from_secs(5 * 60); const RESTART_RETRY_INTERVAL: Duration = Duration::from_millis(50); #[cfg(unix)] @@ -45,12 +48,28 @@ const INSTALL_URL: &str = "https://chatgpt.com/codex/install.sh"; const INSTALL_URL: &str = "https://chatgpt.com/codex/install.ps1"; pub(crate) async fn run(http_client_factory: HttpClientFactory) -> Result<()> { + let http = RouteAwareClientPool::new_without_request_logging( + http_client_factory, + ClientRouteClass::Other, + ); + run_with_http( + &http, + &Daemon::from_environment()?, + ¤t_updater_identity().await?, + ) + .await +} + +async fn run_with_http( + http: &impl InstallerHttp, + daemon: &Daemon, + running_updater_identity: &ExecutableIdentity, +) -> Result<()> { #[cfg(unix)] let mut terminate = signal(SignalKind::terminate()).context("failed to install updater shutdown handler")?; #[cfg(windows)] let updater = { - let daemon = Daemon::from_environment()?; // Updater ownership needs only paths, not settings that may be mid-edit. crate::backend::pid_update_loop_backend(daemon.backend_paths(&DaemonSettings::default())) }; @@ -60,17 +79,63 @@ pub(crate) async fn run(http_client_factory: HttpClientFactory) -> Result<()> { let mut terminate = Signal; #[cfg(windows)] let _installer_job = crate::backend::windows::updater_job()?; - let running_updater_identity = current_updater_identity().await?; + let socket_path = daemon.manual_update_socket_path(); + codex_uds::prepare_private_socket_directory( + socket_path + .parent() + .context("updater socket has no parent")?, + ) + .await?; + if socket_path.exists() { + tokio::fs::remove_file(&socket_path).await?; + } + let mut listener = Some(codex_uds::UnixListener::bind(&socket_path).await?); #[cfg(windows)] updater.mark_ready().await?; - let http = RouteAwareClientPool::new_without_request_logging( - http_client_factory, - ClientRouteClass::Other, - ); - let daemon = Daemon::from_environment()?; - let mut next_check = Instant::now() + INITIAL_UPDATE_DELAY; + let needs_managed_handoff = + match resolved_managed_codex_bin(&daemon.current_managed_codex_bin()?).await { + Ok(managed_bin) => { + executable_identity(&managed_bin).await.ok().as_ref() + != Some(running_updater_identity) + } + Err(_) => true, + }; + let auto_update_enabled = UpdaterSettings::load(&daemon.settings_file) + .await + .map(|settings| settings.auto_update_enabled) + .unwrap_or(true); + let mut next_check = Instant::now() + + if needs_managed_handoff || !auto_update_enabled { + Duration::from_secs(15) + } else { + INITIAL_UPDATE_DELAY + }; + let mut manual_handoff_pending = needs_managed_handoff; loop { tokio::select! { + biased; + _ = terminate.recv() => return Ok(()), + connection = listener.as_mut().context("updater listener closed")?.accept() => { + let connection = connection.context("failed to accept updater request")?; + let disposition = match manual_update::handle_request(connection, http, daemon, running_updater_identity, &mut terminate).await { + Ok(manual_update::RequestDisposition::Stop) => return Ok(()), + Ok(disposition) => disposition, + Err(_) => manual_update::RequestDisposition::Continue, + }; + if UpdaterSettings::load(&daemon.settings_file) + .await + .is_ok_and(|settings| !settings.auto_update_enabled) + { + // Drain requests queued during this one-shot update before exiting. + next_check = Instant::now() + Duration::from_millis(100); + continue; + } + if matches!(disposition, manual_update::RequestDisposition::Unchanged) { + continue; + } + manual_handoff_pending = true; + next_check = Instant::now(); + } _ = sleep_until(next_check) => { match UpdaterSettings::load(&daemon.settings_file).await { Ok(settings) if !settings.auto_update_enabled => return Ok(()), @@ -84,21 +149,97 @@ pub(crate) async fn run(http_client_factory: HttpClientFactory) -> Result<()> { // must stop instead of installing again without ownership. #[cfg(windows)] updater.wait_for_ownership().await?; - match update_once(&http, &running_updater_identity, &mut terminate).await { - Ok(UpdateLoopControl::Continue) | Err(_) => {} - Ok(UpdateLoopControl::Stop) => return Ok(()), + if manual_handoff_pending { + if !daemon.is_stable_standalone_release()? { + if !daemon.has_latest_selection_marker() { + return Ok(()); + } + next_check = Instant::now() + Duration::from_secs(30); + continue; + } + match adopt_managed_updater(daemon, running_updater_identity, &mut listener).await { + Ok(UpdateLoopControl::Stop) => return Ok(()), + Ok(UpdateLoopControl::Continue) => { + manual_handoff_pending = false; + let Some(delay) = next_update_delay(daemon).await else { + return Ok(()); + }; + next_check = Instant::now() + delay; + } + Err(err) => { + if listener.is_none() { + return Err(err); + } + eprintln!("warning: failed to refresh managed updater: {err:#}"); + next_check = Instant::now() + Duration::from_secs(30); + } + } + continue; } - next_check = Instant::now() + match UpdaterSettings::load(&daemon.settings_file).await { - Ok(settings) if !settings.auto_update_enabled => return Ok(()), - Ok(settings) => settings.update_interval(Duration::from_secs(60)), - Err(_) => Duration::from_secs(60), + match update_once(http, daemon, running_updater_identity, &mut terminate, UpdateTrigger::Scheduled).await { + Ok((UpdateLoopControl::Continue, Some(_))) => { + manual_handoff_pending = true; + next_check = Instant::now(); + continue; + } + Ok((UpdateLoopControl::Continue, None)) | Err(_) => {} + Ok((UpdateLoopControl::Stop, _)) => return Ok(()), + } + let Some(delay) = next_update_delay(daemon).await else { + return Ok(()); }; + next_check = Instant::now() + delay; } - _ = terminate.recv() => return Ok(()), } } } +async fn next_update_delay(daemon: &Daemon) -> Option { + match UpdaterSettings::load(&daemon.settings_file).await { + Ok(settings) if !settings.auto_update_enabled => None, + Ok(settings) => Some(settings.update_interval(Duration::from_secs(60))), + Err(_) => Some(Duration::from_secs(60)), + } +} + +async fn adopt_managed_updater( + daemon: &Daemon, + running_identity: &ExecutableIdentity, + listener: &mut Option, +) -> Result { + let managed_bin = resolved_managed_codex_bin(&daemon.current_managed_codex_bin()?).await?; + if executable_identity(&managed_bin).await? == *running_identity { + return Ok(UpdateLoopControl::Continue); + } + if !crate::managed_install::supports_daemon_update_loop(&managed_bin).await { + return Ok(UpdateLoopControl::Stop); + } + #[cfg(unix)] + { + let _ = listener; + reexec_managed_updater(&managed_bin).map(|_| UpdateLoopControl::Stop) + } + #[cfg(windows)] + { + let replacement = crate::backend::pid_update_loop_backend( + daemon.backend_paths_with_bin(&daemon.load_settings().await?, &managed_bin), + ); + listener.take(); + if let Err(err) = replacement.replace_current_updater().await { + // A failed replacement may still own the PID if its cleanup could + // not terminate the successor. Never reopen our request socket then. + replacement.wait_for_ownership().await?; + let socket_path = daemon.manual_update_socket_path(); + if socket_path.exists() { + tokio::fs::remove_file(&socket_path).await?; + } + *listener = Some(codex_uds::UnixListener::bind(&socket_path).await?); + return Err(err); + } + Ok(UpdateLoopControl::Stop) + } +} + async fn sleep_or_terminate(duration: Duration, terminate: &mut Signal) -> bool { tokio::select! { _ = sleep(duration) => false, @@ -111,37 +252,35 @@ enum UpdateLoopControl { Stop, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum UpdateTrigger { + Scheduled, + Manual, +} + async fn update_once( - http: &RouteAwareClientPool, + http: &impl InstallerHttp, + daemon: &Daemon, running_updater_identity: &ExecutableIdentity, terminate: &mut Signal, -) -> Result { - let daemon = Daemon::from_environment()?; - if !UpdaterSettings::load(&daemon.settings_file) - .await? - .auto_update_enabled + trigger: UpdateTrigger, +) -> Result<(UpdateLoopControl, Option)> { + if trigger == UpdateTrigger::Scheduled + && !UpdaterSettings::load(&daemon.settings_file) + .await? + .auto_update_enabled { - return Ok(UpdateLoopControl::Stop); + return Ok((UpdateLoopControl::Stop, None)); } - if !daemon.is_stable_standalone_release()? { + if release_selection_unstable(daemon, trigger)? { // An installer can be between changing current and publishing its // latest-channel marker. Retry after the interval instead of exiting. - return Ok(UpdateLoopControl::Continue); + return Ok((UpdateLoopControl::Continue, None)); } - let codex_home = daemon - .settings_file - .parent() - .and_then(Path::parent) - .context("daemon settings path has no Codex home")?; - let current = std::fs::canonicalize(codex_home.join("packages/standalone/current"))?; - let previous_release = current - .file_name() - .context("managed release has no name")? - .to_string_lossy() - .into_owned(); + let (_codex_home, _, previous_release) = selected_release(daemon)?; let script = tokio::select! { result = fetch_installer_script(http) => result?, - _ = terminate.recv() => return Ok(UpdateLoopControl::Stop), + _ = terminate.recv() => return Ok((UpdateLoopControl::Stop, None)), }; anyhow::ensure!( script @@ -149,89 +288,134 @@ async fn update_once( .any(|window| window == b"CODEX_INSTALL_IF_LATEST"), "standalone installer does not support guarded updates" ); - if !UpdaterSettings::load(&daemon.settings_file) - .await? - .auto_update_enabled + if trigger == UpdateTrigger::Scheduled + && !UpdaterSettings::load(&daemon.settings_file) + .await? + .auto_update_enabled { - return Ok(UpdateLoopControl::Stop); + return Ok((UpdateLoopControl::Stop, None)); } - if !daemon.is_stable_standalone_release()? { - return Ok(UpdateLoopControl::Continue); + if release_selection_unstable(daemon, trigger)? { + return Ok((UpdateLoopControl::Continue, None)); } #[cfg(unix)] if matches!( - run_installer_script(&script, &previous_release, codex_home, terminate.recv()).await?, + run_installer_script(&script, &previous_release, _codex_home, terminate.recv()).await?, UpdateLoopControl::Stop ) { - return Ok(UpdateLoopControl::Stop); + return Ok((UpdateLoopControl::Stop, None)); } #[cfg(windows)] tokio::select! { result = run_installer_script(&script, &previous_release) => { result?; }, - _ = terminate.recv() => return Ok(UpdateLoopControl::Stop), + _ = terminate.recv() => return Ok((UpdateLoopControl::Stop, None)), } - if !daemon.is_stable_standalone_release()? { - return Ok(UpdateLoopControl::Continue); + if release_selection_unstable(daemon, trigger)? { + return Ok((UpdateLoopControl::Continue, None)); } - let managed_codex_bin = resolved_managed_codex_bin(&daemon.managed_codex_bin).await?; - let managed_identity = executable_identity(&managed_codex_bin).await?; - let (restart_mode, updater_refresh_mode) = - update_modes_for_identities(running_updater_identity, &managed_identity); + let managed_codex_bin = + resolved_managed_codex_bin(&daemon.current_managed_codex_bin()?).await?; + let restart_mode = match trigger { + UpdateTrigger::Manual => RestartMode::IfBinaryOrVersionChanged, + UpdateTrigger::Scheduled + if executable_identity(&managed_codex_bin).await? != *running_updater_identity => + { + RestartMode::Always + } + UpdateTrigger::Scheduled => RestartMode::IfVersionChanged, + }; loop { if terminate.recv().now_or_never().flatten().is_some() { - return Ok(UpdateLoopControl::Stop); + return Ok((UpdateLoopControl::Stop, None)); } match daemon - .try_restart_if_running(restart_mode, updater_refresh_mode, &managed_codex_bin) + .try_restart_if_running(restart_mode, &managed_codex_bin) .await? { RestartIfRunningOutcome::Busy => { if sleep_or_terminate(RESTART_RETRY_INTERVAL, terminate).await { - return Ok(UpdateLoopControl::Stop); + return Ok((UpdateLoopControl::Stop, None)); } } RestartIfRunningOutcome::Restarted => { - #[cfg(windows)] - if updater_refresh_mode == UpdaterRefreshMode::ReexecIfManagedBinaryChanged { - return Ok(UpdateLoopControl::Stop); - } - return Ok(UpdateLoopControl::Continue); + return Ok(( + UpdateLoopControl::Continue, + Some(RestartIfRunningOutcome::Restarted), + )); } - RestartIfRunningOutcome::NotRunning - | RestartIfRunningOutcome::NotReady - | RestartIfRunningOutcome::AlreadyCurrent => { - return Ok(if daemon.is_stable_standalone_release()? { - UpdateLoopControl::Continue - } else { - UpdateLoopControl::Stop - }); + RestartIfRunningOutcome::NotRunning => { + return Ok(( + UpdateLoopControl::Continue, + Some(RestartIfRunningOutcome::NotRunning), + )); + } + RestartIfRunningOutcome::AlreadyCurrent + if trigger == UpdateTrigger::Manual + && restart_mode == RestartMode::IfBinaryOrVersionChanged => + { + anyhow::ensure!( + daemon.is_stable_standalone_release()? + && resolved_managed_codex_bin(&daemon.current_managed_codex_bin()?).await? + == managed_codex_bin, + "managed daemon changed during the update; retry" + ); + return Ok(( + UpdateLoopControl::Continue, + Some(RestartIfRunningOutcome::AlreadyCurrent), + )); + } + RestartIfRunningOutcome::NotReady | RestartIfRunningOutcome::AlreadyCurrent => { + anyhow::ensure!( + trigger != UpdateTrigger::Manual, + "managed daemon could not restart; retry when it is ready" + ); + return Ok(( + if daemon.is_stable_standalone_release()? { + UpdateLoopControl::Continue + } else { + UpdateLoopControl::Stop + }, + None, + )); } } } } +fn release_selection_unstable(daemon: &Daemon, trigger: UpdateTrigger) -> Result { + if daemon.is_stable_standalone_release()? { + return Ok(false); + } + anyhow::ensure!( + trigger != UpdateTrigger::Manual, + "standalone install selection changed during the update" + ); + Ok(true) +} + +fn selected_release(daemon: &Daemon) -> Result<(&Path, std::path::PathBuf, String)> { + let home = daemon + .settings_file + .parent() + .and_then(Path::parent) + .context("daemon settings path has no Codex home")?; + let release = std::fs::canonicalize(home.join("packages/standalone/current"))?; + let name = release + .file_name() + .context("managed release has no name")? + .to_string_lossy() + .into_owned(); + Ok((home, release, name)) +} + async fn current_updater_identity() -> Result { let current_exe = std::env::current_exe().context("failed to resolve current updater executable")?; executable_identity(¤t_exe).await } -fn update_modes_for_identities( - running_updater_identity: &ExecutableIdentity, - managed_identity: &ExecutableIdentity, -) -> (RestartMode, UpdaterRefreshMode) { - if running_updater_identity == managed_identity { - (RestartMode::IfVersionChanged, UpdaterRefreshMode::None) - } else { - ( - RestartMode::Always, - UpdaterRefreshMode::ReexecIfManagedBinaryChanged, - ) - } -} - #[cfg(unix)] pub(crate) fn reexec_managed_updater(managed_codex_bin: &std::path::Path) -> Result<()> { let err = StdCommand::new(managed_codex_bin) @@ -263,12 +447,12 @@ async fn run_installer_script( let mut command = Command::new("powershell.exe"); command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW); command.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "try { Invoke-Expression ([Console]::In.ReadToEnd()) } catch { Write-Error $_; exit 1 }"]) - .env("CODEX_NON_INTERACTIVE", "1") .kill_on_drop(true); command }; let mut child = command .env("CODEX_RELEASE", "latest") + .env("CODEX_NON_INTERACTIVE", "1") .env("CODEX_INSTALL_IF_LATEST", "1") .env("CODEX_UPDATE_FROM_RELEASE", previous_release) .kill_on_drop(true) diff --git a/codex-rs/app-server-daemon/src/update_loop_tests.rs b/codex-rs/app-server-daemon/src/update_loop_tests.rs index aebe19e602..ff2b177b86 100644 --- a/codex-rs/app-server-daemon/src/update_loop_tests.rs +++ b/codex-rs/app-server-daemon/src/update_loop_tests.rs @@ -3,41 +3,26 @@ use std::sync::Mutex; use std::time::Duration; use pretty_assertions::assert_eq; +#[cfg(unix)] +use tempfile::TempDir; use super::INSTALL_URL; use super::InstallerHttp; use super::InstallerResponse; use super::fetch_installer_script; -use super::update_modes_for_identities; -use crate::RestartMode; -use crate::UpdaterRefreshMode; +#[cfg(unix)] +use super::manual_update::run as manual_update_once; +#[cfg(unix)] +use crate::Daemon; +#[cfg(unix)] +use crate::UpdateOutput; +#[cfg(unix)] +use crate::UpdateStatus; +#[cfg(unix)] +use crate::managed_install::executable_identity; +#[cfg(unix)] use crate::managed_install::executable_identity_from_bytes; -#[test] -fn unchanged_updater_uses_version_based_restart() { - assert_eq!( - update_modes_for_identities( - &executable_identity_from_bytes(b"same"), - &executable_identity_from_bytes(b"same"), - ), - (RestartMode::IfVersionChanged, UpdaterRefreshMode::None) - ); -} - -#[test] -fn changed_updater_forces_refresh_even_when_version_may_match() { - assert_eq!( - update_modes_for_identities( - &executable_identity_from_bytes(b"old"), - &executable_identity_from_bytes(b"new"), - ), - ( - RestartMode::Always, - UpdaterRefreshMode::ReexecIfManagedBinaryChanged, - ) - ); -} - #[tokio::test] async fn installer_fetch_uses_exact_url_and_preserves_bytes() { let script = b"#!/bin/sh\nprintf 'update bytes'\n".to_vec(); @@ -137,6 +122,369 @@ async fn cancelling_installer_stops_children_and_releases_fallback_lock() { assert!(!delayed.exists()); } +#[cfg(unix)] +fn manual_update_daemon(home: &TempDir) -> (Daemon, String) { + use std::os::unix::fs::PermissionsExt; + + let target = if cfg!(target_os = "macos") { + format!("{}-apple-darwin", std::env::consts::ARCH) + } else { + format!("{}-unknown-linux-musl", std::env::consts::ARCH) + }; + let release = format!("1.0.0-{target}"); + let standalone = home.path().join("packages/standalone"); + let bin = standalone.join("releases").join(&release).join("codex"); + std::fs::create_dir_all(bin.parent().expect("binary parent")).expect("release directory"); + std::fs::write( + &bin, + b"#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo codex 1.0.0; else exec sleep 30; fi\n", + ) + .expect("managed binary"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) + .expect("executable binary"); + std::os::unix::fs::symlink(format!("releases/{release}"), standalone.join("current")) + .expect("current release"); + std::fs::write(standalone.join("auto-update-version"), &release).expect("latest marker"); + let state = home.path().join("app-server-daemon"); + ( + Daemon { + socket_path: home.path().join("app-server-control/server.sock"), + pid_file: state.join("app-server.pid"), + update_pid_file: state.join("app-server-updater.pid"), + operation_lock_file: state.join("daemon.lock"), + settings_file: state.join("settings.json"), + managed_codex_bin: standalone.join("current/codex"), + }, + release, + ) +} + +#[cfg(unix)] +fn test_terminate() -> tokio::signal::unix::Signal { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install test signal handler") +} + +#[cfg(unix)] +#[tokio::test] +async fn manual_request_retries_after_updater_replacement() { + use tokio::io::AsyncReadExt; + use tokio::io::AsyncWriteExt; + + let home = TempDir::new().expect("home"); + let (daemon, _) = manual_update_daemon(&home); + let socket_path = daemon.manual_update_socket_path(); + codex_uds::prepare_private_socket_directory(socket_path.parent().expect("socket parent")) + .await + .expect("socket directory"); + let mut listener = codex_uds::UnixListener::bind(&socket_path) + .await + .expect("old updater socket"); + let expected = UpdateOutput { + status: UpdateStatus::NoUpdate, + managed_codex_path: daemon.managed_codex_bin.clone(), + installed_version: None, + running_version: None, + message: "already current".to_string(), + }; + let reply = expected.clone(); + let server = tokio::spawn(async move { + let mut old = listener.accept().await.expect("first connection"); + let mut request = [0; 7]; + old.read_exact(&mut request).await.expect("first request"); + drop(old); + drop(listener); + tokio::fs::remove_file(&socket_path) + .await + .expect("remove old socket"); + let mut successor = codex_uds::UnixListener::bind(&socket_path) + .await + .expect("successor socket"); + let mut connection = successor.accept().await.expect("retried connection"); + connection + .read_exact(&mut request) + .await + .expect("retried request"); + connection + .write_all(&serde_json::to_vec(&Ok::<_, String>(reply)).expect("serialize response")) + .await + .expect("send response"); + }); + assert_eq!( + super::manual_update::request(&daemon) + .await + .expect("request survives handoff"), + expected + ); + server.await.expect("replacement task"); +} + +#[cfg(unix)] +#[tokio::test] +async fn manual_request_recovers_when_one_shot_updater_exits() { + use tokio::io::AsyncReadExt; + + let home = TempDir::new().expect("home"); + let (daemon, _) = manual_update_daemon(&home); + let socket_path = daemon.manual_update_socket_path(); + codex_uds::prepare_private_socket_directory(socket_path.parent().expect("socket parent")) + .await + .expect("socket directory"); + let mut listener = codex_uds::UnixListener::bind(&socket_path) + .await + .expect("one-shot updater socket"); + let server = tokio::spawn(async move { + let mut connection = listener.accept().await.expect("request connection"); + let mut request = [0; 7]; + connection.read_exact(&mut request).await.expect("request"); + drop(connection); + drop(listener); + tokio::fs::remove_file(socket_path) + .await + .expect("remove exited updater socket"); + }); + // Without the marker, the ordinary startup path reports unsupported. A + // retry that only waits for a successor would time out instead. + std::fs::remove_file(home.path().join("packages/standalone/auto-update-version")) + .expect("remove latest marker"); + let result = tokio::time::timeout( + Duration::from_secs(5), + super::manual_update::request(&daemon), + ) + .await + .expect("retry should return to normal startup") + .expect("unsupported response"); + assert_eq!(result.status, UpdateStatus::Unsupported); + server.await.expect("updater task"); +} + +#[cfg(unix)] +#[tokio::test] +async fn unsupported_request_preserves_updater_schedule() { + use tokio::io::AsyncReadExt; + use tokio::io::AsyncWriteExt; + + let home = TempDir::new().expect("home"); + let (daemon, _) = manual_update_daemon(&home); + let daemon = std::sync::Arc::new(daemon); + let identity = executable_identity(&daemon.managed_codex_bin) + .await + .expect("updater identity"); + let socket_path = daemon.manual_update_socket_path(); + let http = FakeInstallerHttp::new(InstallerResponse::Success(Vec::new())); + let updater_daemon = std::sync::Arc::clone(&daemon); + let worker = + tokio::spawn(async move { super::run_with_http(&http, &updater_daemon, &identity).await }); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while !socket_path.exists() { + assert!( + tokio::time::Instant::now() < deadline, + "updater did not listen" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + std::fs::remove_file(home.path().join("packages/standalone/auto-update-version")) + .expect("remove latest selection"); + let mut malformed = codex_uds::UnixStream::connect(&socket_path) + .await + .expect("connect malformed request"); + malformed + .write_all(b"upd") + .await + .expect("send partial request"); + malformed.shutdown().await.expect("disconnect request"); + let mut discarded = Vec::new(); + malformed + .read_to_end(&mut discarded) + .await + .expect("rejected request"); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!worker.is_finished(), "malformed request stopped updater"); + assert_eq!( + super::manual_update::request(&daemon) + .await + .expect("unsupported response") + .status, + UpdateStatus::Unsupported + ); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!worker.is_finished(), "unsupported request stopped updater"); + worker.abort(); +} + +#[cfg(unix)] +#[tokio::test] +async fn manual_update_restarts_managed_daemon_with_automatic_updates_disabled() { + use futures::SinkExt; + use futures::StreamExt; + use tokio::io::AsyncReadExt; + use tokio::io::AsyncWriteExt; + + let home = TempDir::new().expect("home"); + let (daemon, release) = manual_update_daemon(&home); + let daemon = std::sync::Arc::new(daemon); + std::fs::create_dir_all(daemon.settings_file.parent().expect("state directory")) + .expect("state directory"); + std::fs::write( + &daemon.settings_file, + r#"{"updater":{"autoUpdateEnabled":false}}"#, + ) + .expect("disabled updater"); + std::fs::create_dir_all(daemon.socket_path.parent().expect("socket parent")) + .expect("socket directory"); + let mut listener = codex_uds::UnixListener::bind(&daemon.socket_path) + .await + .expect("control listener"); + let codex_home = home.path().to_path_buf(); + let server = tokio::spawn(async move { + loop { + let connection = listener.accept().await.expect("control connection"); + let mut websocket = tokio_tungstenite::accept_async(connection) + .await + .expect("websocket handshake"); + websocket + .next() + .await + .expect("initialize request") + .expect("frame"); + let version = if std::fs::read_to_string( + codex_home.join("packages/standalone/auto-update-version"), + ) + .expect("selected release") + .starts_with("1.1.0") + { + "1.1.0" + } else { + "1.0.0" + }; + websocket.send(tokio_tungstenite::tungstenite::Message::Text( + serde_json::json!({"id": 1, "result": { + "userAgent": format!("codex_app_server_daemon/{version}"), + "codexHome": codex_home, "platformFamily": "unix", "platformOs": std::env::consts::OS, + }}).to_string().into(), + )).await.expect("initialize response"); + websocket + .next() + .await + .expect("initialized notification") + .expect("frame"); + } + }); + let settings = crate::settings::DaemonSettings::default(); + let backend = crate::backend::pid_backend(daemon.backend_paths(&settings)); + backend.start().await.expect("start daemon"); + let current_pid = || { + serde_json::from_slice::( + &std::fs::read(&daemon.pid_file).expect("daemon PID record"), + ) + .expect("PID JSON")["pid"] + .as_u64() + .expect("PID") + }; + let before = current_pid(); + let next = release.replacen("1.0.0", "1.1.0", 1); + let standalone = home.path().join("packages/standalone"); + let ready = home.path().join("installer-ready"); + let proceed = home.path().join("installer-proceed"); + let script = format!( + "#!/bin/sh\n# CODEX_INSTALL_IF_LATEST\ntest \"$CODEX_INSTALL_IF_LATEST\" = 1 || exit 4\nif [ \"$CODEX_UPDATE_FROM_RELEASE\" = '{next}' ]; then exit 0; fi\ntest \"$CODEX_UPDATE_FROM_RELEASE\" = '{release}' || exit 5\ntouch '{ready}'\nwhile [ ! -e '{proceed}' ]; do sleep .05; done\nmkdir -p '{root}/releases/{next}/bin'\nprintf '#!/bin/sh\\nif [ \"$1\" = --version ]; then echo codex 1.1.0; else exec sleep 30; fi\\n' > '{root}/releases/{next}/bin/codex'\nchmod +x '{root}/releases/{next}/bin/codex'\nln -sfn 'releases/{next}' '{root}/current'\nprintf '{next}' > '{root}/auto-update-version'\n", + root = standalone.display(), + ready = ready.display(), + proceed = proceed.display(), + ); + let http = FakeInstallerHttp::new(InstallerResponse::Success(script.into_bytes())); + let updater_daemon = std::sync::Arc::clone(&daemon); + let worker = tokio::spawn(async move { + super::run_with_http( + &http, + &updater_daemon, + &executable_identity_from_bytes(b"updater"), + ) + .await + }); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while !daemon.manual_update_socket_path().exists() { + assert!( + tokio::time::Instant::now() < deadline, + "updater did not listen" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + let request_daemon = std::sync::Arc::clone(&daemon); + let request = tokio::spawn(async move { super::manual_update::request(&request_daemon).await }); + while !ready.exists() { + assert!( + tokio::time::Instant::now() < deadline, + "installer did not start" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + let mut queued = codex_uds::UnixStream::connect(&daemon.manual_update_socket_path()) + .await + .expect("queue a second update"); + queued + .write_all(b"update\n") + .await + .expect("send second update"); + std::fs::write(proceed, b"go").expect("release installer"); + let output = request + .await + .expect("first request task") + .expect("manual update"); + assert_eq!(output.status, UpdateStatus::Updated); + assert_eq!(output.installed_version.as_deref(), Some("1.1.0")); + assert_eq!(output.running_version.as_deref(), Some("1.1.0")); + assert_eq!( + output.managed_codex_path, + standalone.join("current/bin/codex") + ); + let restarted = current_pid(); + assert_ne!(restarted, before); + let mut response = Vec::new(); + queued + .read_to_end(&mut response) + .await + .expect("second response"); + let second: Result = + serde_json::from_slice(&response).expect("valid second response"); + assert_eq!( + second.expect("second update").status, + UpdateStatus::NoUpdate + ); + assert_eq!(current_pid(), restarted); + tokio::time::timeout(Duration::from_secs(5), worker) + .await + .expect("one-shot updater did not exit") + .expect("updater task") + .expect("updater loop"); + let no_op = FakeInstallerHttp::new(InstallerResponse::Success( + b"#!/bin/sh\n# CODEX_INSTALL_IF_LATEST\nexit 0\n".to_vec(), + )); + use std::io::Write; + std::fs::OpenOptions::new() + .append(true) + .open( + daemon + .current_managed_codex_bin() + .expect("current executable"), + ) + .expect("managed executable") + .write_all(b"\n# same-version replacement\n") + .expect("replace binary bytes"); + let output = manual_update_once( + &no_op, + &daemon, + &executable_identity_from_bytes(b"updater"), + &mut test_terminate(), + ) + .await + .expect("retry with stale running binary"); + assert_eq!(output.status, UpdateStatus::NoUpdate); + assert_ne!(current_pid(), restarted); + backend.stop().await.expect("stop daemon"); + server.abort(); +} + #[cfg(windows)] #[tokio::test] async fn powershell_installer_is_noninteractive_and_reports_script_failure() { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 50aa97352b..d234575b79 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -787,6 +787,9 @@ enum AppServerDaemonSubcommand { /// Restart the local app server daemon. Restart, + /// Update the standalone installation and restart the managed daemon (may interrupt work). + Update, + /// Enable remote control for future starts and a currently running managed daemon. EnableRemoteControl, @@ -1378,6 +1381,10 @@ async fn cli_main( AppServerDaemonSubcommand::Restart => { print_app_server_daemon_output(AppServerLifecycleCommand::Restart).await?; } + AppServerDaemonSubcommand::Update => { + let output = codex_app_server_daemon::update().await?; + println!("{}", serde_json::to_string(&output)?); + } AppServerDaemonSubcommand::EnableRemoteControl => { print_app_server_remote_control_output(AppServerRemoteControlMode::Enabled) .await?; @@ -2616,6 +2623,7 @@ fn app_server_subcommand_name(subcommand: Option<&AppServerSubcommand>) -> &'sta AppServerDaemonSubcommand::Bootstrap(_) => "app-server daemon bootstrap", AppServerDaemonSubcommand::Start => "app-server daemon start", AppServerDaemonSubcommand::Restart => "app-server daemon restart", + AppServerDaemonSubcommand::Update => "app-server daemon update", AppServerDaemonSubcommand::EnableRemoteControl => { "app-server daemon enable-remote-control" } diff --git a/codex-rs/cli/tests/app_server_daemon.rs b/codex-rs/cli/tests/app_server_daemon.rs index 08e164140e..79ab3b3be7 100644 --- a/codex-rs/cli/tests/app_server_daemon.rs +++ b/codex-rs/cli/tests/app_server_daemon.rs @@ -168,6 +168,18 @@ fn managed_start_keeps_updater_on_marker_mismatch_but_stops_it_for_pin() -> Resu std::fs::write(&marker, "0.1.0-other-target")?; assert_eq!(daemon.lifecycle("start")?["status"], "alreadyRunning"); assert_eq!(daemon.pid("app-server-updater.pid")?, updater_pid); + assert_eq!(daemon.lifecycle("update")?["status"], "unsupported"); + std::thread::sleep(Duration::from_millis(250)); + let updater_state = Command::new("/bin/ps") + .args(["-p", &updater_pid.to_string(), "-o", "stat="]) + .output()?; + ensure!( + updater_state.status.success() + && !String::from_utf8_lossy(&updater_state.stdout) + .trim() + .starts_with('Z'), + "updater exited during the latest marker transition" + ); std::fs::remove_file(marker)?; @@ -242,6 +254,7 @@ fn unmanaged_app_server_does_not_launch_updater() -> Result<()> { let output = daemon.lifecycle("start")?; assert_eq!(output["status"], "alreadyRunning"); assert_eq!(output["backend"], Value::Null); + assert_eq!(daemon.lifecycle("update")?["status"], "unsupported"); assert!( !daemon .home @@ -251,3 +264,19 @@ fn unmanaged_app_server_does_not_launch_updater() -> Result<()> { ); Ok(()) } + +#[test] +fn manual_update_rejects_an_unowned_installation() -> Result<()> { + let daemon = TestDaemon::new()?; + std::fs::remove_file( + daemon + .home + .path() + .join("packages/standalone/auto-update-version"), + )?; + + assert_eq!(daemon.lifecycle("update")?["status"], "unsupported"); + assert!(daemon.pid("app-server.pid").is_err()); + assert!(daemon.pid("app-server-updater.pid").is_err()); + Ok(()) +}