fix: an old Desktop never reads a dead owner while the Windows hand-off dies

Same gap as the POSIX hand-off: the -MarkerOp custody watcher took line 1
over only after windows.ps1 exited (WaitForExit, then the marker lock), so
an old packaged Desktop reading in between (lines 1 and 2 only) deleted the
marker while the resumed `hermes update` or a completion survivor still
held the checkout.

Start-MarkerCustodian now names the watcher on line 1 under the marker lock
right after starting it, before any update work, and the hand-off judges
its claim as that watcher from then on ($script:MarkerOwner in
New-MarkerContext): delegate publication, heartbeat and release keep
working. Invoke-MarkerRelease stops the watcher only after the release, so
line 1 stays live until then. Invoke-MarkerCustody keeps a claim that
already names it (judged by the creation time the hand-off recorded) and
falls back to the old takeover only when the handover failed.

Tests (Windows CI lane): the kill cell holds the marker lock across
taskkill /F and runs the shipped legacy reader at once; the pre-publication
kill cell and the A7 race expect the custodian on line 1.
This commit is contained in:
Teknium
2026-10-05 22:37:59 -07:00
parent 4a7c363c0b
commit b2678712f5
5 changed files with 97 additions and 41 deletions

View File

@@ -235,9 +235,10 @@ function Invoke-MarkerRelease {
Start-Sleep -Seconds 1
$waited++
}
Stop-MarkerCustodian
# The custodian (line 1) is stopped only once the release is done: until
# then an old Desktop must keep reading a live owner.
$lock = Open-MarkerLock
if ($null -eq $lock) { Write-HandoffLog "update marker lock stayed busy; leaving the marker to identity-checking readers"; return }
if ($null -eq $lock) { Stop-MarkerCustodian; Write-HandoffLog "update marker lock stayed busy; leaving the marker to identity-checking readers"; return }
try {
if (-not [System.IO.File]::Exists($MarkerPath)) { return }
$info = ConvertFrom-MarkerText (Read-MarkerText)
@@ -258,6 +259,7 @@ function Invoke-MarkerRelease {
Write-HandoffLog "could not release the update marker: $($_.Exception.Message)"
} finally {
$lock.Dispose()
Stop-MarkerCustodian
}
}
@@ -283,12 +285,16 @@ function Update-MarkerHeartbeat {
}
function Start-MarkerCustodian {
# An old packaged Desktop judges the marker by lines 1 and 2 alone, and the
# heartbeat above runs in this process: killed outright (taskkill /F runs
# no finally) it would leave line 1 naming a dead pid while the resumed
# `hermes update` or a completion survivor still mutates the checkout. A
# hidden watcher process takes the claim over then (Invoke-MarkerCustody);
# Invoke-MarkerRelease stops it once our own release is decided.
# An old packaged Desktop judges the marker by lines 1 and 2 alone (never
# the delegate or the marker lock), and we can be killed outright
# (taskkill /F runs no finally) while the resumed `hermes update` or a
# completion survivor still mutates the checkout. A takeover after our
# death would leave an instant in which line 1 names a dead pid. So, before
# any update work starts, a hidden watcher process that outlives us is
# named on line 1 and the claim becomes "ours" as that watcher
# ($script:MarkerOwner). If we die it keeps the marker until that work is
# gone (Invoke-MarkerCustody); Invoke-MarkerRelease stops it once our own
# release is done.
if ($script:MarkerClaim -notin @('claimed', 'adopted')) { return }
$exe = [Diagnostics.Process]::GetCurrentProcess().MainModule.FileName
# A trailing backslash would escape the closing quote (CommandLineToArgvW).
@@ -298,6 +304,23 @@ function Start-MarkerCustodian {
$script:MarkerCustodian = Start-Process -FilePath $exe -ArgumentList $arguments -WindowStyle Hidden -PassThru -ErrorAction Stop
} catch {
Write-HandoffLog "could not start the update marker custodian: $($_.Exception.Message)"
return
}
$custodian = $script:MarkerCustodian.Id
$probe = Get-LiveProcessCt $custodian -Fresh
$lock = if ($probe.Alive -and $null -ne $probe.Ct) { Open-MarkerLock } else { $null }
if ($null -eq $lock) { Write-HandoffLog "could not name the update marker custodian; it takes over only if this hand-off dies"; return }
try {
$ctx = New-MarkerContext
$read = Read-MarkerLocked $ctx
$info = $read.Info
if ($null -ne $info -and $read.Judgement.OwnerState -eq 'ours' -and
(Set-MarkerBodyLocked (Format-MarkerBody $custodian $ctx.Now (Format-Ct $probe.Ct) (Get-MarkerDelegateLine $info) $info.Runs))) {
$script:MarkerOwner = @{ Pid = $custodian; Ct = $probe.Ct }
Write-HandoffLog "update marker names its custodian pid $custodian (hand-off pid $PID)"
}
} finally {
$lock.Dispose()
}
}
@@ -308,32 +331,39 @@ function Stop-MarkerCustodian {
}
function Invoke-MarkerCustody([int]$Of) {
# -MarkerOp custody: once hand-off pid $Of is gone, if its claim is still
# published while its delegate runs or the checkout lock is held, name this
# process on line 1 and keep line 2 young until both end (bounded like the
# R6 wait), then release. A claim the hand-off released or handed over is
# no longer its own: nothing to keep. Only the hand-off ever wrote a
# delegate, so an unlocked look finding neither one nor a held checkout
# lock is final -- no need to wait out a busy marker lock.
# -MarkerOp custody: once hand-off pid $Of is gone, keep its claim -- which
# names this process on line 1 since before the update work started
# (Start-MarkerCustodian) -- and line 2 young while its delegate runs or the
# checkout lock is held (bounded like the R6 wait), then release. Had that
# handover failed, take the claim over now. A claim the hand-off released
# or handed on is no longer its own: nothing to keep. Only the hand-off
# ever wrote a delegate, so an unlocked look is enough to decide.
$watched = Get-Process -Id $Of -ErrorAction SilentlyContinue
if ($watched) { $watched.WaitForExit() }
$peek = ConvertFrom-MarkerText (Read-MarkerText)
if ($null -eq $peek -or $peek.Pid -ne $Of -or ($peek.DelegatePid -le 0 -and -not (Test-CheckoutLockHeld))) { return }
$lock = Open-MarkerLock
if ($null -eq $lock) { return }
try {
$ctx = New-MarkerContext
$read = Read-MarkerLocked $ctx
$info = $read.Info
if ($null -eq $info -or $info.Pid -ne $Of -or (Test-ProcessIdentityLive $Of $info.Ct)) { return }
$delegate = if ($read.Judgement.DelegateState -eq 'live') { Get-MarkerDelegateLine $info } else { $null }
$info = ConvertFrom-MarkerText (Read-MarkerText)
if ($null -eq $info -or $info.Pid -notin @($Of, $PID)) { return }
$delegate = Get-MarkerDelegateLine $info
# Line 1 naming our own live pid is us: judge it by the creation time the
# hand-off recorded for us, not one re-derived another way.
if ($info.Pid -eq $PID) { $script:MarkerOwner = @{ Pid = $PID; Ct = $info.Ct } }
if ($info.Pid -eq $Of) {
if (-not $delegate -and -not (Test-CheckoutLockHeld)) { return }
if (-not (Set-MarkerBodyLocked (Format-MarkerBody $PID $ctx.Now (Format-Ct $ctx.OwnCt) $delegate $info.Runs))) { return }
} finally {
$lock.Dispose()
$lock = Open-MarkerLock
if ($null -eq $lock) { return }
try {
$ctx = New-MarkerContext
$read = Read-MarkerLocked $ctx
$info = $read.Info
if ($null -eq $info -or $info.Pid -ne $Of -or (Test-ProcessIdentityLive $Of $info.Ct)) { return }
$delegate = if ($read.Judgement.DelegateState -eq 'live') { Get-MarkerDelegateLine $info } else { $null }
if (-not $delegate -and -not (Test-CheckoutLockHeld)) { return }
if (-not (Set-MarkerBodyLocked (Format-MarkerBody $PID $ctx.Now (Format-Ct $ctx.OwnCt) $delegate $info.Runs))) { return }
} finally {
$lock.Dispose()
}
}
$script:MarkerClaim = 'claimed'
Write-HandoffLog "update hand-off pid $Of died while its update still holds the checkout; pid $PID keeps the update marker"
Write-HandoffLog "update hand-off pid $Of is gone; pid $PID keeps the update marker while its update holds the checkout"
for ($waited = 0; ($delegate -and (Test-ProcessIdentityLive $info.DelegatePid $info.DelegateCt)) -or (Test-CheckoutLockHeld); $waited++) {
if ($waited -ge $script:MarkerReleaseWaitSeconds) { return }
Update-MarkerHeartbeat

View File

@@ -36,6 +36,7 @@ $script:MarkerBlocker = 0
$script:MarkerOwnCtText = $null
$script:MarkerLastHeartbeat = $null
$script:MarkerCustodian = $null # the watcher process Start-MarkerCustodian started
$script:MarkerOwner = $null # @{ Pid; Ct } of that watcher once line 1 names it instead of us
$script:MarkerReleaseWaited = $false # the R6 wait ran (windows.ps1 re-stamps the result after it)
$script:StartedAt = $null
$script:MarkerRunPattern = '\A[A-Za-z0-9._-]{1,128}\z'
@@ -198,9 +199,10 @@ function Get-MarkerDelegateLine($Info) {
function New-MarkerContext {
# Who "we" are and how a pid is probed; the corpus test injects its own.
# Fresh creation times: a judgement here decides a claim, a delegate or a
# hand-over, which must never ride a reused pid's cached identity.
$own = Get-LiveProcessCt $PID
return @{ OwnPid = [int64]$PID; OwnCt = $own.Ct; Now = (Get-UnixNow); Probe = { param($p) Get-LiveProcessCt $p -Fresh } }
# hand-over, which must never ride a reused pid's cached identity. Once line
# 1 names our custodian (Start-MarkerCustodian), the claim is "ours" as that.
$own = if ($script:MarkerOwner) { $script:MarkerOwner } else { @{ Pid = $PID; Ct = (Get-LiveProcessCt $PID).Ct } }
return @{ OwnPid = [int64]$own.Pid; OwnCt = $own.Ct; Now = (Get-UnixNow); Probe = { param($p) Get-LiveProcessCt $p -Fresh } }
}
function Get-MarkerIdentityState([int64]$ProcessId, $RecordedCt, [int64]$StartedAt, $Ctx) {

View File

@@ -17,6 +17,7 @@ import pytest
from tests.installation_launcher_fixture import publish_fixture_launcher
from tests.scripts.desktop_update.legacy_desktop_reader import legacy_read
from tests.scripts.desktop_update.windows_handoff_support import _HeldLock
ROOT = Path(__file__).resolve().parent.parent.parent.parent
SCRIPT = ROOT / 'scripts/desktop-update/windows.ps1'
@@ -305,8 +306,10 @@ def test_script_killed_right_after_spawning_the_update_leaves_a_live_marker(tmp_
"""C1 rule 6, written by the script itself: windows.ps1 is killed (taskkill /F, no /T) while
its `hermes update` child is only starting up and has not taken the update lock. The marker
must still read LIVE through the child named on line 4 -- also to an old packaged Desktop,
which judges line 1 alone, so the hand-off's custodian takes line 1 -- and be released once
that child is gone."""
which judges line 1 alone, so line 1 names the hand-off's custodian before the update starts
and the first old-reader read right after the kill (the marker lock held, so nothing can
take over yet) already sees a live owner (review 5423056011) -- and be released once that
child is gone."""
install = tmp_path / 'checkout'
publish_fixture_launcher(install, HOLD_CLI)
home = tmp_path / 'home'; home.mkdir()
@@ -329,8 +332,14 @@ def test_script_killed_right_after_spawning_the_update_leaves_a_live_marker(tmp_
settle = time.monotonic() + 3
while time.monotonic() < settle and not any(line.startswith('delegate:') for line in _marker_lines(marker)):
time.sleep(0.01)
subprocess.run(['taskkill', '/F', '/PID', str(script.pid)], capture_output=True, check=True)
script.wait(timeout=30)
a7 = _HeldLock(home) # no marker mutation (a takeover included) until we let go
try:
subprocess.run(['taskkill', '/F', '/PID', str(script.pid)], capture_output=True, check=True)
script.wait(timeout=30)
seen = legacy_read(home)
assert seen['live'] is not None and seen['kept'], ('hand-off just died', seen)
finally:
a7.release()
child = int(child_pid_file.read_text(encoding='utf-8-sig'))
deadline = time.monotonic() + 60
while (lines := _marker_lines(marker))[:1] in ([], [str(script.pid)]): # the custodian takes over

View File

@@ -23,6 +23,7 @@ from tests.scripts.desktop_update.windows_handoff_support import (
_finish,
_op,
_HeldLock,
_custodian,
)
@@ -81,7 +82,7 @@ def test_a7_concurrent_claimants_over_a_dead_marker_yield_one_owner(tmp_path: Pa
refused = [c for c in claimants if c.poll() == 2]
running = [c for c in claimants if c.poll() is None]
assert len(running) == 1 and len(refused) == 3, [c.returncode for c in claimants]
assert marker.read_bytes().decode().split('\n')[0] == str(running[0].pid)
assert marker.read_bytes().decode().split('\n')[0] == _custodian(home, running[0].pid)
finally:
hold.touch()
for c in claimants:
@@ -113,7 +114,8 @@ exit 1
def test_script_killed_before_publishing_the_delegate_runs_no_update(tmp_path: Path) -> None:
"""Kill cell at the pre-publication boundary: windows.ps1 has created its `hermes update`
child but has not published it as the marker delegate (the marker lock is held elsewhere).
Killed there, no update instruction may have run and the marker must read DEAD."""
Killed there, no update instruction may have run, and the custodian line 1 names releases
the marker (nothing holds the checkout)."""
install = tmp_path / 'checkout'
publish_fixture_launcher(install, HOLD_CLI)
home = tmp_path / 'home'; home.mkdir()
@@ -126,7 +128,8 @@ def test_script_killed_before_publishing_the_delegate_runs_no_update(tmp_path: P
lock = None
try:
deadline = time.monotonic() + 60
while not (marker.exists() and marker.read_bytes().split(b'\n')[0] == str(script.pid).encode()):
while not (marker.exists() and _custodian(home, script.pid)
and marker.read_bytes().split(b'\n')[0] == _custodian(home, script.pid).encode()):
assert time.monotonic() < deadline and script.poll() is None, 'script never claimed'
time.sleep(0.02)
lock = _HeldLock(home) # after the claim, before the delegate publication
@@ -141,7 +144,7 @@ def test_script_killed_before_publishing_the_delegate_runs_no_update(tmp_path: P
assert not ran.exists(), 'the update child ran before it was published as the delegate'
assert not _alive(child_pid), 'the never-resumed update child outlived its script'
lines = marker.read_bytes().decode().split('\n')
assert lines[0] == str(script.pid), lines
assert lines[0] == _custodian(home, script.pid), lines
assert not any(line.startswith('delegate:') for line in lines), lines
finally:
if lock:
@@ -150,4 +153,8 @@ def test_script_killed_before_publishing_the_delegate_runs_no_update(tmp_path: P
if script.poll() is None:
subprocess.run(['taskkill', '/T', '/F', '/PID', str(script.pid)], capture_output=True)
script.wait()
assert _op(home, '-MarkerOp', 'reclaim')[:2] == (0, 'reclaimed\n')
deadline = time.monotonic() + 60 # the custodian releases the marker once the lock is free
while (verdict := _op(home, '-MarkerOp', 'reclaim')[1]).startswith('live '):
assert time.monotonic() < deadline
time.sleep(0.2)
assert verdict in ('absent\n', 'reclaimed\n')

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import os
from pathlib import Path
import re
import subprocess
import sys
import time
@@ -91,6 +92,13 @@ def _log(home: Path) -> str:
return path.read_text(encoding='utf-8-sig') if path.exists() else ''
def _custodian(home: Path, handoff: int) -> str:
"""The pid windows.ps1 names on line 1 before any update work starts: its custodian, which
outlives the hand-off so an old Desktop never reads a dead owner ('' until it is named)."""
found = re.search(rf'names its custodian pid (\d+) \(hand-off pid {handoff}\)', _log(home))
return found.group(1) if found else ''
class _HeldLock:
"""Hold the A7 sidecar lock from another process (this one): an open handle
conflicts with the script's FileShare.None open."""