mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
@@ -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? => {
|
||||
|
||||
@@ -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, ¤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<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(¤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(),
|
||||
})
|
||||
}
|
||||
@@ -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<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(¤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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user