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 = {