Files
hermes-agent/scripts/desktop-update/marker.ps1
Teknium b2678712f5 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.
2026-10-06 05:44:21 -07:00

410 lines
20 KiB
PowerShell

# marker.ps1 -- the update marker (contract C1 v2, hand-off protocol 2 / A7)
# for windows.ps1, which dot-sources it as its first act. Pure PowerShell +
# CIM on purpose: the claim runs before the first Add-Type (CLM, #66753).
#
# Body (tests/fixtures/update_marker_corpus.json is authoritative):
# line 1 owner pid, ASCII digits, fits u32 (else MALFORMED); pid 0 = dead
# line 2 started_at unix seconds, ASCII digits (else MALFORMED)
# line 3 ct:<digits[.digits]> owner creation time; anything else = v1
# line 4+ 'delegate:<pid> ct:<ct>' (first well-formed wins) and
# 'run:<[A-Za-z0-9._-]{1,128}>' (first wins); others are ignored
# Per line: one leading BOM (line 1), one trailing CR, then surrounding
# spaces/tabs are dropped; whitespace inside a value makes that line malformed.
# Writers are canonical: "<pid>\n<started>\nct:<ct>\n[delegate:..\n][run:..\n]".
#
# Identity (A7 rule 4): a claim naming OUR pid is ours only at our exact
# creation time (5 ms); without a ct it is a previous incarnation (dead).
# Any other pid: live iff its creation time is within 2 s of the recorded
# one; no recorded ct (v1) or an unreadable one: live for 20 minutes from
# line 2 only (a reused pid must never park every reader).
#
# Mutation (A7 rule 1): every read -> judge -> mutate of the marker happens
# inside ONE hold of an exclusive kernel lock on the sidecar "<marker>.lock"
# ([IO.File]::Open with FileShare.None; the kernel drops it with the process,
# so there is no stale mutex). The wait is bounded (10 s); a busy lock fails
# closed (no claim). The sidecar is never deleted.
$script:MarkerCeilingSeconds = 1200
$script:MarkerCtTolerance = 2.0
$script:MarkerOwnCtEpsilon = 0.005
$script:MarkerLockTimeoutMs = 10000
$script:MarkerHeartbeatSeconds = 300
$script:MarkerReleaseWaitSeconds = 7200
$script:ProcessCtCache = @{}
$script:MarkerClaim = "none" # claimed | adopted | refused
$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'
function ConvertTo-UnixCt([datetime]$Time) {
return [DateTimeOffset]::new($Time.ToUniversalTime()).ToUnixTimeMilliseconds() / 1000.0
}
function Format-Ct([double]$Ct) { return $Ct.ToString('F3', [Globalization.CultureInfo]::InvariantCulture) }
function Get-UnixNow { return [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() }
function Get-ProcessCreationCt([int]$ProcessId) {
# Win32_Process needs only limited query rights, so it reads SYSTEM,
# elevated and other-user processes; Get-Process .StartTime is access
# denied for those under Windows PowerShell 5.1.
if ($ProcessId -eq $PID) { return ConvertTo-UnixCt ([Diagnostics.Process]::GetCurrentProcess().StartTime) }
try {
$row = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$ProcessId" -ErrorAction Stop
if ($row -and $row.CreationDate) { return ConvertTo-UnixCt $row.CreationDate }
} catch {}
return $null
}
function Get-ParentProcessId([int]$ProcessId) {
# The RECORDED parent: Windows keeps no tree, so the pid may be gone or reused.
try {
$row = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$ProcessId" -ErrorAction Stop
if ($row) { return [int]$row.ParentProcessId }
} catch {}
return 0
}
function Get-LiveProcessCt([int64]$ProcessId, [switch]$Fresh) {
# Alive + creation time (unix seconds, $null when unreadable). The time is
# read once per pid and kept while that pid stays alive: a waiter polls
# liveness, never one CIM query per poll. The cache only forgets a pid it
# SAW dead, so a pid that exited and was reused between two polls keeps the
# old incarnation's time: every marker decision passes -Fresh (re-read).
if ($ProcessId -le 0 -or $ProcessId -gt [int]::MaxValue) { return @{ Alive = $false; Ct = $null } }
$id = [int]$ProcessId
$p = Get-Process -Id $id -ErrorAction SilentlyContinue
$alive = [bool]$p
if ($alive) { try { $alive = -not $p.HasExited } catch {} }
if (-not $alive) {
$script:ProcessCtCache.Remove($id)
return @{ Alive = $false; Ct = $null }
}
if ($Fresh -or -not $script:ProcessCtCache.ContainsKey($id)) {
$script:ProcessCtCache[$id] = Get-ProcessCreationCt $id
}
return @{ Alive = $true; Ct = $script:ProcessCtCache[$id] }
}
function Get-ProcessIdentity([int64]$ProcessId, $RecordedCt) {
# live | dead | unknown (alive, but a creation time is missing on a side).
$probe = Get-LiveProcessCt $ProcessId
if (-not $probe.Alive) { return 'dead' }
if ($null -eq $RecordedCt -or $null -eq $probe.Ct) { return 'unknown' }
if ([Math]::Abs($probe.Ct - [double]$RecordedCt) -le $script:MarkerCtTolerance) { return 'live' }
return 'dead'
}
function Test-ProcessIdentityLive([int64]$ProcessId, $RecordedCt) {
return (Get-ProcessIdentity $ProcessId $RecordedCt) -ne 'dead'
}
function Test-ProcessIdentityExact([int64]$ProcessId, $RecordedCt) {
# 'match', never 'unknown': both creation times known and within 2 s.
return (Get-ProcessIdentity $ProcessId $RecordedCt) -eq 'live'
}
# -- parsing ----------------------------------------------------------------
function Read-MarkerText {
try { return [System.IO.File]::ReadAllText($MarkerPath, [System.Text.Encoding]::UTF8) } catch { return $null }
}
function Get-MarkerLineValue([string]$Line) {
if ($Line.EndsWith("`r")) { $Line = $Line.Substring(0, $Line.Length - 1) }
return $Line.Trim([char[]]@([char]32, [char]9))
}
function ConvertTo-MarkerPid([string]$Text) {
# ASCII digits (any count, leading zeros allowed) that fit u32, else $null (a malformed pid).
if ($Text -cnotmatch '\A[0-9]+\z') { return $null }
$Text = $Text.TrimStart('0')
if ($Text.Length -eq 0) { return [int64]0 }
if ($Text.Length -gt 10) { return $null }
$value = [uint64]$Text
if ($value -gt 4294967295) { return $null }
return [int64]$value
}
function ConvertTo-MarkerCt([string]$Text) {
# A creation time past double's range is +Infinity -- it never matches a live process.
# (Windows PowerShell's .NET Framework throws OverflowException there; .NET Core does not.)
$value = 0.0
if ([double]::TryParse($Text, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$value)) { return $value }
return [double]::PositiveInfinity
}
function ConvertFrom-MarkerText([string]$Text) {
# $null = malformed, which every reader treats as dead.
if ($null -eq $Text) { return $null }
if ($Text.Length -gt 0 -and $Text[0] -eq [char]0xFEFF) { $Text = $Text.Substring(1) }
$lines = @($Text -split "`n" | ForEach-Object { Get-MarkerLineValue $_ })
if ($lines.Count -lt 2) { return $null }
$ownerPid = ConvertTo-MarkerPid $lines[0]
if ($null -eq $ownerPid -or $lines[1] -cnotmatch '\A[0-9]+\z') { return $null }
# Line 2 fits u64 (any digit count), else malformed. StartedAt (arithmetic) clamps
# past int64 to "far future"; StartedAtText keeps the exact digits for rewrites.
$startedText = $lines[1].TrimStart('0')
if ($startedText.Length -eq 0) { $startedText = '0' }
if ($startedText.Length -gt 20 -or ($startedText.Length -eq 20 -and [decimal]$startedText -gt [decimal]'18446744073709551615')) { return $null }
$started = if ($startedText.Length -le 18) { [int64]$startedText } else { [int64]::MaxValue }
$info = @{
Pid = $ownerPid; StartedAt = $started; StartedAtText = $startedText; Ct = $null; CtText = $null
DelegatePid = 0; DelegateCt = $null; DelegateCtText = $null; Run = $null; Runs = @()
}
if ($lines.Count -ge 3 -and $lines[2] -cmatch '\Act:([0-9]+(\.[0-9]+)?)\z') {
$info.CtText = $Matches[1]
$info.Ct = ConvertTo-MarkerCt $Matches[1]
}
$runs = New-Object System.Collections.Generic.List[string]
for ($i = 3; $i -lt $lines.Count; $i++) {
$line = $lines[$i]
if ($info.DelegatePid -eq 0 -and $line -cmatch '\Adelegate:([0-9]+) ct:([0-9]+(\.[0-9]+)?)\z') {
$delegatePid = ConvertTo-MarkerPid $Matches[1]
if ($null -ne $delegatePid -and $delegatePid -gt 0) {
$info.DelegatePid = $delegatePid
$info.DelegateCtText = $Matches[2]
$info.DelegateCt = ConvertTo-MarkerCt $Matches[2]
}
} elseif ($line -cmatch '\Arun:([A-Za-z0-9._-]{1,128})\z') {
if ($null -eq $info.Run) { $info.Run = $Matches[1] }
$runs.Add($line)
}
}
$info.Runs = @($runs)
return $info
}
function Format-MarkerBody([int64]$OwnerPid, [string]$StartedAt, $CtText, $DelegateLine, $Runs) {
# Canonical, LF framing on purpose: the Rust/TS/Python readers split on "\n".
$body = "$OwnerPid`n$StartedAt`n"
if ($CtText) { $body += "ct:$CtText`n" }
if ($DelegateLine) { $body += "$DelegateLine`n" }
foreach ($run in @($Runs)) { if ($run) { $body += "$run`n" } }
return $body
}
function Get-MarkerDelegateLine($Info) {
if ($null -eq $Info -or $Info.DelegatePid -le 0) { return $null }
return "delegate:$($Info.DelegatePid) ct:$($Info.DelegateCtText)"
}
# -- judging ----------------------------------------------------------------
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. 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) {
# ours | live | dead for one (pid, ct) identity of a parsed marker.
if ($ProcessId -le 0) { return 'dead' }
if ($ProcessId -eq $Ctx.OwnPid) {
if ($null -ne $RecordedCt -and $null -ne $Ctx.OwnCt -and
[Math]::Abs([double]$RecordedCt - [double]$Ctx.OwnCt) -le $script:MarkerOwnCtEpsilon) { return 'ours' }
return 'dead' # a previous incarnation of our pid, never live
}
$probe = & $Ctx.Probe $ProcessId
if (-not $probe.Alive) { return 'dead' }
if ($null -eq $RecordedCt -or $null -eq $probe.Ct) {
if (($Ctx.Now - $StartedAt) -le $script:MarkerCeilingSeconds) { return 'live' }
return 'dead'
}
if ([Math]::Abs([double]$RecordedCt - [double]$probe.Ct) -le $script:MarkerCtTolerance) { return 'live' }
return 'dead'
}
function Get-MarkerJudgement($Info, $Ctx) {
# verdict: malformed | dead | ours | live; owner: the owner identity if
# live, else the delegate if live, else $null.
if ($null -eq $Info) { return @{ Verdict = 'malformed'; Owner = $null; OwnerState = 'dead'; DelegateState = 'none'; Run = $null } }
$ownerState = Get-MarkerIdentityState $Info.Pid $Info.Ct $Info.StartedAt $Ctx
$delegateState = 'none'
if ($Info.DelegatePid -gt 0) { $delegateState = Get-MarkerIdentityState $Info.DelegatePid $Info.DelegateCt $Info.StartedAt $Ctx }
$owner = $null
if ($ownerState -ne 'dead') { $owner = $Info.Pid } elseif ($delegateState -in @('ours', 'live')) { $owner = $Info.DelegatePid }
$verdict = 'dead'
if ($ownerState -eq 'ours' -or $delegateState -eq 'ours') { $verdict = 'ours' } elseif ($null -ne $owner) { $verdict = 'live' }
return @{ Verdict = $verdict; Owner = $owner; OwnerState = $ownerState; DelegateState = $delegateState; Run = $Info.Run }
}
function Get-MarkerReleaseAction($Info, $Ctx) {
# Corpus 'release': delete | rewrite (Text) | keep.
if ($null -eq $Info) { return @{ Action = 'keep'; Text = $null } }
$ownerState = Get-MarkerIdentityState $Info.Pid $Info.Ct $Info.StartedAt $Ctx
$delegateState = 'none'
if ($Info.DelegatePid -gt 0) { $delegateState = Get-MarkerIdentityState $Info.DelegatePid $Info.DelegateCt $Info.StartedAt $Ctx }
if ($Info.Pid -eq $Ctx.OwnPid -and $ownerState -eq 'ours') {
if ($Info.DelegatePid -gt 0 -and $Info.DelegatePid -ne $Ctx.OwnPid -and $delegateState -eq 'live') {
# A7 rule 5: hand over to the live delegate, keeping started_at and runs.
return @{ Action = 'rewrite'; Text = (Format-MarkerBody $Info.DelegatePid $Info.StartedAtText $Info.DelegateCtText $null $Info.Runs) }
}
return @{ Action = 'delete'; Text = $null }
}
if ($Info.DelegatePid -gt 0 -and $Info.DelegatePid -eq $Ctx.OwnPid -and $delegateState -eq 'ours') {
if ($ownerState -ne 'dead') {
return @{ Action = 'rewrite'; Text = (Format-MarkerBody $Info.Pid $Info.StartedAtText $Info.CtText $null $Info.Runs) }
}
return @{ Action = 'delete'; Text = $null }
}
return @{ Action = 'keep'; Text = $null }
}
# -- the A7 lock and in-lock writes -------------------------------------------
function Open-MarkerLock([int]$TimeoutMs = $script:MarkerLockTimeoutMs) {
# The exclusive kernel lock (A7 rule 1): an open of the sidecar that
# shares nothing. Returns the open stream (Dispose = release) or $null
# when it stayed busy past $TimeoutMs. Never deletes the sidecar.
$clock = [Diagnostics.Stopwatch]::StartNew()
while ($true) {
try {
return [System.IO.File]::Open("$MarkerPath.lock", [System.IO.FileMode]::OpenOrCreate,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
} catch {}
if ($clock.ElapsedMilliseconds -ge $TimeoutMs) { return $null }
Start-Sleep -Milliseconds 25
}
}
function Write-MarkerTemp([string]$Body) {
$tmp = "$MarkerPath.$PID.tmp"
[System.IO.File]::WriteAllText($tmp, $Body, (New-Object System.Text.UTF8Encoding $false))
return $tmp
}
function Publish-MarkerNew([string]$Body) {
# A3: the complete body goes to a tmp sibling, then an exclusive hard link
# publishes it (a lockless reader never sees a half-written claim).
# Returns published | exists | unwritable.
try { $tmp = Write-MarkerTemp $Body } catch {
Write-HandoffLog "WARNING: could not write update marker: $($_.Exception.Message)"
return "unwritable"
}
try {
try {
New-Item -ItemType HardLink -Path $MarkerPath -Value $tmp -ErrorAction Stop | Out-Null
return "published"
} catch {
if ([System.IO.File]::Exists($MarkerPath)) { return "exists" }
}
# No hard links on this volume: a no-replace rename of the full body.
try { [System.IO.File]::Move($tmp, $MarkerPath); return "published" } catch {
if ([System.IO.File]::Exists($MarkerPath)) { return "exists" }
Write-HandoffLog "WARNING: could not publish update marker: $($_.Exception.Message)"
return "unwritable"
}
} finally {
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
}
}
function Set-MarkerBodyLocked([string]$Body) {
# Caller holds the A7 lock. Atomic replace of the whole body; a lockless
# reader holding the file open (no FILE_SHARE_DELETE) can make one attempt
# fail, so retry briefly. A marker that vanished is re-published.
for ($attempt = 0; $attempt -lt 20; $attempt++) {
if (-not [System.IO.File]::Exists($MarkerPath)) {
if ((Publish-MarkerNew $Body) -eq 'published') { return $true }
} else {
$tmp = $null
try {
$tmp = Write-MarkerTemp $Body
[System.IO.File]::Replace($tmp, $MarkerPath, [NullString]::Value)
return $true
} catch {
if ($tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
}
}
Start-Sleep -Milliseconds 50
}
return $false
}
function Remove-MarkerLocked {
# Caller holds the A7 lock and judged the marker inside that hold.
for ($attempt = 0; $attempt -lt 20; $attempt++) {
try { [System.IO.File]::Delete($MarkerPath); return $true } catch {}
Start-Sleep -Milliseconds 50
}
return $false
}
function Test-MarkerFileYoung {
# A3 fallback writers create then write: an empty marker younger than 5 s
# is a claim in flight, not a dead one.
try { return ([DateTime]::UtcNow - [System.IO.File]::GetLastWriteTimeUtc($MarkerPath)).TotalSeconds -lt 5 } catch { return $false }
}
function Read-MarkerLocked($Ctx) {
# absent | busy (unreadable, or an empty claim in flight) | judged.
if (-not [System.IO.File]::Exists($MarkerPath)) { return @{ State = 'absent'; Info = $null; Judgement = @{ Verdict = 'absent'; Owner = $null; OwnerState = 'dead'; DelegateState = 'none'; Run = $null } } }
$text = Read-MarkerText
if ($null -eq $text -or ($text.Length -eq 0 -and (Test-MarkerFileYoung))) { return @{ State = 'busy'; Info = $null; Judgement = $null } }
$info = ConvertFrom-MarkerText $text
return @{ State = 'judged'; Info = $info; Judgement = (Get-MarkerJudgement $info $Ctx) }
}
. (Join-Path $PSScriptRoot 'marker-claim.ps1')
function Get-CheckoutLockPath {
# hermes_cli/update_lock.py::checkout_lock_path: <git common dir>/hermes-update.lock,
# else <root>/.hermes-update.lock.
$root = Get-Variable -Name InstallRoot -ValueOnly -ErrorAction SilentlyContinue
if (-not $root) { return $null }
$dot = Join-Path $root '.git'
$gitDir = $null
if ([System.IO.Directory]::Exists($dot)) {
$gitDir = $dot
} elseif ([System.IO.File]::Exists($dot)) {
$first = (([System.IO.File]::ReadAllText($dot)).TrimStart([char]0xFEFF) -split "`n")[0].Trim()
if ($first -like 'gitdir:*') {
$gitDir = $first.Substring(7).Trim()
if (-not [System.IO.Path]::IsPathRooted($gitDir)) { $gitDir = Join-Path $root $gitDir }
}
}
if (-not $gitDir) { return (Join-Path $root '.hermes-update.lock') }
$commonFile = Join-Path $gitDir 'commondir'
if ([System.IO.File]::Exists($commonFile)) {
$common = (([System.IO.File]::ReadAllText($commonFile)).TrimStart([char]0xFEFF) -split "`n")[0].Trim()
if ($common) {
$gitDir = if ([System.IO.Path]::IsPathRooted($common)) { $common } else { Join-Path $gitDir $common }
}
}
return (Join-Path $gitDir 'hermes-update.lock')
}
function Test-CheckoutLockHeld {
# R6: does some process hold the checkout lock right now? Python takes it with
# msvcrt.locking on one byte at offset 1 MiB (LockFile), and every joiner the job
# could not bind also locks one of the 16 lease bytes just past it (R5b): a leased
# child outlives a killed owner and keeps the checkout busy. Probe all 1+16 bytes
# in one LockFile (it fails if ANY of them is held) and give them straight back.
# A lock file that exists but cannot be opened or probed (denied, opened without
# sharing) counts as held, like marker.sh and the Desktop's own probe: reclaim
# needs a provably free checkout.
$path = Get-CheckoutLockPath
if (-not $path -or -not [System.IO.File]::Exists($path)) { return $false }
$fs = $null
try {
$fs = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read,
([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete))
} catch [System.IO.FileNotFoundException], [System.IO.DirectoryNotFoundException] { return $false } catch { return $true }
try {
try { $fs.Lock(1048576, 17) } catch { return $true }
try { $fs.Unlock(1048576, 17) } catch {}
return $false
} finally {
$fs.Dispose()
}
}