Fix macOS provider launch failures (#3958)

* fix: handle macos provider launch failures

* fix macos provider launch failure race

Co-authored-by: Orca <help@stably.ai>

* fix repeated missing upstream probes

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Trevin Chow 2026-06-03 15:57:44 -07:00 committed by GitHub
parent 2c2ac2edbf
commit 885f3c3b25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 185 additions and 14 deletions

View File

@ -61,6 +61,21 @@ class FakeSocket extends EventEmitter {
}
}
class FakeProvider extends EventEmitter {
kill = vi.fn()
unref = vi.fn()
}
function pendingConnectThatRejectsOnAbort(signal?: AbortSignal): Promise<never> {
return new Promise((_resolve, reject) => {
signal?.addEventListener(
'abort',
() => reject(new Error('native macOS helper app startup was cancelled')),
{ once: true }
)
})
}
async function loadClientModule() {
vi.resetModules()
return await import('./macos-native-provider-client')
@ -68,15 +83,21 @@ async function loadClientModule() {
describe('MacOSNativeProviderClient', () => {
const sockets: FakeSocket[] = []
const providers: FakeProvider[] = []
beforeEach(() => {
vi.useFakeTimers()
sockets.length = 0
providers.length = 0
mkdtempSyncMock.mockImplementation((prefix: string) => `${prefix}${sockets.length}`)
resolveMacOSComputerUseExecutablePathMock.mockReturnValue(
'/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos'
)
spawnMock.mockReturnValue({ unref: vi.fn(), kill: vi.fn() })
spawnMock.mockImplementation(() => {
const provider = new FakeProvider()
providers.push(provider)
return provider
})
connectMacOSProviderSocketMock.mockImplementation(async () => {
const socket = new FakeSocket()
sockets.push(socket)
@ -264,4 +285,44 @@ describe('MacOSNativeProviderClient', () => {
force: true
})
})
it('rejects helper spawn errors before socket connection and removes temp state', async () => {
const { MacOSNativeProviderClient } = await loadClientModule()
const client = new MacOSNativeProviderClient()
connectMacOSProviderSocketMock.mockImplementation((_path, _timeout, signal?: AbortSignal) =>
pendingConnectThatRejectsOnAbort(signal)
)
const call = client.capabilities()
await vi.waitFor(() => expect(providers).toHaveLength(1))
const socketDirectory = mkdtempSyncMock.mock.results[0]?.value as string
const connectSignal = connectMacOSProviderSocketMock.mock.calls[0]?.[2] as AbortSignal
expect(connectSignal.aborted).toBe(false)
providers[0]!.emit('error', new Error('helper missing'))
await expect(call).rejects.toThrow('native macOS helper app failed to start: helper missing')
expect(connectSignal.aborted).toBe(true)
expect(providers[0]!.kill).toHaveBeenCalledWith('SIGTERM')
expect(rmSyncMock).toHaveBeenCalledWith(socketDirectory, {
recursive: true,
force: true
})
})
it('rejects helper exits before socket connection and aborts the pending connect', async () => {
const { MacOSNativeProviderClient } = await loadClientModule()
const client = new MacOSNativeProviderClient()
connectMacOSProviderSocketMock.mockImplementation((_path, _timeout, signal?: AbortSignal) =>
pendingConnectThatRejectsOnAbort(signal)
)
const call = client.capabilities()
await vi.waitFor(() => expect(providers).toHaveLength(1))
const connectSignal = connectMacOSProviderSocketMock.mock.calls[0]?.[2] as AbortSignal
providers[0]!.emit('exit', 13, null)
await expect(call).rejects.toThrow('native macOS helper app exited before connecting: code 13')
expect(connectSignal.aborted).toBe(true)
expect(providers[0]!.kill).toHaveBeenCalledWith('SIGTERM')
})
})

View File

@ -1,5 +1,5 @@
/* eslint-disable max-lines -- Why: the macOS provider transport owns one lifecycle across stdio fallback and helper-app socket mode. */
import { spawn } from 'child_process'
import { spawn, type ChildProcess } from 'child_process'
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import type net from 'net'
import { release, tmpdir } from 'os'
@ -200,14 +200,15 @@ export class MacOSNativeProviderClient {
writeFileSync(socketTokenPath, socketToken, { encoding: 'utf8', mode: 0o600 })
// Why: launching the nested helper via LaunchServices can make TCC evaluate
// Orca.app as responsible; the signed helper executable owns this grant.
const provider = spawn(
helperExecutablePath,
['--agent', socketPath, '--token-file', socketTokenPath],
{ detached: true, stdio: 'ignore' }
)
provider.unref()
const provider = spawnProvider(helperExecutablePath, socketPath, socketTokenPath)
const providerFailure = waitForProviderLaunchFailure(provider)
const connectAbort = new AbortController()
try {
const socket = await connectMacOSProviderSocket(socketPath, HELPER_CONNECT_TIMEOUT_MS)
const socket = await Promise.race([
connectMacOSProviderSocket(socketPath, HELPER_CONNECT_TIMEOUT_MS, connectAbort.signal),
providerFailure.promise
])
providerFailure.cleanup()
rmSync(socketTokenPath, { force: true })
// Why: shutdown/retry can supersede an in-flight connect; old starts
// must not adopt replacement state or clean up the replacement helper.
@ -236,6 +237,8 @@ export class MacOSNativeProviderClient {
this.socket = socket
return socket
} catch (error) {
connectAbort.abort()
providerFailure.cleanup()
// Why: connect failures happen after spawn; terminate the detached
// helper so repeated startup attempts do not leave orphan providers.
provider.kill('SIGTERM')
@ -347,3 +350,51 @@ function isMacOS14OrNewer(): boolean {
const darwinMajor = Number.parseInt(release().split('.')[0] ?? '', 10)
return Number.isFinite(darwinMajor) && darwinMajor >= 23
}
function spawnProvider(
helperExecutablePath: string,
socketPath: string,
socketTokenPath: string
): ChildProcess {
const provider = spawn(
helperExecutablePath,
['--agent', socketPath, '--token-file', socketTokenPath],
{ detached: true, stdio: 'ignore' }
)
provider.unref()
return provider
}
function waitForProviderLaunchFailure(provider: ChildProcess): {
promise: Promise<never>
cleanup: () => void
} {
let cleanup = (): void => {}
const promise = new Promise<never>((_resolve, reject) => {
const fail = (error: Error) => {
reject(
new RuntimeClientError(
'accessibility_error',
`native macOS helper app failed to start: ${error.message}`
)
)
}
const exit = (code: number | null, signal: NodeJS.Signals | null) => {
reject(
new RuntimeClientError(
'accessibility_error',
`native macOS helper app exited before connecting: ${
typeof code === 'number' ? `code ${code}` : `signal ${signal ?? 'unknown'}`
}`
)
)
}
provider.once('error', fail)
provider.once('exit', exit)
cleanup = () => {
provider.off('error', fail)
provider.off('exit', exit)
}
})
return { promise, cleanup }
}

View File

@ -44,4 +44,19 @@ describe('connectMacOSProviderSocket', () => {
vi.useRealTimers()
}
})
it('destroys the pending socket and removes listeners when aborted', async () => {
const socket = new FakeSocket()
createConnectionMock.mockReturnValueOnce(socket)
const abort = new AbortController()
const promise = connectMacOSProviderSocket('/tmp/orca-computer.sock', 5_000, abort.signal)
await Promise.resolve()
abort.abort()
await expect(promise).rejects.toThrow('startup was cancelled')
expect(socket.destroy).toHaveBeenCalledTimes(1)
expect(socket.listenerCount('error')).toBe(0)
expect(socket.listenerCount('connect')).toBe(0)
})
})

View File

@ -3,30 +3,50 @@ import { RuntimeClientError } from './runtime-client-error'
export async function connectMacOSProviderSocket(
socketPath: string,
timeoutMs: number
timeoutMs: number,
signal?: AbortSignal
): Promise<net.Socket> {
const deadline = Date.now() + timeoutMs
let lastError: Error | null = null
while (Date.now() < deadline) {
while (Date.now() < deadline && !signal?.aborted) {
try {
return await connectSocket(socketPath)
return await connectSocket(socketPath, signal)
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
await new Promise((resolve) => setTimeout(resolve, 100))
if (signal?.aborted) {
break
}
await sleep(100, signal)
}
}
if (signal?.aborted) {
throw new RuntimeClientError(
'accessibility_error',
'native macOS helper app startup was cancelled'
)
}
throw new RuntimeClientError(
'action_timeout',
`native macOS helper app did not open its socket: ${lastError?.message ?? 'timed out'}`
)
}
function connectSocket(socketPath: string): Promise<net.Socket> {
function connectSocket(socketPath: string, signal?: AbortSignal): Promise<net.Socket> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(
new RuntimeClientError(
'accessibility_error',
'native macOS helper app startup was cancelled'
)
)
return
}
const socket = net.createConnection(socketPath)
const cleanup = (): void => {
socket.off('error', onError)
socket.off('connect', onConnect)
signal?.removeEventListener('abort', onAbort)
}
const onError = (error: Error): void => {
cleanup()
@ -37,7 +57,31 @@ function connectSocket(socketPath: string): Promise<net.Socket> {
cleanup()
resolve(socket)
}
const onAbort = (): void => {
cleanup()
socket.destroy()
reject(
new RuntimeClientError(
'accessibility_error',
'native macOS helper app startup was cancelled'
)
)
}
socket.once('error', onError)
socket.once('connect', onConnect)
signal?.addEventListener('abort', onAbort, { once: true })
})
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(finish, ms)
const onAbort = (): void => finish()
function finish(): void {
clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
resolve()
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}