diff --git a/codex-rs/app-server-daemon/src/backend/pid.rs b/codex-rs/app-server-daemon/src/backend/pid.rs index fce30642d5..60e793e18d 100644 --- a/codex-rs/app-server-daemon/src/backend/pid.rs +++ b/codex-rs/app-server-daemon/src/backend/pid.rs @@ -93,6 +93,15 @@ impl PidBackend { pub(crate) async fn running_executable_identity(&self) -> Result> { match self.read_pid_file_state().await? { PidFileState::Running(record) if self.record_is_active(&record).await? => { + // 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), diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs index 7b19c16567..3b4fc6c3ed 100644 --- a/codex-rs/app-server-daemon/src/lib.rs +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -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 { 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(¤t_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) } diff --git a/codex-rs/app-server-daemon/src/managed_install.rs b/codex-rs/app-server-daemon/src/managed_install.rs index 067ebcabad..6a60e3c49b 100644 --- a/codex-rs/app-server-daemon/src/managed_install.rs +++ b/codex-rs/app-server-daemon/src/managed_install.rs @@ -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)] { diff --git a/codex-rs/app-server-daemon/src/prepare_install.rs b/codex-rs/app-server-daemon/src/prepare_install.rs index a313e940f7..40cf40b944 100644 --- a/codex-rs/app-server-daemon/src/prepare_install.rs +++ b/codex-rs/app-server-daemon/src/prepare_install.rs @@ -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) } diff --git a/codex-rs/app-server-daemon/src/prepare_install_tests.rs b/codex-rs/app-server-daemon/src/prepare_install_tests.rs index 8901063ab5..446f031f1a 100644 --- a/codex-rs/app-server-daemon/src/prepare_install_tests.rs +++ b/codex-rs/app-server-daemon/src/prepare_install_tests.rs @@ -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; diff --git a/codex-rs/app-server-daemon/src/release_pruning.rs b/codex-rs/app-server-daemon/src/release_pruning.rs new file mode 100644 index 0000000000..6386def696 --- /dev/null +++ b/codex-rs/app-server-daemon/src/release_pruning.rs @@ -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 { + 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 { + 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(¤t)?; + 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 { + 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(¤t)?) + && (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(()) +} diff --git a/codex-rs/app-server-daemon/src/update_loop.rs b/codex-rs/app-server-daemon/src/update_loop.rs index 5e28ad8856..faa1138c4b 100644 --- a/codex-rs/app-server-daemon/src/update_loop.rs +++ b/codex-rs/app-server-daemon/src/update_loop.rs @@ -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 => { diff --git a/codex-rs/app-server-daemon/src/update_loop_tests.rs b/codex-rs/app-server-daemon/src/update_loop_tests.rs index a7894b81f6..02aca068a1 100644 --- a/codex-rs/app-server-daemon/src/update_loop_tests.rs +++ b/codex-rs/app-server-daemon/src/update_loop_tests.rs @@ -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(); } diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 05657ee2ff..e9d0bc8057 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -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) diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 58815c797e..f27d1642be 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -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" diff --git a/scripts/install/test_install_sh.py b/scripts/install/test_install_sh.py index 06465d9372..2d495cddaf 100644 --- a/scripts/install/test_install_sh.py +++ b/scripts/install/test_install_sh.py @@ -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()