mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
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:
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user