fix(update): an adopted own-pid claim is refreshed, not kept near expiry

Adopting a killed update's marker verbatim kept its started_at. When that
claim was close to the 20-minute ceiling, the retry's own lock expired
seconds into its run and a second updater could start over the same
checkout: live, a detached `hermes update` acquired the lock 6s after the
retry adopted a claim 3s from expiry.

A marker naming our pid can only be a dead predecessor's (nothing
pre-writes one for `hermes update`, which always runs under a parent's
claim), so it is a new attempt and is claimed fresh like any dead holder's.
Only one acquire runs per process, so this cannot renew a wedged update.
This commit is contained in:
teknium1
2026-09-23 09:56:59 -07:00
committed by Teknium
parent 7995d8391b
commit 7a61f1d247
2 changed files with 16 additions and 14 deletions

View File

@@ -165,14 +165,12 @@ class UpdateLock:
release. The ancestry path covers staged updaters older than the env-var export. release. The ancestry path covers staged updaters older than the env-var export.
""" """
existing = read_live_update(path=self.path) existing = read_live_update(path=self.path)
if existing is not None: # A live claim naming our own pid is a killed update's marker whose pid this retry
if existing.pid == os.getpid(): # inherited (containers restart pid numbering): no other live process has our pid, and
# No other live process has our pid, so this claim is ours: a desktop pre-write, or # nothing pre-writes a marker for `hermes update` (it always runs under a parent's claim).
# the marker of a killed update whose pid this retry inherited (containers restart # It is a new attempt, so it is claimed fresh like a dead holder's. Keeping the old
# pid numbering). Adopt it verbatim like UpdateMarkerGuard::acquire in update.rs — # started_at would let the ceiling expire mid-run and admit a second updater.
# rewriting started_at would let retries keep a wedged update under the age ceiling. if existing is not None and existing.pid != os.getpid():
self.acquired = True
return True
if existing.pid == _handoff_pid() or _is_ancestor_pid(existing.pid): if existing.pid == _handoff_pid() or _is_ancestor_pid(existing.pid):
return True return True
self.holder = existing self.holder = existing

View File

@@ -99,19 +99,23 @@ def test_refused_lock_does_not_delete_the_live_owners_marker(marker, other_pid):
assert marker.exists(), "a refused claimant must never clear the live owner's lock" assert marker.exists(), "a refused claimant must never clear the live owner's lock"
def test_marker_naming_our_own_pid_is_adopted(marker): def test_marker_naming_our_own_pid_is_adopted(marker, monkeypatch):
"""A killed update's marker names the pid its retry gets (containers restart pid numbering). """A killed update's marker names the pid its retry gets (containers restart pid numbering).
No other live process can hold our pid, so the claim is ours: adopt it verbatim (like No other live process can hold our pid, so the claim is ours: take it instead of refusing
``UpdateMarkerGuard::acquire``) instead of refusing "another update" for up to 20 minutes. "another update" for up to 20 minutes. It is a new attempt, so it is claimed fresh: a
nearly-expired claim must still block a second updater for our whole run.
""" """
started_at = int(time.time()) - 60 _claim(marker, os.getpid(), time.time() - UPDATE_MARKER_MAX_AGE_SECONDS + 5)
_claim(marker, os.getpid(), started_at)
lock = UpdateLock(path=marker) lock = UpdateLock(path=marker)
assert lock.acquire() is True assert lock.acquire() is True
assert lock.acquired is True assert lock.acquired is True
assert marker.read_text(encoding="utf-8").splitlines() == [str(os.getpid()), str(started_at)]
real_time = time.time
monkeypatch.setattr(time, "time", lambda: real_time() + 60)
holder = read_live_update(path=marker)
assert holder is not None and holder.pid == os.getpid(), "a second updater would start mid-run"
lock.release() lock.release()
assert not marker.exists() assert not marker.exists()