fix(updater): carry acquisition age through scripts
This commit is contained in:
committed by
Brooklyn Nicholson
parent
dbc2a9c8e9
commit
ee6a9f8326
@@ -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`)
|
||||
|
||||
88
apps/desktop/electron/update-handoff-marker.test.ts
Normal file
88
apps/desktop/electron/update-handoff-marker.test.ts
Normal file
@@ -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<typeof spawnSync>) {
|
||||
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)
|
||||
})
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
# [-- <args...>] 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
|
||||
|
||||
@@ -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 "<pid>\n<ts>\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) {
|
||||
|
||||
Reference in New Issue
Block a user