Merge pull request #76517 from NousResearch/bb/win-update-lock-handoff
fix(desktop/windows): stale staged installer refuses its own update marker — infinite "Hermes is still running" loop
This commit is contained in:
commit
ee276a7982
|
|
@ -98,6 +98,12 @@ pub fn update_in_progress_marker() -> PathBuf {
|
|||
/// that path), where copying onto ourselves would be a Windows sharing
|
||||
/// violation. Best-effort: a failure here must not fail the install, so the
|
||||
/// caller logs and continues.
|
||||
///
|
||||
/// NOTE: because of that no-op, a user's staged installer is only ever written
|
||||
/// by a full install/repair. Every later `--update` runs the ORIGINAL binary,
|
||||
/// so an installer-protocol change can strand the whole installed base on a
|
||||
/// binary that predates it (see `restage_from_checkout`, which repairs this
|
||||
/// from the freshly-updated checkout).
|
||||
pub fn copy_self_to_hermes_home() -> std::io::Result<()> {
|
||||
let src = std::env::current_exe()?;
|
||||
let dest = installer_dest();
|
||||
|
|
|
|||
|
|
@ -201,7 +201,11 @@ import {
|
|||
sandboxPreflight
|
||||
} from './update-relaunch'
|
||||
import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote'
|
||||
import { resolveStagedUpdaterBinary, spawnUpdaterProcess } from './updater-process'
|
||||
import {
|
||||
resolveStagedUpdaterBinary,
|
||||
spawnUpdaterProcess,
|
||||
stagedUpdaterSupportsPrewrittenMarker
|
||||
} from './updater-process'
|
||||
import { formatBlockerMessage, formatProbeFailedMessage, scanVenvBlockers } from './venv-blocker-scan'
|
||||
import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace'
|
||||
import { createWakeIndicatorWindowController } from './wake-indicator-window'
|
||||
|
|
@ -3012,8 +3016,20 @@ async function applyUpdates(opts = {}) {
|
|||
// the venv. By writing the marker ourselves the renderer's
|
||||
// waitForUpdateToFinish() gate sees a live update and parks instead.
|
||||
// The updater overwrites this with its own PID later; same format.
|
||||
if (Number.isInteger(child.pid)) {
|
||||
//
|
||||
// SKIPPED for pre-#74782 staged updaters: those have no self-PID
|
||||
// exclusion, so they read this very marker as a foreign live owner and
|
||||
// abort with "Another Hermes update is already running (PID <itself>)" —
|
||||
// an unbreakable loop, because the update that would replace the stale
|
||||
// binary is the one being refused. Losing the anti-respawn hardening is
|
||||
// strictly better than never updating again, and the updater still writes
|
||||
// its own marker moments later.
|
||||
if (Number.isInteger(child.pid) && stagedUpdaterSupportsPrewrittenMarker(updater)) {
|
||||
writeUpdateMarker(HERMES_HOME, child.pid)
|
||||
} else if (Number.isInteger(child.pid)) {
|
||||
rememberLog(
|
||||
`[updates] skipping marker pre-write: staged updater predates self-adopt (${updater}); it would refuse its own claim`
|
||||
)
|
||||
}
|
||||
|
||||
rememberLog(`[updates] launched updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release venv shim`)
|
||||
|
|
@ -3100,9 +3116,15 @@ async function handOffWindowsBootstrapRecovery(reason) {
|
|||
|
||||
// Same marker pre-write as applyUpdates — see comment there. The recovery
|
||||
// hand-off has the same window where the renderer can respawn a backend
|
||||
// before the updater writes its own marker.
|
||||
if (Number.isInteger(child.pid)) {
|
||||
// before the updater writes its own marker, and the same stale-updater
|
||||
// exclusion: a pre-#74782 binary would refuse its own pre-written claim and
|
||||
// strand the very recovery meant to heal the install.
|
||||
if (Number.isInteger(child.pid) && stagedUpdaterSupportsPrewrittenMarker(updater)) {
|
||||
writeUpdateMarker(HERMES_HOME, child.pid)
|
||||
} else if (Number.isInteger(child.pid)) {
|
||||
rememberLog(
|
||||
`[bootstrap] skipping marker pre-write: staged updater predates self-adopt (${updater}); it would refuse its own claim`
|
||||
)
|
||||
}
|
||||
|
||||
rememberLog(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,65 @@ import path from 'node:path'
|
|||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { resolveStagedUpdaterBinary, spawnUpdaterProcess } from './updater-process'
|
||||
import {
|
||||
MARKER_SELF_ADOPT_EPOCH_MS,
|
||||
resolveStagedUpdaterBinary,
|
||||
spawnUpdaterProcess,
|
||||
stagedUpdaterSupportsPrewrittenMarker
|
||||
} from './updater-process'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
test('stagedUpdaterSupportsPrewrittenMarker rejects installers predating the self-adopt fix', () => {
|
||||
// The real-world trap: an installer staged at first install months ago, never
|
||||
// refreshed because copy_self_to_hermes_home no-ops during --update.
|
||||
assert.equal(
|
||||
stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', {
|
||||
stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS - 60 * DAY_MS
|
||||
}),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('stagedUpdaterSupportsPrewrittenMarker accepts installers from the fix onward', () => {
|
||||
assert.equal(
|
||||
stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', {
|
||||
stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', {
|
||||
stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS + 30 * DAY_MS
|
||||
}),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('stagedUpdaterSupportsPrewrittenMarker treats an unreadable mtime as unsupported', () => {
|
||||
// Bias toward the path that can always make progress: a skipped pre-write
|
||||
// loses anti-respawn hardening, a wedged updater can never update again.
|
||||
assert.equal(
|
||||
stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', {
|
||||
stagedMtimeMs: () => null
|
||||
}),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveStagedUpdaterBinary still returns a stale staged updater on Windows', () => {
|
||||
// Staleness gates only the marker PRE-WRITE, never the hand-off itself:
|
||||
// the stale binary is the only updater these users have, and it works fine
|
||||
// once it is allowed to write its own claim.
|
||||
assert.equal(
|
||||
resolveStagedUpdaterBinary('C:\\Hermes', {
|
||||
fileExists: () => true,
|
||||
isWindows: true,
|
||||
stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS - 60 * DAY_MS
|
||||
}),
|
||||
path.join('C:\\Hermes', 'hermes-setup.exe')
|
||||
)
|
||||
})
|
||||
|
||||
test('spawnUpdaterProcess hides the updater console and detaches the child on Windows', () => {
|
||||
const calls: Array<{ args: string[]; command: string; options: SpawnOptions }> = []
|
||||
|
|
|
|||
|
|
@ -12,8 +12,20 @@ export interface UpdaterChild {
|
|||
export interface ResolveStagedUpdaterBinaryDeps {
|
||||
isWindows?: boolean
|
||||
fileExists?: (candidate: string) => boolean
|
||||
stagedMtimeMs?: (candidate: string) => number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Staged installers older than this have no self-PID exclusion in
|
||||
* `UpdateMarkerGuard::acquire` and will refuse an update whose marker was
|
||||
* pre-written on their behalf.
|
||||
*
|
||||
* The self-adopt fix landed in #74782 / 160586ff8 (2026-07-30 17:57 +0700).
|
||||
* We compare against the start of 2026-07-31 UTC so the boundary is
|
||||
* unambiguous for binaries staged that same day.
|
||||
*/
|
||||
export const MARKER_SELF_ADOPT_EPOCH_MS = Date.UTC(2026, 6, 31)
|
||||
|
||||
function stagedFileExists(candidate: string): boolean {
|
||||
try {
|
||||
return statSync(candidate).isFile()
|
||||
|
|
@ -22,6 +34,14 @@ function stagedFileExists(candidate: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function stagedFileMtimeMs(candidate: string): number | null {
|
||||
try {
|
||||
return statSync(candidate).mtimeMs
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which staged installer binary — if any — may be handed an update.
|
||||
*
|
||||
|
|
@ -61,6 +81,37 @@ export function resolveStagedUpdaterBinary(
|
|||
return fileExists(candidate) ? candidate : null
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the staged installer is new enough to survive a pre-written marker.
|
||||
*
|
||||
* `copy_self_to_hermes_home` deliberately no-ops during `--update`
|
||||
* (apps/bootstrap-installer/src-tauri/src/paths.rs), so the binary staged by a
|
||||
* user's ORIGINAL install orchestrates every later update — forever. Installers
|
||||
* predating #74782 have no self-PID exclusion in `UpdateMarkerGuard::acquire`,
|
||||
* so when the desktop pre-writes the marker naming that very updater, the
|
||||
* updater reads its own claim as a foreign live owner and aborts with
|
||||
* "Another Hermes update is already running (PID <itself>, started 1s ago)" —
|
||||
* the observed infinite "Install didn't finish" loop. Skipping the pre-write
|
||||
* for those binaries lets them acquire cleanly and run `hermes update`, which
|
||||
* pulls the permanent fixes. See shouldPrewriteUpdateMarker.
|
||||
*
|
||||
* We cannot ask the binary its version without executing it, so use its mtime:
|
||||
* the installer is written to HERMES_HOME at install/repair time, making mtime
|
||||
* a faithful stamp of which installer generation produced it.
|
||||
*
|
||||
* Unreadable mtime counts as UNSUPPORTED — the pre-write is a best-effort
|
||||
* hardening, while a wedged updater is unrecoverable, so we bias toward the
|
||||
* path that can always make progress.
|
||||
*/
|
||||
export function stagedUpdaterSupportsPrewrittenMarker(
|
||||
candidate: string,
|
||||
deps: ResolveStagedUpdaterBinaryDeps = {}
|
||||
): boolean {
|
||||
const mtimeMs = (deps.stagedMtimeMs ?? stagedFileMtimeMs)(candidate)
|
||||
|
||||
return typeof mtimeMs === 'number' && Number.isFinite(mtimeMs) && mtimeMs >= MARKER_SELF_ADOPT_EPOCH_MS
|
||||
}
|
||||
|
||||
export interface SpawnUpdaterProcessDeps {
|
||||
isWindows?: boolean
|
||||
spawnProcess?: (command: string, args: string[], options: SpawnOptions) => UpdaterChild
|
||||
|
|
|
|||
Loading…
Reference in New Issue