fix(update): prevent and self-heal half-updated venvs on Windows (#57659)

Root-causes the July 2026 Windows incident chain (locked _brotlicffi.pyd /
_sodium.pyd during install, then 'No module named annotated_doc' with
'hermes update' insisting 'Already up to date!'):

- hermes update: probe venv core imports even when the checkout is current;
  a half-updated venv (dep sync killed mid-flight by a locked .pyd) is now
  detected and repaired instead of being reported as up to date
- hermes update (Windows): after pausing gateways, refuse to mutate the venv
  while other processes run from the venv interpreter (the Desktop backend
  runs as python.exe so the hermes.exe shim guard never saw it); --force
  keeps the old behavior
- install.ps1 venv stage: disarm gateway autostart Scheduled Tasks before
  the kill sweep (they respawn the gateway inside the kill->delete window),
  make the sweep a bounded loop requiring 3 clean passes, and rename-then-
  delete the old venv (a rename succeeds even with mapped DLLs) with stale-
  dir cleanup on the next run
- desktop updater: 'venv shim still locked after 15s' now ABORTS the update
  hand-off (restarting our backend, surfacing the holder to the user)
  instead of 'proceeding anyway (force)' into guaranteed venv corruption;
  the unlock wait also re-kills respawned backends each poll tick
This commit is contained in:
Teknium 2026-07-03 03:24:08 -07:00 committed by GitHub
parent 741bd9ba42
commit b14d75f8af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 507 additions and 20 deletions

View file

@ -1614,6 +1614,26 @@ function Install-Venv {
if ($env:OS -eq "Windows_NT") {
$myPid = $PID
Write-Info "Stopping any running hermes processes before recreating venv..."
# Disarm respawners FIRST: the gateway autostart Scheduled Task and
# the Startup-folder entry both relaunch a killed gateway within
# seconds, and losing that race re-locks the venv's .pyd files
# between our kill sweep and Remove-Item (the July 2026
# _brotlicffi.pyd incident). schtasks /End stops a running task
# instance; /Change /DISABLE stops it from re-firing mid-install.
# Re-enabled after the venv is recreated (below). Best-effort: a
# missing task just errors quietly.
$gatewayTasksDisabled = @()
try {
schtasks /Query /FO CSV 2>$null | ConvertFrom-Csv | Where-Object { $_.TaskName -like '*Hermes_Gateway*' } | ForEach-Object {
$tn = $_.TaskName
schtasks /End /TN $tn 2>$null | Out-Null
schtasks /Change /TN $tn /DISABLE 2>$null | Out-Null
$gatewayTasksDisabled += $tn
Write-Info " disabled gateway autostart task $tn for the duration of the install"
}
} catch {
Write-Warn "Could not enumerate gateway scheduled tasks: $($_.Exception.Message)"
}
# The launcher CLI (hermes.exe) plus its child tree.
& taskkill /F /T /IM hermes.exe /FI "PID ne $myPid" 2>$null | Out-Null
# taskkill /IM hermes.exe is NOT enough: the gateway/agent that a
@ -1632,27 +1652,68 @@ function Install-Venv {
# ExecutablePath for a process it cannot inspect (a different session)
# instead of throwing, so an unreadable process is skipped rather than
# aborting the whole sweep.
#
# The sweep is a bounded LOOP, not single-shot: supervised processes
# (the Desktop app's backend, a watchdog-managed gateway) respawn in
# the window between one kill pass and the delete. Each pass re-
# enumerates; three consecutive clean passes (or the attempt cap)
# ends the loop.
$venvPrefix = [System.IO.Path]::GetFullPath((Join-Path $InstallDir "venv")).TrimEnd('\') + '\'
try {
Get-CimInstance Win32_Process -ErrorAction Stop |
Where-Object { $_.ProcessId -ne $myPid -and $_.ExecutablePath -and $_.ExecutablePath.StartsWith($venvPrefix, [System.StringComparison]::OrdinalIgnoreCase) } |
ForEach-Object {
Write-Info " stopping PID $($_.ProcessId) ($($_.Name)) running from venv"
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
} catch {
Write-Warn "Could not enumerate venv processes: $($_.Exception.Message)"
$cleanPasses = 0
for ($sweep = 0; $sweep -lt 10 -and $cleanPasses -lt 3; $sweep++) {
$found = 0
try {
Get-CimInstance Win32_Process -ErrorAction Stop |
Where-Object { $_.ProcessId -ne $myPid -and $_.ExecutablePath -and $_.ExecutablePath.StartsWith($venvPrefix, [System.StringComparison]::OrdinalIgnoreCase) } |
ForEach-Object {
$found++
Write-Info " stopping PID $($_.ProcessId) ($($_.Name)) running from venv"
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
} catch {
Write-Warn "Could not enumerate venv processes: $($_.Exception.Message)"
break
}
if ($found -eq 0) { $cleanPasses++ } else { $cleanPasses = 0 }
Start-Sleep -Milliseconds 400
}
Start-Sleep -Milliseconds 800
}
Remove-Item -Recurse -Force "venv" -ErrorAction SilentlyContinue
# A killed process can take a moment to release its file handles, so a
# first Remove-Item may still hit a locked .pyd. Retry once after a short
# pause before giving up and letting the stage fail loudly.
if (Test-Path "venv") {
Start-Sleep -Seconds 2
Remove-Item -Recurse -Force "venv"
# Rename-then-delete: on Windows a directory RENAME succeeds even while
# files inside it are mapped as DLLs (only in-place delete/replace of
# the mapped file is denied, and only same-volume renames are atomic
# moves). Moving the old venv aside means `uv venv` can create a fresh
# one immediately even if some straggler still holds a .pyd from the
# old tree; the renamed dir is deleted best-effort (now, and by the
# cleanup pass below on the NEXT install if a handle outlives this one).
$staleName = "venv.stale.{0}" -f (Get-Date -Format "yyyyMMddHHmmss")
$renamed = $false
try {
Rename-Item -Path "venv" -NewName $staleName -ErrorAction Stop
$renamed = $true
} catch {
Write-Warn "Could not rename venv aside ($($_.Exception.Message)); falling back to in-place delete"
}
if ($renamed) {
Remove-Item -Recurse -Force $staleName -ErrorAction SilentlyContinue
if (Test-Path $staleName) {
Write-Warn "Old venv parked at $staleName (a process still holds files in it); it will be cleaned up on the next install"
}
} else {
Remove-Item -Recurse -Force "venv" -ErrorAction SilentlyContinue
# A killed process can take a moment to release its file handles, so a
# first Remove-Item may still hit a locked .pyd. Retry once after a short
# pause before giving up and letting the stage fail loudly.
if (Test-Path "venv") {
Start-Sleep -Seconds 2
Remove-Item -Recurse -Force "venv"
}
}
}
# Clean up parked venvs from previous installs whose handles have since
# been released. Best-effort — a still-held tree just stays for next time.
Get-ChildItem -Directory -Filter "venv.stale.*" -ErrorAction SilentlyContinue | ForEach-Object {
Remove-Item -Recurse -Force $_.FullName -ErrorAction SilentlyContinue
}
# uv creates the venv and pins the Python version in one step. uv emits
@ -1684,6 +1745,18 @@ function Install-Venv {
Pop-Location
# Re-arm the gateway autostart tasks disabled during the venv teardown.
# Same function scope, so the list survives even under the stage-per-
# process bootstrap. Deliberately NOT started here — dependencies aren't
# installed yet; the task fires normally on next logon and `hermes update`
# / the gateway resume path handles the immediate restart.
if ($gatewayTasksDisabled -and $gatewayTasksDisabled.Count -gt 0) {
foreach ($tn in $gatewayTasksDisabled) {
schtasks /Change /TN $tn /ENABLE 2>$null | Out-Null
}
Write-Info "Re-enabled gateway autostart task(s): $($gatewayTasksDisabled -join ', ')"
}
Write-Success "Virtual environment ready (Python $PythonVersion)"
}