diff --git a/src/main/index.ts b/src/main/index.ts index b120f62c8a5..3868ebf63ea 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -100,6 +100,7 @@ import { shouldBypassSingleInstanceLock, shouldSkipSingleInstanceLock } from './startup/single-instance-lock' +import { shouldDeferLaunchForUpdateInstall } from './startup/update-install-launch-gate' import { startEventLoopStallProbe } from './startup/event-loop-stall-probe' import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe' import { @@ -465,6 +466,24 @@ if (app.isPackaged && process.platform !== 'win32') { configureDevUserDataPath(is.dev) configureOrcaUserDataPathEnv() +// Why: a launch that races Squirrel's in-flight update install would abort it +// ("App Still Running Error") and silently strand the user on the old +// version. Exit instead — ShipIt finishes the install and relaunches the +// updated app itself, so this click still ends with Orca open. Must run +// before the single-instance lock and all userData side effects. +if ( + shouldDeferLaunchForUpdateInstall({ + isPackaged: app.isPackaged, + userDataPath: app.getPath('userData'), + appVersion: app.getVersion() + }) +) { + console.warn( + '[updater] update install in flight for this bundle; exiting so the installer can finish and relaunch' + ) + app.exit(0) +} + // Why: just past createMainWindow's 10s ready-to-show reveal fallback, // so a window revealed on that path still gets its tray icon. const TRAY_CREATE_FALLBACK_MS = 12_000 diff --git a/src/main/startup/update-install-launch-gate.test.ts b/src/main/startup/update-install-launch-gate.test.ts new file mode 100644 index 00000000000..8e9f280c8a8 --- /dev/null +++ b/src/main/startup/update-install-launch-gate.test.ts @@ -0,0 +1,171 @@ +import { mkdtempSync, existsSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + UPDATE_INSTALL_HANDOFF_MARKER_FILE, + UPDATE_INSTALL_HANDOFF_MAX_AGE_MS, + clearUpdateInstallHandoffMarker, + isBundleShipItRunning, + shouldDeferLaunchForUpdateInstall, + writeUpdateInstallHandoffMarker +} from './update-install-launch-gate' + +const APP_BIN = '/Applications/Orca.app/Contents/MacOS/Orca' +const SHIPIT_ROW = + '/Applications/Orca.app/Contents/Frameworks/Squirrel.framework/Resources/ShipIt com.stablyai.orca.ShipIt' + +const tempDirs: string[] = [] + +function makeUserData(): string { + const dir = mkdtempSync(path.join(tmpdir(), 'orca-launch-gate-')) + tempDirs.push(dir) + return dir +} + +function markerFile(userData: string): string { + return path.join(userData, UPDATE_INSTALL_HANDOFF_MARKER_FILE) +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('isBundleShipItRunning', () => { + it('matches only this bundle’s Squirrel installer', () => { + const otherBundle = + '/Applications/Other.app/Contents/Frameworks/Squirrel.framework/Resources/ShipIt' + expect(isBundleShipItRunning(APP_BIN, `${otherBundle}\n/usr/bin/ps`)).toBe(false) + expect(isBundleShipItRunning(APP_BIN, `/usr/bin/ps\n${SHIPIT_ROW}`)).toBe(true) + }) + + it('ignores processes that merely mention the installer path in arguments', () => { + const grepRow = `/bin/zsh -c grep ${SHIPIT_ROW}` + expect(isBundleShipItRunning(APP_BIN, grepRow)).toBe(false) + }) +}) + +describe('shouldDeferLaunchForUpdateInstall', () => { + it('defers a same-version launch while this bundle’s installer is alive', () => { + const userData = makeUserData() + writeUpdateInstallHandoffMarker(userData, '1.0.51') + + const defer = shouldDeferLaunchForUpdateInstall({ + isPackaged: true, + userDataPath: userData, + appVersion: '1.0.51', + deps: { platform: 'darwin', executablePath: APP_BIN, readProcessList: () => SHIPIT_ROW } + }) + + expect(defer).toBe(true) + expect(existsSync(markerFile(userData))).toBe(true) + }) + + it('lets the post-update relaunch through and clears the marker', () => { + const userData = makeUserData() + writeUpdateInstallHandoffMarker(userData, '1.0.51') + + const defer = shouldDeferLaunchForUpdateInstall({ + isPackaged: true, + userDataPath: userData, + appVersion: '1.0.61', + deps: { platform: 'darwin', executablePath: APP_BIN, readProcessList: () => SHIPIT_ROW } + }) + + expect(defer).toBe(false) + expect(existsSync(markerFile(userData))).toBe(false) + }) + + it('launches normally once the installer has exited (aborted/failed install)', () => { + const userData = makeUserData() + writeUpdateInstallHandoffMarker(userData, '1.0.51') + + const defer = shouldDeferLaunchForUpdateInstall({ + isPackaged: true, + userDataPath: userData, + appVersion: '1.0.51', + deps: { platform: 'darwin', executablePath: APP_BIN, readProcessList: () => '/usr/bin/ps' } + }) + + expect(defer).toBe(false) + expect(existsSync(markerFile(userData))).toBe(false) + }) + + it('expires stale markers instead of gating launches', () => { + const userData = makeUserData() + const staleCreatedAt = 1_000_000 + writeUpdateInstallHandoffMarker(userData, '1.0.51', staleCreatedAt) + + const defer = shouldDeferLaunchForUpdateInstall({ + isPackaged: true, + userDataPath: userData, + appVersion: '1.0.51', + deps: { + platform: 'darwin', + executablePath: APP_BIN, + now: staleCreatedAt + UPDATE_INSTALL_HANDOFF_MAX_AGE_MS + 1, + readProcessList: () => SHIPIT_ROW + } + }) + + expect(defer).toBe(false) + expect(existsSync(markerFile(userData))).toBe(false) + }) + + it('fails open on corrupt markers, unreadable process tables, and other platforms', () => { + const userData = makeUserData() + writeFileSync(markerFile(userData), 'not json') + expect( + shouldDeferLaunchForUpdateInstall({ + isPackaged: true, + userDataPath: userData, + appVersion: '1.0.51', + deps: { platform: 'darwin', executablePath: APP_BIN, readProcessList: () => SHIPIT_ROW } + }) + ).toBe(false) + + writeUpdateInstallHandoffMarker(userData, '1.0.51') + expect( + shouldDeferLaunchForUpdateInstall({ + isPackaged: true, + userDataPath: userData, + appVersion: '1.0.51', + deps: { + platform: 'darwin', + executablePath: APP_BIN, + readProcessList: () => { + throw new Error('ps timed out') + } + } + }) + ).toBe(false) + + writeUpdateInstallHandoffMarker(userData, '1.0.51') + expect( + shouldDeferLaunchForUpdateInstall({ + isPackaged: true, + userDataPath: userData, + appVersion: '1.0.51', + deps: { platform: 'win32', executablePath: APP_BIN, readProcessList: () => SHIPIT_ROW } + }) + ).toBe(false) + + expect( + shouldDeferLaunchForUpdateInstall({ + isPackaged: false, + userDataPath: userData, + appVersion: '1.0.51', + deps: { platform: 'darwin', executablePath: APP_BIN, readProcessList: () => SHIPIT_ROW } + }) + ).toBe(false) + }) + + it('clearUpdateInstallHandoffMarker removes the marker', () => { + const userData = makeUserData() + writeUpdateInstallHandoffMarker(userData, '1.0.51') + clearUpdateInstallHandoffMarker(userData) + expect(existsSync(markerFile(userData))).toBe(false) + }) +}) diff --git a/src/main/startup/update-install-launch-gate.ts b/src/main/startup/update-install-launch-gate.ts new file mode 100644 index 00000000000..801c1f79984 --- /dev/null +++ b/src/main/startup/update-install-launch-gate.ts @@ -0,0 +1,149 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +// Why: Squirrel.Mac installs by swapping the bundle AFTER the app exits, and +// Electron's fork aborts the whole install ("App Still Running Error") when +// another instance of the bundle shows up mid-install. A user who reopens +// Orca in the seconds between "windows closed" and "updated app relaunched" +// becomes that instance: the update is silently discarded and they come back +// on the old version. The quitting app drops a handoff marker; a same-version +// launch that finds the marker fresh while this bundle's ShipIt installer is +// still alive exits instead — ShipIt then finishes and relaunches the updated +// app itself, so the user's click still ends with Orca open. +export const UPDATE_INSTALL_HANDOFF_MARKER_FILE = 'update-install-handoff.json' +// Why: the handoff normally resolves in seconds; the ceiling only bounds how +// long a crashed install could keep gating launches. The live-ShipIt check is +// the real gate, so a stale marker never locks the app out. +export const UPDATE_INSTALL_HANDOFF_MAX_AGE_MS = 10 * 60 * 1000 + +const PROCESS_LIST_TIMEOUT_MS = 2_000 +const PROCESS_LIST_MAX_BYTES = 16 * 1024 * 1024 + +export type UpdateInstallHandoffMarker = { + appVersion: string + createdAtMs: number +} + +function markerPath(userDataPath: string): string { + return path.join(userDataPath, UPDATE_INSTALL_HANDOFF_MARKER_FILE) +} + +/** Written by the updater immediately before the native install handoff. */ +export function writeUpdateInstallHandoffMarker( + userDataPath: string, + appVersion: string, + now = Date.now() +): void { + try { + const marker: UpdateInstallHandoffMarker = { appVersion, createdAtMs: now } + writeFileSync(markerPath(userDataPath), JSON.stringify(marker)) + } catch { + // Why: the marker only powers the relaunch-race guard; failing to write + // it must never block the install itself. + } +} + +export function clearUpdateInstallHandoffMarker(userDataPath: string): void { + try { + rmSync(markerPath(userDataPath), { force: true }) + } catch { + /* Removal is best-effort; a stale marker self-expires. */ + } +} + +function readUpdateInstallHandoffMarker(userDataPath: string): UpdateInstallHandoffMarker | null { + try { + if (!existsSync(markerPath(userDataPath))) { + return null + } + const parsed = JSON.parse(readFileSync(markerPath(userDataPath), 'utf8')) + if (typeof parsed?.appVersion !== 'string' || typeof parsed?.createdAtMs !== 'number') { + return null + } + return { appVersion: parsed.appVersion, createdAtMs: parsed.createdAtMs } + } catch { + return null + } +} + +function readProcessCommandList(): string { + return execFileSync('ps', ['-xo', 'command='], { + encoding: 'utf8', + timeout: PROCESS_LIST_TIMEOUT_MS, + maxBuffer: PROCESS_LIST_MAX_BYTES + }) +} + +/** True when this bundle's Squirrel ShipIt installer is still running. The + * bundle root comes from the executable path, so only an installer working on + * this exact install is considered. */ +export function isBundleShipItRunning(executablePath: string, processCommandList: string): boolean { + // /Applications/Orca.app/Contents/MacOS/Orca → /Applications/Orca.app + const bundleRoot = path.resolve(executablePath, '..', '..', '..') + const shipItPath = path.join( + bundleRoot, + 'Contents', + 'Frameworks', + 'Squirrel.framework', + 'Resources', + 'ShipIt' + ) + // Why: anchor on argv[0]. A shell or grep merely mentioning the ShipIt path + // in its arguments must not read as a live installer. + return processCommandList.split('\n').some((line) => line.trimStart().startsWith(shipItPath)) +} + +export type UpdateInstallLaunchGateDeps = { + platform?: NodeJS.Platform + executablePath?: string + now?: number + readProcessList?: () => string +} + +/** + * Decide whether this launch should exit to let an in-flight update install + * finish. Sync by design — it must resolve before any startup side effects, + * and the process-table read only happens on the rare fresh-marker path. + * Fails open everywhere: an unreadable marker or process table means launch. + */ +export function shouldDeferLaunchForUpdateInstall(options: { + isPackaged: boolean + userDataPath: string + appVersion: string + deps?: UpdateInstallLaunchGateDeps +}): boolean { + const platform = options.deps?.platform ?? process.platform + if (platform !== 'darwin' || !options.isPackaged) { + return false + } + const marker = readUpdateInstallHandoffMarker(options.userDataPath) + if (!marker) { + return false + } + // A different running version means the install already happened (or was + // rolled elsewhere) — this launch is the post-update relaunch. + if (marker.appVersion !== options.appVersion) { + clearUpdateInstallHandoffMarker(options.userDataPath) + return false + } + const now = options.deps?.now ?? Date.now() + if (now - marker.createdAtMs > UPDATE_INSTALL_HANDOFF_MAX_AGE_MS) { + clearUpdateInstallHandoffMarker(options.userDataPath) + return false + } + let processList: string + try { + processList = (options.deps?.readProcessList ?? readProcessCommandList)() + } catch { + clearUpdateInstallHandoffMarker(options.userDataPath) + return false + } + if (isBundleShipItRunning(options.deps?.executablePath ?? process.execPath, processList)) { + return true + } + // Same version and no installer left: the install died (aborted/failed). + // Clear the marker so this and future launches proceed normally. + clearUpdateInstallHandoffMarker(options.userDataPath) + return false +} diff --git a/src/main/updater-conflicting-app-instances.test.ts b/src/main/updater-conflicting-app-instances.test.ts new file mode 100644 index 00000000000..c1e46148ff1 --- /dev/null +++ b/src/main/updater-conflicting-app-instances.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { + findConflictingAppInstancePids, + parseSameExecutablePids +} from './updater-conflicting-app-instances' + +const ORCA_BIN = '/Applications/Orca.app/Contents/MacOS/Orca' + +describe('parseSameExecutablePids', () => { + it('matches other pids running the exact executable path', () => { + const output = [ + ` 100 ${ORCA_BIN}`, + ' 200 /Applications/Orca.app/Contents/Frameworks/Orca Helper.app/Contents/MacOS/Orca Helper', + ` 300 ${ORCA_BIN}`, + ' 400 /usr/sbin/cfprefsd' + ].join('\n') + + expect(parseSameExecutablePids(output, ORCA_BIN, 100)).toEqual([300]) + }) + + it('keeps executable paths containing spaces intact', () => { + const binWithSpaces = '/Users/dev/My Apps/Orca.app/Contents/MacOS/Orca' + const output = ` 512 ${binWithSpaces}\n 513 ${binWithSpaces} Helper` + + expect(parseSameExecutablePids(output, binWithSpaces, 1)).toEqual([512]) + }) + + it('ignores dev-shell noise and malformed rows', () => { + const output = ['not-a-row', ' -5 /bin/zsh', ` 42 ${ORCA_BIN}extra`].join('\n') + + expect(parseSameExecutablePids(output, ORCA_BIN, 1)).toEqual([]) + }) +}) + +describe('findConflictingAppInstancePids', () => { + it('reports other same-binary instances on darwin', async () => { + const pids = await findConflictingAppInstancePids({ + platform: 'darwin', + executablePath: ORCA_BIN, + currentPid: 100, + readProcessList: async () => ` 100 ${ORCA_BIN}\n 777 ${ORCA_BIN}` + }) + + expect(pids).toEqual([777]) + }) + + it('skips the check off darwin — Win/Linux installers own this', async () => { + const pids = await findConflictingAppInstancePids({ + platform: 'win32', + executablePath: ORCA_BIN, + currentPid: 100, + readProcessList: async () => ` 777 ${ORCA_BIN}` + }) + + expect(pids).toEqual([]) + }) + + it('fails open when the process table is unreadable', async () => { + const pids = await findConflictingAppInstancePids({ + platform: 'darwin', + executablePath: ORCA_BIN, + currentPid: 100, + readProcessList: async () => { + throw new Error('ps timed out') + } + }) + + expect(pids).toEqual([]) + }) +}) diff --git a/src/main/updater-conflicting-app-instances.ts b/src/main/updater-conflicting-app-instances.ts new file mode 100644 index 00000000000..5df49cea31e --- /dev/null +++ b/src/main/updater-conflicting-app-instances.ts @@ -0,0 +1,94 @@ +import { execFile } from 'node:child_process' + +// Why: Squirrel.Mac's ShipIt waits for EVERY running instance of the target +// bundle (NSRunningApplication matched by bundle id + bundle URL) to exit +// before it installs, and aborts with "App Still Running Error" if one +// appears mid-install. A second packaged Orca — a manually opened copy or an +// agent/e2e rig launching /Applications/Orca.app with a temp profile, often +// windowless and invisible — silently stalls the update forever while the +// user sees "app quit but never relaunched, still on the old version". +// Detecting those instances before the install handoff turns that silent +// wedge into an actionable message. +const PROCESS_LIST_TIMEOUT_MS = 2_000 +// Why: a busy dev machine's per-user process table can exceed execFile's 1MB +// default; truncation would silently drop a conflicting instance. +const PROCESS_LIST_MAX_BYTES = 16 * 1024 * 1024 + +export type ProcessListReader = () => Promise + +function readCurrentUserProcessList(): Promise { + return new Promise((resolve, reject) => { + // Why: `-x` (without `-a`) scopes to the current user's processes, which + // mirrors NSRunningApplication's login-session scope — another macOS + // user's Orca does not block ShipIt and must not block the update here. + // On macOS `comm` is the full executable path, so instances launched from + // the same bundle match exactly whatever their argv or profile is. + execFile( + 'ps', + ['-xo', 'pid=,comm='], + { encoding: 'utf8', timeout: PROCESS_LIST_TIMEOUT_MS, maxBuffer: PROCESS_LIST_MAX_BYTES }, + (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + } + ) + }) +} + +/** Parse `ps -xo pid=,comm=` output into pids whose executable path equals + * `executablePath`, excluding `currentPid`. Executable paths may contain + * spaces, so only the leading pid column is positional. */ +export function parseSameExecutablePids( + psOutput: string, + executablePath: string, + currentPid: number +): number[] { + const pids: number[] = [] + for (const line of psOutput.split('\n')) { + const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line) + if (!match) { + continue + } + const pid = Number(match[1]) + if (pid !== currentPid && match[2] === executablePath) { + pids.push(pid) + } + } + return pids +} + +export type ConflictingInstanceDeps = { + platform?: NodeJS.Platform + executablePath?: string + currentPid?: number + readProcessList?: ProcessListReader +} + +/** + * Pids of other running app instances launched from this same executable. + * + * macOS-only by design: this mirrors Squirrel.Mac's pre-install wait/abort + * semantics. The Windows and Linux installers manage running instances + * themselves. Fails open — an undetectable process table must never block an + * update install. + */ +export async function findConflictingAppInstancePids( + deps: ConflictingInstanceDeps = {} +): Promise { + if ((deps.platform ?? process.platform) !== 'darwin') { + return [] + } + try { + const output = await (deps.readProcessList ?? readCurrentUserProcessList)() + return parseSameExecutablePids( + output, + deps.executablePath ?? process.execPath, + deps.currentPid ?? process.pid + ) + } catch { + return [] + } +} diff --git a/src/main/updater.mac-install.test.ts b/src/main/updater.mac-install.test.ts index f48754afd4f..edc34b4102b 100644 --- a/src/main/updater.mac-install.test.ts +++ b/src/main/updater.mac-install.test.ts @@ -64,6 +64,7 @@ const { appMock: { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), + getPath: vi.fn(() => '/tmp/orca-test-user-data'), on: appOn, emit: appEmit, quit: vi.fn() @@ -117,6 +118,20 @@ vi.mock('./updater-nudge', () => ({ shouldApplyNudge: vi.fn().mockReturnValue(false) })) +const findConflictingAppInstancePidsMock = vi.hoisted(() => vi.fn(async () => [] as number[])) +vi.mock('./updater-conflicting-app-instances', () => ({ + findConflictingAppInstancePids: findConflictingAppInstancePidsMock +})) + +const { writeHandoffMarkerMock, clearHandoffMarkerMock } = vi.hoisted(() => ({ + writeHandoffMarkerMock: vi.fn(), + clearHandoffMarkerMock: vi.fn() +})) +vi.mock('./startup/update-install-launch-gate', () => ({ + writeUpdateInstallHandoffMarker: writeHandoffMarkerMock, + clearUpdateInstallHandoffMarker: clearHandoffMarkerMock +})) + describe('updater mac install handoff', () => { beforeEach(() => { vi.resetModules() @@ -131,10 +146,64 @@ describe('updater mac install handoff', () => { appMock.isPackaged = true isMock.dev = false killAllPtyMock.mockReset() + findConflictingAppInstancePidsMock.mockReset() + findConflictingAppInstancePidsMock.mockResolvedValue([]) + writeHandoffMarkerMock.mockReset() + clearHandoffMarkerMock.mockReset() vi.unstubAllGlobals() vi.useRealTimers() }) + it.runIf(process.platform === 'darwin')( + 'blocks the install handoff while another instance of the app bundle is running', + async () => { + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, quitAndInstall } = await import('./updater') + + setupAutoUpdater(mainWindow as never) + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + await new Promise((r) => setTimeout(r, 0)) + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + const nativeDownloadedHandler = nativeUpdaterMock.on.mock.calls.find( + ([eventName]) => eventName === 'update-downloaded' + )?.[1] as (() => void) | undefined + nativeDownloadedHandler?.() + + findConflictingAppInstancePidsMock.mockResolvedValue([4242]) + quitAndInstall() + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'error', + message: expect.stringContaining('PID 4242') + }) + ) + }) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + + // Once the conflicting instance is gone, a retry must install normally. + findConflictingAppInstancePidsMock.mockResolvedValue([]) + quitAndInstall() + await vi.waitFor(() => { + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(false, true) + }) + // The relaunch-race handoff marker must be down before the native call. + expect(writeHandoffMarkerMock).toHaveBeenCalledWith('/tmp/orca-test-user-data', '1.0.51') + expect(writeHandoffMarkerMock.mock.invocationCallOrder[0]).toBeLessThan( + autoUpdaterMock.quitAndInstall.mock.invocationCallOrder[0] + ) + } + ) + it.runIf(process.platform === 'darwin')( 'waits for Squirrel.Mac before honoring a manual quit that should install the update', async () => { diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 0bed8d0a3ce..ee74fa3ad63 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -71,6 +71,7 @@ const { appMock: { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), + getPath: vi.fn(() => '/tmp/orca-test-user-data'), on: appOn, emit: appEmit, quit: vi.fn() @@ -135,6 +136,20 @@ const { armExitWatchdogMock, disarmExitWatchdogMock } = vi.hoisted(() => ({ disarmExitWatchdogMock: vi.fn() })) +const findConflictingAppInstancePidsMock = vi.hoisted(() => vi.fn(async () => [] as number[])) +vi.mock('./updater-conflicting-app-instances', () => ({ + findConflictingAppInstancePids: findConflictingAppInstancePidsMock +})) + +const { writeHandoffMarkerMock, clearHandoffMarkerMock } = vi.hoisted(() => ({ + writeHandoffMarkerMock: vi.fn(), + clearHandoffMarkerMock: vi.fn() +})) +vi.mock('./startup/update-install-launch-gate', () => ({ + writeUpdateInstallHandoffMarker: writeHandoffMarkerMock, + clearUpdateInstallHandoffMarker: clearHandoffMarkerMock +})) + vi.mock('./update-install-exit-watchdog', () => ({ armUpdateInstallExitWatchdog: armExitWatchdogMock, disarmUpdateInstallExitWatchdog: disarmExitWatchdogMock @@ -168,6 +183,10 @@ describe('updater', () => { appMock.isPackaged = true isMock.dev = false killAllPtyMock.mockReset() + findConflictingAppInstancePidsMock.mockReset() + findConflictingAppInstancePidsMock.mockResolvedValue([]) + writeHandoffMarkerMock.mockReset() + clearHandoffMarkerMock.mockReset() armExitWatchdogMock.mockReset() disarmExitWatchdogMock.mockReset() powerMonitorOnMock.mockReset() diff --git a/src/main/updater.ts b/src/main/updater.ts index 290100f0f63..6269e164f22 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -17,6 +17,11 @@ import { armUpdateInstallExitWatchdog, disarmUpdateInstallExitWatchdog } from './update-install-exit-watchdog' +import { findConflictingAppInstancePids } from './updater-conflicting-app-instances' +import { + clearUpdateInstallHandoffMarker, + writeUpdateInstallHandoffMarker +} from './startup/update-install-launch-gate' import { registerAutoUpdaterHandlers } from './updater-events' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { @@ -572,6 +577,28 @@ async function performQuitAndInstall(): Promise { pendingQuitAndInstallTimer = null } + // Why: Squirrel.Mac's ShipIt waits for ALL instances of the installed + // bundle to exit before installing (and aborts if one appears mid-install), + // so a second packaged Orca — often an invisible agent/e2e-launched one — + // makes "Restart to Update" quit the app and then silently never install + // or relaunch. Surface those instances instead of starting a doomed quit. + const conflictingPids = await findConflictingAppInstancePids() + if (conflictingPids.length > 0) { + quitAndInstallInProgress = false + recordUpdaterLifecycle( + 'quit_and_install_blocked_by_other_instances', + { pids: conflictingPids.join(','), count: conflictingPids.length }, + { + level: 'warn', + message: `Update install blocked: ${conflictingPids.length} other running instance(s) of this app (pids ${conflictingPids.join(', ')})` + } + ) + sendErrorStatus( + `Another running copy of Orca is blocking the update (PID ${conflictingPids.join(', ')}) — possibly one launched by an agent or test run. Quit it, then install the update again.` + ) + return + } + markMacQuitAndInstallInFlight() // Set this BEFORE anything else so the `activate` handler in index.ts @@ -610,6 +637,11 @@ async function performQuitAndInstall(): Promise { // Why: mark before the call so a sync 'error' during quitAndInstall can // recover; pre-native errors must not look like install failure. quitAndInstallNativeInvoked = true + if (process.platform === 'darwin') { + // Why: lets a launch that races Squirrel's install back off instead of + // aborting it (see update-install-launch-gate). Cleared on recovery. + writeUpdateInstallHandoffMarker(app.getPath('userData'), app.getVersion()) + } // Why: invoke quitAndInstall before killAllPty/remove close listeners so a // sync 'error' (common "no filepath" path) recovers while windows and // local PTYs are still intact. @@ -668,6 +700,9 @@ function resetQuitForUpdateState(): void { quitAndInstallNativeInvoked = false disarmUpdateInstallExitWatchdog() resetMacInstallState() + // Why: install recovery keeps this process alive, so the handoff marker + // would otherwise gate the user's next manual relaunch for no reason. + clearUpdateInstallHandoffMarker(app.getPath('userData')) } // Why: electron-updater often reports quitAndInstall failures via the 'error'