From 2c2ac2edbf3ef78163fd7c51dd1dfa74662487f9 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Wed, 3 Jun 2026 15:46:00 -0700 Subject: [PATCH] fix: handle detached launch spawn errors (#3957) --- src/cli/runtime/launch.test.ts | 32 +++++++++++++++++++++++++++++- src/cli/runtime/launch.ts | 36 ++++++++++++++++++---------------- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts index c3cf5b306..61f79f56f 100644 --- a/src/cli/runtime/launch.test.ts +++ b/src/cli/runtime/launch.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'events' import { resolve } from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,7 +10,12 @@ vi.mock('child_process', () => ({ spawn: spawnMock })) -import { serveOrcaApp } from './launch' +import { launchOrcaApp, serveOrcaApp } from './launch' + +class FakeChildProcess extends EventEmitter { + kill = vi.fn() + unref = vi.fn() +} describe('serveOrcaApp', () => { beforeEach(() => { @@ -147,3 +153,27 @@ describe('serveOrcaApp', () => { } }) }) + +describe('launchOrcaApp', () => { + beforeEach(() => { + spawnMock.mockReset() + }) + + afterEach(() => { + delete process.env.ORCA_OPEN_COMMAND + delete process.env.ORCA_APP_EXECUTABLE + delete process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT + }) + + it('handles asynchronous detached spawn errors without throwing', async () => { + process.env.ORCA_APP_EXECUTABLE = '/missing/Orca' + const child = new FakeChildProcess() + spawnMock.mockReturnValue(child) + + launchOrcaApp() + child.emit('error', new Error('ENOENT')) + await Promise.resolve() + + expect(child.unref).toHaveBeenCalled() + }) +}) diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index 6a7058c98..dd7270e83 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -5,22 +5,16 @@ import { RuntimeClientError } from './types' export function launchOrcaApp(): void { const overrideCommand = process.env.ORCA_OPEN_COMMAND if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) { - spawnProcess(overrideCommand, { - detached: true, - stdio: 'ignore', - shell: true - }).unref() + spawnDetached(overrideCommand, [], { shell: true }) return } const overrideExecutable = process.env.ORCA_APP_EXECUTABLE if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) { - spawnProcess(overrideExecutable, getExecutableAppArgs(), { - detached: true, - stdio: 'ignore', + spawnDetached(overrideExecutable, getExecutableAppArgs(), { ...getExecutableSpawnOptions(overrideExecutable), env: stripElectronRunAsNode(process.env) - }).unref() + }) return } @@ -31,20 +25,16 @@ export function launchOrcaApp(): void { // Why: launching the inner MacOS binary directly can trigger macOS app // launch failures and bypass normal bundle lifecycle. The public // packaged CLI should re-open the .app the same way Finder does. - spawnProcess('open', [appBundlePath], { - detached: true, - stdio: 'ignore', + spawnDetached('open', [appBundlePath], { env: stripElectronRunAsNode(process.env) - }).unref() + }) return } } - spawnProcess(process.execPath, [], { - detached: true, - stdio: 'ignore', + spawnDetached(process.execPath, [], { env: stripElectronRunAsNode(process.env) - }).unref() + }) return } @@ -54,6 +44,18 @@ export function launchOrcaApp(): void { ) } +function spawnDetached(command: string, args: string[], options: SpawnOptions): void { + const child = spawnProcess(command, args, { + detached: true, + stdio: 'ignore', + ...options + }) + // Why: detached launch errors are reported asynchronously after this function + // returns; openOrca already reports the user-facing timeout if startup fails. + child.once('error', () => {}) + child.unref() +} + export function serveOrcaApp( args: { json?: boolean