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
This commit is contained in:
Eric Traut
2026-09-07 19:11:41 +00:00
committed by copyberry
parent 333c41eef6
commit daca1fab84
8 changed files with 1005 additions and 185 deletions
+12 -1
View File
@@ -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
@@ -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<Option<ExecutableIdentity>> {
match self.read_pid_file_state().await? {
PidFileState::Running(record) if self.record_is_active(&record).await? => {
+62 -69
View File
@@ -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<String>,
pub running_version: Option<String>,
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<UpdateOutput> {
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<RestartIfRunningOutcome> {
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<PathBuf> {
// 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> {
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<bool> {
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 {
@@ -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<UpdateOutput> {
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, &current_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<UpdateOutput, String> =
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<UpdateOutput> {
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<RequestDisposition> {
#[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::<std::io::Error>()
.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<UpdateOutput, String> =
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<UpdateOutput> {
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(&current_managed_codex_path).await?);
let updated = previous_release != selected_release(daemon)?.1
|| previous_identity != executable_identity(&current_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(),
})
}
+269 -85
View File
@@ -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()?,
&current_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<Duration> {
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<codex_uds::UnixListener>,
) -> Result<UpdateLoopControl> {
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<UpdateLoopControl> {
let daemon = Daemon::from_environment()?;
if !UpdaterSettings::load(&daemon.settings_file)
.await?
.auto_update_enabled
trigger: UpdateTrigger,
) -> Result<(UpdateLoopControl, Option<RestartIfRunningOutcome>)> {
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<bool> {
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<ExecutableIdentity> {
let current_exe =
std::env::current_exe().context("failed to resolve current updater executable")?;
executable_identity(&current_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)
@@ -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::<serde_json::Value>(
&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<crate::UpdateOutput, String> =
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() {
+8
View File
@@ -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"
}
+29
View File
@@ -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(())
}