fix(desktop): recover failed App Installer handoffs
A failed descriptor open stopped the backend but left no recovery path. Keep ownership of the ready relaunch waiter, cancel it on failure, and restore only after backend teardown begins. Preserve cleanup errors. Primary and pooled stops retain failed child handles for retry. Replacement stays blocked until the prior process exits, including while a persistent ownership claim is pending. Successful handoff keeps the update gate closed while the application quits. Verified: 178 focused tests passed with eight host skips. Both desktop typechecks and lint passed. Tests use real disposable children and real strategy/registration owners with external apply edges intercepted. No installed app, native signed update, or full desktop build was run.
This commit is contained in:
@@ -20,6 +20,8 @@
|
||||
* the function body.
|
||||
*/
|
||||
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
|
||||
export interface StopBackendChildDeps {
|
||||
/** Defaults to the real platform check; injectable for tests. */
|
||||
isWindows?: boolean
|
||||
@@ -101,3 +103,39 @@ export function stopBackendTreesForUpdate(
|
||||
|
||||
deps.stopAllPoolBackends()
|
||||
}
|
||||
|
||||
export async function waitForBackendExit(
|
||||
child: ChildProcess | null | undefined,
|
||||
escalate: (child: ChildProcess) => void,
|
||||
timeoutMs = 5000
|
||||
): Promise<void> {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) { return }
|
||||
|
||||
const exited = (): boolean => child.exitCode !== null || child.signalCode !== null
|
||||
|
||||
const wait = (delay: number): Promise<void> => new Promise(resolve => {
|
||||
if (exited()) {
|
||||
resolve()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const finish = (): void => {
|
||||
clearTimeout(timer)
|
||||
child.removeListener('exit', finish)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const timer = setTimeout(finish, delay)
|
||||
child.once('exit', finish)
|
||||
})
|
||||
|
||||
await wait(timeoutMs)
|
||||
|
||||
if (exited()) { return }
|
||||
|
||||
escalate(child)
|
||||
await wait(1000)
|
||||
|
||||
if (!exited()) { throw new Error(`Backend PID ${child.pid} did not exit after escalation`) }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { type ChildProcess, spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { waitForBackendExit } from './backend-child'
|
||||
import { createBackendConnectionState } from './backend-connection-state'
|
||||
|
||||
type FakeProcess = { id: string }
|
||||
@@ -105,3 +108,94 @@ test('an invalidated attempt cannot attach a late-spawned process', () => {
|
||||
assert.equal(state.attachProcess(staleAttempt, { id: 'late' }), null)
|
||||
assert.equal(state.getProcess(), null)
|
||||
})
|
||||
|
||||
test('a failed primary stop retains its child and blocks a replacement until retry exits', async () => {
|
||||
const state = createBackendConnectionState<ChildProcess, string>()
|
||||
|
||||
const child = spawn(process.execPath, ['-e', 'process.stdout.write("ready"); setInterval(() => {}, 1000)'], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true
|
||||
})
|
||||
|
||||
try {
|
||||
await once(child.stdout!, 'data')
|
||||
const attempt = state.startAttempt()
|
||||
state.setPromise(attempt, Promise.resolve('ready'))
|
||||
const owner = state.attachProcess(attempt, child)
|
||||
assert.ok(owner)
|
||||
const failure = new Error('primary is still running')
|
||||
const calls: ChildProcess[] = []
|
||||
|
||||
const fail = async (current: ChildProcess): Promise<void> => { calls.push(current); throw failure }
|
||||
|
||||
const stopping = state.stopProcess(fail)
|
||||
assert.equal(state.getProcess(), null)
|
||||
assert.equal(state.getPromise(), null)
|
||||
assert.throws(() => state.startAttempt(), /has not stopped/)
|
||||
assert.equal(state.stopProcess(fail), stopping)
|
||||
await assert.rejects(stopping, error => error === failure)
|
||||
state.invalidate()
|
||||
assert.throws(() => state.startAttempt(), /has not stopped/)
|
||||
assert.equal(child.exitCode, null)
|
||||
assert.equal(child.signalCode, null)
|
||||
|
||||
await state.stopProcess(async current => {
|
||||
calls.push(current)
|
||||
current.kill()
|
||||
await waitForBackendExit(current, process => { process.kill('SIGKILL') })
|
||||
})
|
||||
assert.deepEqual(calls, [child, child])
|
||||
assert.ok(child.exitCode !== null || child.signalCode !== null)
|
||||
const replacement = state.startAttempt()
|
||||
const connection = Promise.resolve('new')
|
||||
assert.equal(state.setPromise(replacement, connection), true)
|
||||
assert.equal(state.clearForCurrentProcess(owner), false)
|
||||
assert.equal(state.getPromise(), connection)
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
const closed = once(child, 'close')
|
||||
child.kill()
|
||||
await closed
|
||||
}
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test('shutdown sees a spawned child while its persistent claim is still pending', async () => {
|
||||
const state = createBackendConnectionState<ChildProcess, string>()
|
||||
|
||||
const child = spawn(process.execPath, ['-e', 'process.stdout.write("ready"); setInterval(() => {}, 1000)'], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true
|
||||
})
|
||||
|
||||
const claim = deferred<void>()
|
||||
|
||||
try {
|
||||
await once(child.stdout!, 'data')
|
||||
const attempt = state.startAttempt()
|
||||
|
||||
const claiming = state.claimProcess(attempt, child, async current => {
|
||||
assert.equal(current, child)
|
||||
assert.equal(state.getProcess(), child)
|
||||
await claim.promise
|
||||
})
|
||||
|
||||
assert.equal(state.getProcess(), child)
|
||||
await state.stopProcess(async current => {
|
||||
assert.equal(current, child)
|
||||
current.kill()
|
||||
await waitForBackendExit(current, process => { process.kill('SIGKILL') })
|
||||
})
|
||||
assert.ok(child.exitCode !== null || child.signalCode !== null)
|
||||
claim.resolve()
|
||||
assert.equal(await claiming, null, 'a completed claim cannot revive the stopped generation')
|
||||
assert.equal(state.getProcess(), null)
|
||||
assert.doesNotThrow(() => state.startAttempt())
|
||||
} finally {
|
||||
claim.resolve()
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
const closed = once(child, 'close')
|
||||
child.kill()
|
||||
await closed
|
||||
}
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
@@ -8,13 +8,33 @@ export type BackendProcessOwner<TProcess> = {
|
||||
process: TProcess
|
||||
}
|
||||
|
||||
interface PendingBackendStop<TProcess> {
|
||||
process: TProcess
|
||||
completion: Promise<void>
|
||||
failed: boolean
|
||||
}
|
||||
|
||||
export function createBackendConnectionState<TProcess, TConnection>() {
|
||||
let generation = 0
|
||||
let process: TProcess | null = null
|
||||
let promise: Promise<TConnection> | null = null
|
||||
let stopping: PendingBackendStop<TProcess> | null = null
|
||||
|
||||
function invalidate(): TProcess | null {
|
||||
const currentProcess = process
|
||||
generation += 1
|
||||
process = null
|
||||
promise = null
|
||||
|
||||
return currentProcess
|
||||
}
|
||||
|
||||
return {
|
||||
startAttempt(): BackendConnectionAttempt<TConnection> {
|
||||
if (stopping) {
|
||||
throw new Error('The previous backend has not stopped. Retry its shutdown before starting a replacement.')
|
||||
}
|
||||
|
||||
return { generation, promise: null }
|
||||
},
|
||||
|
||||
@@ -46,6 +66,22 @@ export function createBackendConnectionState<TProcess, TConnection>() {
|
||||
return { generation, process: nextProcess }
|
||||
},
|
||||
|
||||
async claimProcess(
|
||||
attempt: BackendConnectionAttempt<TConnection>,
|
||||
nextProcess: TProcess,
|
||||
claim: (current: TProcess) => Promise<unknown>
|
||||
): Promise<BackendProcessOwner<TProcess> | null> {
|
||||
const owner = this.attachProcess(attempt, nextProcess)
|
||||
|
||||
if (!owner) {
|
||||
return null
|
||||
}
|
||||
|
||||
await claim(nextProcess)
|
||||
|
||||
return owner.generation === generation && process === nextProcess ? owner : null
|
||||
},
|
||||
|
||||
clearForCurrentProcess(owner: BackendProcessOwner<TProcess>): boolean {
|
||||
if (owner.generation !== generation || owner.process !== process) {
|
||||
return false
|
||||
@@ -75,14 +111,27 @@ export function createBackendConnectionState<TProcess, TConnection>() {
|
||||
return promise
|
||||
},
|
||||
|
||||
invalidate(): TProcess | null {
|
||||
const currentProcess = process
|
||||
invalidate,
|
||||
|
||||
generation += 1
|
||||
process = null
|
||||
promise = null
|
||||
stopProcess(stop: (current: TProcess) => Promise<void>): Promise<void> {
|
||||
if (stopping && !stopping.failed) {
|
||||
return stopping.completion
|
||||
}
|
||||
|
||||
return currentProcess
|
||||
const current = stopping?.process ?? invalidate()
|
||||
|
||||
if (current === null) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const completion = Promise.resolve().then(() => stop(current)).then(
|
||||
() => { stopping = null },
|
||||
error => { stopping!.failed = true; throw error }
|
||||
)
|
||||
|
||||
stopping = { process: current, completion, failed: false }
|
||||
|
||||
return completion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
69
apps/desktop/electron/backend-exit.test.ts
Normal file
69
apps/desktop/electron/backend-exit.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { type ChildProcess, spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { waitForBackendExit } from './backend-child'
|
||||
|
||||
async function makeChild(): Promise<ChildProcess> {
|
||||
const child = spawn(process.execPath, ['-e', 'process.stdout.write("ready"); setInterval(() => {}, 1000)'], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true
|
||||
})
|
||||
|
||||
await once(child.stdout!, 'data')
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
test('backend exit waits through escalation and removes its listener', async () => {
|
||||
const child = await makeChild()
|
||||
let escalated = 0
|
||||
const before = child.listenerCount('exit')
|
||||
|
||||
try {
|
||||
await waitForBackendExit(child, process => { escalated++; process.kill() }, 0)
|
||||
assert.equal(escalated, 1)
|
||||
assert.ok(child.exitCode !== null || child.signalCode !== null)
|
||||
assert.equal(child.listenerCount('exit'), before)
|
||||
await waitForBackendExit(child, () => { throw new Error('an exited process must not be killed again') })
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
const exited = once(child, 'close')
|
||||
child.kill()
|
||||
await exited
|
||||
}
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test('backend exit refuses a live child rather than reporting a completed shutdown', async () => {
|
||||
const child = await makeChild()
|
||||
const before = child.listenerCount('exit')
|
||||
|
||||
try {
|
||||
await assert.rejects(waitForBackendExit(child, () => {}, 0), /did not exit/)
|
||||
assert.equal(child.exitCode, null)
|
||||
assert.equal(child.signalCode, null)
|
||||
assert.equal(child.listenerCount('exit'), before)
|
||||
} finally {
|
||||
const exited = once(child, 'close')
|
||||
child.kill()
|
||||
await exited
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test('a failed spawn has no process to escalate', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'backend-no-process-'))
|
||||
const child = spawn(path.join(root, 'absent-executable'), [], { stdio: 'ignore' })
|
||||
|
||||
try {
|
||||
await once(child, 'error')
|
||||
assert.equal(child.pid, undefined)
|
||||
await waitForBackendExit(child, () => { throw new Error('a failed spawn must not be signalled') }, 0)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 15_000)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { type ChildProcess, execFileSync, spawn } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import http from 'node:http'
|
||||
@@ -35,7 +35,7 @@ import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } fro
|
||||
import { appIconCandidates, resolveAppIcon } from './app-icon'
|
||||
import { stageAppInstallerFile } from './app-installer-file'
|
||||
import { runAppInstallerChecker } from './appinstaller-checker'
|
||||
import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child'
|
||||
import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate, waitForBackendExit as waitForBackendExitImpl } from './backend-child'
|
||||
import {
|
||||
type BackendOutputTail,
|
||||
claimDecision,
|
||||
@@ -3157,15 +3157,8 @@ function resolvePackagedUpdateStrategy(): UpdaterStrategy | null {
|
||||
appVersion: app.getVersion(),
|
||||
log: rememberLog,
|
||||
emitProgress: emitUpdateProgress,
|
||||
beforeInstall: async () => {
|
||||
isQuittingForHandoff = true
|
||||
await Promise.all([teardownPrimaryBackendAndWait(), stopAllPoolBackends()])
|
||||
},
|
||||
onInstallFailure: async () => {
|
||||
isQuittingForHandoff = false
|
||||
updateInFlight = false
|
||||
await startHermes()
|
||||
}
|
||||
beforeInstall: teardownBundledBackend,
|
||||
onInstallFailure: restoreBundledBackend
|
||||
})
|
||||
|
||||
return packagedUpdateStrategy
|
||||
@@ -3191,6 +3184,7 @@ function resolvePackagedUpdateStrategy(): UpdaterStrategy | null {
|
||||
open: file => shell.openPath(file)
|
||||
},
|
||||
teardownBundledBackend,
|
||||
restoreBundledBackend,
|
||||
emitUpdateProgress,
|
||||
appVersion: app.getVersion(),
|
||||
quit: () => app.quit(),
|
||||
@@ -3312,23 +3306,32 @@ function isLightVariant(): boolean {
|
||||
return INSTALL_STAMP?.payload === 'light'
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful teardown before the OS App Installer swaps the package: stop the
|
||||
* primary backend + all pool backends (tree-kill their children) so no
|
||||
* process keeps files in the install dir locked while Windows replaces it.
|
||||
* Same primitive the update hand-off uses (stopBackendTreesForUpdate).
|
||||
*/
|
||||
/** Invalidate connections and wait for every owned backend before the swap. */
|
||||
async function teardownBundledBackend(): Promise<void> {
|
||||
const hermesProcess = backendConnectionState.getProcess()
|
||||
isQuittingForHandoff = true
|
||||
const results = await Promise.allSettled([teardownPrimaryBackendAndWait(), stopAllPoolBackends()])
|
||||
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason)
|
||||
|
||||
if (hermesProcess || backendPool.size > 0) {
|
||||
stopBackendTreesForUpdate(hermesProcess, {
|
||||
forceKillProcessTree,
|
||||
stopAllPoolBackends
|
||||
})
|
||||
if (errors.length) {
|
||||
// An incomplete shutdown blocks replacement until a retry proves exit.
|
||||
backendStartFailure = new AggregateError(errors, 'Backend shutdown failed')
|
||||
throw backendStartFailure
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBundledBackend(): Promise<void> {
|
||||
try {
|
||||
// A failed stop retains its process handle. Retry before enabling a new start.
|
||||
await teardownBundledBackend()
|
||||
} finally {
|
||||
isQuittingForHandoff = false
|
||||
updateInFlight = false
|
||||
}
|
||||
|
||||
backendStartFailure = null
|
||||
await startHermes()
|
||||
}
|
||||
|
||||
// Set to true when the desktop is about to quit so a detached swap/install/
|
||||
// uninstall script can take over. On macOS, app.quit() closes windows but
|
||||
// window-all-closed deliberately keeps the process alive (standard Electron
|
||||
@@ -10891,10 +10894,11 @@ function resetHermesConnection({ soft = false } = {}) {
|
||||
// dashboard process to actually exit (SIGKILL after 5s) so the next
|
||||
// startHermes() spawns fresh instead of racing the dying one. Shared by the
|
||||
// connection-config and profile switch flows.
|
||||
async function teardownPrimaryBackendAndWait({ soft = false } = {}) {
|
||||
// Capture the reference before resetHermesConnection() invalidates it.
|
||||
const hermesProcess = backendConnectionState.getProcess()
|
||||
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
|
||||
async function teardownPrimaryBackendAndWait({ soft = false }: { soft?: boolean } = {}): Promise<void> {
|
||||
const stopping = backendConnectionState.stopProcess(async child => {
|
||||
stopBackendChild(child)
|
||||
await waitForBackendExit(child)
|
||||
})
|
||||
|
||||
if (soft) {
|
||||
softRehomeInProgress = true
|
||||
@@ -10902,7 +10906,7 @@ async function teardownPrimaryBackendAndWait({ soft = false } = {}) {
|
||||
|
||||
try {
|
||||
resetHermesConnection({ soft })
|
||||
await waitForBackendExit(dying)
|
||||
await stopping
|
||||
} finally {
|
||||
if (soft) {
|
||||
softRehomeInProgress = false
|
||||
@@ -10939,53 +10943,20 @@ function broadcastConnectionsChanged(payload: { connectionId: string; reason: 'r
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForBackendExit(child, timeoutMs = 5000) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
const exited = () => child.exitCode !== null || child.signalCode !== null
|
||||
|
||||
const wait = delay =>
|
||||
new Promise<void>(resolve => {
|
||||
if (exited()) {
|
||||
resolve()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(resolve, delay)
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
await wait(timeoutMs)
|
||||
|
||||
if (exited()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (IS_WINDOWS && Number.isInteger(child.pid)) {
|
||||
forceKillProcessTree(child.pid)
|
||||
} else if (Number.isInteger(child.pid)) {
|
||||
async function waitForBackendExit(child: ChildProcess | null | undefined, timeoutMs = 5000): Promise<void> {
|
||||
await waitForBackendExitImpl(child, processToStop => {
|
||||
if (IS_WINDOWS && Number.isInteger(processToStop.pid)) {
|
||||
forceKillProcessTree(processToStop.pid)
|
||||
} else if (Number.isInteger(processToStop.pid)) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL')
|
||||
process.kill(-processToStop.pid!, 'SIGKILL')
|
||||
} catch {
|
||||
child.kill('SIGKILL')
|
||||
processToStop.kill('SIGKILL')
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGKILL')
|
||||
processToStop.kill('SIGKILL')
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// Await the escalation as well; do not let shutdown or failed adoption race
|
||||
// a still-running backend.
|
||||
await wait(1000)
|
||||
}, timeoutMs)
|
||||
}
|
||||
|
||||
// The profile the primary (window) backend runs as. readActiveDesktopProfile()
|
||||
@@ -12325,7 +12296,7 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po
|
||||
// SIGTERM -> SIGKILL escalation in waitForBackendExit() resolves. Previously
|
||||
// SIGTERM + immediate entry delete dropped the handle and a slow child
|
||||
// survived detached under PID 1.
|
||||
const poolStopper = createPoolStopper({
|
||||
const poolStopper = createPoolStopper<ChildProcess>({
|
||||
pool: backendPool,
|
||||
stopChild: child => stopBackendChild(child),
|
||||
waitForExit: child => waitForBackendExit(child)
|
||||
@@ -12383,9 +12354,8 @@ function reapInstallRootedStragglers(excludePids: number[]): void {
|
||||
}
|
||||
|
||||
const backendShutdown = createBackendShutdownCoordinator(async () => {
|
||||
const primary = backendConnectionState.invalidate()
|
||||
|
||||
stopBackendChild(primary)
|
||||
const primary = backendConnectionState.getProcess()
|
||||
const primaryStop = teardownPrimaryBackendAndWait()
|
||||
const pooledStops = stopAllPoolBackends()
|
||||
|
||||
if (poolIdleReaper) {
|
||||
@@ -12393,7 +12363,7 @@ const backendShutdown = createBackendShutdownCoordinator(async () => {
|
||||
poolIdleReaper = null
|
||||
}
|
||||
|
||||
await Promise.all([waitForBackendExit(primary), pooledStops])
|
||||
await Promise.all([primaryStop, pooledStops])
|
||||
|
||||
reapInstallRootedStragglers(Number.isInteger(primary?.pid) ? [primary.pid] : [])
|
||||
})
|
||||
@@ -12636,6 +12606,10 @@ async function startHermes() {
|
||||
const backendNonce = crypto.randomBytes(16).toString('hex')
|
||||
const parentIdentityEnv = parentWatchdogEnv(process.pid, parentStartMarker, backendNonce)
|
||||
|
||||
if (!backendConnectionState.isCurrentAttempt(connectionAttempt)) {
|
||||
throw new Error('Hermes backend start was superseded by a newer connection attempt.')
|
||||
}
|
||||
|
||||
const hermesProcess = spawn(
|
||||
backend.command,
|
||||
backend.args,
|
||||
@@ -12694,14 +12668,14 @@ async function startHermes() {
|
||||
// Mark handled so an early rejection (child dies during the claim) can't
|
||||
// surface as an unhandled rejection before the Promise.race below attaches.
|
||||
portAnnouncement.catch(() => {})
|
||||
await claimBackendChild(
|
||||
hermesProcess,
|
||||
|
||||
const processOwner = await backendConnectionState.claimProcess(connectionAttempt, hermesProcess, child => claimBackendChild(
|
||||
child,
|
||||
`${backend.command} ${backend.args.join(' ')}`,
|
||||
profile,
|
||||
backendNonce,
|
||||
primaryOutputTail
|
||||
)
|
||||
const processOwner = backendConnectionState.attachProcess(connectionAttempt, hermesProcess)
|
||||
))
|
||||
|
||||
if (!processOwner) {
|
||||
stopBackendChild(hermesProcess)
|
||||
@@ -12841,9 +12815,10 @@ async function startHermes() {
|
||||
throw error
|
||||
}
|
||||
|
||||
const failedProcess = backendConnectionState.invalidate()
|
||||
stopBackendChild(failedProcess)
|
||||
await waitForBackendExit(failedProcess)
|
||||
await backendConnectionState.stopProcess(async failedProcess => {
|
||||
stopBackendChild(failedProcess)
|
||||
await waitForBackendExit(failedProcess)
|
||||
})
|
||||
|
||||
if (error instanceof FirstRunSetupResetError) {
|
||||
throw error
|
||||
|
||||
@@ -89,10 +89,11 @@ test('stop of an unknown key resolves without signalling anything', async () =>
|
||||
assert.equal(stopper.inFlight('ghost'), undefined)
|
||||
})
|
||||
|
||||
test('stopAll stops every pooled backend and resolves after all exits', async () => {
|
||||
test('stopAll waits for current and already-stopping backends', async () => {
|
||||
const { addChild, exitResolvers, pool, stopper } = harness()
|
||||
const a = addChild('a')
|
||||
const b = addChild('b')
|
||||
const priorStop = stopper.stop('a')
|
||||
|
||||
let settled = false
|
||||
|
||||
@@ -104,12 +105,13 @@ test('stopAll stops every pooled backend and resolves after all exits', async ()
|
||||
assert.equal(a.killed, true)
|
||||
assert.equal(b.killed, true)
|
||||
|
||||
exitResolvers.get(a)?.()
|
||||
exitResolvers.get(b)?.()
|
||||
await Promise.resolve()
|
||||
await new Promise(setImmediate)
|
||||
assert.equal(settled, false, 'must wait for EVERY child, not the first')
|
||||
|
||||
exitResolvers.get(b)?.()
|
||||
await all
|
||||
exitResolvers.get(a)?.()
|
||||
await Promise.all([all, priorStop])
|
||||
assert.equal(settled, true)
|
||||
})
|
||||
|
||||
@@ -136,3 +138,32 @@ test('a respawn can await the in-flight stop before reusing the key', async () =
|
||||
|
||||
assert.deepEqual(order, ['exit-signal', 'spawn'])
|
||||
})
|
||||
|
||||
test('failed stops block respawn and retain the child for a later stop retry', async () => {
|
||||
const child: Child = { exited: false, killed: false }
|
||||
const pool = new Map([['profile', { process: child }]])
|
||||
const failure = new Error('child is still alive')
|
||||
const attempts: Child[] = []
|
||||
let refuses = true
|
||||
|
||||
const stopper = createPoolStopper({
|
||||
pool,
|
||||
stopChild: current => { attempts.push(current!) },
|
||||
waitForExit: async current => {
|
||||
if (refuses) { throw failure }
|
||||
current!.exited = true
|
||||
}
|
||||
})
|
||||
|
||||
const failed = stopper.stop('profile')
|
||||
await assert.rejects(failed, error => error === failure)
|
||||
assert.equal(pool.has('profile'), false)
|
||||
assert.equal(stopper.inFlight('profile'), failed)
|
||||
await assert.rejects(stopper.inFlight('profile')!, error => error === failure)
|
||||
|
||||
refuses = false
|
||||
await stopper.stopAll()
|
||||
assert.deepEqual(attempts, [child, child])
|
||||
assert.equal(child.exited, true)
|
||||
assert.equal(stopper.inFlight('profile'), undefined)
|
||||
})
|
||||
|
||||
@@ -22,17 +22,17 @@
|
||||
* directly instead of grepping main.ts source text.
|
||||
*/
|
||||
|
||||
export interface PoolStopEntry {
|
||||
process?: unknown
|
||||
export interface PoolStopEntry<Process = unknown> {
|
||||
process?: Process
|
||||
}
|
||||
|
||||
export interface PoolStopperDeps {
|
||||
export interface PoolStopperDeps<Process = unknown> {
|
||||
/** The live backend pool. Entries are evicted synchronously on stop. */
|
||||
pool: Map<string, PoolStopEntry>
|
||||
pool: Map<string, PoolStopEntry<Process>>
|
||||
/** Signal the child (tree/group kill per platform). Synchronous. */
|
||||
stopChild: (child: unknown) => void
|
||||
stopChild: (child: Process | undefined) => void
|
||||
/** Bounded wait: resolves when the child exits, escalating to SIGKILL. */
|
||||
waitForExit: (child: unknown) => Promise<void>
|
||||
waitForExit: (child: Process | undefined) => Promise<void>
|
||||
}
|
||||
|
||||
export interface PoolStopper {
|
||||
@@ -44,17 +44,23 @@ export interface PoolStopper {
|
||||
stopAll: () => Promise<void>
|
||||
}
|
||||
|
||||
export function createPoolStopper(deps: PoolStopperDeps): PoolStopper {
|
||||
const stops = new Map<string, Promise<void>>()
|
||||
interface PendingStop<Process> {
|
||||
entry: PoolStopEntry<Process>
|
||||
completion: Promise<void>
|
||||
failed: boolean
|
||||
}
|
||||
|
||||
export function createPoolStopper<Process>(deps: PoolStopperDeps<Process>): PoolStopper {
|
||||
const stops = new Map<string, PendingStop<Process>>()
|
||||
|
||||
function stop(key: string): Promise<void> {
|
||||
const inFlight = stops.get(key)
|
||||
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
if (inFlight && !inFlight.failed) {
|
||||
return inFlight.completion
|
||||
}
|
||||
|
||||
const entry = deps.pool.get(key)
|
||||
const entry = inFlight?.entry ?? deps.pool.get(key)
|
||||
|
||||
if (!entry) {
|
||||
return Promise.resolve()
|
||||
@@ -67,20 +73,27 @@ export function createPoolStopper(deps: PoolStopperDeps): PoolStopper {
|
||||
const stopping = (async () => {
|
||||
deps.stopChild(entry.process)
|
||||
await deps.waitForExit(entry.process)
|
||||
})().finally(() => {
|
||||
stops.delete(key)
|
||||
})
|
||||
})().then(
|
||||
() => { stops.delete(key) },
|
||||
error => { pending.failed = true; throw error }
|
||||
)
|
||||
|
||||
stops.set(key, stopping)
|
||||
const pending: PendingStop<Process> = { entry, completion: stopping, failed: false }
|
||||
|
||||
stops.set(key, pending)
|
||||
|
||||
return stopping
|
||||
}
|
||||
|
||||
return {
|
||||
inFlight: key => stops.get(key),
|
||||
inFlight: key => stops.get(key)?.completion,
|
||||
stop,
|
||||
stopAll: async () => {
|
||||
await Promise.all([...deps.pool.keys()].map(stop))
|
||||
const pending = new Set([...deps.pool.keys(), ...stops.keys()])
|
||||
const results = await Promise.allSettled([...pending].map(stop))
|
||||
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason)
|
||||
|
||||
if (errors.length) { throw new AggregateError(errors, 'Backend pool shutdown failed') }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
114
apps/desktop/electron/updater/app-installer-recovery.test.ts
Normal file
114
apps/desktop/electron/updater/app-installer-recovery.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { AppInstallerStrategy, type AppInstallerStrategyDeps } from './app-installer'
|
||||
import { PENDING_RELAUNCH_FILENAME, registerUpdateRelaunch } from './relaunch'
|
||||
|
||||
for (const failureAt of ['prepare', 'register', 'teardown', 'open', 'none']) {
|
||||
test(`App Installer restores only after teardown begins (${failureAt})`, async () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'appinstaller-recovery-'))
|
||||
const marker = path.join(home, PENDING_RELAUNCH_FILENAME)
|
||||
const failure = new Error(`failed ${failureAt}`)
|
||||
const calls: string[] = []
|
||||
const progress: string[] = []
|
||||
let running = true
|
||||
|
||||
const act = (at: string): void => {
|
||||
calls.push(at)
|
||||
|
||||
if (at === failureAt) {throw failure}
|
||||
}
|
||||
|
||||
const deps: AppInstallerStrategyDeps = {
|
||||
python: 'unused-checker', script: 'unused.py',
|
||||
run: async () => { throw new Error('configured feed does not need the checker') },
|
||||
channel: 'stable', light: false, appVersion: '1.0', feedBaseUrl: 'https://example.invalid',
|
||||
installer: {
|
||||
prepare: async () => {
|
||||
act('prepare')
|
||||
|
||||
return 'fixture.appinstaller'
|
||||
},
|
||||
open: async () => {
|
||||
assert.equal(running, false)
|
||||
act('open')
|
||||
|
||||
return ''
|
||||
}
|
||||
},
|
||||
registerPendingRelaunch: version => registerUpdateRelaunch(home, version, {
|
||||
relaunch: async () => {
|
||||
act('register')
|
||||
|
||||
return { cancel: async () => { calls.push('cancel') } }
|
||||
}
|
||||
}),
|
||||
teardownBundledBackend: async () => { running = false; act('teardown') },
|
||||
restoreBundledBackend: async () => { running = true; calls.push('restore') },
|
||||
emitUpdateProgress: value => { progress.push(value.stage) },
|
||||
quit: () => { calls.push('quit') }
|
||||
}
|
||||
|
||||
try {
|
||||
if (failureAt === 'none') {
|
||||
const result = await new AppInstallerStrategy(deps).apply({})
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.handedOff, true, 'keep backend restart blocked until the quitting app exits')
|
||||
assert.deepEqual(calls, ['prepare', 'register', 'teardown', 'open', 'quit'])
|
||||
assert.equal(fs.existsSync(marker), true)
|
||||
} else {
|
||||
await assert.rejects(new AppInstallerStrategy(deps).apply({}), error => error === failure)
|
||||
assert.equal(running, true)
|
||||
assert.equal(calls.includes('quit'), false)
|
||||
assert.equal(calls.includes('restore'), ['teardown', 'open'].includes(failureAt))
|
||||
assert.equal(calls.includes('cancel'), ['teardown', 'open'].includes(failureAt))
|
||||
assert.equal(fs.existsSync(marker), false)
|
||||
assert.equal(progress.at(-1), 'error')
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('handoff errors retain cleanup failures while still restoring the backend', async () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'appinstaller-errors-'))
|
||||
const original = new Error('descriptor open failed')
|
||||
const cancellation = new Error('waiter stop failed')
|
||||
const recovery = new Error('backend restart failed')
|
||||
const order: string[] = []
|
||||
|
||||
const deps: AppInstallerStrategyDeps = {
|
||||
python: 'unused', script: 'unused.py', run: async () => ({ code: 0, stdout: '' }),
|
||||
channel: 'stable', light: false, appVersion: '1.0', feedBaseUrl: 'https://example.invalid',
|
||||
installer: { prepare: async () => 'file', open: async () => { throw original } },
|
||||
registerPendingRelaunch: version => registerUpdateRelaunch(home, version, {
|
||||
relaunch: async () => ({ cancel: async () => { order.push('cancel'); throw cancellation } })
|
||||
}),
|
||||
teardownBundledBackend: async () => { order.push('stop') },
|
||||
restoreBundledBackend: async () => { order.push('restore'); throw recovery },
|
||||
emitUpdateProgress: event => { if (event.stage === 'error') {order.push('error')} },
|
||||
quit: () => { throw new Error('failed handoff must not quit') }
|
||||
}
|
||||
|
||||
try {
|
||||
await assert.rejects(new AppInstallerStrategy(deps).apply({}), error => {
|
||||
assert.ok(error instanceof AggregateError)
|
||||
assert.equal(error.cause, original)
|
||||
assert.equal(error.errors[0], original)
|
||||
assert.ok(error.errors[1] instanceof AggregateError)
|
||||
assert.equal(error.errors[1].errors[0], cancellation)
|
||||
assert.equal(error.errors[2], recovery)
|
||||
|
||||
return true
|
||||
})
|
||||
assert.deepEqual(order, ['stop', 'cancel', 'restore', 'error'])
|
||||
assert.equal(fs.existsSync(path.join(home, PENDING_RELAUNCH_FILENAME)), false)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
// updater/app-installer-strategy.test.ts — the apply-flow contract: the
|
||||
// relaunch registration (marker + detached waiter) completes BEFORE the OS
|
||||
// hand-off, teardown runs before the trigger, and quit is unconditional.
|
||||
// hand-off. Teardown finishes before the descriptor opens.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -26,12 +26,13 @@ function makeDeps(over: Partial<AppInstallerStrategyDeps> = {}) {
|
||||
return '' }
|
||||
},
|
||||
teardownBundledBackend: async () => { calls.push('teardown') },
|
||||
restoreBundledBackend: async () => { calls.push('restore') },
|
||||
emitUpdateProgress: () => {},
|
||||
appVersion: '0.18.2',
|
||||
quit: () => { calls.push('quit') },
|
||||
registerPendingRelaunch: async () => { calls.push('relaunch-marker');
|
||||
|
||||
return true },
|
||||
return { automatic: true, cancel: async () => {} } },
|
||||
...over
|
||||
}
|
||||
|
||||
@@ -44,7 +45,7 @@ describe('AppInstallerStrategy.apply', () => {
|
||||
const strategy = new AppInstallerStrategy(deps)
|
||||
const result = await strategy.apply({})
|
||||
|
||||
expect(result).toEqual({ ok: true, manual: false, bundled: true, mechanism: 'app-installer' })
|
||||
expect(result).toEqual({ ok: true, manual: false, bundled: true, handedOff: true, mechanism: 'app-installer' })
|
||||
expect(calls).toEqual(['prepare', 'relaunch-marker', 'teardown', 'open', 'quit'])
|
||||
})
|
||||
|
||||
@@ -52,7 +53,7 @@ describe('AppInstallerStrategy.apply', () => {
|
||||
const progress: string[] = []
|
||||
|
||||
const { deps, calls } = makeDeps({
|
||||
registerPendingRelaunch: async () => false,
|
||||
registerPendingRelaunch: async () => ({ automatic: false, cancel: async () => {} }),
|
||||
emitUpdateProgress: event => { progress.push(event.message) }
|
||||
})
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
win32AppInstallerFeedPath
|
||||
} from '../app-updater'
|
||||
|
||||
import type { RelaunchRegistration } from './relaunch'
|
||||
|
||||
import type { UpdaterApplyResultWire, UpdaterStatusWire } from './index'
|
||||
|
||||
export interface AppInstallerStrategyDeps {
|
||||
@@ -35,20 +37,15 @@ export interface AppInstallerStrategyDeps {
|
||||
installer: { prepare: (url: string) => Promise<string>; open: (file: string) => Promise<string> }
|
||||
/** Graceful backend teardown before the package swap. */
|
||||
teardownBundledBackend: () => void | Promise<void>
|
||||
restoreBundledBackend: () => Promise<void>
|
||||
/** Progress emitter for the updates overlay. */
|
||||
emitUpdateProgress: (payload: { stage: string; message: string; percent: number | null }) => void
|
||||
/** App version label for the status wire. */
|
||||
appVersion: string
|
||||
/** Quit the app (after handing the swap to the OS). */
|
||||
quit: () => void
|
||||
/**
|
||||
* Register a one-shot post-update relaunch before quitting, so Hermes
|
||||
* reopens on the new version with no user action. Resolves true only once
|
||||
* the relaunch mechanism has acknowledged (the waiter handshake) — the
|
||||
* caller must await this BEFORE quitting. Resolves false when the
|
||||
* mechanism could not be started (relaunch stays manual).
|
||||
*/
|
||||
registerPendingRelaunch: (targetVersion: string) => Promise<boolean>
|
||||
/** Retain marker and waiter ownership until the OS accepts the handoff. */
|
||||
registerPendingRelaunch: (fromVersion: string) => Promise<RelaunchRegistration>
|
||||
}
|
||||
|
||||
export interface CheckOutcome {
|
||||
@@ -109,29 +106,57 @@ export class AppInstallerStrategy {
|
||||
percent: 100
|
||||
})
|
||||
|
||||
await triggerAppInstallerUpdate(
|
||||
feedBaseUrl,
|
||||
this.deps.channel,
|
||||
this.deps.light,
|
||||
this.deps.installer,
|
||||
async () => {
|
||||
const registered = await this.deps.registerPendingRelaunch(this.deps.appVersion)
|
||||
let registration: RelaunchRegistration | undefined
|
||||
let teardownStarted = false
|
||||
|
||||
if (!registered) {
|
||||
this.deps.emitUpdateProgress({
|
||||
stage: 'restart', percent: 100,
|
||||
message: 'Automatic relaunch could not be registered. Reopen Hermes after App Installer finishes.'
|
||||
})
|
||||
try {
|
||||
await triggerAppInstallerUpdate(
|
||||
feedBaseUrl,
|
||||
this.deps.channel,
|
||||
this.deps.light,
|
||||
this.deps.installer,
|
||||
async () => {
|
||||
registration = await this.deps.registerPendingRelaunch(this.deps.appVersion)
|
||||
|
||||
if (!registration.automatic) {
|
||||
this.deps.emitUpdateProgress({
|
||||
stage: 'restart', percent: 100,
|
||||
message: 'Automatic relaunch could not be registered. Reopen Hermes after App Installer finishes.'
|
||||
})
|
||||
}
|
||||
|
||||
teardownStarted = true
|
||||
await this.deps.teardownBundledBackend()
|
||||
},
|
||||
sourceUri
|
||||
)
|
||||
|
||||
this.deps.quit()
|
||||
} catch (error) {
|
||||
const errors: unknown[] = [error]
|
||||
|
||||
try {
|
||||
await registration?.cancel()
|
||||
} catch (cancelError) {
|
||||
errors.push(cancelError)
|
||||
}
|
||||
|
||||
if (teardownStarted) {
|
||||
try {
|
||||
await this.deps.restoreBundledBackend()
|
||||
} catch (restoreError) {
|
||||
errors.push(restoreError)
|
||||
}
|
||||
}
|
||||
|
||||
await this.deps.teardownBundledBackend()
|
||||
},
|
||||
sourceUri
|
||||
)
|
||||
const message = errors.map(item => item instanceof Error ? item.message : String(item)).join('; ')
|
||||
this.deps.emitUpdateProgress({ stage: 'error', message, percent: null })
|
||||
|
||||
this.deps.quit()
|
||||
if (errors.length > 1) { throw new AggregateError(errors, message, { cause: error }) }
|
||||
throw error
|
||||
}
|
||||
|
||||
return { ok: true, manual: false, bundled: true, mechanism: this.mechanism }
|
||||
return { ok: true, manual: false, bundled: true, handedOff: true, mechanism: this.mechanism }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
83
apps/desktop/electron/updater/relaunch-registration.test.ts
Normal file
83
apps/desktop/electron/updater/relaunch-registration.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { PENDING_RELAUNCH_FILENAME, registerUpdateRelaunch } from './relaunch'
|
||||
import type { RelaunchWaiterHandle } from './relaunch-waiter'
|
||||
|
||||
for (const automatic of [true, false]) {
|
||||
test(`registration owns its marker and cancellation (automatic=${automatic})`, async () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'relaunch-registration-'))
|
||||
const marker = path.join(home, PENDING_RELAUNCH_FILENAME)
|
||||
let resolveReady!: (handle: RelaunchWaiterHandle | undefined) => void
|
||||
const ready = new Promise<RelaunchWaiterHandle | undefined>(resolve => { resolveReady = resolve })
|
||||
let cancelled = 0
|
||||
let registered = false
|
||||
|
||||
try {
|
||||
const pending = registerUpdateRelaunch(home, '1.0', { relaunch: () => ready })
|
||||
.then(result => { registered = true;
|
||||
|
||||
return result })
|
||||
|
||||
await new Promise(setImmediate)
|
||||
assert.equal(registered, false)
|
||||
assert.equal(JSON.parse(fs.readFileSync(marker, 'utf8')).fromVersion, '1.0')
|
||||
resolveReady(automatic ? { cancel: async () => { cancelled++ } } : undefined)
|
||||
const registration = await pending
|
||||
assert.equal(registration.automatic, automatic)
|
||||
assert.equal(fs.existsSync(marker), true)
|
||||
await Promise.all([registration.cancel(), registration.cancel()])
|
||||
await registration.cancel()
|
||||
assert.equal(cancelled, automatic ? 1 : 0)
|
||||
assert.equal(fs.existsSync(marker), false)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('start and cancellation failures preserve the cause and still clean owned markers', async () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'relaunch-failures-'))
|
||||
const marker = path.join(home, PENDING_RELAUNCH_FILENAME)
|
||||
const failure = new Error('owned child refused to stop')
|
||||
|
||||
try {
|
||||
await assert.rejects(registerUpdateRelaunch(home, '1.0', {
|
||||
relaunch: async () => { throw failure }
|
||||
}), error => error === failure)
|
||||
assert.equal(fs.existsSync(marker), false)
|
||||
|
||||
let attempts = 0
|
||||
|
||||
const registration = await registerUpdateRelaunch(home, '1.0', {
|
||||
relaunch: async () => ({ cancel: async () => { attempts++; throw failure } })
|
||||
})
|
||||
|
||||
await assert.rejects(registration.cancel(), error => error instanceof AggregateError && error.errors.includes(failure))
|
||||
await assert.rejects(registration.cancel(), error => error instanceof AggregateError && error.errors.includes(failure))
|
||||
assert.equal(attempts, 1)
|
||||
assert.equal(fs.existsSync(marker), false)
|
||||
|
||||
const blocked = await registerUpdateRelaunch(home, '1.0', {
|
||||
relaunch: async () => ({ cancel: async () => { throw failure } })
|
||||
})
|
||||
|
||||
fs.unlinkSync(marker)
|
||||
fs.mkdirSync(marker)
|
||||
fs.writeFileSync(path.join(marker, 'foreign-data'), 'keep')
|
||||
await assert.rejects(blocked.cancel(), error => {
|
||||
assert.ok(error instanceof AggregateError)
|
||||
assert.equal(error.errors[0], failure)
|
||||
assert.equal(error.errors.length, 2)
|
||||
|
||||
return true
|
||||
})
|
||||
assert.equal(fs.readFileSync(path.join(marker, 'foreign-data'), 'utf8'), 'keep')
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
115
apps/desktop/electron/updater/relaunch-waiter-lifecycle.test.ts
Normal file
115
apps/desktop/electron/updater/relaunch-waiter-lifecycle.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { type ChildProcess, spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test, vi } from 'vitest'
|
||||
|
||||
import { type SpawnWaiter, startRelaunchWaiter } from './relaunch-waiter'
|
||||
|
||||
const scriptPath = path.resolve(import.meta.dirname, '../../scripts/update-relaunch-waiter.ps1')
|
||||
|
||||
async function stop(child: ChildProcess | undefined): Promise<void> {
|
||||
if (child?.pid && child.exitCode === null && child.signalCode === null) {
|
||||
const exited = once(child, 'close')
|
||||
child.kill()
|
||||
await exited
|
||||
}
|
||||
}
|
||||
|
||||
for (const mode of [
|
||||
{ name: 'ready', ready: true },
|
||||
{ name: 'silent', ready: false },
|
||||
{ name: 'spawn-error', ready: false, missing: true },
|
||||
{ name: 'cancel-timeout', ready: true, refuseKill: true },
|
||||
{ name: 'startup-cancel-timeout', ready: false, refuseKill: true },
|
||||
{ name: 'cleanup-error', ready: true, refuseCleanup: true }
|
||||
]) {
|
||||
test(`waiter ownership survives its complete lifecycle (${mode.name})`, async () => {
|
||||
let child: ChildProcess | undefined
|
||||
let originalKill: ChildProcess['kill'] | undefined
|
||||
let stage = ''
|
||||
let closed = false
|
||||
const cleanupError = new Error('fixture staging cleanup refused')
|
||||
|
||||
const start: SpawnWaiter = (_command, args, options) => {
|
||||
stage = options.cwd
|
||||
const readyFile = args[args.indexOf('-ReadyFile') + 1]
|
||||
child = spawn(mode.missing ? path.join(stage, 'missing.exe') : process.execPath, ['-e', `
|
||||
const fs = require('node:fs');
|
||||
if (process.env.TEST_READY === 'yes') fs.writeFileSync(process.env.READY_FILE, 'ready');
|
||||
setInterval(() => {}, 1000);
|
||||
`], {
|
||||
...options,
|
||||
env: { ...process.env, READY_FILE: readyFile, TEST_READY: mode.ready ? 'yes' : 'no' }
|
||||
})
|
||||
originalKill = child.kill.bind(child)
|
||||
|
||||
if (mode.refuseKill) { child.kill = () => false }
|
||||
child.once('close', () => { closed = true })
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
try {
|
||||
const starting = startRelaunchWaiter({
|
||||
processId: process.pid,
|
||||
processStartTimeMs: Date.now(),
|
||||
identityName: 'disposable-waiter-test',
|
||||
scriptPath
|
||||
}, { spawn: start, handshakeTimeoutMs: mode.ready ? 10_000 : 2_000, cancelTimeoutMs: 2_000, pollMs: 20 })
|
||||
|
||||
if (!mode.ready && mode.refuseKill) {
|
||||
await assert.rejects(starting, /did not exit after cancellation/)
|
||||
assert.equal(closed, false)
|
||||
assert.equal(fs.existsSync(stage), true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const handle = await starting
|
||||
|
||||
if (mode.ready) {
|
||||
assert.ok(handle && typeof handle === 'object', 'readiness must retain the cancellation handle')
|
||||
assert.equal(closed, false)
|
||||
|
||||
if (mode.refuseCleanup) {
|
||||
const remove = fs.promises.rm.bind(fs.promises)
|
||||
vi.spyOn(fs.promises, 'rm').mockImplementation((file, options) => {
|
||||
return file === stage ? Promise.reject(cleanupError) : remove(file, options)
|
||||
})
|
||||
}
|
||||
|
||||
const cancelled = handle.cancel()
|
||||
assert.equal(handle.cancel(), cancelled, 'concurrent cancellation shares its result')
|
||||
|
||||
if (mode.refuseKill || mode.refuseCleanup) {
|
||||
await assert.rejects(cancelled, error => mode.refuseCleanup
|
||||
? error === cleanupError
|
||||
: error instanceof Error && error.message.includes('did not exit after cancellation'))
|
||||
assert.equal(handle.cancel(), cancelled)
|
||||
assert.equal(closed, !mode.refuseKill)
|
||||
assert.equal(fs.existsSync(stage), true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await cancelled
|
||||
await handle.cancel()
|
||||
} else {
|
||||
assert.equal(handle, undefined, 'a failed start has no live mechanism')
|
||||
}
|
||||
|
||||
assert.equal(closed, true, 'the actual child has exited before the caller proceeds')
|
||||
assert.equal(fs.existsSync(stage), false)
|
||||
} finally {
|
||||
vi.restoreAllMocks()
|
||||
|
||||
if (child && originalKill) { child.kill = originalKill }
|
||||
await stop(child)
|
||||
|
||||
if (stage) {fs.rmSync(stage, { recursive: true, force: true })}
|
||||
}
|
||||
}, 20_000)
|
||||
}
|
||||
@@ -45,9 +45,15 @@ function fakeSpawn(handshake: 'ready' | 'error' | 'exit-nonzero' | 'none'): { sp
|
||||
|
||||
const child: any = new EventEmitter()
|
||||
|
||||
child.pid = handshake === 'error' ? undefined : 123
|
||||
|
||||
child.unref = () => {}
|
||||
|
||||
child.kill = () => { seen.killed = true; child.emit('exit', null) }
|
||||
child.kill = () => {
|
||||
seen.killed = true
|
||||
child.emit('exit', null)
|
||||
child.emit('close', null)
|
||||
}
|
||||
|
||||
const readyFile = args[args.indexOf('-ReadyFile') + 1]
|
||||
|
||||
@@ -57,8 +63,10 @@ function fakeSpawn(handshake: 'ready' | 'error' | 'exit-nonzero' | 'none'): { sp
|
||||
fs.writeFileSync(readyFile, '0.18.1|Family|App')
|
||||
} else if (handshake === 'error') {
|
||||
child.emit('error', new Error('spawn ENOENT'))
|
||||
child.emit('close', -1)
|
||||
} else if (handshake === 'exit-nonzero') {
|
||||
child.emit('exit', 3)
|
||||
child.emit('close', 3)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -109,7 +117,7 @@ describe('startRelaunchWaiter', () => {
|
||||
|
||||
const result = await startRelaunchWaiter({ ...OPTIONS, scriptPath }, { spawn, pollMs: 10 })
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(result).toHaveProperty('cancel')
|
||||
expect(seen.command).toBe(POWERSHELL_PATH)
|
||||
expect(seen.opts.detached).toBe(true)
|
||||
expect(seen.opts.stdio).toBe('ignore')
|
||||
@@ -123,34 +131,35 @@ describe('startRelaunchWaiter', () => {
|
||||
expect(fs.existsSync(stagedScript)).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves true on the ready-file handshake (the waiter snapshotted the OLD package)', async () => {
|
||||
it('retains cancellation after the ready-file handshake', async () => {
|
||||
const scriptPath = await stageScript()
|
||||
const { spawn } = fakeSpawn('ready')
|
||||
|
||||
const result = await startRelaunchWaiter({ ...OPTIONS, scriptPath }, { spawn, pollMs: 10 })
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(result).toHaveProperty('cancel')
|
||||
await result!.cancel()
|
||||
})
|
||||
|
||||
it('resolves false when the child errors before the handshake', async () => {
|
||||
it('returns no handle after a failed spawn closes', async () => {
|
||||
const scriptPath = await stageScript()
|
||||
const { spawn } = fakeSpawn('error')
|
||||
|
||||
const result = await startRelaunchWaiter({ ...OPTIONS, scriptPath }, { spawn, pollMs: 10 })
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves false on a non-zero early exit (waiter never signalled ready)', async () => {
|
||||
it('returns no handle on an early exit without readiness', async () => {
|
||||
const scriptPath = await stageScript()
|
||||
const { spawn } = fakeSpawn('exit-nonzero')
|
||||
|
||||
const result = await startRelaunchWaiter({ ...OPTIONS, scriptPath }, { spawn, pollMs: 10 })
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves false when the handshake never arrives within the deadline', async () => {
|
||||
it('stops the child before returning when readiness times out', async () => {
|
||||
const scriptPath = await stageScript()
|
||||
const { spawn, seen } = fakeSpawn('none')
|
||||
|
||||
@@ -159,12 +168,12 @@ describe('startRelaunchWaiter', () => {
|
||||
{ spawn, handshakeTimeoutMs: 80, pollMs: 20 }
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(result).toBeUndefined()
|
||||
expect(seen.killed).toBe(true)
|
||||
expect(fs.existsSync(seen.opts.cwd)).toBe(false)
|
||||
}, 5_000)
|
||||
|
||||
it('resolves false when staging fails (missing script), never throwing', async () => {
|
||||
it('returns no handle when a missing script leaves no staging', async () => {
|
||||
const spawn = (() => {
|
||||
throw new Error('should not be reached')
|
||||
}) as unknown as SpawnWaiter
|
||||
@@ -174,7 +183,7 @@ describe('startRelaunchWaiter', () => {
|
||||
{ spawn }
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('the staged handshake file uses the reserved ready filename', async () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface RelaunchWaiterDeps {
|
||||
handshakeTimeoutMs?: number
|
||||
/** Poll interval for the ready file (tests shrink this). */
|
||||
pollMs?: number
|
||||
cancelTimeoutMs?: number
|
||||
}
|
||||
|
||||
/** Pure: the exact argv the waiter is spawned with. */
|
||||
@@ -68,38 +69,50 @@ export function buildRelaunchWaiterArgs(options: RelaunchWaiterOptions, readyFil
|
||||
]
|
||||
}
|
||||
|
||||
/** Stage script + handshake file into a fresh temp dir outside the package. */
|
||||
export async function stageRelaunchWaiter(options: RelaunchWaiterOptions): Promise<WaiterStaging> {
|
||||
const stageDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'hermes-relaunch-'))
|
||||
/** Stage outside the package so its replacement does not invalidate the waiter. */
|
||||
async function stageRelaunchWaiter(options: RelaunchWaiterOptions): Promise<WaiterStaging | undefined> {
|
||||
let stageDir: string
|
||||
|
||||
try {
|
||||
stageDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'hermes-relaunch-'))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const scriptPath = path.join(stageDir, RELAUNCH_WAITER_SCRIPT)
|
||||
const readyFile = path.join(stageDir, RELAUNCH_WAITER_READY_FILENAME)
|
||||
|
||||
try {
|
||||
await fs.promises.copyFile(options.scriptPath, scriptPath)
|
||||
} catch (error) {
|
||||
await fs.promises.rm(stageDir, { recursive: true, force: true })
|
||||
throw error
|
||||
try {
|
||||
await fs.promises.rm(stageDir, { recursive: true, force: true })
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], 'Relaunch waiter staging cleanup failed')
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { stageDir, scriptPath, readyFile }
|
||||
}
|
||||
|
||||
/** Return true only after the waiter confirms its package snapshot. */
|
||||
export interface RelaunchWaiterHandle {
|
||||
cancel: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Return ownership after readiness, or no handle after a safely stopped failure. */
|
||||
export async function startRelaunchWaiter(
|
||||
options: RelaunchWaiterOptions,
|
||||
deps: RelaunchWaiterDeps = {}
|
||||
): Promise<boolean> {
|
||||
): Promise<RelaunchWaiterHandle | undefined> {
|
||||
const spawn = deps.spawn ?? (nodeSpawn as unknown as SpawnWaiter)
|
||||
const handshakeTimeoutMs = deps.handshakeTimeoutMs ?? DEFAULT_RELAUNCH_WAITER_HANDSHAKE_MS
|
||||
const pollMs = deps.pollMs ?? 250
|
||||
const cancelTimeoutMs = deps.cancelTimeoutMs ?? 10_000
|
||||
const staging = await stageRelaunchWaiter(options)
|
||||
|
||||
let staging: WaiterStaging
|
||||
|
||||
try {
|
||||
staging = await stageRelaunchWaiter(options)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (!staging) { return undefined }
|
||||
|
||||
const cleanup = () => fs.promises.rm(staging.stageDir, { recursive: true, force: true })
|
||||
let child: ChildProcess
|
||||
@@ -108,13 +121,50 @@ export async function startRelaunchWaiter(
|
||||
child = spawn(POWERSHELL_PATH,
|
||||
buildRelaunchWaiterArgs({ ...options, scriptPath: staging.scriptPath }, staging.readyFile),
|
||||
{ detached: true, stdio: 'ignore', windowsHide: true, cwd: staging.stageDir })
|
||||
} catch {
|
||||
await cleanup()
|
||||
} catch (error) {
|
||||
try {
|
||||
await cleanup()
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], 'Relaunch waiter spawn cleanup failed')
|
||||
}
|
||||
|
||||
return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
return new Promise<boolean>(resolve => {
|
||||
let closed = false
|
||||
|
||||
const closedPromise = new Promise<void>(resolve => {
|
||||
child.once('close', () => { closed = true; resolve() })
|
||||
})
|
||||
|
||||
let cancellation: Promise<void> | undefined
|
||||
|
||||
const cancel = (): Promise<void> => {
|
||||
cancellation ??= (async () => {
|
||||
if (!closed) {
|
||||
if (child.pid !== undefined) { child.kill() }
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
closedPromise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error('Relaunch waiter did not exit after cancellation')), cancelTimeoutMs)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
await cleanup()
|
||||
})()
|
||||
|
||||
return cancellation
|
||||
}
|
||||
|
||||
const ready = await new Promise<boolean>(resolve => {
|
||||
let settled = false
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const deadline = Date.now() + handshakeTimeoutMs
|
||||
@@ -123,18 +173,11 @@ export async function startRelaunchWaiter(
|
||||
if (settled) { return }
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
|
||||
if (ready) {
|
||||
child.unref()
|
||||
resolve(true)
|
||||
} else {
|
||||
child.kill()
|
||||
void cleanup().catch(() => {}).finally(() => resolve(false))
|
||||
}
|
||||
resolve(ready)
|
||||
}
|
||||
|
||||
child.once('error', () => finish(false))
|
||||
child.once('exit', () => finish(false))
|
||||
child.once('close', () => finish(false))
|
||||
|
||||
const check = () => {
|
||||
if (settled) { return }
|
||||
@@ -150,4 +193,14 @@ export async function startRelaunchWaiter(
|
||||
|
||||
check()
|
||||
})
|
||||
|
||||
if (!ready) {
|
||||
await cancel()
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
child.unref()
|
||||
|
||||
return { cancel }
|
||||
}
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
// updater/relaunch.ts — unconditional relaunch after an App Installer swap.
|
||||
//
|
||||
// Contract: clicking Update always ends in Hermes reopening on the new
|
||||
// version — no user action between quit and relaunch. Implementation: a
|
||||
// one-shot pending-relaunch marker written before we quit. The OS App
|
||||
// Installer swaps the package; on next launch (whenever it happens) Hermes
|
||||
// reads the marker, shows the "updated" toast, and deletes it.
|
||||
//
|
||||
// The marker is a JSON file under HERMES_HOME (not a run-key / scheduled
|
||||
// task): a registry entry can't be made one-shot safely from a dying process,
|
||||
// and a file survives the package swap (HERMES_HOME lives outside the
|
||||
// package). The actual relaunch is the detached waiter
|
||||
// (relaunch-waiter.ts + scripts/update-relaunch-waiter.ps1): it waits for
|
||||
// this process to exit, waits for the installed package version to change,
|
||||
// and activates the NEW package. The marker's job is only to tell the NEW
|
||||
// version it was an update relaunch, so it can toast + clean up.
|
||||
//
|
||||
// If the OS does NOT relaunch (update applied later, on next launch), the
|
||||
// marker is still correct: the first launch after the swap detects it and
|
||||
// toasts. A stale marker (update cancelled/failed) self-deletes when its
|
||||
// expected-version no longer matches a later install... no — cancelled
|
||||
// updates mean the version never changed, so we stamp with "expected NEW
|
||||
// version unknown" instead: the marker only records that an update was
|
||||
// STARTED; the new version decides "was it really me?" by comparing versions
|
||||
// recorded at write time vs launch time.
|
||||
// The marker survives a package swap and records the version that started it.
|
||||
// The separate waiter owns automatic relaunch. Cancelling an update stops
|
||||
// that waiter and removes its marker, including when startup stays manual.
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import type { RelaunchWaiterHandle } from './relaunch-waiter'
|
||||
|
||||
export interface PendingRelaunchMarker {
|
||||
schemaVersion: 1
|
||||
/** Version of the app that wrote the marker (pre-update). */
|
||||
@@ -63,38 +43,74 @@ export function writePendingRelaunch(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the post-update relaunch: write the pending-relaunch marker AND
|
||||
* start the relaunch mechanism. The mechanism itself is injected — the
|
||||
* App Installer arm passes the detached relaunch waiter (relaunch-waiter.ts),
|
||||
* which re-opens the NEW package after the OS swap; nothing here re-executes
|
||||
* the current binary. The relaunched instance reads the marker, toasts, and
|
||||
* cleans it up. Failure never blocks the update — relaunch just stays
|
||||
* manual (the marker alone still produces the "updated" toast on the next
|
||||
* manual launch).
|
||||
*/
|
||||
export interface UpdateRelaunchDeps {
|
||||
/** Start the relaunch mechanism (the detached relaunch waiter). */
|
||||
relaunch: () => boolean | void | Promise<boolean | void>
|
||||
/** Return a ready waiter, or no handle after a safely stopped failure. */
|
||||
relaunch: () => RelaunchWaiterHandle | undefined | Promise<RelaunchWaiterHandle | undefined>
|
||||
}
|
||||
|
||||
export interface RelaunchRegistration {
|
||||
automatic: boolean
|
||||
cancel: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Marker failure permits an update, but failed waiter cleanup must abort it. */
|
||||
export async function registerUpdateRelaunch(
|
||||
hermesHome: string,
|
||||
fromVersion: string,
|
||||
deps: UpdateRelaunchDeps,
|
||||
writeFile: (file: string, contents: string) => void = (f, c) => fs.writeFileSync(f, c)
|
||||
): Promise<boolean> {
|
||||
// Marker first: a relaunch that wins the race against the write still
|
||||
// leaves the next manual launch marker-less (honest), but a marker without
|
||||
// a started mechanism is the exact "marker is not a mechanism" gap.
|
||||
writePendingRelaunch(hermesHome, fromVersion, writeFile)
|
||||
): Promise<RelaunchRegistration> {
|
||||
const ownsMarker = writePendingRelaunch(hermesHome, fromVersion, writeFile)
|
||||
|
||||
const removeMarker = (): void => {
|
||||
if (!ownsMarker) { return }
|
||||
|
||||
try {
|
||||
fs.unlinkSync(markerPath(hermesHome))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error }
|
||||
}
|
||||
}
|
||||
|
||||
let waiter: RelaunchWaiterHandle | undefined
|
||||
|
||||
try {
|
||||
// `undefined` (a fire-and-forget mechanism) counts as started; only an
|
||||
// explicit false or a throw means registration failed.
|
||||
return (await deps.relaunch()) !== false
|
||||
} catch {
|
||||
return false
|
||||
waiter = await deps.relaunch()
|
||||
} catch (error) {
|
||||
try {
|
||||
removeMarker()
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], 'Relaunch registration cleanup failed', { cause: error })
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
let cancellation: Promise<void> | undefined
|
||||
|
||||
return {
|
||||
automatic: waiter !== undefined,
|
||||
cancel: () => {
|
||||
cancellation ??= (async () => {
|
||||
const errors: unknown[] = []
|
||||
|
||||
try {
|
||||
await waiter?.cancel()
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
|
||||
try {
|
||||
removeMarker()
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
|
||||
if (errors.length) { throw new AggregateError(errors, 'Relaunch cancellation failed') }
|
||||
})()
|
||||
|
||||
return cancellation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,40 +167,29 @@ describe('registerUpdateRelaunch — the mechanism, not just the marker', () =>
|
||||
const ok = await registerUpdateRelaunch(
|
||||
'/home',
|
||||
'0.18.2',
|
||||
{ relaunch: () => { started += 1 } },
|
||||
{ relaunch: () => { started += 1;
|
||||
|
||||
return { cancel: async () => {} } } },
|
||||
(f, c) => { files[f] = c as string }
|
||||
)
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(ok.automatic).toBe(true)
|
||||
expect(started).toBe(1)
|
||||
expect(Object.keys(files)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('awaits the mechanism outcome: a waiter that reports false leaves the marker as the only trace', async () => {
|
||||
it('retains the marker when a safely failed waiter requires manual relaunch', async () => {
|
||||
const files: Record<string, string> = {}
|
||||
|
||||
const ok = await registerUpdateRelaunch(
|
||||
'/home',
|
||||
'0.18.2',
|
||||
{ relaunch: async () => false },
|
||||
{ relaunch: async () => undefined },
|
||||
(f, c) => { files[f] = c as string }
|
||||
)
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(ok.automatic).toBe(false)
|
||||
expect(Object.keys(files)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a mechanism throw never blocks the update (marker-only manual relaunch)', async () => {
|
||||
const files: Record<string, string> = {}
|
||||
|
||||
const ok = await registerUpdateRelaunch(
|
||||
'/home',
|
||||
'0.18.2',
|
||||
{ relaunch: () => { throw new Error('spawn failed') } },
|
||||
(f, c) => { files[f] = c as string }
|
||||
)
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(Object.keys(files)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user