From 68c46fa07fe75a4b0d7dbd6f24653ab3da765d31 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 05:57:43 -0700 Subject: [PATCH] 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) * 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) * 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) * 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) * 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) * 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 --------- Co-authored-by: jbamlee Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: m4air --- ...ctron-updater-mac-staging-contract.test.ts | 60 ++++++ src/main/updater-events.test.ts | 4 + src/main/updater-events.ts | 58 ++--- src/main/updater-handler-context.ts | 50 +++++ src/main/updater-mac-install.ts | 7 + src/main/updater-mac-quit-guard.test.ts | 2 + .../updater.headless-serve-install.test.ts | 198 +++++++++++++++++- src/main/updater/updater-download-install.ts | 1 + src/main/updater/updater-install-execution.ts | 107 ++++++++-- src/main/updater/updater-install-support.ts | 8 + src/main/updater/updater-setup.ts | 4 + src/main/updater/updater-state.ts | 3 + 12 files changed, 439 insertions(+), 63 deletions(-) create mode 100644 src/main/electron-updater-mac-staging-contract.test.ts create mode 100644 src/main/updater-handler-context.ts diff --git a/src/main/electron-updater-mac-staging-contract.test.ts b/src/main/electron-updater-mac-staging-contract.test.ts new file mode 100644 index 00000000000..8b6c50aaf87 --- /dev/null +++ b/src/main/electron-updater-mac-staging-contract.test.ts @@ -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() + }) +}) diff --git a/src/main/updater-events.test.ts b/src/main/updater-events.test.ts index 6f5ce5fbb87..c368acb28f5 100644 --- a/src/main/updater-events.test.ts +++ b/src/main/updater-events.test.ts @@ -90,6 +90,10 @@ function createContext(overrides?: Partial): 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(), diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index d2b7f2a1e83..2553cacb594 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -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 - shouldDeferMacQuitForInstall: () => boolean - recordCompletedUpdateCheck: () => void - restoreReleaseUpdateSource: () => void - sendCheckFailureStatus: ( - message: string, - userInitiated?: boolean, - source?: 'event' | 'promise' | 'fallback-promise', - sourceError?: unknown - ) => Promise - 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 diff --git a/src/main/updater-handler-context.ts b/src/main/updater-handler-context.ts new file mode 100644 index 00000000000..b5aeb710ac8 --- /dev/null +++ b/src/main/updater-handler-context.ts @@ -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 + 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 + 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 +} diff --git a/src/main/updater-mac-install.ts b/src/main/updater-mac-install.ts index ee45db2d0f4..ee83dbafdaf 100644 --- a/src/main/updater-mac-install.ts +++ b/src/main/updater-mac-install.ts @@ -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 + 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({ diff --git a/src/main/updater-mac-quit-guard.test.ts b/src/main/updater-mac-quit-guard.test.ts index 2819b328c8a..ea8f033dfab 100644 --- a/src/main/updater-mac-quit-guard.test.ts +++ b/src/main/updater-mac-quit-guard.test.ts @@ -46,6 +46,8 @@ function registerGuard( getPendingInstallVersion: () => '2.0.0', getKnownReleaseUrl: () => undefined, performQuitAndInstall, + commitStagedMacInstall: vi.fn(), + failMacStaging: vi.fn(), shouldDeferMacQuitForInstall: () => true, sendStatus: vi.fn() }) diff --git a/src/main/updater.headless-serve-install.test.ts b/src/main/updater.headless-serve-install.test.ts index ebf7fb6269b..0cffa0b7760 100644 --- a/src/main/updater.headless-serve-install.test.ts +++ b/src/main/updater.headless-serve-install.test.ts @@ -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): 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 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 + 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 }) +} diff --git a/src/main/updater/updater-download-install.ts b/src/main/updater/updater-download-install.ts index 8b672eedbbd..bec35c977e3 100644 --- a/src/main/updater/updater-download-install.ts +++ b/src/main/updater/updater-download-install.ts @@ -30,6 +30,7 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { this.finishActiveUpdateCheckAttempt() this.clearBackgroundCheckLaunchPending() if ( + !this.isMacStagingDeferredToInstall() && deferMacQuitUntilInstallerReady( this.currentStatus, this.hasInstallableDownloadedVersion(), diff --git a/src/main/updater/updater-install-execution.ts b/src/main/updater/updater-install-execution.ts index 036fab4e119..0be7f97aa6c 100644 --- a/src/main/updater/updater-install-execution.ts +++ b/src/main/updater/updater-install-execution.ts @@ -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 { @@ -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 ( diff --git a/src/main/updater/updater-install-support.ts b/src/main/updater/updater-install-support.ts index 2da273e152e..ebeb134d475 100644 --- a/src/main/updater/updater-install-support.ts +++ b/src/main/updater/updater-install-support.ts @@ -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. diff --git a/src/main/updater/updater-setup.ts b/src/main/updater/updater-setup.ts index 2b43964d5c0..abfe80bc675 100644 --- a/src/main/updater/updater-setup.ts +++ b/src/main/updater/updater-setup.ts @@ -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(), diff --git a/src/main/updater/updater-state.ts b/src/main/updater/updater-state.ts index 32177e3183c..4f740886831 100644 --- a/src/main/updater/updater-state.ts +++ b/src/main/updater/updater-state.ts @@ -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 | null = null protected nudgeCheckTimer: ReturnType | null = null protected pendingQuitAndInstallTimer: ReturnType | null = null + protected macDeferredStagingTimer: ReturnType | null = null protected quitAndInstallInProgress = false protected updateInstallMode: UpdateInstallMode = 'interactive' protected lastInstallDeferralVersion = {