fix(updater): install macOS orca serve updates instead of waiting on Squirrel forever (#27466)

* fix(updater): let supervised macOS serve installs stage Squirrel during install

Supervised `orca serve` keeps autoInstallOnAppQuit off, so electron-updater's
MacUpdater hands the download to Squirrel.Mac only inside quitAndInstall. The
updater, however, held the status at "downloading 100%" and gated quitAndInstall
on Squirrel having already staged the bundle. Neither could happen first, so
every macOS serve update stalled at macos_waiting_for_squirrel, remote installs
failed with remote_update_not_downloaded, and a local install request timed out
and quit without installing.

In supervised serve mode, report the download as installable and let the install
proceed to the handoff and native quitAndInstall. The install commits and arms
the exit watchdog when Squirrel signals it has staged the bundle; a staging
failure still recovers through the existing pre-commit error path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(updater): hold destructive install prep until Squirrel stages the serve bundle

The supervised macOS install now calls electron-updater's quitAndInstall before
Squirrel has staged anything, and that staging completes asynchronously. Three
consequences needed handling:

- PTY cleanup and close-listener removal ran before staging, so a Squirrel
  failure recovered the server with its in-process sessions already killed.
  They now run when Squirrel reports the bundle staged, together with the
  commit and exit watchdog.
- Any updater 'error' during staging (for example a background check) failed
  the handoff while MacUpdater's pending listener would still quit and install.
  While staging is awaited, only Squirrel's native error ends it.
- Nothing bounded a Squirrel that never answers. After two minutes the install
  recovers through the existing pre-commit failure path and the server keeps
  serving.

isMacStagingDeferredToInstall is now darwin-only, so Linux supervised installs
keep their immediate commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(updater): report a slow Squirrel staging instead of abandoning the install

Recovering the install on the staging timeout reset Orca's state and failed the
supervisor handoff, but electron-updater's MacUpdater keeps its own completion
listener. A Squirrel that finished after the timeout still quit the app and
installed, now with a failed handoff, so the supervisor skipped the replacement
spawn.

The timeout now only reports a non-retryable error status and records the slow
staging; the handoff and in-flight install stay live so a late staging commits
and relaunches normally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(updater): commit a staged serve install before its session cleanup

Squirrel's update-downloaded listeners run in registration order, and Orca's
runs before the one MacUpdater adds to quit the app. The staged-install commit
ran PTY cleanup first, so a throwing cleanup callback skipped the commit, the
exit watchdog, and MacUpdater's quit, leaving the install stuck in progress.

Commit and arm the watchdog first, then run cleanup under the same
post_commit_cleanup_failed handling performQuitAndInstall already uses.

The tests now register MacUpdater's persistent quit listener per unstaged
install, so a retried install is followed through completion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(updater): pin the MacUpdater staging behavior supervised serve installs rely on

The supervised macOS serve install assumes electron-updater's MacUpdater starts
Squirrel staging only inside quitAndInstall when autoInstallOnAppQuit is off,
and quits the app once staged when autoRunAppAfterInstall is off. Every updater
suite mocks electron-updater, so run the installed MacUpdater methods directly;
an upgrade that changed either behavior now fails here instead of silently
restoring the deadlock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(updater): adapt the serve staging tests to current main

Main's macOS quit-guard test predates the staging callbacks this change
adds, so it now passes them, and the new install test builds its window
without a type assertion.

Co-authored-by: jbamlee <dev.jbam@gmail.com>

---------

Co-authored-by: jbamlee <dev.jbam@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-11 05:57:43 -07:00
committed by GitHub
co-authored by jbamlee Claude Opus 5 m4air
parent eade6da6ff
commit 68c46fa07f
12 changed files with 439 additions and 63 deletions
@@ -0,0 +1,60 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { MacUpdater } from 'electron-updater/out/MacUpdater'
// Why: supervised serve installs rely on these MacUpdater behaviors, which every updater suite mocks;
// an electron-updater upgrade that changed them would silently bring back the macOS serve deadlock.
describe('electron-updater MacUpdater staging contract', () => {
it('stages Squirrel from quitAndInstall, not from the finished download, when autoInstallOnAppQuit is off', async () => {
const nativeUpdater = Object.assign(new EventEmitter(), {
checkForUpdates: vi.fn(),
setFeedURL: vi.fn()
})
const updater = {
autoInstallOnAppQuit: false,
squirrelDownloadedUpdate: false,
nativeUpdater,
server: null,
_logger: { info: vi.fn(), warn: vi.fn() },
debug: vi.fn(),
dispatchUpdateDownloaded: vi.fn(),
handleUpdateDownloaded: vi.fn(),
closeServerIfExists(): void {
MacUpdater.prototype['closeServerIfExists'].call(this)
}
}
// Why: a regression that stages here never settles the await, so cleanup must not depend on the body finishing.
onTestFinished(() => updater.closeServerIfExists())
await MacUpdater.prototype['updateDownloaded'].call(
updater,
{ info: { size: 1 }, url: new URL('https://example.invalid/Orca-arm64-mac.zip') },
{ downloadedFile: 'unread-because-size-is-known.zip' }
)
expect(updater.dispatchUpdateDownloaded).toHaveBeenCalledOnce()
expect(nativeUpdater.checkForUpdates).not.toHaveBeenCalled()
MacUpdater.prototype.quitAndInstall.call(updater)
expect(nativeUpdater.checkForUpdates).toHaveBeenCalledOnce()
expect(updater.handleUpdateDownloaded).not.toHaveBeenCalled()
nativeUpdater.emit('update-downloaded')
expect(updater.handleUpdateDownloaded).toHaveBeenCalledOnce()
})
it('quits the app without relaunching once staged when autoRunAppAfterInstall is off', () => {
const app = { quit: vi.fn() }
const nativeUpdater = { quitAndInstall: vi.fn() }
MacUpdater.prototype['handleUpdateDownloaded'].call({
autoRunAppAfterInstall: false,
app,
nativeUpdater,
closeServerIfExists: vi.fn()
})
expect(app.quit).toHaveBeenCalledOnce()
expect(nativeUpdater.quitAndInstall).not.toHaveBeenCalled()
})
})
+4
View File
@@ -90,6 +90,10 @@ function createContext(overrides?: Partial<HandlerContext>): HandlerContext {
markUpdateAvailableEventPending: vi.fn(),
markMissingManifestPrereleaseFallbackChecking: vi.fn(),
performQuitAndInstall: vi.fn(),
commitStagedMacInstall: vi.fn(),
failMacStaging: vi.fn(),
isAwaitingMacStaging: vi.fn(() => false),
isMacStagingDeferredToInstall: vi.fn(() => false),
shouldDeferMacQuitForInstall: vi.fn(() => true),
recordCompletedUpdateCheck: vi.fn(),
restoreReleaseUpdateSource: vi.fn(),
+12 -46
View File
@@ -1,5 +1,4 @@
import { app } from 'electron'
import type { UpdateStatus } from '../shared/update-status-types'
import {
isMacInstallerReady,
registerMacUpdaterEvents,
@@ -7,7 +6,7 @@ import {
} from './updater-mac-install'
import { compareVersions } from './updater-fallback'
import { fetchChangelog } from './updater-changelog'
import type { ElectronAutoUpdater } from './electron-updater-loader'
import type { UpdaterHandlerContext } from './updater-handler-context'
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
import {
getRetainedLinuxPackageManualInstallStatus,
@@ -20,49 +19,6 @@ import * as linuxPackageRecovery from './linux-package-update-recovery'
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
type UpdaterHandlerContext = {
autoUpdater: ElectronAutoUpdater
clearBackgroundCheckLaunchPending: () => void
clearAvailableUpdateContext: () => void
consumeMissingManifestPrereleaseFallbackResult: () => { userInitiated: boolean } | null
getPublishingWindowLastGoodCheck: () => { lastGoodTag: string } | null
getMissingManifestPrereleaseFallbackUserInitiated: () => boolean | null
getCurrentStatus: () => UpdateStatus
getActiveUpdateCheckEventAttemptId: () => number | null
getKnownReleaseUrl: () => string | undefined
getPendingInstallVersion: () => string
getUserInitiatedCheck: () => boolean
handleQuitAndInstallFailure: (error?: unknown) => boolean
isQuitAndInstallHandoffActive: () => boolean
hasInstallableDownloadedVersion: () => boolean
isLocalBuildCheck: () => boolean
isPinnedBuildCheck: () => boolean
shouldHandleUpdaterErrorEvent: () => boolean
clearUpdateAvailableEventPending: (attemptId: number | null) => void
isActiveUpdateCheckAttempt: (attemptId: number) => boolean
markUpdateCheckEventAttempt: () => boolean
markUpdateAvailableEventPending: (attemptId: number | null) => void
markMissingManifestPrereleaseFallbackChecking: () => void
performQuitAndInstall: () => void | Promise<void>
shouldDeferMacQuitForInstall: () => boolean
recordCompletedUpdateCheck: () => void
restoreReleaseUpdateSource: () => void
sendCheckFailureStatus: (
message: string,
userInitiated?: boolean,
source?: 'event' | 'promise' | 'fallback-promise',
sourceError?: unknown
) => Promise<void>
sendErrorStatus: (message: string, userInitiated?: boolean) => void
sendStatus: (status: UpdateStatus) => void
scheduleAutomaticUpdateCheck: (delayMs: number) => void
shouldSuppressMissingManifestPrereleaseFallbackEvent: (message: string, error: unknown) => boolean
suppressMissingManifestPrereleaseFallbackPromiseFailure: (message: string) => void
setAvailableReleaseUrl: (releaseUrl: string | null) => void
setAvailableVersion: (version: string | null) => void
setUserInitiatedCheck: (value: boolean) => void
}
export function registerAutoUpdaterHandlers({
autoUpdater,
clearBackgroundCheckLaunchPending,
@@ -87,6 +43,10 @@ export function registerAutoUpdaterHandlers({
markUpdateAvailableEventPending,
markMissingManifestPrereleaseFallbackChecking,
performQuitAndInstall,
commitStagedMacInstall,
failMacStaging,
isAwaitingMacStaging,
isMacStagingDeferredToInstall,
shouldDeferMacQuitForInstall,
recordCompletedUpdateCheck,
restoreReleaseUpdateSource,
@@ -106,6 +66,8 @@ export function registerAutoUpdaterHandlers({
getPendingInstallVersion,
getKnownReleaseUrl,
performQuitAndInstall,
commitStagedMacInstall,
failMacStaging,
shouldDeferMacQuitForInstall,
sendStatus
})
@@ -293,7 +255,7 @@ export function registerAutoUpdaterHandlers({
return
}
// On macOS, defer 'downloaded' until Squirrel.Mac finishes processing; other platforms are ready immediately.
if (process.platform === 'darwin' && !macInstallerReady) {
if (process.platform === 'darwin' && !macInstallerReady && !isMacStagingDeferredToInstall()) {
// Keep the UI at 100% downloaded while Squirrel processes, to avoid a premature "ready to install".
recordUpdaterLifecycle('macos_waiting_for_squirrel', { version: info.version })
sendStatus({ state: 'downloading', percent: 100, version: info.version })
@@ -304,6 +266,10 @@ export function registerAutoUpdaterHandlers({
autoUpdater.on('error', (err) => {
const message = err?.message ?? 'Unknown error'
// Why: the native Squirrel listener owns failure while it stages; MacUpdater re-emits that error here too.
if (isAwaitingMacStaging()) {
return
}
// Why: quitAndInstall reports "no staged update" via this error event (async on macOS); recover quit flags before suppression guards run.
if (handleQuitAndInstallFailure(err)) {
return
+50
View File
@@ -0,0 +1,50 @@
import type { UpdateStatus } from '../shared/update-status-types'
import type { ElectronAutoUpdater } from './electron-updater-loader'
/** Updater state and actions that the electron-updater event handlers read and drive. */
export type UpdaterHandlerContext = {
autoUpdater: ElectronAutoUpdater
clearBackgroundCheckLaunchPending: () => void
clearAvailableUpdateContext: () => void
consumeMissingManifestPrereleaseFallbackResult: () => { userInitiated: boolean } | null
getPublishingWindowLastGoodCheck: () => { lastGoodTag: string } | null
getMissingManifestPrereleaseFallbackUserInitiated: () => boolean | null
getCurrentStatus: () => UpdateStatus
getActiveUpdateCheckEventAttemptId: () => number | null
getKnownReleaseUrl: () => string | undefined
getPendingInstallVersion: () => string
getUserInitiatedCheck: () => boolean
handleQuitAndInstallFailure: (error?: unknown) => boolean
isQuitAndInstallHandoffActive: () => boolean
hasInstallableDownloadedVersion: () => boolean
isLocalBuildCheck: () => boolean
isPinnedBuildCheck: () => boolean
shouldHandleUpdaterErrorEvent: () => boolean
clearUpdateAvailableEventPending: (attemptId: number | null) => void
isActiveUpdateCheckAttempt: (attemptId: number) => boolean
markUpdateCheckEventAttempt: () => boolean
markUpdateAvailableEventPending: (attemptId: number | null) => void
markMissingManifestPrereleaseFallbackChecking: () => void
performQuitAndInstall: () => void | Promise<void>
commitStagedMacInstall: () => void
failMacStaging: (error: unknown) => void
isAwaitingMacStaging: () => boolean
isMacStagingDeferredToInstall: () => boolean
shouldDeferMacQuitForInstall: () => boolean
recordCompletedUpdateCheck: () => void
restoreReleaseUpdateSource: () => void
sendCheckFailureStatus: (
message: string,
userInitiated?: boolean,
source?: 'event' | 'promise' | 'fallback-promise',
sourceError?: unknown
) => Promise<void>
sendErrorStatus: (message: string, userInitiated?: boolean) => void
sendStatus: (status: UpdateStatus) => void
scheduleAutomaticUpdateCheck: (delayMs: number) => void
shouldSuppressMissingManifestPrereleaseFallbackEvent: (message: string, error: unknown) => boolean
suppressMissingManifestPrereleaseFallbackPromiseFailure: (message: string) => void
setAvailableReleaseUrl: (releaseUrl: string | null) => void
setAvailableVersion: (version: string | null) => void
setUserInitiatedCheck: (value: boolean) => void
}
+7
View File
@@ -10,6 +10,8 @@ export function registerMacUpdaterEvents({
getPendingInstallVersion,
getKnownReleaseUrl,
performQuitAndInstall,
commitStagedMacInstall,
failMacStaging,
shouldDeferMacQuitForInstall,
sendStatus
}: {
@@ -18,11 +20,16 @@ export function registerMacUpdaterEvents({
getPendingInstallVersion: () => string
getKnownReleaseUrl: () => string | undefined
performQuitAndInstall: () => void | Promise<void>
commitStagedMacInstall: () => void
failMacStaging: (error: unknown) => void
shouldDeferMacQuitForInstall: () => boolean
sendStatus: (status: UpdateStatus) => void
}): void {
if (process.platform === 'darwin') {
nativeUpdater.on('error', failMacStaging)
nativeUpdater.on('update-downloaded', () => {
// Why: registered before MacUpdater's quitAndInstall listener, so this commits before its app.quit().
commitStagedMacInstall()
const hasInstallableVersion = hasInstallableDownloadedVersion()
handleMacInstallerReady(hasInstallableVersion, performQuitAndInstall, () => {
sendStatus({
+2
View File
@@ -46,6 +46,8 @@ function registerGuard(
getPendingInstallVersion: () => '2.0.0',
getKnownReleaseUrl: () => undefined,
performQuitAndInstall,
commitStagedMacInstall: vi.fn(),
failMacStaging: vi.fn(),
shouldDeferMacQuitForInstall: () => true,
sendStatus: vi.fn()
})
+196 -2
View File
@@ -1,5 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader'
import { MAC_DEFERRED_STAGING_TIMEOUT_MS } from './updater/updater-state'
import type { BrowserWindow } from 'electron'
function windowSending(send: ReturnType<typeof vi.fn>): BrowserWindow {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the updater only reaches the window through webContents.send.
return { webContents: { send } } as unknown as BrowserWindow
}
const {
appMock,
@@ -9,6 +16,7 @@ const {
recordUpdaterLifecycleMock,
requestServeUpdateHandoffMock,
failServeUpdateHandoffMock,
armUpdateInstallExitWatchdogMock,
resetHandlers
} = vi.hoisted(() => {
const appHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
@@ -63,6 +71,7 @@ const {
recordUpdaterLifecycleMock: vi.fn(),
requestServeUpdateHandoffMock: vi.fn(() => true),
failServeUpdateHandoffMock: vi.fn(),
armUpdateInstallExitWatchdogMock: vi.fn(),
resetHandlers: () => {
appHandlers.clear()
updaterHandlers.clear()
@@ -101,7 +110,7 @@ vi.mock('./updater-prerelease-feed', () => ({
getReleaseDownloadUrl: vi.fn()
}))
vi.mock('./update-install-exit-watchdog', () => ({
armUpdateInstallExitWatchdog: vi.fn(),
armUpdateInstallExitWatchdog: armUpdateInstallExitWatchdogMock,
disarmUpdateInstallExitWatchdog: vi.fn()
}))
vi.mock('./updater-lifecycle-diagnostics', () => ({
@@ -134,6 +143,7 @@ describe('headless serve update install handoff', () => {
recordUpdaterLifecycleMock.mockReset()
requestServeUpdateHandoffMock.mockReset().mockReturnValue(true)
failServeUpdateHandoffMock.mockReset()
armUpdateInstallExitWatchdogMock.mockReset()
resetHandlers()
})
@@ -371,6 +381,186 @@ describe('headless serve update install handoff', () => {
)
})
describe('on macOS, where Squirrel stages the bundle only inside quitAndInstall', () => {
const originalPlatform = process.platform
beforeEach(() => {
setPlatform('darwin')
})
afterEach(() => {
setPlatform(originalPlatform)
})
const STAGED_INSTALL = [
'native-quit-and-install',
'install-committed',
'in-process-pty-cleanup',
'mac-updater-app-quit'
]
// Models electron-updater's MacUpdater with autoInstallOnAppQuit off: each unstaged quitAndInstall adds a
// persistent native listener that app.quit()s and only starts Squirrel; native errors are re-emitted first.
const downloadSupervisedUpdate = async (
lifecycle: string[]
): Promise<{
send: ReturnType<typeof vi.fn>
quitAndInstall: () => void
squirrel: { staged: () => void; failed: (error: Error) => void }
}> => {
const send = vi.fn()
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
return Promise.resolve(null)
})
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
lifecycle.push('native-quit-and-install')
nativeUpdaterMock.on('update-downloaded', () => {
lifecycle.push('mac-updater-app-quit')
appMock.quit()
})
})
killAllPtyMock.mockImplementation(() => lifecycle.push('in-process-pty-cleanup'))
armUpdateInstallExitWatchdogMock.mockImplementation(() => lifecycle.push('install-committed'))
const { checkForUpdatesFromMenu, downloadUpdate, quitAndInstall, setupAutoUpdater } =
await loadUpdaterModule()
setupAutoUpdater(windowSending(send), {
getLastUpdateCheckAt: () => Date.now(),
installMode: 'supervised-headless-serve'
})
checkForUpdatesFromMenu()
await vi.advanceTimersByTimeAsync(0)
downloadUpdate()
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
// Why: EventEmitter semantics — listeners run in registration order and a throw stops the rest.
const emitNative = (event: string, ...args: unknown[]): void => {
for (const [name, handler] of nativeUpdaterMock.on.mock.calls) {
if (name === event && typeof handler === 'function') {
handler(...args)
}
}
}
const squirrel = {
staged: (): void => emitNative('update-downloaded'),
failed: (error: Error): void => {
autoUpdaterMock.emit('error', error)
emitNative('error', error)
}
}
return { send, quitAndInstall, squirrel }
}
it('reports the download installable and commits only once Squirrel stages it', async () => {
const lifecycle: string[] = []
const { send, quitAndInstall, squirrel } = await downloadSupervisedUpdate(lifecycle)
expect(send).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'downloaded', version: '1.0.61' })
)
quitAndInstall()
await vi.advanceTimersByTimeAsync(5_000)
expect(requestServeUpdateHandoffMock).toHaveBeenCalledWith('1.0.61')
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(true, false)
expect(lifecycle).toEqual(['native-quit-and-install'])
squirrel.staged()
expect(lifecycle).toEqual(STAGED_INSTALL)
expect(failServeUpdateHandoffMock).not.toHaveBeenCalled()
})
it('keeps the server intact and retryable when Squirrel fails to stage', async () => {
const lifecycle: string[] = []
const { send, quitAndInstall, squirrel } = await downloadSupervisedUpdate(lifecycle)
quitAndInstall()
await vi.advanceTimersByTimeAsync(5_000)
squirrel.failed(new Error('Code signature did not pass validation'))
expect(failServeUpdateHandoffMock).toHaveBeenCalledOnce()
expect(lifecycle).toEqual(['native-quit-and-install'])
expect(send).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'error' })
)
quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
squirrel.staged()
expect(requestServeUpdateHandoffMock).toHaveBeenCalledTimes(2)
expect(armUpdateInstallExitWatchdogMock).toHaveBeenCalledOnce()
expect(killAllPtyMock).toHaveBeenCalledOnce()
// Why: MacUpdater keeps the failed attempt's listener too, so both quit; Orca commits once.
expect(lifecycle).toEqual([
'native-quit-and-install',
'native-quit-and-install',
'install-committed',
'in-process-pty-cleanup',
'mac-updater-app-quit',
'mac-updater-app-quit'
])
})
it('still commits and lets MacUpdater quit when session cleanup throws', async () => {
const lifecycle: string[] = []
const { quitAndInstall, squirrel } = await downloadSupervisedUpdate(lifecycle)
killAllPtyMock.mockImplementation(() => {
lifecycle.push('in-process-pty-cleanup')
throw new Error('PTY cleanup callback failed')
})
quitAndInstall()
await vi.advanceTimersByTimeAsync(5_000)
squirrel.staged()
expect(lifecycle).toEqual(STAGED_INSTALL)
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
'post_commit_cleanup_failed',
{ errorType: 'Error' },
expect.objectContaining({ level: 'warn' })
)
})
it('does not abandon the staging for an unrelated updater error', async () => {
const lifecycle: string[] = []
const { quitAndInstall, squirrel } = await downloadSupervisedUpdate(lifecycle)
quitAndInstall()
await vi.advanceTimersByTimeAsync(5_000)
autoUpdaterMock.emit('error', new Error('Background update check failed'))
squirrel.staged()
expect(failServeUpdateHandoffMock).not.toHaveBeenCalled()
expect(lifecycle).toEqual(STAGED_INSTALL)
})
it('reports a silent Squirrel but keeps the handoff live for a late staging', async () => {
const lifecycle: string[] = []
const { send, quitAndInstall, squirrel } = await downloadSupervisedUpdate(lifecycle)
quitAndInstall()
await vi.advanceTimersByTimeAsync(MAC_DEFERRED_STAGING_TIMEOUT_MS + 100)
expect(send).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'error', version: '1.0.61', retryable: false })
)
expect(failServeUpdateHandoffMock).not.toHaveBeenCalled()
expect(lifecycle).toEqual(['native-quit-and-install'])
squirrel.staged()
expect(lifecycle).toEqual(STAGED_INSTALL)
expect(failServeUpdateHandoffMock).not.toHaveBeenCalled()
})
})
it.runIf(process.platform === 'darwin')(
'defers a pre-staged macOS update resumed from the native-ready continuation',
async () => {
@@ -534,3 +724,7 @@ describe('headless serve update install handoff', () => {
expect(() => checkForRemoteServerUpdate('runtime-1')).toThrow('remote_update_manual_required')
})
})
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
}
@@ -30,6 +30,7 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus {
this.finishActiveUpdateCheckAttempt()
this.clearBackgroundCheckLaunchPending()
if (
!this.isMacStagingDeferredToInstall() &&
deferMacQuitUntilInstallerReady(
this.currentStatus,
this.hasInstallableDownloadedVersion(),
+92 -15
View File
@@ -14,6 +14,7 @@ import { LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE } from '../linux-package-download
import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics'
import { requestServeUpdateHandoff, failServeUpdateHandoff } from '../serve-update-handoff'
import { UpdaterPackageRecovery } from './updater-package-recovery'
import { MAC_DEFERRED_STAGING_TIMEOUT_MS } from './updater-state'
export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery {
protected async performQuitAndInstall(): Promise<void> {
@@ -164,24 +165,36 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery {
return
}
killAllPty()
span.addEvent('local_pty_kill_all')
for (const win of BrowserWindow.getAllWindows()) {
win.removeAllListeners('close')
// Why: this staging finishes after quitAndInstall returns; hold destructive prep until Squirrel stages so a staging failure leaves the server intact.
if (this.isMacStagingDeferredToInstall() && !isMacInstallerReady()) {
// Why: MacUpdater keeps its own completion listener that quits once Squirrel stages, so abandoning the
// install here would let a late staging apply with a failed handoff; report and keep the handoff live.
this.macDeferredStagingTimer = setTimeout(() => {
this.macDeferredStagingTimer = null
recordUpdaterLifecycle(
'macos_deferred_staging_slow',
{ timeoutMs: MAC_DEFERRED_STAGING_TIMEOUT_MS },
{ level: 'warn', message: 'Squirrel has not staged the supervised serve update' }
)
this.sendInstallFailureStatus({
state: 'error',
message:
'macOS has not finished preparing the update. Orca keeps serving and installs it once ready; restart the Orca service if it never does.',
...(pendingVersion ? { version: pendingVersion } : {}),
retryable: false
})
}, MAC_DEFERRED_STAGING_TIMEOUT_MS)
span.addEvent('awaiting_squirrel_staging')
return
}
span.addEvent('window_close_listeners_removed', {
windowCount: BrowserWindow.getAllWindows().length
})
const windowCount = this.releaseSessionsForInstaller()
span.addEvent('local_pty_kill_all')
span.addEvent('window_close_listeners_removed', { windowCount })
// Why: committed installs keep quittingForUpdate so dock activate can't reopen the old process; macOS without Squirrel stays uncommitted so late native errors can still recover.
if (
!this.updateInstallCommitted &&
(process.platform !== 'darwin' || isMacInstallerReady())
) {
this.updateInstallCommitted = true
// Why: past commit the installer waits for this process to exit; a wedged async shutdown would strand the user with no app and no update (#4438).
armUpdateInstallExitWatchdog()
if (process.platform !== 'darwin' || isMacInstallerReady()) {
this.commitInFlightInstall()
}
})
} catch (error) {
@@ -218,6 +231,70 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery {
}
}
// Why: supervised serve keeps autoInstallOnAppQuit off, so electron-updater's MacUpdater asks Squirrel to stage only inside quitAndInstall; waiting for Squirrel first never ends.
protected isMacStagingDeferredToInstall(): boolean {
return process.platform === 'darwin' && this.updateInstallMode === 'supervised-headless-serve'
}
protected isAwaitingMacStaging(): boolean {
return (
this.isMacStagingDeferredToInstall() &&
this.quitAndInstallInProgress &&
this.quitAndInstallNativeInvoked &&
!this.updateInstallCommitted
)
}
/** Squirrel staged the bundle that quitAndInstall asked for; the installer now owns the swap. */
protected commitStagedMacInstall(): void {
if (!this.isAwaitingMacStaging()) {
return
}
this.clearMacDeferredStagingTimer()
// Why: commit first; this runs inside Squirrel's event, so a throwing cleanup would otherwise skip the watchdog and MacUpdater's quit listener.
this.commitInFlightInstall()
recordUpdaterLifecycle('macos_deferred_staging_committed', {
version: this.getPendingInstallVersion() || null
})
try {
this.releaseSessionsForInstaller()
} catch (error) {
recordUpdaterLifecycle(
'post_commit_cleanup_failed',
{ errorType: error instanceof Error ? error.name : typeof error },
{
level: 'warn',
message: 'Update install cleanup failed after commit; install already applied'
}
)
}
}
// Why: only Squirrel's own error ends the staging; an unrelated updater error (e.g. a background check) must not abandon it.
protected failMacStaging(error: unknown): void {
if (this.isAwaitingMacStaging()) {
this.handleQuitAndInstallFailure(error)
}
}
private releaseSessionsForInstaller(): number {
killAllPty()
const windows = BrowserWindow.getAllWindows()
for (const win of windows) {
win.removeAllListeners('close')
}
return windows.length
}
protected commitInFlightInstall(): void {
if (!this.quitAndInstallInProgress || this.updateInstallCommitted) {
return
}
this.updateInstallCommitted = true
// Why: past commit the installer waits for this process to exit; a wedged async shutdown would strand the user with no app and no update (#4438).
armUpdateInstallExitWatchdog()
}
// Why: quitAndInstall failures arrive via 'error'; recover only after native invoke and before commit, else clearing quittingForUpdate lets dock activate reopen the old process mid-installer.
protected handleQuitAndInstallFailure(error?: unknown): boolean {
if (
@@ -76,10 +76,18 @@ export abstract class UpdaterInstallSupport extends UpdaterCheckState {
this.quittingForUpdate = false
this.updateInstallCommitted = false
this.quitAndInstallNativeInvoked = false
this.clearMacDeferredStagingTimer()
disarmUpdateInstallExitWatchdog()
resetMacInstallState()
}
protected clearMacDeferredStagingTimer(): void {
if (this.macDeferredStagingTimer) {
clearTimeout(this.macDeferredStagingTimer)
this.macDeferredStagingTimer = null
}
}
/**
* On macOS a pre-commit failure means Squirrel rejected the staged update, and quitting does re-stage
* it — so keep that advice there. Everywhere else a restart is not known to help.
+4
View File
@@ -199,6 +199,10 @@ export class UpdaterSetup extends UpdaterDownloadInstall {
markMissingManifestPrereleaseFallbackChecking: () =>
this.markMissingManifestPrereleaseFallbackChecking(),
performQuitAndInstall: () => this.performQuitAndInstall(),
commitStagedMacInstall: () => this.commitStagedMacInstall(),
failMacStaging: (error) => this.failMacStaging(error),
isAwaitingMacStaging: () => this.isAwaitingMacStaging(),
isMacStagingDeferredToInstall: () => this.isMacStagingDeferredToInstall(),
shouldDeferMacQuitForInstall: () => this.updateInstallMode === 'interactive',
recordCompletedUpdateCheck: () => this.recordCompletedUpdateCheck(),
restoreReleaseUpdateSource: () => this.restoreReleaseUpdateSource(),
+3
View File
@@ -17,6 +17,8 @@ export const PRE_QUIT_CLEANUP_TIMEOUT_MS = 2_500
export const REQUIRED_PRE_QUIT_CLEANUP_TIMEOUT_MS = 90_000
export const UPDATE_CHECK_SILENT_SETTLE_DELAY_MS = 1_000
export const UPDATE_CHECK_STALL_TIMEOUT_MS = 45_000
// Why: Squirrel re-reads the whole bundle from the localhost proxy and verifies its signature; past this, a silent Squirrel is reported instead of left invisible.
export const MAC_DEFERRED_STAGING_TIMEOUT_MS = 120_000
export type CheckFailureSource = 'event' | 'promise' | 'fallback-promise'
export type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean }
@@ -56,6 +58,7 @@ export abstract class UpdaterState {
protected autoUpdateCheckTimer: ReturnType<typeof setTimeout> | null = null
protected nudgeCheckTimer: ReturnType<typeof setTimeout> | null = null
protected pendingQuitAndInstallTimer: ReturnType<typeof setTimeout> | null = null
protected macDeferredStagingTimer: ReturnType<typeof setTimeout> | null = null
protected quitAndInstallInProgress = false
protected updateInstallMode: UpdateInstallMode = 'interactive'
protected lastInstallDeferralVersion = {