Prune unused app-server daemon releases safely (#53827)

## What changed

- Record the current and previous dedicated daemon selections in `selection-history`, bound to the selection link's identity. Preserve that history when an install leaves the selection unchanged.
- Prune unused releases after package preparation and scheduled update processing, including updates that require no restart. Retain the current and previous selections, packages used by the daemon or updater, and the calling executable's package.
- Require lifecycle and installer locks, valid selection history, and verified package boundaries before cleanup. Skip unsafe or unrecognized packages and retire releases before best-effort deletion so partial failures are not retried.
- Resolve the updater's actual executable identity after an executable handoff that preserves its PID.

## Testing

Extend coverage for stale selection history, retention of the last two selections, packages containing external symlinks, partial deletion and reinstall, cleanup without restarting the daemon, and repeated installs preserving selection history.

GitOrigin-RevId: 19a04db1cc356a2219f2afb137b9525e249ab6fb
This commit is contained in:
Eric Traut
2026-10-14 01:49:56 +00:00
committed by copyberry
parent c3733756a9
commit 0960e379a5
11 changed files with 479 additions and 21 deletions
@@ -93,6 +93,15 @@ impl PidBackend {
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? => {
// Updaters can exec a different release without changing PID. Only the
// updater itself can reliably identify its executable after a handoff.
if matches!(self.command_kind, PidCommandKind::UpdateLoop { .. })
&& record.pid == std::process::id()
{
return crate::managed_install::executable_identity(&std::env::current_exe()?)
.await
.map(Some);
}
Ok(record.executable_identity)
}
_ => Ok(None),
+16
View File
@@ -16,6 +16,7 @@ pub use launch::restart_with_features;
pub use launch::start_with_features;
mod managed_install;
mod prepare_install;
mod release_pruning;
pub use prepare_install::InstallRequest;
pub use prepare_install::update_from_cli;
mod remote_control_client;
@@ -521,6 +522,7 @@ impl Daemon {
&self,
mode: RestartMode,
managed_codex_bin: &Path,
package_root: &Path,
) -> Result<RestartIfRunningOutcome> {
let operation_lock = self.open_operation_lock_file().await?;
if !try_lock_file(&operation_lock)? {
@@ -616,6 +618,20 @@ impl Daemon {
{
return Ok(RestartIfRunningOutcome::AlreadyCurrent);
}
// All exits for busy, not-ready or superseded selections precede cleanup.
// Keep the operation lock, and recheck readiness's selection under both locks.
if package_root.ends_with("app-server-daemon")
&& let Ok(_install_lock) = install_lock::acquire_install_lock(package_root).await
&& let Ok(current_bin) = self.current_managed_codex_bin()
&& managed_install::resolved_managed_codex_bin(&current_bin)
.await
.ok()
.as_deref()
== Some(managed_codex_bin)
&& let Err(error) = release_pruning::prune(self, package_root, &settings).await
{
tracing::debug!(%error, "skipped daemon release pruning");
}
Ok(outcome)
}
@@ -186,7 +186,7 @@ pub(crate) struct ExecutableIdentity {
digest: [u8; 32],
// Distinguish release generations whose executable bytes are identical.
#[serde(default, skip_serializing_if = "Option::is_none")]
path_digest: Option<[u8; 32]>,
pub(crate) path_digest: Option<[u8; 32]>,
}
impl ExecutableIdentity {
@@ -222,7 +222,7 @@ pub(crate) fn executable_identity_from_reader(
})
}
fn path_digest(path: &Path) -> [u8; 32] {
pub(crate) fn path_digest(path: &Path) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
#[cfg(unix)]
{
@@ -309,6 +309,7 @@ async fn prepare_from_package(
#[cfg(not(windows))]
std::fs::rename(stage.path(), &release)?;
}
let release = release.canonicalize()?;
let standalone = home.join("packages/standalone");
let canonical_source = source.canonicalize()?;
let follows_latest = mode == InstallMode::Missing
@@ -377,15 +378,22 @@ async fn prepare_from_package(
} else if marker.exists() {
std::fs::remove_file(marker)?;
}
#[cfg(unix)]
{
let temporary = tempfile::TempDir::new_in(&root)?;
let link = temporary.path().join("current");
std::os::unix::fs::symlink(&release, &link)?;
std::fs::rename(link, root.join("current"))?;
if previous_release.as_deref() != Some(release.as_path()) {
#[cfg(unix)]
{
let temporary = tempfile::TempDir::new_in(&root)?;
let link = temporary.path().join("current");
std::os::unix::fs::symlink(&release, &link)?;
std::fs::rename(link, root.join("current"))?;
}
#[cfg(windows)]
windows::select_release(&root, &release)?;
if let Err(error) =
crate::release_pruning::record_selection(&root, previous_release.as_deref())
{
tracing::warn!(%error, "could not record daemon selection for pruning");
}
}
#[cfg(windows)]
windows::select_release(&root, &release)?;
if backend.is_some() {
let selected = Daemon {
pid_file: daemon.pid_file.with_file_name(crate::DAEMON_PID_FILE_NAME),
@@ -400,6 +408,9 @@ async fn prepare_from_package(
)?;
selected.wait_until_ready().await?;
}
if let Err(error) = crate::release_pruning::prune(daemon, &root, settings).await {
tracing::debug!(%error, "skipped daemon release pruning");
}
Ok(true)
}
@@ -3,6 +3,7 @@
use super::InstallMode;
use super::prepare_from_package;
use super::validate_package;
use crate::release_pruning;
use crate::settings::DaemonSettings;
use pretty_assertions::assert_eq;
use std::path::Path;
@@ -79,6 +80,75 @@ async fn seeds_full_package() {
selected.file_name().expect("name").to_string_lossy()
);
assert!(validate_package(&selected, "bin/codex").is_ok());
let root = standalone;
let releases = root.join("releases").canonicalize().unwrap();
let target = super::platform_target().unwrap();
// Reinstall the seed's exact package after partially deleting an older copy.
let original = old;
let digest = super::package_tree(&original, /*destination*/ None).unwrap();
let old = releases.join(format!("local-{digest}-{target}"));
let previous = releases.join(format!("0.151.0-{target}"));
package(&old, "0.152.0");
// Match the alias present in actual Unix downloads.
std::os::unix::fs::symlink("bin/codex", old.join("codex")).unwrap();
package(&previous, "0.151.0");
let outside = temp.path().join("outside");
package(&outside, "0.149.0");
let linked = releases.join(format!("0.148.0-{target}"));
package(&linked, "0.148.0");
std::os::unix::fs::symlink(&outside, linked.join("codex-resources/other")).unwrap();
let _operation_lock = daemon.acquire_operation_lock().await.unwrap();
let _install_lock = crate::install_lock::acquire_install_lock(&root)
.await
.unwrap();
// An older selector can move away and back without updating history.
// The name alone must not authorize deletion.
std::fs::remove_file(root.join("current")).unwrap();
std::os::unix::fs::symlink(&previous, root.join("current")).unwrap();
std::fs::remove_file(root.join("current")).unwrap();
std::os::unix::fs::symlink(&selected, root.join("current")).unwrap();
let _ = release_pruning::prune(&daemon, &root, &settings).await;
assert!(old.join("codex-resources/nested/runtime").is_file());
release_pruning::record_selection(&root, Some(&previous)).unwrap();
// Root can delete read-only files. Other users simulate a real partial failure.
use std::os::unix::fs::PermissionsExt;
let read_only = old.join("codex-resources/nested");
std::fs::set_permissions(&read_only, std::fs::Permissions::from_mode(0o555)).unwrap();
let _ = release_pruning::prune(&daemon, &root, &settings).await;
assert!(!old.exists());
for retained in [&selected, &previous, &outside, &linked] {
assert!(retained.join("codex-resources/nested/runtime").is_file());
}
// Reinstall after a partial deletion, and verify that leftovers are not retried.
drop(_install_lock);
drop(_operation_lock);
if unsafe { libc::geteuid() } != 0 {
let leftover = std::fs::read_dir(&releases)
.unwrap()
.map(Result::unwrap)
.find(|entry| entry.file_name().to_string_lossy().starts_with(".retired."))
.unwrap()
.path()
.join("release/codex-resources/nested");
std::fs::set_permissions(&leftover, std::fs::Permissions::from_mode(0o755)).unwrap();
prepare_from_package(
&daemon,
&settings,
InstallMode::Replace,
Some(&original),
&old_bin,
|_| Ok(true),
)
.await
.unwrap();
assert_eq!(root.join("current").canonicalize().unwrap(), old);
// Subsequent pruning does not retry a retirement, even when deletable now.
assert_eq!(std::fs::read(leftover.join("runtime")).unwrap(), b"runtime");
}
}
#[tokio::test]
@@ -336,6 +406,14 @@ async fn explicit_selection_requires_unchanged_cli_and_pins_all_versions() {
std::fs::read(&bin).unwrap()
);
assert_eq!(std::fs::read(previous.join("bin/codex")).unwrap(), before);
// Different resources, even at the same version: retain last two selections.
for entry in std::fs::read_dir(root.join("releases")).unwrap() {
let path = entry.unwrap().path().canonicalize().unwrap();
assert!(
[&selected, &previous].contains(&&path),
"unexpected release: {path:?}"
);
}
assert!(!root.join("auto-update-version").exists());
assert!(daemon.running_backend(&settings).await.unwrap().is_none());
previous = selected;
@@ -0,0 +1,251 @@
//! Tracks dedicated selections under the installer lock. Pruning requires both lifecycle and
//! installer locks and valid history; inspect idle packages before a single best-effort retirement.
use std::io::Write;
use std::path::Path;
use anyhow::Context;
use anyhow::Result;
#[cfg(windows)]
use windows_sys::Win32::Storage::FileSystem as winfs;
use crate::Daemon;
use crate::backend;
use crate::managed_install;
use crate::settings::DaemonSettings;
#[derive(serde::Deserialize)]
struct Manifest {
version: semver::Version,
target: String,
entrypoint: String,
}
#[cfg(unix)]
fn selection_identity(current: &Path) -> Result<String> {
use std::os::unix::fs::MetadataExt;
let meta = current.symlink_metadata()?;
Ok(format!(
"{} {} {}.{:09}",
meta.dev(),
meta.ino(),
meta.ctime(),
meta.ctime_nsec()
))
}
#[cfg(windows)]
fn selection_identity(current: &Path) -> Result<String> {
use std::os::windows::fs::OpenOptionsExt;
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::System::IO::DeviceIoControl;
let file = std::fs::OpenOptions::new()
.access_mode(winfs::FILE_READ_ATTRIBUTES)
.share_mode(winfs::FILE_SHARE_READ | winfs::FILE_SHARE_WRITE | winfs::FILE_SHARE_DELETE)
.custom_flags(winfs::FILE_FLAG_BACKUP_SEMANTICS | winfs::FILE_FLAG_OPEN_REPARSE_POINT)
.open(current)?;
// FSCTL_READ_FILE_USN_DATA, with default V2 record version. The input is always
// "current", so 1024 bytes accommodates its journal record including the file name.
let mut record = [0u8; 1024];
let mut returned = 0;
if unsafe {
DeviceIoControl(
file.as_raw_handle(),
0x0009_00eb,
std::ptr::null(),
0,
record.as_mut_ptr().cast(),
record.len() as u32,
&raw mut returned,
std::ptr::null_mut(),
)
} == 0
{
return Err(std::io::Error::last_os_error().into());
}
let usn = i64::from_le_bytes(record[24..32].try_into()?);
anyhow::ensure!(
returned >= 32 && u16::from_le_bytes(record[4..6].try_into()?) == 2 && usn > 0,
"no reliable daemon selection journal record"
);
let file_id = u64::from_le_bytes(record[8..16].try_into()?);
Ok(format!("{file_id:x} {usn:x}"))
}
/// Call after changing the selection, while holding the installer lock.
pub(crate) fn record_selection(root: &Path, previous: Option<&Path>) -> Result<()> {
let current = root.join("current");
let selected = current.canonicalize()?;
let previous = previous
.and_then(Path::file_name)
.unwrap_or_default()
.to_string_lossy();
// If previous came from a shared install, conservatively retain its name here too.
let name = selected.file_name().unwrap_or_default().to_string_lossy();
let identity = selection_identity(&current)?;
let mut file = tempfile::NamedTempFile::new_in(root)?;
write!(file, "{name}\n{previous}\n{identity}\n")?;
file.persist(root.join("selection-history"))?;
Ok(())
}
/// Only ordinary files/directories can belong to a release. Linux device IDs alone
/// cannot distinguish same-device bind mounts; missing procfs disables pruning.
fn owned_mount(path: &Path) -> Result<u64> {
let meta = path.symlink_metadata()?;
anyhow::ensure!(
meta.is_dir() || meta.is_file(),
"not an ordinary package entry"
);
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
anyhow::ensure!(
meta.file_attributes() & winfs::FILE_ATTRIBUTE_REPARSE_POINT == 0,
"package entry is a reparse point"
);
// Volume mount points on Windows are reparse points.
Ok(0)
}
#[cfg(target_os = "linux")]
{
use std::os::fd::AsRawFd;
use std::os::unix::fs::OpenOptionsExt;
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)?;
let info = std::fs::read_to_string(format!("/proc/self/fdinfo/{}", file.as_raw_fd()))?;
info.lines()
.find_map(|line| line.strip_prefix("mnt_id:"))
.context("unknown package mount")?
.trim()
.parse()
.context("invalid package mount")
}
#[cfg(all(unix, not(target_os = "linux")))]
{
use std::os::unix::fs::MetadataExt;
Ok(meta.dev())
}
}
pub(crate) async fn prune(daemon: &Daemon, root: &Path, settings: &DaemonSettings) -> Result<()> {
let home = daemon
.settings_file
.parent()
.and_then(Path::parent)
.context("missing Codex home")?;
// Require a real dedicated root, never a shared alias or an in-progress migration.
let releases = root.join("releases");
let mount = owned_mount(&home.join("packages"))?;
anyhow::ensure!(
root == home.join("packages/app-server-daemon")
&& owned_mount(root)? == mount
&& owned_mount(&releases)? == mount
&& root.join(".migration-current").symlink_metadata().is_err(),
"invalid daemon package directory"
);
let releases = releases.canonicalize()?;
let current = root.join("current");
let selected = current.canonicalize()?;
let history = std::fs::read_to_string(root.join("selection-history"))?;
let previous = history
.lines()
.nth(1)
.context("missing previous selection")?;
let name = selected
.file_name()
.context("missing selection")?
.to_string_lossy();
anyhow::ensure!(
selected.parent() == Some(releases.as_path())
&& history == format!("{name}\n{previous}\n{}\n", selection_identity(&current)?)
&& (previous.is_empty()
|| Path::new(previous).file_name().and_then(|n| n.to_str()) == Some(previous)),
"invalid or stale daemon selection"
);
let paths = daemon.current_installation()?.backend_paths(settings);
let server = backend::pid_backend(paths.clone());
let updater = backend::pid_update_loop_backend(paths);
anyhow::ensure!(
server.is_starting_or_running().await?
|| crate::client::probe(&daemon.socket_path).await.is_err(),
"unmanaged daemon"
);
let mut identities = Vec::new();
for worker in [server, updater] {
if worker.is_starting_or_running().await? {
identities.push(
worker
.running_executable_identity()
.await?
.and_then(|identity| identity.path_digest)
.context("unknown running package")?,
);
}
}
let caller = std::env::current_exe()?.canonicalize()?;
'releases: for entry in std::fs::read_dir(&releases)? {
let entry = entry?;
let path = entry.path();
if path == selected
|| entry.file_name() == previous
|| caller.starts_with(&path)
|| !owned_mount(&path).is_ok_and(|id| id == mount)
{
continue;
}
let Some(manifest) = std::fs::File::open(path.join("codex-package.json"))
.ok()
.and_then(|file| serde_json::from_reader::<_, Manifest>(file).ok())
else {
continue;
};
// Accept only managed release names, never arbitrary or legacy folders.
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
let local = name
.strip_prefix("local-")
.and_then(|s| s.strip_suffix(&format!("-{}", manifest.target)));
if !(name == format!("{}-{}", manifest.version, manifest.target)
|| local.is_some_and(|digest| blake3::Hash::from_hex(digest).is_ok()))
|| !matches!(manifest.entrypoint.as_str(), "bin/codex" | "bin/codex.exe")
{
continue;
}
let Ok(executable) = path.join(manifest.entrypoint).canonicalize() else {
continue;
};
if identities.contains(&managed_install::path_digest(&executable)) {
continue;
}
let mut pending = vec![path.clone()];
while let Some(child) = pending.pop() {
// The Unix installer adds this alias; it never traverses outside the release.
#[cfg(unix)]
if child == path.join("codex")
&& std::fs::read_link(&child).ok().as_deref() == Some(Path::new("bin/codex"))
{
continue;
}
if !owned_mount(&child).is_ok_and(|id| id == mount) {
continue 'releases;
}
if child.is_dir() {
for entry in std::fs::read_dir(child)? {
pending.push(entry?.path());
}
}
}
// Clean up a failed rename, but never retry deletion after retirement.
let retired = tempfile::Builder::new()
.prefix(".retired.")
.tempdir_in(&releases)?;
std::fs::rename(&path, retired.path().join("release"))?;
std::fs::remove_dir_all(retired.keep())?;
}
Ok(())
}
@@ -446,7 +446,7 @@ async fn update_once(
return Ok((UpdateLoopControl::Stop, None));
}
match daemon
.try_restart_if_running(restart_mode, &managed_codex_bin)
.try_restart_if_running(restart_mode, &managed_codex_bin, &package_root)
.await?
{
RestartIfRunningOutcome::Busy => {
@@ -720,14 +720,33 @@ async fn test_control_server(
#[tokio::test]
async fn scheduled_no_op_preserves_daemon_and_hands_off_stale_updater() {
let home = TempDir::new().expect("home");
let (daemon, _) = manual_update_daemon(&home);
let (mut daemon, release) = manual_update_daemon(&home);
let root = home.path().join("packages/app-server-daemon");
std::fs::rename(home.path().join("packages/standalone"), &root).unwrap();
daemon = daemon.current_installation().unwrap();
let stale = root
.join("releases")
.join(release.replace("1.0.0", "0.8.0"));
std::fs::create_dir_all(stale.join("bin")).unwrap();
std::fs::copy(&daemon.managed_codex_bin, stale.join("bin/codex")).unwrap();
std::fs::write(
stale.join("codex-package.json"),
serde_json::json!({
"version": "0.8.0",
"target": release.strip_prefix("1.0.0-").unwrap(),
"entrypoint": "bin/codex"
})
.to_string(),
)
.unwrap();
crate::release_pruning::record_selection(&root, /*previous*/ None).unwrap();
let settings = crate::settings::DaemonSettings::default();
let backend = crate::backend::pid_backend(daemon.backend_paths(&settings));
backend.start().await.expect("start daemon");
let server = test_control_server(&daemon, home.path()).await;
let pid_record = std::fs::read(&daemon.pid_file).expect("daemon PID record");
let no_op = FakeInstallerHttp::new(InstallerResponse::Success(
b"# CODEX_INSTALL_IF_LATEST\nexit 0\n".to_vec(),
b"# CODEX_INSTALL_IF_LATEST CODEX_INSTALL_DAEMON_ONLY\nexit 0\n".to_vec(),
));
let outcome = super::update_once(
@@ -751,6 +770,8 @@ async fn scheduled_no_op_preserves_daemon_and_hands_off_stale_updater() {
std::fs::read(&daemon.pid_file).expect("daemon PID record"),
pid_record
);
assert!(daemon.managed_codex_bin.is_file());
assert!(!stale.exists());
backend.stop().await.expect("stop daemon");
server.abort();
}
+55 -7
View File
@@ -522,7 +522,7 @@ function Move-OldStandaloneBinIfApproved {
function Add-JunctionSupportType {
# Older installer types remain loaded when users rerun irm | iex in one session.
if (([System.Management.Automation.PSTypeName]'CodexInstaller.JunctionV2').Type) {
if (([System.Management.Automation.PSTypeName]'CodexInstaller.JunctionV3').Type) {
return
}
@@ -536,7 +536,7 @@ using Microsoft.Win32.SafeHandles;
namespace CodexInstaller
{
public static class JunctionV2
public static class JunctionV3
{
private const uint GENERIC_WRITE = 0x40000000;
private const uint FILE_SHARE_READ = 0x00000001;
@@ -574,6 +574,35 @@ namespace CodexInstaller
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle file, StringBuilder path, uint length, uint flags);
// Read the journal entry for the junction itself, never the target.
// Unlike a timestamp this changes on an in-place retarget, including away and back.
public static string SelectionIdentity(string path)
{
using (SafeFileHandle handle = CreateFileW(
path, 0x80, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
IntPtr.Zero, OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero))
{
if (handle.IsInvalid) { throw new Win32Exception(Marshal.GetLastWin32Error()); }
IntPtr buffer = Marshal.AllocHGlobal(1024);
try {
int returned;
// No input requests USN_RECORD_V2. current's filename fits in 1024 bytes.
if (!DeviceIoControl(handle, 0x000900eb, null, 0, buffer, 1024, out returned, IntPtr.Zero)) {
throw new Win32Exception(Marshal.GetLastWin32Error());
}
long usn = Marshal.ReadInt64(buffer, 24);
if (returned < 32 || Marshal.ReadInt16(buffer, 4) != 2 || usn <= 0) {
throw new IOException("No reliable daemon selection journal record.");
}
return Marshal.ReadInt64(buffer, 8).ToString("x")
+ " " + usn.ToString("x");
} finally {
Marshal.FreeHGlobal(buffer);
}
}
}
public static string ResolveDirectory(string path)
{
using (SafeFileHandle handle = CreateFileW(
@@ -660,7 +689,7 @@ function Set-JunctionTarget {
)
Add-JunctionSupportType
[CodexInstaller.JunctionV2]::SetTarget($LinkPath, $TargetPath)
[CodexInstaller.JunctionV3]::SetTarget($LinkPath, $TargetPath)
}
function Test-IsJunction {
@@ -691,14 +720,14 @@ function Ensure-Junction {
$item = Get-Item -LiteralPath $LinkPath -Force
if (Test-IsJunction -Path $LinkPath) {
Add-JunctionSupportType
$existingTarget = [CodexInstaller.JunctionV2]::ResolveDirectory($LinkPath)
$existingTarget = [CodexInstaller.JunctionV3]::ResolveDirectory($LinkPath)
if (-not [string]::IsNullOrWhiteSpace($InstallerOwnedTargetPrefix)) {
$ownedTargetPrefix = [CodexInstaller.JunctionV2]::ResolveDirectory($InstallerOwnedTargetPrefix) + "\"
$ownedTargetPrefix = [CodexInstaller.JunctionV3]::ResolveDirectory($InstallerOwnedTargetPrefix) + "\"
if (-not $existingTarget.StartsWith($ownedTargetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to retarget junction at $LinkPath because it is not managed by this installer."
}
}
if ($existingTarget.Equals([CodexInstaller.JunctionV2]::ResolveDirectory($TargetPath), [System.StringComparison]::OrdinalIgnoreCase)) {
if ($existingTarget.Equals([CodexInstaller.JunctionV3]::ResolveDirectory($TargetPath), [System.StringComparison]::OrdinalIgnoreCase)) {
return
}
@@ -1030,7 +1059,7 @@ try {
$currentTarget = if (Test-Path -LiteralPath $currentDir) { (Get-Item -LiteralPath $currentDir).Target } else { $null }
if ($Release -ne "latest" -or [string]::IsNullOrEmpty($previousRelease) -or [string]::IsNullOrEmpty($currentTarget) -or
-not (Test-Path -LiteralPath (Join-Path $releasesDir $previousRelease)) -or
[CodexInstaller.JunctionV2]::ResolveDirectory($currentDir) -ne [CodexInstaller.JunctionV2]::ResolveDirectory((Join-Path $releasesDir $previousRelease))) {
[CodexInstaller.JunctionV3]::ResolveDirectory($currentDir) -ne [CodexInstaller.JunctionV3]::ResolveDirectory((Join-Path $releasesDir $previousRelease))) {
if ($env:CODEX_INSTALL_IF_CURRENT -eq "1") { throw "Daemon selection changed; retry the update." }
$script:guardRejected = $true
return
@@ -1119,7 +1148,26 @@ try {
throw "The production release does not support daemon-owned packages; the current selection was left unchanged."
}
}
$previousDirectory = $null
if ($daemonOnly -and -not $deferSelection) {
Add-JunctionSupportType
if (Test-Path -LiteralPath $currentDir) {
$previousDirectory = [CodexInstaller.JunctionV3]::ResolveDirectory($currentDir)
}
}
Ensure-Junction -LinkPath $currentDir -TargetPath $releaseDir -InstallerOwnedTargetPrefix $releasesDir
if ($daemonOnly -and -not $deferSelection -and
$previousDirectory -cne [CodexInstaller.JunctionV3]::ResolveDirectory($currentDir)) {
try {
$previousName = if ($null -eq $previousDirectory) { "" } else { Split-Path -Leaf $previousDirectory }
$selectionId = [CodexInstaller.JunctionV3]::SelectionIdentity($currentDir)
$history = Join-Path $standaloneRoot "selection-history"
[System.IO.File]::WriteAllText("$history.tmp.$PID", "$releaseName`n$previousName`n$selectionId`n")
Move-Item -LiteralPath "$history.tmp.$PID" -Destination $history -Force -ErrorAction Stop
} catch {
Write-WarningStep "Could not record daemon selection; pruning will be skipped."
}
}
if ($Release -eq "latest") {
$tempMarker = "$autoUpdateVersion.tmp.$PID"
[System.IO.File]::WriteAllText($tempMarker, $releaseName)
+19 -1
View File
@@ -1267,8 +1267,26 @@ if [ "$DAEMON_ONLY" = "1" ] && [ "${CODEX_INSTALL_DEFER_SELECTION:-0}" != "1" ];
echo "The production release does not support daemon-owned packages; the current selection was left unchanged." >&2
exit 1
fi
# A no-op must not replace the selection link: history is bound to it.
selected_dir="$(cd -P "$release_dir" && pwd)"
previous_dir="$(cd -P "$CURRENT_LINK" 2>/dev/null && pwd)" || previous_dir=""
if [ "$previous_dir" != "$selected_dir" ]; then
update_current_link "$release_dir"
if ! (
history="$STANDALONE_ROOT/selection-history"
# GNU and BSD stat both inspect the link itself by default.
selection_id="$(LC_ALL=C stat -c '%d %i %.9Z' "$CURRENT_LINK" 2>/dev/null ||
LC_ALL=C stat -f '%d %i %Fc' "$CURRENT_LINK")"
printf '%s\n%s\n%s\n' "$release_name" "${previous_dir##*/}" "$selection_id" > "$history.tmp.$$"
mv -f "$history.tmp.$$" "$history"
); then
warn "Could not record daemon selection; pruning will be skipped."
fi
fi
else
# Do not publish history for a migration until Rust has committed the cutover.
update_current_link "$release_dir"
fi
update_current_link "$release_dir"
if [ "$RELEASE" = "latest" ]; then
printf '%s' "$release_name" > "$AUTO_UPDATE_VERSION.tmp.$$"
mv -f "$AUTO_UPDATE_VERSION.tmp.$$" "$AUTO_UPDATE_VERSION"
+6
View File
@@ -183,6 +183,12 @@ class InstallShTest(unittest.TestCase):
(daemon / "current").resolve(), daemon / "releases" / release_name
)
self.assertEqual((daemon / "auto-update-version").read_text(), release_name)
history = daemon / "selection-history"
original_history = history.read_bytes()
self.assertEqual(history.read_text().splitlines()[:2], [release_name, ""])
repeated, _ = run_installer_in(root, "latest", daemon_only=True, **options)
self.assertEqual(repeated.returncode, 0, repeated.stderr)
self.assertEqual(history.read_bytes(), original_history)
options["daemon_only"] = True
local = daemon / "releases/local-development"
local.mkdir()