fix(updater): stop other and racing app instances from silently killing macOS update installs

Squirrel.Mac's ShipIt waits for every running instance of the installed
bundle to exit before applying an update, and aborts with "App Still
Running Error" when an instance appears mid-install. Two flows hit this
in practice:

1. A second packaged Orca (a manually opened copy, or an agent/e2e rig
   launching the installed bundle with a temp profile — often windowless
   and invisible) makes "Restart to Update" quit the app and then never
   install or relaunch. Reopening just focuses the old still-running
   copy, so the user reads it as "the update didn't take".
2. A single-instance user who reopens Orca during the seconds ShipIt
   spends swapping the bundle becomes the aborting instance and
   discards their own update.

Fix both ends:

- Before the native install handoff, scan for other processes running
  this executable and surface them on the update card (with PIDs)
  instead of starting a doomed quit. Fails open, macOS-only — the
  Windows and Linux installers manage running instances themselves.
- Drop a version-stamped handoff marker at install time; a same-version
  launch that finds the marker fresh while this bundle's ShipIt is
  still alive exits instead of aborting the install. ShipIt then
  finishes and relaunches the updated app, so the click still ends with
  Orca open — on the new version. The post-update relaunch (different
  version), a dead installer, stale or corrupt markers, and unreadable
  process tables all fail open, so the gate can never lock the app out.
This commit is contained in:
Brennan Benson
2026-07-16 17:28:31 -07:00
parent 1cdc8e85b3
commit 17870106a4
8 changed files with 626 additions and 0 deletions
+19
View File
@@ -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
@@ -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)
})
})
@@ -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
}
@@ -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([])
})
})
@@ -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<string>
function readCurrentUserProcessList(): Promise<string> {
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<number[]> {
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 []
}
}
+69
View File
@@ -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 () => {
+19
View File
@@ -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()
+35
View File
@@ -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<void> {
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<void> {
// 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'