fix(install): heal the updater's self-owned-marker refusal loop on Windows

An updater binary spawns 'hermes update' while holding the update marker
with its own PID. A checkout that predates the HERMES_UPDATE_HANDOFF_PID
env fix (8c76fe19f) and the ancestor-pid fallback runs its pre-pull
update_lock.py, reads that marker as a live foreign update, and exits 2
— and the updater deliberately skips its retry for exit 2, so the
refusal loops forever: the update being refused is the one that ships
the fix, and the failure screen's Retry re-enters the same state.

Detect the case with a raw marker read (live_marker_owner deliberately
maps self-ownership to None, so it cannot answer this), drop our own
claim, and retry the child once with the marker absent. The guard
re-removes on Drop (idempotent) and the desktop is already gone at this
point, so nothing races the brief marker-free window.

Fixes #75788

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
3x3xX3N0N
2026-08-03 18:32:25 -04:00
committed by Teknium
parent afee35700e
commit 26c987c38d
@@ -156,6 +156,24 @@ fn live_marker_owner(path: &Path) -> Option<MarkerOwner> {
Some(MarkerOwner { pid, age_secs })
}
/// True when the on-disk marker names THIS process as its owner.
///
/// `live_marker_owner` cannot answer this: it deliberately maps
/// self-ownership to `None` (the #74761 pre-write adoption). The exit-2
/// self-heal below needs the raw fact, because a `hermes update` child that
/// refuses over OUR marker is a handoff-recognition failure, not a real
/// concurrent update.
fn marker_owned_by_self(path: &Path) -> bool {
std::fs::read_to_string(path)
.ok()
.and_then(|raw| {
raw.lines()
.next()
.and_then(|line| line.trim().parse::<u32>().ok())
})
== Some(std::process::id())
}
/// True when a process with `pid` currently exists.
#[cfg(windows)]
fn pid_is_alive(pid: u32) -> bool {
@@ -425,6 +443,40 @@ async fn run_update(app: AppHandle) -> Result<()> {
)
.await?;
}
// Self-owned-marker heal (#75788). Exit 2 means the child refused over a
// live update marker with a foreign owner. When that "foreign" owner is
// THIS process, the child simply failed to recognize the handoff — a
// checkout predating the HERMES_UPDATE_HANDOFF_PID env fix (8c76fe19f)
// and the ancestor-pid fallback runs its pre-pull update_lock.py, reads
// our marker, and exits 2 every time. The refusal loop is unbreakable
// from the user's side because the update being refused is the one that
// ships the fix. The marker exists to serialize updates and this process
// IS the update: drop our claim and retry once with the marker absent.
// The guard re-removes on Drop (idempotent), and the desktop is already
// gone at this point, so nothing races the brief marker-free window.
if update.exit_code == Some(UPDATE_EXIT_CONCURRENT)
&& marker_owned_by_self(&crate::paths::update_in_progress_marker())
{
emit_log(
&app,
Some("update"),
LogStream::Stdout,
"[update] child refused over this updater's own marker (stale \
checkout without handoff recognition); clearing the claim and \
retrying once…",
);
_update_marker.complete();
update = run_streamed(
&app,
&hermes,
&update_args,
&install_root,
&child_env,
Some("update"),
)
.await?;
}
let update_ms = started.elapsed().as_millis() as u64;
match update.exit_code {