From 8cd9751963b3046e8393f2007c9104a56a63fa84 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 3 Oct 2026 16:27:02 -0700 Subject: [PATCH] fix(updater): keep macOS Orca open when background instances block updates (#24952) * fix(updater): guard macOS installs against running app instances * fix(updater): match native app blockers and preserve quit lifecycle * fix(updater): keep ordinary macOS quit on Squirrel's install-on-exit path Converting every quit with a staged update into quitAndInstall made Cmd+Q relaunch Orca, refused the quit when background instances existed, and hijacked app.relaunch()+app.quit() restart flows (profile switch, admin restart) into an update install racing the relaunched old app. Only Update & Restart runs the running-instance preflight now; the quit-without-install allowance is no longer reachable and is removed. * fix(updater): preserve quit intent through macOS staging * test(native-chat): explicitly model legacy published tab ownership --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: m4air --- .github/workflows/macos-updater-tests.yml | 49 +++ ...aude-child-work-evidence-retention.test.ts | 7 +- ...date-running-instances.integration.test.ts | 97 +++++ .../macos-update-running-instances.test.ts | 106 +++++ src/main/macos-update-running-instances.ts | 53 +++ .../startup/desktop-startup-ordering.test.ts | 4 + .../main-process-quit-update-veto.test.ts | 103 +++++ src/main/startup/main-process-quit.ts | 5 +- src/main/updater-events.test.ts | 3 +- src/main/updater-fallback.ts | 1 + ...ter-linux-package-recovery-actions.test.ts | 8 +- src/main/updater-mac-install.ts | 81 ++-- src/main/updater-mac-quit-guard.test.ts | 253 +++++++++++ src/main/updater-test-harness.ts | 40 +- src/main/updater.check-failure.test.ts | 9 + src/main/updater.check-preflight.test.ts | 88 ++++ src/main/updater.fallback.test.ts | 1 + .../updater.headless-serve-install.test.ts | 4 + .../updater.install-failure-cause.test.ts | 9 + src/main/updater.mac-install.test.ts | 396 +++++++++++++++++- src/main/updater/updater-download-install.ts | 19 +- src/main/updater/updater-install-execution.ts | 47 ++- src/main/updater/updater-menu-checks.ts | 15 + src/main/updater/updater-scheduling.ts | 15 + .../window/dashboard-popout-window.test.ts | 27 ++ src/main/window/dashboard-popout-window.ts | 7 +- .../main-window-state-lifecycle.test.ts | 64 +++ .../window/main-window-state-lifecycle.ts | 9 +- .../components/UpdateCard.error-card.test.tsx | 31 ++ .../update-card/update-card-error-model.ts | 12 + ...cturedAgentSessionAttentionBridge.test.tsx | 11 +- .../GeneralUpdateSettingsSection.test.tsx | 39 +- .../settings/GeneralUpdateSettingsSection.tsx | 7 + src/shared/update-status-types.ts | 2 + 34 files changed, 1552 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/macos-updater-tests.yml create mode 100644 src/main/macos-update-running-instances.integration.test.ts create mode 100644 src/main/macos-update-running-instances.test.ts create mode 100644 src/main/macos-update-running-instances.ts create mode 100644 src/main/startup/main-process-quit-update-veto.test.ts create mode 100644 src/main/updater-mac-quit-guard.test.ts create mode 100644 src/main/window/main-window-state-lifecycle.test.ts diff --git a/.github/workflows/macos-updater-tests.yml b/.github/workflows/macos-updater-tests.yml new file mode 100644 index 00000000000..056a40204b4 --- /dev/null +++ b/.github/workflows/macos-updater-tests.yml @@ -0,0 +1,49 @@ +name: macOS updater regression tests + +on: + pull_request: + paths: + - '.github/workflows/macos-updater-tests.yml' + - 'src/main/macos-update-running-instances*' + - 'src/main/updater*' + - 'src/main/updater/**' + - 'src/main/startup/main-process-quit*' + - 'src/main/window/main-window-state-lifecycle*' + - 'src/main/window/dashboard-popout-window*' + - 'src/shared/child-process/**' + - 'src/shared/update-status-types.ts' + - 'pnpm-lock.yaml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: macos-updater-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + updater: + runs-on: macos-15 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - name: Exercise native application registry and update shutdown + env: + ORCA_BACKGROUND_LAUNCH: '1' + run: >- + pnpm exec vitest run --config config/vitest.config.ts + src/main/macos-update-running-instances.test.ts + src/main/macos-update-running-instances.integration.test.ts + src/main/updater.mac-install.test.ts + src/main/updater.headless-serve-install.test.ts + src/main/updater-mac-quit-guard.test.ts + src/main/startup/desktop-startup-ordering.test.ts + src/main/startup/main-process-quit-update-veto.test.ts + src/main/window/main-window-state-lifecycle.test.ts + src/main/window/dashboard-popout-window.test.ts diff --git a/src/main/claude/claude-child-work-evidence-retention.test.ts b/src/main/claude/claude-child-work-evidence-retention.test.ts index 2ba58138c24..c9c3250bcab 100644 --- a/src/main/claude/claude-child-work-evidence-retention.test.ts +++ b/src/main/claude/claude-child-work-evidence-retention.test.ts @@ -9,6 +9,7 @@ import type { StructuredAgentSessionEventSink } from '../native-chat/agent-sessi import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { claudeChildOperation, drainClaudeChildWork } from './claude-child-work-evidence' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { claudeToolResults, claudeToolUses, @@ -85,7 +86,11 @@ describe('Claude child operation output retention', () => { translator.handle({ type: 'message', sessionId: 'orca', message, observedAt: 1_000 }) const join = vi.spyOn(Array.prototype, 'join') const evidence = drainClaudeChildWork( - { childWork: new ClaudeChildWorkDecoder(), translator }, + { + childWork: new ClaudeChildWorkDecoder(), + translator, + prompts: new ClaudePromptRegistry() + }, message, 1_000 ) diff --git a/src/main/macos-update-running-instances.integration.test.ts b/src/main/macos-update-running-instances.integration.test.ts new file mode 100644 index 00000000000..afc5c3d7e66 --- /dev/null +++ b/src/main/macos-update-running-instances.integration.test.ts @@ -0,0 +1,97 @@ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { once } from 'node:events' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { expect, it, vi } from 'vitest' +import { runProcess, spawnProcess } from '../shared/child-process/run-process' +import { getMacUpdateRunningInstances } from './macos-update-running-instances' + +const APPLICATION_SOURCE = ` +#import +#import +int main(int argc, const char *argv[]) { + if (argc > 1) { sleep(30); return 0; } + @autoreleasepool { + NSApplication *app = [NSApplication sharedApplication]; + [app setActivationPolicy:NSApplicationActivationPolicyProhibited]; + [app run]; + } + return 0; +} +` + +it.runIf(process.platform === 'darwin')( + 'matches ShipIt with registered sibling apps, excluding same-executable workers and other bundle copies', + async () => { + const root = mkdtempSync(path.join(tmpdir(), 'orca-update-instances-')) + const bundle = path.join(root, 'Orca Test.app') + const executable = path.join(bundle, 'Contents', 'MacOS', 'Orca Test') + mkdirSync(path.dirname(executable), { recursive: true }) + const sourcePath = path.join(root, 'application.m') + writeFileSync(sourcePath, APPLICATION_SOURCE) + writeFileSync( + path.join(bundle, 'Contents', 'Info.plist'), + ` + CFBundleIdentifiercom.stablyai.${path.basename(root)} + CFBundleExecutableOrca Test + CFBundlePackageTypeAPPL + ` + ) + const children: ReturnType[] = [] + const closed: Promise[] = [] + try { + const compilation = await runProcess({ + program: '/usr/bin/clang', + args: ['-framework', 'AppKit', sourcePath, '-o', executable], + timeoutMs: 15_000 + }) + expect(compilation.code, compilation.stderr).toBe(0) + const otherBundle = path.join(root, 'Other Orca.app') + cpSync(bundle, otherBundle, { recursive: true }) + for (const [program, args] of [ + [executable, []], + [executable, []], + [executable, ['--worker']], + [path.join(otherBundle, 'Contents', 'MacOS', 'Orca Test'), []] + ] satisfies [string, string[]][]) { + const child = spawnProcess({ + program, + args, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + children.push(child) + closed.push(once(child, 'close')) + } + const [self, sibling, worker, otherCopy] = children + await vi.waitFor( + async () => { + expect(self.pid).toBeTypeOf('number') + expect(sibling.pid).toBeTypeOf('number') + expect(await getMacUpdateRunningInstances(executable, self.pid)).toEqual([sibling.pid]) + expect( + await getMacUpdateRunningInstances( + path.join(otherBundle, 'Contents', 'MacOS', 'Orca Test'), + 0 + ) + ).toEqual([otherCopy.pid]) + const workerListing = await runProcess({ + program: '/bin/ps', + args: ['-p', String(worker.pid), '-ww', '-o', 'comm='] + }) + expect(workerListing.stdout.trim()).toBe(executable) + }, + { timeout: 5000 } + ) + sibling.kill('SIGTERM') + await closed[1] + expect(await getMacUpdateRunningInstances(executable, self.pid)).toEqual([]) + } finally { + for (const child of children) { + child.kill('SIGTERM') + } + await Promise.all(closed) + rmSync(root, { recursive: true, force: true }) + } + }, + 25_000 +) diff --git a/src/main/macos-update-running-instances.test.ts b/src/main/macos-update-running-instances.test.ts new file mode 100644 index 00000000000..f6af6f04f53 --- /dev/null +++ b/src/main/macos-update-running-instances.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getMacUpdateRunningInstances, + parseMacUpdateRunningInstances +} from './macos-update-running-instances' + +const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) +vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) + +const executable = '/Applications/Orca Test.app/Contents/MacOS/Orca Test' + +describe('macOS update running instances', () => { + beforeEach(() => { + runProcessMock.mockReset() + }) + + it('excludes the current process from the native registry result', () => { + expect(parseMacUpdateRunningInstances('[100,101,102]\n', 100)).toEqual([101, 102]) + expect(parseMacUpdateRunningInstances('[]\n', 100)).toEqual([]) + }) + + it.each([ + 'not JSON', + '', + '{}', + 'null', + '[0]', + '[-1]', + '[1.5]', + '["101"]', + '[null]', + '[9007199254740992]' + ])('rejects invalid registry output: %s', (listing) => { + expect(() => parseMacUpdateRunningInstances(listing, 100)).toThrow() + }) + + it.runIf(process.platform === 'darwin')( + 'passes the bundle path as an argument rather than executable script', + async () => { + const unusualExecutable = '/Applications/Orca "Test" $HOME.app/Contents/MacOS/Orca Test' + runProcessMock.mockResolvedValue({ code: 0, stdout: '[]', timedOut: false }) + await expect(getMacUpdateRunningInstances(unusualExecutable)).resolves.toEqual([]) + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + args: [ + '-l', + 'JavaScript', + '-e', + expect.not.stringContaining('$HOME'), + '/Applications/Orca "Test" $HOME.app' + ] + }) + ) + } + ) + + it('skips development runtimes without probing the host', async () => { + expect(await getMacUpdateRunningInstances('/usr/local/bin/node')).toEqual([]) + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it.each(['linux', 'win32'])('does not probe on %s', async (platform) => { + vi.stubGlobal('process', { ...process, platform }) + try { + expect(await getMacUpdateRunningInstances(executable)).toEqual([]) + expect(runProcessMock).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) + + it.runIf(process.platform === 'darwin')( + 'uses the bounded native application registry query and preserves paths with spaces', + async () => { + runProcessMock.mockResolvedValue({ + code: 0, + stdout: '[100,101]\n', + timedOut: false + }) + expect(await getMacUpdateRunningInstances(executable, 100)).toEqual([101]) + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + program: '/usr/bin/osascript', + args: [ + '-l', + 'JavaScript', + '-e', + expect.stringContaining('runningApplicationsWithBundleIdentifier'), + '/Applications/Orca Test.app' + ], + timeoutMs: 5000, + killOnOutputLimit: true + }) + ) + } + ) + + it.runIf(process.platform === 'darwin').each([ + { code: 1, timedOut: false }, + { code: null, timedOut: true }, + { code: 0, timedOut: false, outputTruncated: true } + ])('fails closed for incomplete query results: %j', async (result) => { + runProcessMock.mockResolvedValue({ stdout: '', ...result }) + await expect(getMacUpdateRunningInstances(executable)).rejects.toThrow('Could not check') + }) +}) diff --git a/src/main/macos-update-running-instances.ts b/src/main/macos-update-running-instances.ts new file mode 100644 index 00000000000..1ce757d30f5 --- /dev/null +++ b/src/main/macos-update-running-instances.ts @@ -0,0 +1,53 @@ +import path from 'node:path' +import { runProcess } from '../shared/child-process/run-process' + +const RUNNING_INSTANCES_SCRIPT = `function run(argv) { + ObjC.import('AppKit'); + const bundle = $.NSBundle.bundleWithPath(argv[0]); + const identifier = ObjC.unwrap(bundle.bundleIdentifier); + if (typeof identifier !== 'string' || !identifier) throw new Error('Missing bundle identifier'); + const target = $.NSURL.fileURLWithPath(argv[0]).URLByStandardizingPath; + const apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier(identifier); + const pids = []; + for (let i = 0; i < apps.count; i++) { + const app = apps.objectAtIndex(i); + if (app.bundleURL && app.bundleURL.URLByStandardizingPath.isEqual(target)) { + pids.push(Number(app.processIdentifier)); + } + } + return JSON.stringify(pids); +}` + +/** Squirrel waits for every main application process from the target bundle. */ +export async function getMacUpdateRunningInstances( + executable = process.execPath, + currentPid = process.pid +): Promise { + if (process.platform !== 'darwin' || !executable.includes('.app/Contents/MacOS/')) { + return [] + } + const bundlePath = path.dirname(path.dirname(path.dirname(executable))) + // Match ShipIt's registry query; run-as-node workers share the executable but do not block it. + const result = await runProcess({ + program: '/usr/bin/osascript', + args: ['-l', 'JavaScript', '-e', RUNNING_INSTANCES_SCRIPT, bundlePath], + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024, + killOnOutputLimit: true + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error('Could not check running Orca instances') + } + return parseMacUpdateRunningInstances(result.stdout, currentPid) +} + +export function parseMacUpdateRunningInstances(listing: string, currentPid: number): number[] { + const pids: unknown = JSON.parse(listing) + if ( + !Array.isArray(pids) || + !pids.every((pid: unknown) => typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0) + ) { + throw new Error('Invalid macOS application listing') + } + return pids.filter((pid: number) => pid !== currentPid) +} diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index ce8148ea5d2..485a56c37b8 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -19,6 +19,10 @@ describe('startup ordering', () => { expect(beforeQuitStart).toBeGreaterThanOrEqual(0) expect(willQuitStart).toBeGreaterThan(beforeQuitStart) expect(windowAllClosedStart).toBeGreaterThan(willQuitStart) + expect(beforeQuit.indexOf('event.defaultPrevented')).toBeGreaterThanOrEqual(0) + expect(beforeQuit.indexOf('event.defaultPrevented')).toBeLessThan( + beforeQuit.indexOf('state.isQuitting = true') + ) expect(beforeQuit).not.toContain('unsubscribeSystemResumeBroadcast') expect(commitIndex).toBeGreaterThanOrEqual(0) expect(disposeIndex).toBeGreaterThan(commitIndex) diff --git a/src/main/startup/main-process-quit-update-veto.test.ts b/src/main/startup/main-process-quit-update-veto.test.ts new file mode 100644 index 00000000000..ea46417fe40 --- /dev/null +++ b/src/main/startup/main-process-quit-update-veto.test.ts @@ -0,0 +1,103 @@ +import { EventEmitter } from 'node:events' +import { afterEach, expect, it, vi } from 'vitest' + +const dependencyExports: [string, string[]][] = [ + ['../ipc/filesystem-watcher', ['closeAllWatchers']], + ['../ipc/worktree-base-directory-watcher', ['disposeWorktreeBaseDirectoryWatchers']], + ['../ipc/folder-repo-git-upgrade', ['stopFolderRepoGitUpgradeWatch']], + ['../ipc/pty', ['killAllPty']], + ['../daemon/daemon-init', ['disconnectDaemon', 'shutdownDaemon']], + ['../ipc/ssh-shutdown-drain', ['beginSshShutdown']], + ['../agent-hooks/server', ['agentHookServer']], + ['../agent-hooks/wsl-hook-relay-manager', ['wslHookRelayManager']], + ['../agent-hooks/managed-agent-hook-controls', ['removeManagedAgentHooksAsync']], + ['../runtime/structured-agent-session-runtime', ['stopStructuredAgentSessionRuntime']], + [ + '../runtime/structured-agent-session-runtime-teardown', + ['setStructuredAgentSessionTeardownTrigger'] + ], + ['../runtime/orca-runtime-files', ['awaitRuntimeFileWatcherUnsubscribes']], + ['../runtime/runtime-metadata', ['clearRuntimeMetadataIfOwned']], + [ + '../browser/paired-runtime-browser-client-host-runtime', + ['shutdownPairedRuntimeBrowserClientHosts'] + ], + ['../browser/browser-manager', ['browserManager']], + ['../codex/codex-state-db-backfill-recovery', ['stopCodexStateDbBackfillRecoveries']], + ['../codex/codex-account-session-bridge', ['stopCodexAccountSessionBridges']], + ['../git/local-repo-ref-maintenance', ['awaitPackedRefsLockRelease']], + ['../worktree-background-removal', ['stopBackgroundWorktreeRemovals']], + ['../quit-teardown-deadline', ['settleTeardownWithinDeadline', 'settleWithinMs']], + ['../quit-teardown-start-gate', ['quitTeardownStartGate']], + ['../dock/unread-badge', ['setUnreadDockBadgeCount']], + ['../tray/system-tray', ['destroySystemTray']], + ['../telemetry/client', ['shutdownTelemetry']], + ['../observability', ['shutdownObservability']], + ['../updater', ['isQuittingForUpdate']], + ['../updater-lifecycle-diagnostics', ['recordUpdaterLifecycle']], + ['../macos-tcc-prompt-notice', ['stopTccPromptNotice']], + ['../terminal-history-gc', ['cancelHistoryGc']], + ['./window-all-closed-quit-policy', ['shouldQuitWhenAllWindowsClosed']], + ['./configure-process', ['isDevParentShutdownRequested']], + ['../persistence', ['getCanonicalUserDataPath']] +] + +it('keeps startup services live when the updater has vetoed before-quit', async () => { + vi.resetModules() + const app = new EventEmitter() + const fenceAndCloseNow = vi.fn() + const setMobileRelayPairingProvider = vi.fn() + const unsubscribeAgentAwakeStatusChanges = vi.fn() + const dispose = vi.fn() + const stop = vi.fn() + const state = { + isQuitting: false, + desktopRelayService: { fenceAndCloseNow }, + runtimeRpc: { setMobileRelayPairingProvider }, + unsubscribeAgentAwakeStatusChanges, + agentAwakeService: { dispose }, + rateLimits: { stop } + } + vi.doMock('electron', () => ({ app })) + vi.doMock('./main-process-state', () => ({ mainProcessState: state })) + for (const [moduleName, exports] of dependencyExports) { + vi.doMock(moduleName, () => Object.fromEntries(exports.map((name) => [name, vi.fn()]))) + } + const exitListenersBefore = process.listeners('exit') + const { installMainProcessQuitHandlers } = await import('./main-process-quit') + installMainProcessQuitHandlers() + + app.emit('before-quit', { defaultPrevented: true }) + + expect(state.isQuitting).toBe(false) + expect(fenceAndCloseNow).not.toHaveBeenCalled() + expect(setMobileRelayPairingProvider).not.toHaveBeenCalled() + expect(unsubscribeAgentAwakeStatusChanges).not.toHaveBeenCalled() + expect(dispose).not.toHaveBeenCalled() + expect(stop).not.toHaveBeenCalled() + expect(state.agentAwakeService).toEqual({ dispose }) + expect(state.unsubscribeAgentAwakeStatusChanges).toBe(unsubscribeAgentAwakeStatusChanges) + + app.emit('before-quit', { defaultPrevented: false }) + + expect(state.isQuitting).toBe(true) + expect(fenceAndCloseNow).toHaveBeenCalledOnce() + expect(setMobileRelayPairingProvider).toHaveBeenCalledWith(null) + expect(unsubscribeAgentAwakeStatusChanges).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledOnce() + expect(stop).toHaveBeenCalledOnce() + for (const listener of process.listeners('exit')) { + if (!exitListenersBefore.includes(listener)) { + process.removeListener('exit', listener) + } + } +}) + +afterEach(() => { + vi.doUnmock('electron') + vi.doUnmock('./main-process-state') + for (const [moduleName] of dependencyExports) { + vi.doUnmock(moduleName) + } + vi.resetModules() +}) diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 5a89c9e283f..7f218b93e6c 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -67,7 +67,10 @@ function shutdownWatchersOnce(): Promise { } function installBeforeQuitHandler(): void { - app.on('before-quit', () => { + app.on('before-quit', (event: Event) => { + if (event.defaultPrevented) { + return + } if (isQuittingForUpdate()) { recordUpdaterLifecycle('before_quit_allowed', undefined, { message: 'before-quit allowed for update install' diff --git a/src/main/updater-events.test.ts b/src/main/updater-events.test.ts index 6a643ae64a5..6f5ce5fbb87 100644 --- a/src/main/updater-events.test.ts +++ b/src/main/updater-events.test.ts @@ -12,7 +12,8 @@ const { appMock: { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), - on: vi.fn() + on: vi.fn(), + prependListener: vi.fn() }, nativeUpdaterMock: { on: vi.fn() }, getLinuxPackageTypeMock: vi.fn<() => 'deb' | 'rpm' | 'non-root' | 'unusable'>(() => 'deb'), diff --git a/src/main/updater-fallback.ts b/src/main/updater-fallback.ts index 62ec3ff3b8c..e108469acdd 100644 --- a/src/main/updater-fallback.ts +++ b/src/main/updater-fallback.ts @@ -51,6 +51,7 @@ export function statusesEqual(left: UpdateStatus, right: UpdateStatus): boolean left.message === right.message && left.version === right.version && left.retryable === right.retryable && + left.retryAction === right.retryAction && left.userInitiated === right.userInitiated && left.activeNudgeId === right.activeNudgeId && // Recovery identity fences async actions, so same-valued recaptures must reach the renderer. diff --git a/src/main/updater-linux-package-recovery-actions.test.ts b/src/main/updater-linux-package-recovery-actions.test.ts index 9a546dd8fac..60912443180 100644 --- a/src/main/updater-linux-package-recovery-actions.test.ts +++ b/src/main/updater-linux-package-recovery-actions.test.ts @@ -38,7 +38,13 @@ const { } } return { - appMock: { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: vi.fn(), quit: vi.fn() }, + appMock: { + isPackaged: true, + getVersion: vi.fn(() => '1.0.51'), + on: vi.fn(), + prependListener: vi.fn(), + quit: vi.fn() + }, autoUpdaterMock, clearTrackedLinuxPackageArtifactMock: vi.fn(), getTrackedLinuxPackageArtifactMock: vi.fn(), diff --git a/src/main/updater-mac-install.ts b/src/main/updater-mac-install.ts index 234cdc0b2c4..ee45db2d0f4 100644 --- a/src/main/updater-mac-install.ts +++ b/src/main/updater-mac-install.ts @@ -34,11 +34,17 @@ export function registerMacUpdaterEvents({ }) } - app.on('before-quit', (event) => { + // Why: veto before startup listeners begin shutting down services. + app.prependListener('before-quit', (event) => { if (!shouldDeferMacQuitForInstall()) { return } - if (consumeMacInstallGuardBypass()) { + // Why: an Update & Restart is checking blockers or cleaning up; a second quit must not tear down underneath it. + if (macInstallPreflightInProgress) { + event.preventDefault() + return + } + if (shouldBypassMacInstallGuard()) { recordUpdaterLifecycle('macos_before_quit_guard_bypassed') return } @@ -50,7 +56,8 @@ export function registerMacUpdaterEvents({ getCurrentStatus(), hasInstallableDownloadedVersion(), getPendingInstallVersion, - sendStatus + sendStatus, + 'quit' ) ) { recordUpdaterLifecycle('macos_before_quit_deferred', { @@ -63,16 +70,23 @@ export function registerMacUpdaterEvents({ /** Whether Squirrel.Mac has finished downloading the update from the localhost proxy. */ let squirrelReady = false +let macInstallPreflightInProgress = false + +export function setMacInstallPreflightInProgress(value: boolean): void { + macInstallPreflightInProgress = value + if (value) { + bypassMacInstallGuardUntilNextAttempt = false + } +} /** Remembers a user/app quit request that arrived before Squirrel.Mac had a * staged update ready to apply. Without this handoff, quitting during the * localhost-proxy phase exits back into the old app and the update is lost. */ -let installRequestedAfterSquirrelReady = false +let requestedActionAfterSquirrelReady: 'quit' | 'install' | null = null /** Prevents the updater-specific before-quit guard from re-blocking the * quitAndInstall-triggered shutdown that is supposed to apply the update. */ let quitAndInstallInFlight = false -/** Lets a timed-out quit attempt proceed exactly once so the app never gets - * trapped open if Squirrel.Mac stops short of the native ready signal. */ -let bypassMacInstallGuardOnce = false +/** Both quit passes must proceed when native readiness times out. */ +let bypassMacInstallGuardUntilNextAttempt = false let pendingInstallTimeout: ReturnType | null = null function clearPendingInstallTimeout(): void { @@ -83,9 +97,10 @@ function clearPendingInstallTimeout(): void { } export function resetMacInstallState(): void { - installRequestedAfterSquirrelReady = false + macInstallPreflightInProgress = false + requestedActionAfterSquirrelReady = null quitAndInstallInFlight = false - bypassMacInstallGuardOnce = false + bypassMacInstallGuardUntilNextAttempt = false clearPendingInstallTimeout() } @@ -95,18 +110,18 @@ export function beginMacUpdateDownload(): void { } export function markMacQuitAndInstallInFlight(): void { - installRequestedAfterSquirrelReady = false + requestedActionAfterSquirrelReady = null quitAndInstallInFlight = true - bypassMacInstallGuardOnce = false + bypassMacInstallGuardUntilNextAttempt = false clearPendingInstallTimeout() } -export function consumeMacInstallGuardBypass(): boolean { - if (!bypassMacInstallGuardOnce) { - return false - } - bypassMacInstallGuardOnce = false - return true +function shouldBypassMacInstallGuard(): boolean { + return bypassMacInstallGuardUntilNextAttempt +} + +export function isMacInstallRequested(): boolean { + return requestedActionAfterSquirrelReady === 'install' } export function isMacQuitAndInstallInFlight(): boolean { @@ -135,13 +150,20 @@ export function deferMacQuitUntilInstallerReady( currentStatus: UpdateStatus, hasNewerDownloadedVersion: boolean, getPendingInstallVersion: () => string, - sendStatus: (status: UpdateStatus) => void + sendStatus: (status: UpdateStatus) => void, + intent: 'quit' | 'install' = 'install' ): boolean { if (!isWaitingForMacInstallerReadiness(currentStatus, hasNewerDownloadedVersion)) { return false } - installRequestedAfterSquirrelReady = true + if (intent === 'install') { + bypassMacInstallGuardUntilNextAttempt = false + } + // Why: an ordinary retry of quit must not downgrade a pending Update & Restart. + if (intent === 'install' || requestedActionAfterSquirrelReady !== 'install') { + requestedActionAfterSquirrelReady = intent + } sendStatus({ state: 'downloading', percent: 100, version: getPendingInstallVersion() }) if (pendingInstallTimeout) { @@ -150,7 +172,7 @@ export function deferMacQuitUntilInstallerReady( pendingInstallTimeout = setTimeout(() => { pendingInstallTimeout = null - if (!installRequestedAfterSquirrelReady || quitAndInstallInFlight) { + if (!requestedActionAfterSquirrelReady || quitAndInstallInFlight) { return } @@ -162,11 +184,11 @@ export function deferMacQuitUntilInstallerReady( message: `macOS installer was not ready after ${MAC_INSTALL_READY_TIMEOUT_MS}ms; allowing quit without install` } ) - installRequestedAfterSquirrelReady = false + requestedActionAfterSquirrelReady = null // This is a safety valve. The updater path should wait for ShipIt so the // staged update can apply, but if the native ready signal never arrives we // must let the app close instead of trapping the user in a blocked quit. - bypassMacInstallGuardOnce = true + bypassMacInstallGuardUntilNextAttempt = true app.quit() }, MAC_INSTALL_READY_TIMEOUT_MS) @@ -181,14 +203,24 @@ export function handleMacInstallerReady( squirrelReady = true clearPendingInstallTimeout() recordUpdaterLifecycle('macos_installer_ready', { - deferredInstallRequested: installRequestedAfterSquirrelReady, + deferredInstallRequested: requestedActionAfterSquirrelReady === 'install', + deferredQuitRequested: requestedActionAfterSquirrelReady === 'quit', hasNewerDownloadedVersion }) - if (installRequestedAfterSquirrelReady && hasNewerDownloadedVersion) { + if (requestedActionAfterSquirrelReady === 'quit') { + requestedActionAfterSquirrelReady = null + app.quit() + return + } + + if (requestedActionAfterSquirrelReady === 'install' && hasNewerDownloadedVersion) { + setMacInstallPreflightInProgress(true) void Promise.resolve() .then(() => onReadyToInstall()) .catch((error) => { + requestedActionAfterSquirrelReady = null + setMacInstallPreflightInProgress(false) recordUpdaterLifecycle( 'macos_deferred_install_handoff_failed', { errorType: error instanceof Error ? error.name : typeof error }, @@ -198,6 +230,7 @@ export function handleMacInstallerReady( return } + requestedActionAfterSquirrelReady = null if (hasNewerDownloadedVersion) { onReadyToReportDownloaded() } diff --git a/src/main/updater-mac-quit-guard.test.ts b/src/main/updater-mac-quit-guard.test.ts new file mode 100644 index 00000000000..2819b328c8a --- /dev/null +++ b/src/main/updater-mac-quit-guard.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { appMock, nativeUpdaterMock } = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + return { + appMock: Object.assign(new EventEmitter(), { quit: vi.fn() }), + nativeUpdaterMock: new EventEmitter() + } +}) + +vi.mock('electron', () => ({ app: appMock, autoUpdater: nativeUpdaterMock })) +vi.mock('./updater-lifecycle-diagnostics', () => ({ recordUpdaterLifecycle: vi.fn() })) + +import { + beginMacUpdateDownload, + deferMacQuitUntilInstallerReady, + handleMacInstallerReady, + isMacInstallRequested, + markMacQuitAndInstallInFlight, + registerMacUpdaterEvents, + resetMacInstallState, + setMacInstallPreflightInProgress +} from './updater-mac-install' + +function quitEvent(): { defaultPrevented: boolean; preventDefault: () => void } { + const event = { + defaultPrevented: false, + preventDefault: () => { + event.defaultPrevented = true + } + } + return event +} + +function registerGuard( + hasUpdate: boolean, + performQuitAndInstall = vi.fn(), + status: 'downloaded' | 'downloading' = 'downloaded' +): void { + registerMacUpdaterEvents({ + getCurrentStatus: () => + status === 'downloaded' + ? { state: 'downloaded', version: '2.0.0' } + : { state: 'downloading', percent: 100, version: '2.0.0' }, + hasInstallableDownloadedVersion: () => hasUpdate, + getPendingInstallVersion: () => '2.0.0', + getKnownReleaseUrl: () => undefined, + performQuitAndInstall, + shouldDeferMacQuitForInstall: () => true, + sendStatus: vi.fn() + }) +} + +describe.runIf(process.platform === 'darwin')('macOS quit guard ordering', () => { + beforeEach(() => { + appMock.removeAllListeners() + nativeUpdaterMock.removeAllListeners() + beginMacUpdateDownload() + appMock.quit.mockReset() + }) + + afterEach(() => { + resetMacInstallState() + vi.useRealTimers() + }) + + it('resumes an ordinary quit after native readiness without starting a relaunching install', () => { + const install = vi.fn() + registerGuard(true, install, 'downloading') + const firstQuit = quitEvent() + appMock.emit('before-quit', firstQuit) + expect(firstQuit.defaultPrevented).toBe(true) + + nativeUpdaterMock.emit('update-downloaded') + + expect(appMock.quit).toHaveBeenCalledOnce() + expect(install).not.toHaveBeenCalled() + for (let pass = 0; pass < 2; pass++) { + const resumedQuit = quitEvent() + appMock.emit('before-quit', resumedQuit) + expect(resumedQuit.defaultPrevented).toBe(false) + } + }) + + it('keeps an explicit deferred install when an ordinary quit arrives afterward', async () => { + const install = vi.fn() + registerGuard(true, install, 'downloading') + expect( + deferMacQuitUntilInstallerReady( + { state: 'downloading', percent: 100, version: '2.0.0' }, + true, + () => '2.0.0', + vi.fn() + ) + ).toBe(true) + const ordinaryQuit = quitEvent() + appMock.emit('before-quit', ordinaryQuit) + expect(ordinaryQuit.defaultPrevented).toBe(true) + nativeUpdaterMock.emit('update-downloaded') + const quitBeforeInstallCallback = quitEvent() + appMock.emit('before-quit', quitBeforeInstallCallback) + expect(quitBeforeInstallCallback.defaultPrevented).toBe(true) + await Promise.resolve() + + expect(install).toHaveBeenCalledOnce() + expect(appMock.quit).not.toHaveBeenCalled() + }) + + it('releases the requested install when readiness has no installable version', () => { + const install = vi.fn() + registerGuard(true, install, 'downloading') + deferMacQuitUntilInstallerReady( + { state: 'downloading', percent: 100, version: '2.0.0' }, + true, + () => '2.0.0', + vi.fn() + ) + expect(isMacInstallRequested()).toBe(true) + handleMacInstallerReady(false, install, vi.fn()) + expect(isMacInstallRequested()).toBe(false) + expect(install).not.toHaveBeenCalled() + }) + + it('releases the readiness handoff guard when the install callback rejects', async () => { + registerGuard(true, vi.fn(), 'downloading') + deferMacQuitUntilInstallerReady( + { state: 'downloading', percent: 100, version: '2.0.0' }, + true, + () => '2.0.0', + vi.fn() + ) + handleMacInstallerReady( + true, + () => { + throw new Error('handoff rejected') + }, + vi.fn() + ) + await Promise.resolve() + await Promise.resolve() + expect(isMacInstallRequested()).toBe(false) + const ordinaryQuit = quitEvent() + appMock.emit('before-quit', ordinaryQuit) + expect(ordinaryQuit.defaultPrevented).toBe(false) + }) + + it('allows both timeout shutdown passes and revokes that allowance for a new deferred install', async () => { + vi.useFakeTimers() + const install = vi.fn() + registerGuard(true, install, 'downloading') + appMock.emit('before-quit', quitEvent()) + await vi.advanceTimersByTimeAsync(15_000) + expect(appMock.quit).toHaveBeenCalledOnce() + for (let pass = 0; pass < 2; pass++) { + const timeoutQuit = quitEvent() + appMock.emit('before-quit', timeoutQuit) + expect(timeoutQuit.defaultPrevented).toBe(false) + } + + deferMacQuitUntilInstallerReady( + { state: 'downloading', percent: 100, version: '2.0.0' }, + true, + () => '2.0.0', + vi.fn() + ) + const retryQuit = quitEvent() + appMock.emit('before-quit', retryQuit) + expect(retryQuit.defaultPrevented).toBe(true) + nativeUpdaterMock.emit('update-downloaded') + await Promise.resolve() + expect(install).toHaveBeenCalledOnce() + }) + + it('vetoes a quit during install preflight before previously registered startup services shut down', () => { + const shutdown = vi.fn() + appMock.on('before-quit', (event) => { + if (!event.defaultPrevented) { + shutdown() + } + }) + registerGuard(true) + handleMacInstallerReady(true, vi.fn(), vi.fn()) + setMacInstallPreflightInProgress(true) + const event = quitEvent() + + appMock.emit('before-quit', event) + + expect(event.defaultPrevented).toBe(true) + expect(shutdown).not.toHaveBeenCalled() + }) + + it('lets an ordinary quit with a staged update exit instead of converting it into a relaunching install', () => { + // Why: restart flows call app.relaunch() then app.quit(); converting that quit into + // quitAndInstall would race the relaunched old app against ShipIt. + const install = vi.fn() + registerGuard(true, install) + handleMacInstallerReady(true, vi.fn(), vi.fn()) + const event = quitEvent() + + appMock.emit('before-quit', event) + + expect(event.defaultPrevented).toBe(false) + expect(install).not.toHaveBeenCalled() + }) + + it('vetoes duplicate quits through cleanup and allows the native install shutdown', () => { + registerGuard(true) + handleMacInstallerReady(true, vi.fn(), vi.fn()) + setMacInstallPreflightInProgress(true) + markMacQuitAndInstallInFlight() + for (let attempt = 0; attempt < 2; attempt++) { + const event = quitEvent() + appMock.emit('before-quit', event) + expect(event.defaultPrevented).toBe(true) + } + + setMacInstallPreflightInProgress(false) + const nativeQuit = quitEvent() + appMock.emit('before-quit', nativeQuit) + expect(nativeQuit.defaultPrevented).toBe(false) + }) + + it('allows both ordinary quit passes after refusal and vetoes a new install attempt', () => { + const install = vi.fn() + registerGuard(true, install) + handleMacInstallerReady(true, vi.fn(), vi.fn()) + resetMacInstallState() + const normalQuit = quitEvent() + appMock.emit('before-quit', normalQuit) + expect(normalQuit.defaultPrevented).toBe(false) + expect(install).not.toHaveBeenCalled() + + const teardownQuit = quitEvent() + appMock.emit('before-quit', teardownQuit) + expect(teardownQuit.defaultPrevented).toBe(false) + expect(install).not.toHaveBeenCalled() + + setMacInstallPreflightInProgress(true) + const retryQuit = quitEvent() + appMock.emit('before-quit', retryQuit) + expect(retryQuit.defaultPrevented).toBe(true) + expect(install).not.toHaveBeenCalled() + }) + + it('allows ordinary quits when no update is available', () => { + const install = vi.fn() + registerGuard(false, install) + const event = quitEvent() + appMock.emit('before-quit', event) + expect(event.defaultPrevented).toBe(false) + expect(install).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/updater-test-harness.ts b/src/main/updater-test-harness.ts index 83379b7ac9b..955ae25058a 100644 --- a/src/main/updater-test-harness.ts +++ b/src/main/updater-test-harness.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { afterAll, vi } from 'vitest' import type { Mock } from 'vitest' import { clearTrackedRealTimers, trackRealTimers } from './updater-test-timer-tracking' @@ -28,6 +29,7 @@ type AppMock = { isPackaged: boolean getVersion: Mock<() => string> on: Mock<(event: string, handler: (...args: unknown[]) => void) => AppMock> + prependListener: Mock<(event: string, handler: (...args: unknown[]) => void) => AppMock> emit: (event: string, ...args: unknown[]) => void quit: UpdaterSpy } @@ -102,33 +104,25 @@ export const PRE_COMMIT_INSTALL_FAILURE = * `vi.hoisted` block so the mocks exist before the mock factories run. */ export function createUpdaterMocks(): UpdaterMocks { - const appEventHandlers = new Map void)[]>() - const eventHandlers = new Map void)[]>() - + const appEvents = new EventEmitter() + const updaterEvents = new EventEmitter() const appOn = vi.fn((event: string, handler: (...args: unknown[]) => void) => { - const handlers = appEventHandlers.get(event) ?? [] - handlers.push(handler) - appEventHandlers.set(event, handlers) + appEvents.on(event, handler) return appMock }) - - const appEmit = (event: string, ...args: unknown[]) => { - for (const handler of appEventHandlers.get(event) ?? []) { - handler(...args) - } + const appPrependListener = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + appEvents.prependListener(event, handler) + return appMock + }) + const appEmit = (event: string, ...args: unknown[]): void => { + appEvents.emit(event, ...args) } - const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { - const handlers = eventHandlers.get(event) ?? [] - handlers.push(handler) - eventHandlers.set(event, handlers) + updaterEvents.on(event, handler) return autoUpdaterMock }) - - const emit = (event: string, ...args: unknown[]) => { - for (const handler of eventHandlers.get(event) ?? []) { - handler(...args) - } + const emit = (event: string, ...args: unknown[]): void => { + updaterEvents.emit(event, ...args) } // Why: `vi.resetModules()` abandons the previous test's `updater` module instance but cannot cancel @@ -161,9 +155,10 @@ export function createUpdaterMocks(): UpdaterMocks { const reset = () => { currentGeneration += 1 - appEventHandlers.clear() + appEvents.removeAllListeners() appOn.mockClear() - eventHandlers.clear() + appPrependListener.mockClear() + updaterEvents.removeAllListeners() on.mockClear() autoUpdaterMock.checkForUpdates.mockReset().mockResolvedValue(null) autoUpdaterMock.downloadUpdate.mockReset() @@ -201,6 +196,7 @@ export function createUpdaterMocks(): UpdaterMocks { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: appOn, + prependListener: appPrependListener, emit: appEmit, quit: vi.fn() } diff --git a/src/main/updater.check-failure.test.ts b/src/main/updater.check-failure.test.ts index 62d228bf3a7..5093052290f 100644 --- a/src/main/updater.check-failure.test.ts +++ b/src/main/updater.check-failure.test.ts @@ -20,6 +20,13 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock, return appMock }) + const appPrependListener = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + const handlers = appEventHandlers.get(event) ?? [] + handlers.unshift(handler) + appEventHandlers.set(event, handlers) + return appMock + }) + const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { const handlers = eventHandlers.get(event) ?? [] handlers.push(handler) @@ -36,6 +43,7 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock, const reset = () => { appEventHandlers.clear() appOn.mockClear() + appPrependListener.mockClear() eventHandlers.clear() on.mockClear() autoUpdaterMock.checkForUpdates.mockReset() @@ -61,6 +69,7 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock, isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: appOn, + prependListener: appPrependListener, quit: vi.fn() }, browserWindowMock: { diff --git a/src/main/updater.check-preflight.test.ts b/src/main/updater.check-preflight.test.ts index 6b2618aad05..5e1476e8fb9 100644 --- a/src/main/updater.check-preflight.test.ts +++ b/src/main/updater.check-preflight.test.ts @@ -4,6 +4,7 @@ import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-load const { appMock, autoUpdaterMock, + nativeUpdaterMock, fetchChangelogMock, fetchNewerReleaseTagsMock, moduleFactories, @@ -31,6 +32,93 @@ describe('updater', () => { resetUpdaterMocks() }) + it('keeps the staged target when installation cancels a queued background feed check', async () => { + vi.useFakeTimers() + let resolveQueuedTags: (value: { tags: string[]; state: 'ready' }) => void = () => {} + fetchNewerReleaseTagsMock + .mockResolvedValueOnce({ tags: ['v1.0.61'], state: 'ready' }) + .mockImplementationOnce( + () => + new Promise<{ tags: string[]; state: 'ready' }>((resolve) => { + resolveQueuedTags = resolve + }) + ) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + autoUpdaterMock.downloadUpdate.mockResolvedValue([]) + let rejectCleanup: (error: Error) => void = () => {} + const onBeforeQuit = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectCleanup = reject + }) + ) + const send = vi.fn() + const { + setupAutoUpdater, + checkForUpdatesFromMenu, + checkForUpdates, + downloadUpdate, + quitAndInstall, + getUpdateStatus + } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater reads only webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now(), + onBeforeQuit, + onBeforeQuitFailure: 'abort' + }) + checkForUpdatesFromMenu() + await vi.waitFor(() => expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce()) + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + await vi.advanceTimersByTimeAsync(0) + downloadUpdate() + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + const nativeReady = nativeUpdaterMock.on.mock.calls.find( + ([event]) => event === 'update-downloaded' + )?.[1] + if (typeof nativeReady === 'function') { + nativeReady() + } + expect(getUpdateStatus()).toEqual( + expect.objectContaining({ + state: 'downloaded', + version: '1.0.61' + }) + ) + const stagedFeed = autoUpdaterMock.setFeedURL.mock.calls.at(-1) + + checkForUpdates() + await vi.waitFor(() => expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(2)) + quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + expect(onBeforeQuit).toHaveBeenCalledOnce() + resolveQueuedTags({ tags: ['v1.0.71'], state: 'ready' }) + await vi.advanceTimersByTimeAsync(1000) + + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce() + expect(autoUpdaterMock.setFeedURL.mock.calls.at(-1)).toEqual(stagedFeed) + expect(getUpdateStatus()).toEqual( + expect.objectContaining({ + state: 'downloaded', + version: '1.0.61' + }) + ) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + + rejectCleanup(new Error('required checkpoint failed')) + await vi.advanceTimersByTimeAsync(0) + expect(getUpdateStatus().state).toBe('error') + autoUpdaterMock.downloadUpdate.mockClear() + downloadUpdate() + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledOnce() + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'downloading', + percent: 0, + version: '1.0.61' + }) + }) + it('ignores stale updater events while a new check is still in feed preflight', async () => { vi.useFakeTimers() let resolveSecondTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {} diff --git a/src/main/updater.fallback.test.ts b/src/main/updater.fallback.test.ts index 767fef421e3..0b2912bcced 100644 --- a/src/main/updater.fallback.test.ts +++ b/src/main/updater.fallback.test.ts @@ -101,6 +101,7 @@ describe('statusesEqual', () => { expect(statusesEqual(error, { ...error, version: '1.0.62' })).toBe(false) expect(statusesEqual(error, { ...error, retryable: true })).toBe(false) + expect(statusesEqual(error, { ...error, retryAction: 'install' })).toBe(false) expect(statusesEqual(error, { ...error })).toBe(true) }) }) diff --git a/src/main/updater.headless-serve-install.test.ts b/src/main/updater.headless-serve-install.test.ts index bae6474cc66..ebf7fb6269b 100644 --- a/src/main/updater.headless-serve-install.test.ts +++ b/src/main/updater.headless-serve-install.test.ts @@ -31,6 +31,10 @@ const { appHandlers.set(event, [...(appHandlers.get(event) ?? []), handler]) return appMock }), + prependListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + appHandlers.set(event, [handler, ...(appHandlers.get(event) ?? [])]) + return appMock + }), emit: (event: string, ...args: unknown[]) => emit(appHandlers, event, ...args), quit: vi.fn() } diff --git a/src/main/updater.install-failure-cause.test.ts b/src/main/updater.install-failure-cause.test.ts index bef63d6912a..95b539ca00e 100644 --- a/src/main/updater.install-failure-cause.test.ts +++ b/src/main/updater.install-failure-cause.test.ts @@ -23,6 +23,13 @@ const { return appMock }) + const appPrependListener = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + const handlers = appEventHandlers.get(event) ?? [] + handlers.unshift(handler) + appEventHandlers.set(event, handlers) + return appMock + }) + const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { const handlers = eventHandlers.get(event) ?? [] handlers.push(handler) @@ -39,6 +46,7 @@ const { const reset = () => { appEventHandlers.clear() appOn.mockClear() + appPrependListener.mockClear() eventHandlers.clear() on.mockClear() autoUpdaterMock.checkForUpdates.mockReset() @@ -64,6 +72,7 @@ const { isPackaged: true, getVersion: vi.fn(() => '1.4.162'), on: appOn, + prependListener: appPrependListener, quit: vi.fn(), exit: vi.fn() }, diff --git a/src/main/updater.mac-install.test.ts b/src/main/updater.mac-install.test.ts index 563b8562fd6..a3faef056ef 100644 --- a/src/main/updater.mac-install.test.ts +++ b/src/main/updater.mac-install.test.ts @@ -8,7 +8,8 @@ const { autoUpdaterMock, shellMock, isMock, - killAllPtyMock + killAllPtyMock, + getMacUpdateRunningInstancesMock } = vi.hoisted(() => { const appEventHandlers = new Map void)[]>() const eventHandlers = new Map void)[]>() @@ -66,6 +67,9 @@ const { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: appOn, + prependListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + appEventHandlers.set(event, [handler, ...(appEventHandlers.get(event) ?? [])]) + }), emit: appEmit, quit: vi.fn() }, @@ -80,6 +84,7 @@ const { openExternal: vi.fn() }, isMock: { dev: false }, + getMacUpdateRunningInstancesMock: vi.fn(async (): Promise => []), killAllPtyMock: vi.fn() } }) @@ -118,8 +123,25 @@ vi.mock('./updater-nudge', () => ({ shouldApplyNudge: vi.fn().mockReturnValue(false) })) +vi.mock('./macos-update-running-instances', () => ({ + getMacUpdateRunningInstances: getMacUpdateRunningInstancesMock +})) + warmUpdaterModule() +async function prepareStagedMacUpdate(downloadUpdate: () => void): Promise { + await vi.waitFor(() => expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)) + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + await vi.advanceTimersByTimeAsync(0) + downloadUpdate() + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + const nativeReady = nativeUpdaterMock.on.mock.calls.find( + ([event]) => event === 'update-downloaded' + )?.[1] + nativeReady?.() +} + describe('updater mac install handoff', () => { beforeEach(() => { vi.resetModules() @@ -134,18 +156,21 @@ describe('updater mac install handoff', () => { appMock.isPackaged = true isMock.dev = false killAllPtyMock.mockReset() + getMacUpdateRunningInstancesMock.mockReset().mockResolvedValue([]) autoUpdaterMock.downloadUpdate.mockResolvedValue([]) vi.unstubAllGlobals() vi.useRealTimers() }) it.runIf(process.platform === 'darwin')( - 'waits for Squirrel.Mac before honoring a manual quit that should install the update', + 'resumes an ordinary quit when Squirrel becomes ready without checking blockers or relaunching', async () => { + vi.useFakeTimers() const sendMock = vi.fn() const mainWindow = { webContents: { send: sendMock } } autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) const { setupAutoUpdater, downloadUpdate } = await loadUpdaterModule() setupAutoUpdater(mainWindow as never) @@ -156,7 +181,7 @@ describe('updater mac install handoff', () => { autoUpdaterMock.emit('update-available', { version: '1.0.61' }) // Why: the update-available handler is now async (it awaits fetchChangelog). // Flush microtasks so setAvailableVersion runs before update-downloaded fires. - await new Promise((r) => setTimeout(r, 0)) + await vi.advanceTimersByTimeAsync(0) downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) @@ -173,9 +198,14 @@ describe('updater mac install handoff', () => { nativeDownloadedHandler?.() - await vi.waitFor(() => { - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(false, true) - }) + await vi.advanceTimersByTimeAsync(0) + expect(appMock.quit).toHaveBeenCalledOnce() + expect(getMacUpdateRunningInstancesMock).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + const resumedPreventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault: resumedPreventDefault }) + appMock.emit('before-quit', { preventDefault: resumedPreventDefault }) + expect(resumedPreventDefault).not.toHaveBeenCalled() expect(sendMock).toHaveBeenCalledWith('updater:status', { state: 'downloading', percent: 100, @@ -184,6 +214,60 @@ describe('updater mac install handoff', () => { } ) + it.runIf(process.platform === 'darwin')( + 'checks blockers for an explicit install requested before Squirrel becomes ready', + async () => { + vi.useFakeTimers() + const send = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) + const { + setupAutoUpdater, + downloadUpdate, + quitAndInstall, + isQuittingForUpdate, + checkForUpdates, + checkForUpdatesFromMenu, + getUpdateStatus + } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater reads only webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send } } as never) + await vi.waitFor(() => expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce()) + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + await vi.advanceTimersByTimeAsync(0) + downloadUpdate() + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + quitAndInstall() + checkForUpdatesFromMenu() + checkForUpdatesFromMenu({ localBuild: true }) + checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'v1.0.70' }) + checkForUpdates() + downloadUpdate() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce() + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledOnce() + expect(getUpdateStatus()).toEqual( + expect.objectContaining({ state: 'downloading', percent: 100, version: '1.0.61' }) + ) + const nativeReady = nativeUpdaterMock.on.mock.calls.find( + ([event]) => event === 'update-downloaded' + )?.[1] + nativeReady?.() + await vi.advanceTimersByTimeAsync(0) + + expect(getMacUpdateRunningInstancesMock).toHaveBeenCalledOnce() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(appMock.quit).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ state: 'error', version: '1.0.61', retryAction: 'install' }) + ) + } + ) + it.runIf(process.platform === 'darwin')( 'ignores duplicate quit requests while deferred mac install cleanup is running', async () => { @@ -211,6 +295,7 @@ describe('updater mac install handoff', () => { downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + quitAndInstall() const preventDefault = vi.fn() appMock.emit('before-quit', { preventDefault }) expect(preventDefault).toHaveBeenCalledTimes(1) @@ -224,6 +309,7 @@ describe('updater mac install handoff', () => { await vi.advanceTimersByTimeAsync(0) expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(getMacUpdateRunningInstancesMock).toHaveBeenCalledOnce() expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() quitAndInstall() @@ -309,7 +395,305 @@ describe('updater mac install handoff', () => { const secondPreventDefault = vi.fn() appMock.emit('before-quit', { preventDefault: secondPreventDefault }) expect(secondPreventDefault).not.toHaveBeenCalled() + appMock.emit('before-quit', { preventDefault: secondPreventDefault }) + expect(secondPreventDefault).not.toHaveBeenCalled() expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() } ) + + it.runIf(process.platform === 'darwin')( + 'keeps the app and terminals open when another bundle instance blocks installation, then allows retry', + async () => { + vi.useFakeTimers() + const send = vi.fn() + const onBeforeQuit = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall, isQuittingForUpdate } = + await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654, 10718]) + + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + + expect(onBeforeQuit).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'error', + message: expect.stringContaining('9654, 10718'), + retryAction: 'install' + }) + ) + + getMacUpdateRunningInstancesMock.mockResolvedValue([]) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + } + ) + + it.runIf(process.platform === 'darwin')( + 'keeps the app open when process enumeration fails', + async () => { + vi.useFakeTimers() + const send = vi.fn() + const onBeforeQuit = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall, isQuittingForUpdate } = + await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockRejectedValue(new Error('probe failed')) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + expect(onBeforeQuit).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'error', + message: expect.stringContaining('Could not check'), + retryAction: 'install' + }) + ) + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + message: expect.stringMatching(/close the other Orca instances.*quit Orca/i) + }) + ) + } + ) + + it.runIf(process.platform === 'darwin')( + 'leaves an ordinary quit with a staged update to Squirrel instead of starting a relaunching install', + async () => { + vi.useFakeTimers() + const onBeforeQuit = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + appMock.emit('before-quit', { preventDefault }) + await vi.advanceTimersByTimeAsync(0) + expect(preventDefault).not.toHaveBeenCalled() + expect(getMacUpdateRunningInstancesMock).not.toHaveBeenCalled() + expect(onBeforeQuit).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + } + ) + + it.runIf(process.platform === 'darwin')( + 'blocks duplicate quits while the running-instance check is pending', + async () => { + vi.useFakeTimers() + const onBeforeQuit = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + + let finishProbe: (pids: number[]) => void = () => {} + getMacUpdateRunningInstancesMock.mockImplementation( + () => + new Promise((resolve) => { + finishProbe = resolve + }) + ) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledTimes(2) + expect(getMacUpdateRunningInstancesMock).toHaveBeenCalledTimes(1) + finishProbe([9654]) + await vi.advanceTimersByTimeAsync(0) + expect(onBeforeQuit).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + } + ) + + it.runIf(process.platform === 'darwin')( + 'allows ordinary quitting after a refused install without requiring background servers to exit', + async () => { + vi.useFakeTimers() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).not.toHaveBeenCalled() + // The main will-quit handler requests quit again after asynchronous teardown. + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + } + ) + + it.runIf(process.platform === 'darwin')( + 'prevents duplicate quits until asynchronous update cleanup finishes', + async () => { + vi.useFakeTimers() + let finishCleanup = (): void => {} + const onBeforeQuit = vi.fn( + () => + new Promise((resolve) => { + finishCleanup = resolve + }) + ) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + finishCleanup() + await vi.advanceTimersByTimeAsync(0) + const nativePreventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault: nativePreventDefault }) + expect(nativePreventDefault).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + } + ) + + it.runIf(process.platform === 'darwin')( + 'cannot use a previous refusal to bypass a pending retry check', + async () => { + vi.useFakeTimers() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + let finishProbe = (_pids: number[]): void => {} + getMacUpdateRunningInstancesMock.mockImplementation( + () => + new Promise((resolve) => { + finishProbe = resolve + }) + ) + quitAndInstall() + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1000) + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledTimes(2) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + finishProbe([]) + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledOnce() + } + ) + + it.runIf(process.platform === 'darwin')( + 'permits ordinary shutdown after required update cleanup rejects', + async () => { + vi.useFakeTimers() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const onBeforeQuit = vi.fn().mockRejectedValue(new Error('required cleanup failed')) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { + onBeforeQuit, + onBeforeQuitFailure: 'abort' + }) + await prepareStagedMacUpdate(downloadUpdate) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).not.toHaveBeenCalled() + expect(onBeforeQuit).toHaveBeenCalledOnce() + } + ) + + it.runIf(process.platform === 'darwin')( + 'keeps the install target and quit guard when checks or downloads are requested during a retry', + async () => { + vi.useFakeTimers() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { + setupAutoUpdater, + downloadUpdate, + quitAndInstall, + checkForUpdates, + checkForUpdatesFromMenu, + getUpdateStatus + } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + let finishProbe = (_pids: number[]): void => {} + getMacUpdateRunningInstancesMock.mockImplementation( + () => + new Promise((resolve) => { + finishProbe = resolve + }) + ) + const failedStatus = getUpdateStatus() + const checks = autoUpdaterMock.checkForUpdates.mock.calls.length + const downloads = autoUpdaterMock.downloadUpdate.mock.calls.length + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + return Promise.resolve(undefined) + }) + const requestCompetingActions = (): void => { + checkForUpdatesFromMenu() + checkForUpdatesFromMenu({ localBuild: true }) + checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'v1.0.70' }) + checkForUpdates() + downloadUpdate() + } + quitAndInstall() + requestCompetingActions() + await vi.advanceTimersByTimeAsync(1000) + requestCompetingActions() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(checks) + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(downloads) + expect(getUpdateStatus()).toEqual(failedStatus) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledOnce() + finishProbe([]) + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledOnce() + } + ) }) diff --git a/src/main/updater/updater-download-install.ts b/src/main/updater/updater-download-install.ts index 455e56e6efa..8b672eedbbd 100644 --- a/src/main/updater/updater-download-install.ts +++ b/src/main/updater/updater-download-install.ts @@ -1,4 +1,9 @@ -import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady } from '../updater-mac-install' +import { + beginMacUpdateDownload, + deferMacQuitUntilInstallerReady, + isMacInstallRequested, + setMacInstallPreflightInProgress +} from '../updater-mac-install' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' import { isExternallyManagedLinuxInstall } from '../linux-update-package-type' import { LINUX_PACKAGE_EXTERNALLY_MANAGED_MESSAGE } from '../linux-package-downloaded-status' @@ -12,7 +17,8 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress || this.pendingQuitAndInstallTimer || - this.quitAndInstallInProgress + this.quitAndInstallInProgress || + isMacInstallRequested() ) { return } @@ -20,6 +26,9 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { if (this.deferHeadlessServeInstall('install', this.getPendingInstallVersion())) { return } + // A queued check must not repoint the feed while native staging or installation is pending. + this.finishActiveUpdateCheckAttempt() + this.clearBackgroundCheckLaunchPending() if ( deferMacQuitUntilInstallerReady( this.currentStatus, @@ -31,6 +40,9 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { return } + if (process.platform === 'darwin') { + setMacInstallPreflightInProgress(true) + } // Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing. this.pendingQuitAndInstallTimer = setTimeout(() => { void this.performQuitAndInstall() @@ -41,6 +53,9 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { if ( this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress || + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() || this.downloadInFlight ) { return diff --git a/src/main/updater/updater-install-execution.ts b/src/main/updater/updater-install-execution.ts index 257f8e4fa93..036fab4e119 100644 --- a/src/main/updater/updater-install-execution.ts +++ b/src/main/updater/updater-install-execution.ts @@ -2,7 +2,12 @@ import { BrowserWindow } from 'electron' import { killAllPty } from '../ipc/pty' import { withUpdaterSpan } from '../observability/instrumentation' import { runWithLaunchPath } from '../startup/hydrate-shell-path' -import { markMacQuitAndInstallInFlight, isMacInstallerReady } from '../updater-mac-install' +import { + markMacQuitAndInstallInFlight, + isMacInstallerReady, + setMacInstallPreflightInProgress +} from '../updater-mac-install' +import { getMacUpdateRunningInstances } from '../macos-update-running-instances' import { armUpdateInstallExitWatchdog } from '../update-install-exit-watchdog' import { getLinuxPackageType } from '../linux-update-package-type' import { LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE } from '../linux-package-downloaded-status' @@ -51,14 +56,49 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { }) return } + this.finishActiveUpdateCheckAttempt() + this.clearBackgroundCheckLaunchPending() this.quitAndInstallInProgress = true - markMacQuitAndInstallInFlight() - // Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle. this.quittingForUpdate = true try { + if (process.platform === 'darwin') { + setMacInstallPreflightInProgress(true) + let blockers: number[] + try { + blockers = await getMacUpdateRunningInstances() + } catch { + this.resetQuitForUpdateState() + this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted') + this.sendInstallFailureStatus({ + state: 'error', + version: pendingVersion, + retryAction: 'install', + message: + 'Could not check for other running Orca instances. Orca remains open. Try again. If the check keeps failing, close the other Orca instances and background orca serve servers, then quit Orca to let the update install on exit. Reopen Orca afterwards.' + }) + recordUpdaterLifecycle('macos_running_instances_check_failed') + return + } + if (blockers.length > 0) { + this.resetQuitForUpdateState() + this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted') + this.sendInstallFailureStatus({ + state: 'error', + version: pendingVersion, + retryAction: 'install', + message: `Close the other Orca instances (process IDs: ${blockers.slice(0, 10).join(', ')}) before installing this update. Background orca serve instances also need to stop. Orca remains open; retry the update after closing them.` + }) + recordUpdaterLifecycle('macos_install_blocked_by_running_instances', { + pids: blockers.slice(0, 10).join(', '), + count: blockers.length + }) + return + } + } + markMacQuitAndInstallInFlight() await withUpdaterSpan({ stage: 'install' }, async (span) => { span.setAttribute('updater.version', pendingVersion || 'unknown') span.setAttribute('updater.platform', process.platform) @@ -104,6 +144,7 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { return } // Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure. + setMacInstallPreflightInProgress(false) this.quitAndInstallNativeInvoked = true // Why: invoke before killAllPty/removing close listeners so a sync 'error' can recover while windows and PTYs are intact. const supervisorOwnsRelaunch = this.updateInstallMode === 'supervised-headless-serve' diff --git a/src/main/updater/updater-menu-checks.ts b/src/main/updater/updater-menu-checks.ts index 5e5294f29dc..f6710d555b0 100644 --- a/src/main/updater/updater-menu-checks.ts +++ b/src/main/updater/updater-menu-checks.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import { is } from '@electron-toolkit/utils' +import { isMacInstallRequested } from '../updater-mac-install' import type { UpdateCheckOptions } from '../../shared/update-status-types' import type { ReleaseChannel } from '../../shared/release-channel' import { UpdaterScheduling } from './updater-scheduling' @@ -7,6 +8,13 @@ import { UpdaterScheduling } from './updater-scheduling' /** Handles checks initiated from the desktop menu and modifier-key variants. */ export abstract class UpdaterMenuChecks extends UpdaterScheduling { protected checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { + if ( + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() + ) { + return + } if (!app.isPackaged || is.dev) { this.sendStatus({ state: 'not-available', userInitiated: true }) return @@ -60,6 +68,13 @@ export abstract class UpdaterMenuChecks extends UpdaterScheduling { const attemptId = this.beginUpdateCheckAttempt() const autoUpdater = this.getAutoUpdater() const launch = (): Promise | undefined => { + if ( + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() + ) { + return undefined + } if (!this.isActiveUpdateCheckAttempt(attemptId)) { return undefined } diff --git a/src/main/updater/updater-scheduling.ts b/src/main/updater/updater-scheduling.ts index 954431385d0..70993c61ff2 100644 --- a/src/main/updater/updater-scheduling.ts +++ b/src/main/updater/updater-scheduling.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import { is } from '@electron-toolkit/utils' +import { isMacInstallRequested } from '../updater-mac-install' import { withUpdaterSpan } from '../observability/instrumentation' import { AUTO_UPDATE_CHECK_INTERVAL_MS, @@ -45,6 +46,13 @@ export abstract class UpdaterScheduling extends UpdaterCheckFailure { protected runBackgroundUpdateCheck( nudgeId: string | null = this.getPersistedPendingUpdateNudgeId() ): boolean { + if ( + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() + ) { + return false + } // Why: a pinned dev jump owns the feed until it settles; a background check would repoint it mid-flight and download the wrong build. if ( this.activeUpdateSource !== 'release' || @@ -69,6 +77,13 @@ export abstract class UpdaterScheduling extends UpdaterCheckFailure { const attemptId = this.beginUpdateCheckAttempt() const autoUpdater = this.getAutoUpdater() const launch = (): Promise | undefined => { + if ( + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() + ) { + return undefined + } if (!this.isActiveUpdateCheckAttempt(attemptId)) { return undefined } diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts index 960379a797d..791133de126 100644 --- a/src/main/window/dashboard-popout-window.test.ts +++ b/src/main/window/dashboard-popout-window.test.ts @@ -329,6 +329,33 @@ describe('createOrFocusDashboardPopout', () => { } }) + it('keeps saving bounds after a vetoed quit and freezes them only on an allowed quit', () => { + vi.useFakeTimers() + try { + const store = makeStore() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The window fixture reads getUI, updateUI, and onUIChanged supplied by this store. + createOrFocusDashboardPopout(store as never) + const win = instances[0] + const freeze = appOnMock.mock.calls.find(([event]) => event === 'before-quit')?.[1] + expect(freeze).toBeTypeOf('function') + freeze({ defaultPrevented: true }) + win.bounds = { x: 10, y: 20, width: 1200, height: 900 } + win.emit('resize') + vi.advanceTimersByTime(500) + expect(store.updateUI).toHaveBeenCalledWith({ + dashboardPopoutBounds: { x: 10, y: 20, width: 1200, height: 900 } + }) + + store.updateUI.mockClear() + freeze({ defaultPrevented: false }) + win.emit('resize') + vi.advanceTimersByTime(500) + expect(store.updateUI).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('closeDashboardPopout closes an open window', () => { createOrFocusDashboardPopout(makeStore() as never) const win = instances[0] diff --git a/src/main/window/dashboard-popout-window.ts b/src/main/window/dashboard-popout-window.ts index a6b082ac1ae..15e4964a46f 100644 --- a/src/main/window/dashboard-popout-window.ts +++ b/src/main/window/dashboard-popout-window.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, nativeTheme, type WebContents } from 'electron' +import { app, BrowserWindow, nativeTheme, type WebContents, type Event } from 'electron' import { join } from 'node:path' import { is } from '@electron-toolkit/utils' import type { Store } from '../persistence' @@ -264,7 +264,10 @@ export function createOrFocusDashboardPopout( window.on('resize', saveBounds) window.on('move', saveBounds) - const freezeBounds = (): void => { + const freezeBounds = (event?: Event): void => { + if (event?.defaultPrevented) { + return + } windowClosing = true if (boundsTimer) { clearTimeout(boundsTimer) diff --git a/src/main/window/main-window-state-lifecycle.test.ts b/src/main/window/main-window-state-lifecycle.test.ts new file mode 100644 index 00000000000..3015481033f --- /dev/null +++ b/src/main/window/main-window-state-lifecycle.test.ts @@ -0,0 +1,64 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const { appMock } = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + return { appMock: new EventEmitter() } +}) +vi.mock('electron', () => ({ app: appMock })) +vi.mock('./foreground-activation-policy', () => ({ + isWindowlessLaunch: () => true, + showWindowWithoutStealingFocus: vi.fn() +})) +vi.mock('./main-window-visual-lifecycle', () => ({ + MIN_WIDTH: 480, + MIN_HEIGHT: 360, + syncTrafficLightPosition: vi.fn() +})) + +import { installMainWindowStateLifecycle } from './main-window-state-lifecycle' + +beforeEach(() => { + vi.useFakeTimers() + appMock.removeAllListeners() +}) +afterEach(() => vi.useRealTimers()) + +it('continues saving bounds after an updater quit veto and freezes them on allowed quit', async () => { + const mainWindow = Object.assign(new EventEmitter(), { + webContents: Object.assign(new EventEmitter(), { + send: vi.fn(), + setZoomLevel: vi.fn() + }), + isDestroyed: () => false, + isFullScreen: () => false, + isMaximized: () => false, + getBounds: () => ({ x: 0, y: 0, width: 1200, height: 800 }) + }) + const updateUI = vi.fn() + const lifecycle = installMainWindowStateLifecycle({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture implements the window members read by the bounds lifecycle. + mainWindow: mainWindow as never, + revealOnDidFinishLoad: false, + savedMaximized: false, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Bounds persistence reads only updateUI from this store fixture. + store: { updateUI } as never + }) + appMock.emit('before-quit', { defaultPrevented: true }) + expect(lifecycle.isWindowClosing()).toBe(false) + mainWindow.emit('resize') + await vi.advanceTimersByTimeAsync(500) + expect(updateUI).toHaveBeenCalledWith({ + windowMaximized: false, + windowBounds: { x: 0, y: 0, width: 1200, height: 800 } + }) + + updateUI.mockClear() + appMock.emit('before-quit', { defaultPrevented: false }) + expect(lifecycle.isWindowClosing()).toBe(true) + mainWindow.emit('resize') + await vi.advanceTimersByTimeAsync(500) + expect(updateUI).not.toHaveBeenCalled() + lifecycle.clearInitialRevealFallbackTimer() + lifecycle.dispose() +}) diff --git a/src/main/window/main-window-state-lifecycle.ts b/src/main/window/main-window-state-lifecycle.ts index 2a5345bef50..21ce1b54bd3 100644 --- a/src/main/window/main-window-state-lifecycle.ts +++ b/src/main/window/main-window-state-lifecycle.ts @@ -1,4 +1,4 @@ -import { app, type BrowserWindow } from 'electron' +import { app, type BrowserWindow, type Event } from 'electron' import type { Store } from '../persistence' import { uiZoomFactorFromLevel } from '../../shared/ui-zoom-level' import { isWindowlessLaunch, showWindowWithoutStealingFocus } from './foreground-activation-policy' @@ -7,7 +7,7 @@ import { MIN_HEIGHT, MIN_WIDTH, syncTrafficLightPosition } from './main-window-v export type MainWindowStateLifecycle = { clearInitialRevealFallbackTimer: () => void dispose: () => void - freezeBoundsOnQuit: () => void + freezeBoundsOnQuit: (event?: Event) => void isWindowClosing: () => boolean resumeBoundsPersistence: () => void } @@ -105,7 +105,10 @@ export function installMainWindowStateLifecycle(args: { mainWindow.on('move', saveBounds) // Why: the auto-updater calls removeAllListeners('close') before quitting, so latch on app 'before-quit' too to freeze bounds during teardown. - const freezeBoundsOnQuit = (): void => { + const freezeBoundsOnQuit = (event?: Event): void => { + if (event?.defaultPrevented) { + return + } windowClosing = true if (boundsTimer) { clearTimeout(boundsTimer) diff --git a/src/renderer/src/components/UpdateCard.error-card.test.tsx b/src/renderer/src/components/UpdateCard.error-card.test.tsx index 186a285b26d..7fe9890b48d 100644 --- a/src/renderer/src/components/UpdateCard.error-card.test.tsx +++ b/src/renderer/src/components/UpdateCard.error-card.test.tsx @@ -95,6 +95,37 @@ afterEach(() => { useAppStore.setState(useAppStore.getInitialState(), true) }) +describe('UpdateCard staged install recovery', () => { + it.each([undefined, 'local'] as const)( + 'retries a blocked staged install without downloading again (source %s)', + (source) => { + renderWithInitialStatus({ + state: 'error', + version: '1.4.200', + message: + 'Close the other Orca instances (process IDs: 12345) before installing this update.', + retryAction: 'install', + ...(source ? { source } : {}) + }) + + expect(screen.getByText(/process IDs: 12345/)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Try Again' })) + expect(quitAndInstall).toHaveBeenCalledTimes(1) + expect(download).not.toHaveBeenCalled() + expect(screen.queryByRole('button', { name: 'Retry Download' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Choose Another Build' })).toBeNull() + } + ) + + it('keeps the download retry for errors from older hosts without an install action', () => { + renderWithInitialStatus({ state: 'error', version: '1.4.200', message: 'Download failed' }) + + fireEvent.click(screen.getByRole('button', { name: 'Retry Download' })) + expect(download).toHaveBeenCalledTimes(1) + expect(quitAndInstall).not.toHaveBeenCalled() + }) +}) + describe('UpdateCard Windows signature failures', () => { it('does not offer the rejected version as a manual publisher-check bypass', () => { const message = diff --git a/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts b/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts index 1a6ab8672a0..a911d58ad62 100644 --- a/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts +++ b/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts @@ -47,6 +47,18 @@ export function buildUpdateCardErrorModel({ } : null } + if (status.retryAction === 'install' && status.retryable !== false) { + return { + title: translate('auto.components.UpdateCard.4cf109845a', 'Update Error'), + summary: status.message, + detail: status.message, + releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), + primaryAction: { + label: translate('auto.components.UpdateCard.2c2d3e03ca', 'Try Again'), + onClick: onInstallRetry + } + } + } if (isLocalBuild) { return { title: cachedVersion diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx index d83cf01b19b..346b1d449b1 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx @@ -391,7 +391,7 @@ describe('StructuredAgentSessionAttentionBridge', () => { }) }) - it('keeps a published paired chat subscribed and delivers after a workspace-id collision', async () => { + it('keeps a legacy published paired chat subscribed and delivers after a workspace-id collision', async () => { const store = mocks.store if (!store) { throw new Error('test store was not initialized') @@ -445,10 +445,15 @@ describe('StructuredAgentSessionAttentionBridge', () => { 100 ) ) - const tab = store.getState().unifiedTabsByWorktree[WORKSPACE][0] - if (!tab) { + const publishedTab = store.getState().unifiedTabsByWorktree[WORKSPACE][0] + if (!publishedTab) { throw new Error('snapshot did not publish a chat tab') } + expect(publishedTab.executionHostId).toBe('runtime:env-1') + // Why: restored tabs from before host stamping can still have ambiguous catalog ownership. + const tab = { ...publishedTab } + delete tab.executionHostId + store.setState({ unifiedTabsByWorktree: { [WORKSPACE]: [tab] } }) expect(tab.contentType).toBe('agent-session') expect(tab.executionHostId).toBeUndefined() render() diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx index f569c9f5114..996b15eb88e 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '../../store' import { GeneralUpdateSettingsSection } from './GeneralUpdateSettingsSection' @@ -7,7 +7,10 @@ import { GeneralUpdateSettingsSection } from './GeneralUpdateSettingsSection' vi.mock('./GeneralRemoteServerUpdates', () => ({ GeneralRemoteServerUpdates: () => null })) vi.mock('./ReleaseChannelSection', () => ({ ReleaseChannelSection: () => null })) +const quitAndInstall = vi.fn() + beforeEach(() => { + quitAndInstall.mockReset().mockResolvedValue(undefined) useAppStore.setState({ updateStatus: { state: 'available', version: '1.4.200', changelog: null } }) @@ -17,6 +20,7 @@ beforeEach(() => { updater: { check: vi.fn(), download: vi.fn(), + quitAndInstall, getVersion: vi.fn().mockResolvedValue('1.4.199') } } @@ -35,3 +39,36 @@ it('describes the available action as a download', () => { expect(screen.getByText(/is available\. Click "Download Update" to download it\./)).toBeTruthy() expect(screen.queryByText(/download and install it/)).toBeNull() }) + +it('retries installation from the settings panel when a staged update is blocked', () => { + useAppStore.setState({ + updateStatus: { + state: 'error', + version: '1.4.200', + message: 'Close the other Orca instances before installing this update.', + retryAction: 'install' + } + }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Try Again' })) + expect(quitAndInstall).toHaveBeenCalledTimes(1) +}) + +it.each([undefined, false] as const)( + 'does not offer install retry without an install action or when retry is refused (%s)', + (retryable) => { + useAppStore.setState({ + updateStatus: { + state: 'error', + version: '1.4.200', + message: 'Update failed.', + ...(retryable === false ? { retryAction: 'install', retryable } : {}) + } + }) + render() + + expect(screen.queryByRole('button', { name: 'Try Again' })).toBeNull() + expect(quitAndInstall).not.toHaveBeenCalled() + } +) diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index 1e59c200fca..fb4d5171f1b 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -138,6 +138,13 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { )} {updateStatus.version}) + ) : updateStatus.state === 'error' && + updateStatus.retryAction === 'install' && + updateStatus.retryable !== false ? ( + ) : updateStatus.state === 'downloaded' ? (