From ee6a9f83261a73b634699b6d30edc4e37353ce45 Mon Sep 17 00:00:00 2001 From: fangliquanflq Date: Fri, 14 Aug 2026 16:22:41 +0800 Subject: [PATCH] fix(updater): carry acquisition age through scripts --- apps/desktop/electron/main.ts | 8 +- .../electron/update-handoff-marker.test.ts | 88 +++++++++++++++++++ apps/desktop/electron/update-marker.test.ts | 11 +++ apps/desktop/electron/update-marker.ts | 12 ++- scripts/desktop-update/posix.sh | 23 ++++- scripts/desktop-update/windows.ps1 | 19 +++- 6 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/electron/update-handoff-marker.test.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 8acc4e7eeb..92ec2104d8 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -3640,6 +3640,7 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { let child if (scriptHandoff) { + const updateStartedAt = Math.floor(Date.now() / 1000) // A bare detached+hidden powershell spawn silently dies before -File // processing (console-subsystem init failure — see // wrapHandoffForDetachedConsole). Route through `cmd start` so the @@ -3663,6 +3664,7 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { env: { ...process.env, HERMES_HOME, + HERMES_UPDATE_STARTED_AT: String(updateStartedAt), PATH: pathWithHermesManagedNode(venvBin) }, detached: true, @@ -3677,7 +3679,7 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { // The `hermes update` child adopts the SCRIPT's claim via // update_lock.py's process-ancestry rule; no mtime heuristics needed. if (Number.isInteger(child.pid)) { - writeUpdateMarker(HERMES_HOME, child.pid) + writeUpdateMarker(HERMES_HOME, child.pid, { startedAt: updateStartedAt }) } rememberLog( @@ -4020,6 +4022,7 @@ async function applyUpdatesPosixHandoff(opts: any) { } const args = [...handoff.args, '--install-root', updateRoot, '--branch', branch, '--desktop-pid', String(process.pid)] + const updateStartedAt = Math.floor(Date.now() / 1000) // Relaunch target: the running .app bundle on mac (script swaps the // rebuilt bundle over it), the running binary elsewhere. The script's gate @@ -4052,6 +4055,7 @@ async function applyUpdatesPosixHandoff(opts: any) { env: { ...process.env, HERMES_HOME, + HERMES_UPDATE_STARTED_AT: String(updateStartedAt), PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin')) }, detached: true, @@ -4062,7 +4066,7 @@ async function applyUpdatesPosixHandoff(opts: any) { // until the script claims the marker with its own pid as step 0. If the // script never starts, the dead pid reads as stale and self-deletes. if (Number.isInteger(child.pid)) { - writeUpdateMarker(HERMES_HOME, child.pid) + writeUpdateMarker(HERMES_HOME, child.pid, { startedAt: updateStartedAt }) } rememberLog(`[updates] launched posix hand-off: ${handoff.scriptPath} (branch ${branch}); quitting to hand off`) diff --git a/apps/desktop/electron/update-handoff-marker.test.ts b/apps/desktop/electron/update-handoff-marker.test.ts new file mode 100644 index 0000000000..c0ce1592bd --- /dev/null +++ b/apps/desktop/electron/update-handoff-marker.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { test } from 'vitest' + +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..') +const POSIX_SCRIPT = path.join(REPO_ROOT, 'scripts', 'desktop-update', 'posix.sh') +const WINDOWS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'desktop-update', 'windows.ps1') + +function sandbox(tag: string) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-handoff-marker-${tag}-`)) + const installRoot = path.join(home, 'hermes-agent') + fs.mkdirSync(installRoot) + + return { home, installRoot } +} + +function markerStartedAt(home: string): number { + const [, startedAt] = fs.readFileSync(path.join(home, '.hermes-update-in-progress'), 'utf8').split('\n') + + return Number.parseInt(startedAt, 10) +} + +function runPosix(installRoot: string, startedAt?: string) { + const env = { ...process.env } + if (startedAt === undefined) delete env.HERMES_UPDATE_STARTED_AT + else env.HERMES_UPDATE_STARTED_AT = startedAt + + return spawnSync('/bin/bash', [POSIX_SCRIPT, '--install-root', installRoot, '--self-test-marker'], { + env, + encoding: 'utf8' + }) +} + +function runWindows(installRoot: string, startedAt?: string) { + const env = { ...process.env } + if (startedAt === undefined) delete env.HERMES_UPDATE_STARTED_AT + else env.HERMES_UPDATE_STARTED_AT = startedAt + + return spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + WINDOWS_SCRIPT, + '-InstallRoot', + installRoot, + '-NoUi', + '-NoMarkerCleanup', + '-SelfTestMarker' + ], + { env, encoding: 'utf8' } + ) +} + +function assertScriptHandoff(run: (installRoot: string, startedAt?: string) => ReturnType) { + const preserved = sandbox('preserved') + const acquiredAt = Math.floor(Date.now() / 1000) - 300 + const preservedResult = run(preserved.installRoot, String(acquiredAt)) + + assert.equal(preservedResult.status, 0, preservedResult.stderr || preservedResult.stdout) + assert.equal(markerStartedAt(preserved.home), acquiredAt, 'the script must preserve the Desktop acquisition time') + + const refreshed = sandbox('refreshed') + fs.writeFileSync(path.join(refreshed.home, '.hermes-update-in-progress'), '999999\n1\n') + const before = Math.floor(Date.now() / 1000) + const refreshedResult = run(refreshed.installRoot, 'malformed') + const after = Math.floor(Date.now() / 1000) + + assert.equal(refreshedResult.status, 0, refreshedResult.stderr || refreshedResult.stdout) + assert.ok( + markerStartedAt(refreshed.home) >= before && markerStartedAt(refreshed.home) <= after, + 'an invalid hand-off timestamp must start a fresh claim' + ) +} + +test.skipIf(process.platform === 'win32')('POSIX hand-off preserves the Desktop marker acquisition time', () => { + assertScriptHandoff(runPosix) +}) + +test.skipIf(process.platform !== 'win32')('PowerShell hand-off preserves the Desktop marker acquisition time', () => { + assertScriptHandoff(runWindows) +}) diff --git a/apps/desktop/electron/update-marker.test.ts b/apps/desktop/electron/update-marker.test.ts index 216497d554..412fab013e 100644 --- a/apps/desktop/electron/update-marker.test.ts +++ b/apps/desktop/electron/update-marker.test.ts @@ -128,6 +128,17 @@ test('writeUpdateMarker preserves a live holder age across pid hand-off', () => assert.equal(Number.parseInt(startedLine, 10), startedAt, 'the holder age must not restart during hand-off') }) +test('writeUpdateMarker uses the acquisition time passed to a detached script', () => { + const home = tmpHome('write-script-acquired-at') + const now = 1_000_000_000_000 + const startedAt = Math.floor(now / 1000) - 300 + + writeUpdateMarker(home, 2020, { now: () => now, startedAt }) + + const [, startedLine] = fs.readFileSync(markerPath(home), 'utf8').split('\n') + assert.equal(Number.parseInt(startedLine, 10), startedAt) +}) + test('writeUpdateMarker is best-effort (no throw on bad path)', () => { // A non-existent directory should not throw. const badHome = path.join(os.tmpdir(), 'hermes-marker-nonexistent-' + Date.now()) diff --git a/apps/desktop/electron/update-marker.ts b/apps/desktop/electron/update-marker.ts index 79fabbbe65..80f4aa5d3a 100644 --- a/apps/desktop/electron/update-marker.ts +++ b/apps/desktop/electron/update-marker.ts @@ -132,20 +132,26 @@ export function writeUpdateMarker( { kill, now = Date.now, - maxAgeMs = UPDATE_MARKER_MAX_AGE_MS + maxAgeMs = UPDATE_MARKER_MAX_AGE_MS, + startedAt }: { now?: () => number maxAgeMs?: number kill?: typeof process.kill + startedAt?: number } = {} ) { const file = markerPath(hermesHome) const nowMs = now() const owner = readLiveUpdateMarker(hermesHome, { kill, maxAgeMs, now: () => nowMs }) - const startedAt = owner ? Math.floor((nowMs - owner.ageMs) / 1000) : Math.floor(nowMs / 1000) + const acquiredAt = typeof startedAt === 'number' && Number.isInteger(startedAt) + ? startedAt + : owner + ? Math.floor((nowMs - owner.ageMs) / 1000) + : Math.floor(nowMs / 1000) try { - fs.writeFileSync(file, `${pid}\n${startedAt}\n`, 'utf8') + fs.writeFileSync(file, `${pid}\n${acquiredAt}\n`, 'utf8') } catch { // Best-effort: if we can't write the marker, proceed anyway. The // updater will write its own when it reaches run_update. diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh index 50f36d787c..78cdf4444d 100755 --- a/scripts/desktop-update/posix.sh +++ b/scripts/desktop-update/posix.sh @@ -19,6 +19,7 @@ # [--sandbox-fallback] linux: the caller vouches for a sandbox opt-out # (ELECTRON_DISABLE_SANDBOX / --no-sandbox launch) # [--no-ui] [--no-marker-cleanup] [--self-test-ui] [--self-test-gate] +# [--self-test-marker] # [-- ] linux: filtered launch args to replay # # The shim (ui.html in a chromeless browser app window) is decoration: it @@ -37,7 +38,8 @@ set -u ORIGINAL_ARGS=("$@") INSTALL_ROOT="" BRANCH="main" DESKTOP_PID=0 RELAUNCH_TARGET="" RELAUNCH_CWD="" SANDBOX_FALLBACK=0 RELAUNCH_ARGS=() -NO_UI=0 NO_MARKER_CLEANUP=0 SELF_TEST_UI=0 SELF_TEST_GATE=0 HANDOFF_DAEMONIZED=0 +NO_UI=0 NO_MARKER_CLEANUP=0 SELF_TEST_UI=0 SELF_TEST_GATE=0 SELF_TEST_MARKER=0 +HANDOFF_DAEMONIZED=0 while [ $# -gt 0 ]; do case "$1" in --install-root) INSTALL_ROOT="$2"; shift 2 ;; @@ -51,6 +53,7 @@ while [ $# -gt 0 ]; do --self-test-ui) SELF_TEST_UI=1; shift ;; --self-test-gate) SELF_TEST_GATE=1; shift ;; --daemonized) HANDOFF_DAEMONIZED=1; shift ;; + --self-test-marker) SELF_TEST_MARKER=1; NO_UI=1; NO_MARKER_CLEANUP=1; shift ;; --) shift; RELAUNCH_ARGS=("$@"); shift $# ;; *) echo "unknown arg: $1" >&2; exit 64 ;; esac @@ -466,7 +469,23 @@ rm -f "$RESULT" 2>/dev/null || true # Marker claim: same cross-process lock contract as windows.ps1 / # update_lock.py (the `hermes update` child adopts it via process ancestry). -printf '%s\n%s\n' "$$" "$(date +%s)" > "$MARKER" 2>/dev/null || log "WARNING: could not write update marker" +# The Desktop supplies one acquisition time for the whole ownership chain. +NOW="$(date +%s)" +STARTED_AT="${HERMES_UPDATE_STARTED_AT:-$NOW}" +case "$STARTED_AT" in ''|*[!0-9]*) STARTED_AT="$NOW" ;; esac +MIN_STARTED_AT=$((NOW - 1200)) +# Compare the validated decimal strings before doing arithmetic. Shell integer +# expansion can wrap on an attacker-controlled value wider than signed 64-bit. +if [ "${#STARTED_AT}" -ne "${#NOW}" ] \ + || [[ "$STARTED_AT" > "$NOW" || "$STARTED_AT" < "$MIN_STARTED_AT" ]]; then + STARTED_AT="$NOW" +fi +printf '%s\n%s\n' "$$" "$STARTED_AT" > "$MARKER" 2>/dev/null || log "WARNING: could not write update marker" + +if [ "$SELF_TEST_MARKER" -eq 1 ]; then + trap - EXIT + exit 0 +fi # Wait out the Desktop (FAIL CLOSED: updating under live backends bricks). if [ "$DESKTOP_PID" -gt 0 ] 2>/dev/null; then diff --git a/scripts/desktop-update/windows.ps1 b/scripts/desktop-update/windows.ps1 index 937bd5891b..f0bfd52839 100644 --- a/scripts/desktop-update/windows.ps1 +++ b/scripts/desktop-update/windows.ps1 @@ -35,7 +35,8 @@ # # Marker: we claim HERMES_HOME\.hermes-update-in-progress with OUR pid as # step 0 (the wrapper cmd.exe pid the Desktop saw is useless -- it exits -# immediately). hermes_cli/update_lock.py's ancestry rule lets our +# immediately), retaining HERMES_UPDATE_STARTED_AT from the Desktop hand-off. +# hermes_cli/update_lock.py's ancestry rule lets our # `hermes update` child adopt the claim; electron/update-marker.ts parks a # relaunched Desktop on it. Cleanup only removes the marker while WE still # own it (a handoff partner that rewrote it keeps its claim). @@ -48,7 +49,8 @@ param( [switch]$NoUi, [switch]$NoMarkerCleanup, [switch]$SelfTestUi, - [switch]$SelfTestPipeDrain + [switch]$SelfTestPipeDrain, + [switch]$SelfTestMarker ) if (-not $SelfTestUi -and -not $SelfTestPipeDrain -and -not $InstallRoot) { @@ -899,14 +901,25 @@ try { # -- 0. Claim the update marker with OUR pid --------------------------- try { $epoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $startedAt = 0L + $hasStartedAt = [int64]::TryParse($env:HERMES_UPDATE_STARTED_AT, [ref]$startedAt) + if (-not $hasStartedAt -or $startedAt -gt $epoch -or ($epoch - $startedAt) -gt 1200) { + $startedAt = $epoch + } # WriteAllText for byte-exact LF framing: Set-Content emits CRLF and # the marker contract (Rust/TS/Python readers) is "\n\n". - [System.IO.File]::WriteAllText($MarkerPath, "$PID`n$epoch`n") + [System.IO.File]::WriteAllText($MarkerPath, "$PID`n$startedAt`n") Write-HandoffLog "claimed update marker (pid $PID)" } catch { Write-HandoffLog "WARNING: could not write update marker: $($_.Exception.Message)" } + if ($SelfTestMarker) { + $finalCode = 0 + $finalMsg = "marker self-test complete" + exit 0 + } + # -- 1. Wait for the Desktop to exit (FAIL CLOSED) ---------------------- Publish-UiProgress "Waiting for Hermes to close" if ($DesktopPid -gt 0) {