fix(daemon): fork the detached daemon through a utility process so it stops inheriting Chromium descriptors

This commit is contained in:
Brennan Benson
2026-08-30 16:15:57 -07:00
committed by Merge Sim
parent f7d8d7f77a
commit 1d8153b42e
13 changed files with 955 additions and 0 deletions
@@ -262,6 +262,24 @@ describe('electron-builder config', () => {
)
})
// Why: the descriptor-clean daemon fork loads this entry with
// utilityProcess.fork; a dropped rollup input would silently put every
// Linux/Windows launch on the direct-fork fallback, resurrecting the
// inherited-descriptor leak into the daemon and its PTY children.
it('bundles the utility launcher shim the descriptor-clean daemon fork loads', async () => {
const forkSource = await readFile(
join(SRC_MAIN_DIR, 'daemon', 'daemon-utility-process-fork.ts'),
'utf8'
)
const entryFilename = forkSource.match(/'(daemon-utility-launcher-shim\.js)'/)?.[1]
expect(entryFilename).toBeDefined()
const viteConfig = await readFile(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf8')
expect(viteConfig).toMatch(new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`))
// utilityProcess reads asar directly, so the shim deliberately stays packed.
expect(electronBuilderConfig.asarUnpack).not.toContain(`out/main/${entryFilename}`)
})
it('uses the multi-size icon source for Linux packages', () => {
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
})
+5
View File
@@ -211,6 +211,11 @@ export const electronViteConfig: UserConfig = {
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
// Why: forked as an Electron utility process so the detached daemon it
// spawns starts without inherited Chromium descriptors (Linux/Windows).
'daemon-utility-launcher-shim': resolve(
'src/main/daemon/daemon-utility-launcher-shim.ts'
),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
@@ -0,0 +1,3 @@
// Fake daemon that dies during startup so the shim's exit relay is observable.
process.send({ type: 'starting' })
process.exit(7)
@@ -0,0 +1,5 @@
// Fake daemon for the utility-launcher shim tests: reports ready over IPC,
// writes a stderr marker, then waits to be killed (self-exits as a backstop).
process.send({ type: 'ready', startedAtMs: 123 })
process.stderr.write('utility-shim-fixture-stderr')
setTimeout(() => process.exit(0), 15000)
@@ -0,0 +1,160 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FAKE_DAEMON_ENTRY_PATH, FAKE_USER_DATA_PATH } from './daemon-init-test-harness'
const {
forkMock,
checkDaemonHealthMock,
daemonClientMock,
spawnerInstances,
importFresh,
installDefaultNetConnectStub,
moduleFactories
} = await vi.hoisted(async () =>
(await import('./daemon-init-test-harness')).createDaemonInitMocks()
)
const { canForkThroughUtilityMock, forkThroughUtilityMock } = vi.hoisted(() => ({
canForkThroughUtilityMock: vi.fn(() => false),
forkThroughUtilityMock: vi.fn()
}))
vi.mock('fs', () => moduleFactories.fs())
vi.mock('child_process', async (importOriginal) =>
moduleFactories.childProcess(await importOriginal<Record<string, unknown>>())
)
vi.mock('net', () => moduleFactories.net())
vi.mock('./daemon-health', () => moduleFactories.daemonHealth())
vi.mock('./daemon-pid-identity', () => moduleFactories.daemonPidIdentity())
vi.mock('./daemon-tcc-attribution', () => moduleFactories.daemonTccAttribution())
vi.mock('./daemon-bundle-staleness', () => moduleFactories.daemonBundleStaleness())
vi.mock('./daemon-stale-kill', () => moduleFactories.daemonStaleKill())
vi.mock('./daemon-process-start-time', () => moduleFactories.daemonProcessStartTime())
vi.mock('./daemon-pid-file-parse', () => moduleFactories.daemonPidFileParse())
vi.mock('./client', () => moduleFactories.client())
vi.mock('./daemon-lifecycle-event', () => moduleFactories.daemonLifecycleEvent())
vi.mock('./daemon-spawner', () => moduleFactories.daemonSpawner())
vi.mock('./daemon-pty-adapter', () => moduleFactories.daemonPtyAdapter())
vi.mock('../ipc/pty', () => moduleFactories.ipcPty())
vi.mock('./daemon-utility-process-fork', () => ({
canForkDaemonThroughUtilityProcess: canForkThroughUtilityMock,
forkDaemonThroughUtilityProcess: forkThroughUtilityMock
}))
function fakeLaunchedChild(): {
pid: number
on(event: string, cb: (arg?: unknown) => void): unknown
off(): unknown
disconnect: ReturnType<typeof vi.fn>
unref: ReturnType<typeof vi.fn>
} {
return {
pid: 12345,
on(event: string, cb: (arg?: unknown) => void) {
if (event === 'message') {
queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
}
return this
},
off() {
return this
},
disconnect: vi.fn(),
unref: vi.fn()
}
}
/** importFresh resets forkMock, so callers must queue fork results AFTER this. */
async function primeLauncher(): Promise<
(socketPath: string, tokenPath: string) => Promise<unknown>
> {
const mod = await importFresh()
checkDaemonHealthMock.mockResolvedValue('unreachable')
await mod.initDaemonPtyProvider()
return spawnerInstances[0].launcher as (socketPath: string, tokenPath: string) => Promise<unknown>
}
describe('daemon-init: descriptor-clean daemon fork', () => {
beforeEach(() => {
installDefaultNetConnectStub()
canForkThroughUtilityMock.mockReset()
canForkThroughUtilityMock.mockReturnValue(false)
forkThroughUtilityMock.mockReset()
})
afterEach(() => {
vi.clearAllMocks()
})
it('forks through the utility-process launcher when it is available', async () => {
const launcher = await primeLauncher()
canForkThroughUtilityMock.mockReturnValue(true)
forkThroughUtilityMock.mockImplementation(async () => fakeLaunchedChild())
// Why: the harness's endpoint-identity reader derives the launch nonce from
// forkMock's argv; the utility path never calls forkMock, so read it from
// the utility spec instead.
daemonClientMock.mockImplementation(function MockUtilityAdoptionClient() {
return {
ensureConnected: vi.fn(async () => {}),
getDaemonIdentity: vi.fn(() => {
const spec = forkThroughUtilityMock.mock.calls.at(-1)?.[0] as
| { args: string[] }
| undefined
const nonceIndex = spec ? spec.args.indexOf('--launch-nonce') : -1
return nonceIndex >= 0 && spec
? { pid: 12345, startedAtMs: 1_000_000, launchNonce: spec.args[nonceIndex + 1] }
: null
}),
request: vi.fn(async () => ({ sessions: [] })),
disconnect: vi.fn()
}
})
await launcher('/fake/socket', '/fake/token')
expect(forkThroughUtilityMock).toHaveBeenCalledOnce()
expect(forkMock).not.toHaveBeenCalled()
const spec = forkThroughUtilityMock.mock.calls[0][0] as {
entryPath: string
args: string[]
cwd: string
env: NodeJS.ProcessEnv
execPath: string
}
expect(spec.entryPath).toBe(FAKE_DAEMON_ENTRY_PATH)
expect(spec.cwd).toBe(FAKE_USER_DATA_PATH)
expect(spec.execPath).toBe(process.execPath)
expect(spec.env.ELECTRON_RUN_AS_NODE).toBe('1')
expect(spec.env.ORCA_USER_DATA_PATH).toBe(FAKE_USER_DATA_PATH)
expect(spec.args).toEqual(
expect.arrayContaining(['--socket', '/fake/socket', '--entry-path', FAKE_DAEMON_ENTRY_PATH])
)
})
it('falls back to the direct fork when the utility launch fails, so the daemon still exists', async () => {
const launcher = await primeLauncher()
canForkThroughUtilityMock.mockReturnValue(true)
forkThroughUtilityMock.mockRejectedValue(new Error('utility spawn refused'))
forkMock.mockReturnValueOnce(fakeLaunchedChild())
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
await launcher('/fake/socket', '/fake/token')
} finally {
warnSpy.mockRestore()
}
expect(forkThroughUtilityMock).toHaveBeenCalledOnce()
expect(forkMock).toHaveBeenCalledOnce()
})
it('keeps the direct fork where the utility hop is unavailable (macOS, plain-node hosts)', async () => {
const launcher = await primeLauncher()
canForkThroughUtilityMock.mockReturnValue(false)
forkMock.mockReturnValueOnce(fakeLaunchedChild())
await launcher('/fake/socket', '/fake/token')
expect(forkThroughUtilityMock).not.toHaveBeenCalled()
expect(forkMock).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,28 @@
/**
* Wire types between the daemon-forking main process and the utility-process
* launcher shim. Both sides bundle separately, so the contract lives alone.
*/
export type UtilityDaemonForkSpec = {
/** Absolute path to daemon-entry.js (unpacked in packaged builds). */
entryPath: string
args: readonly string[]
cwd: string
/** Full daemon environment, composed by main — never placed in argv. */
env: NodeJS.ProcessEnv
/** Binary to run as plain Node; the relocated Windows host when staged. */
execPath: string
}
export type DaemonShimDownMessage =
| { kind: 'spawn'; spec: UtilityDaemonForkSpec }
| { kind: 'release' }
export type DaemonShimUpMessage =
| { kind: 'shim-ready' }
| { kind: 'spawned'; pid: number }
| { kind: 'spawn-error'; message: string }
| { kind: 'daemon-message'; message: unknown }
| { kind: 'daemon-stderr'; text: string }
| { kind: 'daemon-error'; message: string }
| { kind: 'daemon-exit'; code: number | null; signal: NodeJS.Signals | null }
@@ -0,0 +1,166 @@
import { EventEmitter } from 'node:events'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { runDaemonUtilityLauncherShim, type ShimParentPort } from './daemon-utility-launcher-shim'
import type { DaemonShimUpMessage, UtilityDaemonForkSpec } from './daemon-utility-fork-messages'
const READY_FIXTURE = join(__dirname, '__fixtures__', 'utility-shim-ready-daemon.cjs')
const EXITING_FIXTURE = join(__dirname, '__fixtures__', 'utility-shim-exiting-daemon.cjs')
type FakePort = ShimParentPort & {
posted: DaemonShimUpMessage[]
deliver(message: unknown): void
started: boolean
waitFor<K extends DaemonShimUpMessage['kind']>(
kind: K,
timeoutMs?: number
): Promise<Extract<DaemonShimUpMessage, { kind: K }>>
}
function createFakePort(): FakePort {
const emitter = new EventEmitter()
const posted: DaemonShimUpMessage[] = []
const port: FakePort = {
posted,
started: false,
on(_event, listener) {
emitter.on('message', listener)
return port
},
start() {
port.started = true
},
postMessage(message) {
posted.push(message as DaemonShimUpMessage)
emitter.emit('posted', message)
},
deliver(message) {
emitter.emit('message', { data: message })
},
waitFor(kind, timeoutMs = 10_000) {
const existing = posted.find((message) => message.kind === kind)
if (existing) {
return Promise.resolve(existing as Extract<DaemonShimUpMessage, { kind: typeof kind }>)
}
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`timed out waiting for shim message ${kind}`)),
timeoutMs
)
const onPosted = (message: DaemonShimUpMessage): void => {
if (message.kind === kind) {
clearTimeout(timer)
emitter.off('posted', onPosted)
resolve(message as Extract<DaemonShimUpMessage, { kind: typeof kind }>)
}
}
emitter.on('posted', onPosted)
})
}
}
return port
}
function specFor(
entryPath: string,
overrides: Partial<UtilityDaemonForkSpec> = {}
): UtilityDaemonForkSpec {
return {
entryPath,
args: ['--socket', '/fake/sock'],
cwd: process.cwd(),
env: { ...process.env, ORCA_SHIM_TEST: '1' },
execPath: process.execPath,
...overrides
}
}
const spawnedPids: number[] = []
afterEach(() => {
for (const pid of spawnedPids.splice(0)) {
try {
process.kill(pid, 'SIGKILL')
} catch {
// already gone
}
}
})
describe('daemon-utility-launcher-shim', () => {
it('spawns the daemon detached as plain args on the provided binary', () => {
const port = createFakePort()
const child = new EventEmitter() as EventEmitter & { pid: number; stderr: null }
child.pid = 4242
child.stderr = null
const spawn = vi.fn(() => child as never)
runDaemonUtilityLauncherShim(port, spawn, () => {})
expect(port.started).toBe(true)
expect(port.posted[0]).toEqual({ kind: 'shim-ready' })
const spec = specFor('/fake/daemon-entry.js')
port.deliver({ kind: 'spawn', spec })
expect(spawn).toHaveBeenCalledWith({
program: process.execPath,
args: ['/fake/daemon-entry.js', '--socket', '/fake/sock'],
cwd: spec.cwd,
env: spec.env,
detached: true,
stdio: ['ignore', 'ignore', 'pipe', 'ipc']
})
expect(port.posted).toContainEqual({ kind: 'spawned', pid: 4242 })
// A duplicate spawn request must not fork a second daemon.
port.deliver({ kind: 'spawn', spec })
expect(spawn).toHaveBeenCalledTimes(1)
})
it('reports a spawn failure and exits nonzero', () => {
const port = createFakePort()
const exit = vi.fn()
runDaemonUtilityLauncherShim(
port,
() => {
throw new Error('no binary')
},
exit
)
port.deliver({ kind: 'spawn', spec: specFor('/fake/daemon-entry.js') })
expect(port.posted).toContainEqual({ kind: 'spawn-error', message: 'no binary' })
expect(exit).toHaveBeenCalledWith(1)
})
it('relays IPC readiness and stderr from a real daemon child', async () => {
const port = createFakePort()
const exit = vi.fn()
runDaemonUtilityLauncherShim(port, undefined, exit)
port.deliver({ kind: 'spawn', spec: specFor(READY_FIXTURE) })
const spawned = await port.waitFor('spawned')
spawnedPids.push(spawned.pid)
expect(spawned.pid).toBeGreaterThan(0)
const ready = await port.waitFor('daemon-message')
expect(ready.message).toMatchObject({ type: 'ready', startedAtMs: 123 })
const stderr = await port.waitFor('daemon-stderr')
expect(stderr.text).toContain('utility-shim-fixture-stderr')
// Release must detach without killing: the shim exits, the daemon stays.
port.deliver({ kind: 'release' })
expect(exit).toHaveBeenCalledWith(0)
expect(() => process.kill(spawned.pid, 0)).not.toThrow()
})
it('relays the daemon exit code from a real child', async () => {
const port = createFakePort()
runDaemonUtilityLauncherShim(port, undefined, () => {})
port.deliver({ kind: 'spawn', spec: specFor(EXITING_FIXTURE) })
const spawned = await port.waitFor('spawned')
spawnedPids.push(spawned.pid)
const exited = await port.waitFor('daemon-exit')
expect(exited).toEqual({ kind: 'daemon-exit', code: 7, signal: null })
})
})
@@ -0,0 +1,124 @@
/**
* Utility-process launcher shim for the detached terminal daemon.
*
* Why this hop exists: the Electron main process carries Chromium-owned
* descriptors that are not close-on-exec (POSIX) / not inheritance-protected
* (Windows) — the DevTools CDP listener, the crashpad client channel, mojo
* socketpairs, and writable profile file descriptors among them. A daemon
* forked directly from main inherits all of them on Linux and Windows, passes
* them to every PTY child, and — because the daemon outlives the app — keeps
* dead-instance resources alive: the CDP port stays bound with no acceptor, so
* a relaunched app comes back debugger-less. A utility process is launched by
* Chromium's own process launcher, which grants children an explicit
* stdio/ipc-only descriptor set, so a daemon forked from HERE starts clean.
*
* Runs inside `utilityProcess.fork` with no window and no electron imports;
* talks to the main process only through `process.parentPort`.
*/
import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process'
import type {
DaemonShimDownMessage,
DaemonShimUpMessage,
UtilityDaemonForkSpec
} from './daemon-utility-fork-messages'
export type ShimParentPort = {
on(event: 'message', listener: (event: { data: unknown }) => void): unknown
postMessage(message: unknown): void
start?: () => void
}
type ShimSpawn = (spec: {
program: string
args: readonly string[]
cwd: string
env: NodeJS.ProcessEnv
detached: boolean
stdio: ('ignore' | 'pipe' | 'ipc')[]
}) => SpawnedProcess
/** Delay before self-exit after relaying the daemon's exit, so the message wins the race. */
const EXIT_RELAY_LINGER_MS = 2000
export function runDaemonUtilityLauncherShim(
port: ShimParentPort,
spawn: ShimSpawn = spawnProcess,
exit: (code: number) => void = (code) => process.exit(code)
): void {
let child: SpawnedProcess | null = null
let launched = false
let released = false
const post = (message: DaemonShimUpMessage): void => port.postMessage(message)
const release = (): void => {
if (released) {
return
}
released = true
if (child) {
// Mirror of the direct-fork launcher: drop IPC and stderr so the daemon
// runs detached, then leave; the daemon must not die with this shim.
if (child.connected) {
child.disconnect()
}
child.stderr?.destroy()
child.unref()
}
exit(0)
}
const launch = (spec: UtilityDaemonForkSpec): void => {
try {
child = spawn({
program: spec.execPath,
args: [spec.entryPath, ...spec.args],
cwd: spec.cwd,
env: spec.env,
detached: true,
stdio: ['ignore', 'ignore', 'pipe', 'ipc']
})
} catch (error) {
post({ kind: 'spawn-error', message: error instanceof Error ? error.message : String(error) })
exit(1)
return
}
child.on('message', (message) => post({ kind: 'daemon-message', message }))
child.stderr?.on('data', (chunk: Buffer | string) =>
post({ kind: 'daemon-stderr', text: chunk.toString('utf8') })
)
child.on('error', (error) => post({ kind: 'daemon-error', message: error.message }))
child.on('exit', (code, signal) => {
post({ kind: 'daemon-exit', code, signal })
// The parent kills this shim on receipt; the linger only covers a parent
// that is already gone.
setTimeout(() => exit(0), EXIT_RELAY_LINGER_MS)
})
if (typeof child.pid === 'number') {
post({ kind: 'spawned', pid: child.pid })
} else {
post({ kind: 'spawn-error', message: 'daemon child has no pid' })
exit(1)
}
}
port.on('message', (event) => {
const message = event.data as DaemonShimDownMessage | null
if (!message || typeof message !== 'object') {
return
}
if (message.kind === 'spawn' && !launched) {
launched = true
launch(message.spec)
} else if (message.kind === 'release') {
release()
}
})
port.start?.()
post({ kind: 'shim-ready' })
}
const parentPort = (process as unknown as { parentPort?: ShimParentPort }).parentPort
if (parentPort) {
runDaemonUtilityLauncherShim(parentPort)
}
@@ -0,0 +1,170 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment'
import {
canForkDaemonThroughUtilityProcess,
forkDaemonThroughUtilityProcess,
setDaemonUtilityProcessFork,
type UtilityProcessForkFn,
type UtilityProcessLike
} from './daemon-utility-process-fork'
import type { DaemonShimDownMessage, UtilityDaemonForkSpec } from './daemon-utility-fork-messages'
class FakeShim extends EventEmitter implements UtilityProcessLike {
pid = 999
posted: DaemonShimDownMessage[] = []
killed = false
postMessage(message: unknown): void {
this.posted.push(message as DaemonShimDownMessage)
}
kill(): boolean {
this.killed = true
return true
}
}
const SPEC: UtilityDaemonForkSpec = {
entryPath: '/fake/daemon-entry.js',
args: ['--socket', '/fake/sock'],
cwd: '/fake/userData',
env: { ELECTRON_RUN_AS_NODE: '1' },
execPath: '/fake/electron'
}
let shim: FakeShim
let forkedPaths: string[]
const forkFn: UtilityProcessForkFn = (modulePath) => {
forkedPaths.push(modulePath)
return shim
}
/** Runs the handshake to a resolved child: shim-ready -> spawn -> spawned. */
async function forkSettledChild() {
const promise = forkDaemonThroughUtilityProcess(SPEC, forkFn)
shim.emit('message', { kind: 'shim-ready' })
shim.emit('message', { kind: 'spawned', pid: 777 })
return await promise
}
beforeEach(() => {
shim = new FakeShim()
forkedPaths = []
setAppEnvironment({
getAppPath: () => '/fake/app',
getPath: () => '/fake/userData',
getVersion: () => '1.2.3',
isPackaged: () => false,
onWillQuit: () => {},
exit: () => {},
getAppMetrics: () => []
} as unknown as AppEnvironment)
})
afterEach(() => {
vi.useRealTimers()
setDaemonUtilityProcessFork(null)
})
describe('canForkDaemonThroughUtilityProcess', () => {
it('never uses the utility hop on macOS: posix_spawn already strips descriptors and TCC needs the direct fork', () => {
setDaemonUtilityProcessFork(forkFn)
expect(canForkDaemonThroughUtilityProcess('darwin')).toBe(false)
})
it('uses the utility hop on Linux and Windows once the desktop installs the port', () => {
setDaemonUtilityProcessFork(forkFn)
expect(canForkDaemonThroughUtilityProcess('linux')).toBe(true)
expect(canForkDaemonThroughUtilityProcess('win32')).toBe(true)
})
it('declines on hosts that install no port (plain-node serve)', () => {
expect(canForkDaemonThroughUtilityProcess('linux')).toBe(false)
expect(canForkDaemonThroughUtilityProcess('win32')).toBe(false)
})
})
describe('forkDaemonThroughUtilityProcess', () => {
it('forks the shim entry and hands it the spawn spec over postMessage, never argv', async () => {
const child = await forkSettledChild()
expect(forkedPaths[0]).toContain('daemon-utility-launcher-shim.js')
expect(shim.posted).toEqual([{ kind: 'spawn', spec: SPEC }])
expect(child.pid).toBe(777)
expect(child.connected).toBe(true)
})
it('relays daemon IPC messages, stderr, and exit through the ChildProcess surface', async () => {
const child = await forkSettledChild()
const messages: unknown[] = []
const stderrChunks: string[] = []
const exits: [number | null, NodeJS.Signals | null][] = []
child.on('message', (message) => messages.push(message))
child.stderr?.on('data', (chunk) => stderrChunks.push(chunk.toString('utf8')))
child.on('exit', (code, signal) => exits.push([code, signal]))
shim.emit('message', { kind: 'daemon-message', message: { type: 'ready' } })
shim.emit('message', { kind: 'daemon-stderr', text: 'boom trace' })
shim.emit('message', { kind: 'daemon-exit', code: 1, signal: null })
expect(messages).toEqual([{ type: 'ready' }])
expect(stderrChunks).toEqual(['boom trace'])
expect(exits).toEqual([[1, null]])
expect(child.exitCode).toBe(1)
// Nothing left to relay once the daemon is gone.
expect(shim.killed).toBe(true)
})
it('rejects when the shim reports a spawn failure', async () => {
const promise = forkDaemonThroughUtilityProcess(SPEC, forkFn)
shim.emit('message', { kind: 'shim-ready' })
shim.emit('message', { kind: 'spawn-error', message: 'ENOENT' })
await expect(promise).rejects.toThrow('ENOENT')
expect(shim.killed).toBe(true)
})
it('rejects when the shim dies during the handshake', async () => {
const promise = forkDaemonThroughUtilityProcess(SPEC, forkFn)
shim.emit('message', { kind: 'shim-ready' })
shim.emit('exit', 1)
await expect(promise).rejects.toThrow('exited during the launch handshake')
})
it('rejects when the shim never answers', async () => {
vi.useFakeTimers()
const promise = forkDaemonThroughUtilityProcess(SPEC, forkFn)
const assertion = expect(promise).rejects.toThrow('handshake timed out')
await vi.advanceTimersByTimeAsync(10_001)
await assertion
expect(shim.killed).toBe(true)
})
it('surfaces an unexpected shim death after launch as a child error', async () => {
const child = await forkSettledChild()
const errors: Error[] = []
child.on('error', (error) => errors.push(error))
shim.emit('exit', 1)
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain('before the daemon settled')
})
it('suppresses late daemon-error relays after release: no listener remains to catch them', async () => {
const child = await forkSettledChild()
child.disconnect()
// Would be an uncaught exception if emitted with no 'error' listener.
expect(() =>
shim.emit('message', { kind: 'daemon-error', message: 'late failure' })
).not.toThrow()
})
it('disconnect releases the shim instead of killing the daemon', async () => {
const child = await forkSettledChild()
const errors: Error[] = []
child.on('error', (error) => errors.push(error))
child.disconnect()
expect(child.connected).toBe(false)
expect(shim.posted).toContainEqual({ kind: 'release' })
// The shim exiting after release is the expected shutdown, not a failure.
shim.emit('exit', 0)
expect(errors).toEqual([])
})
})
@@ -0,0 +1,255 @@
/**
* Forks the detached terminal daemon through an Electron utility process on
* Linux and Windows so it starts with a clean descriptor/handle table.
*
* Why: Chromium descriptors in the Electron main process (the CDP listener,
* crashpad channel, mojo socketpairs, writable profile files) are inheritable
* on Linux and Windows, and a daemon forked directly from main carries them —
* and hands them to every PTY child — for its whole detached lifetime. The
* observable damage: after the app exits or restarts, the daemon lineage keeps
* the CDP port bound with no acceptor (the relaunched app comes back
* debugger-less), keeps a dead instance's crashpad handler alive, and pins
* deleted shared-memory segments. Chromium launches utility processes with an
* explicit stdio-only descriptor grant on both platforms, so a daemon forked
* from a utility-process shim inherits none of that.
*
* macOS keeps the direct fork: libuv spawns with POSIX_SPAWN_CLOEXEC_DEFAULT
* there (children already start clean), and macOS TCC attribution relies on
* the direct app→daemon fork chain (STA-3491).
*/
import { EventEmitter } from 'node:events'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { getAppEnvironment } from '../../shared/app-environment'
import type {
DaemonShimDownMessage,
DaemonShimUpMessage,
UtilityDaemonForkSpec
} from './daemon-utility-fork-messages'
/**
* The structural slice of ChildProcess the daemon launcher consumes. The
* direct-fork path returns a real ChildProcess, which satisfies this.
*/
export type LaunchedDaemonChild = {
pid?: number | undefined
exitCode: number | null
signalCode: NodeJS.Signals | null
connected: boolean
stderr: LaunchedDaemonStderr | null
on(event: 'message', listener: (message: unknown) => void): unknown
on(event: 'error', listener: (error: Error) => void): unknown
on(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown
once(event: 'exit', listener: (code: number | null) => void): unknown
// Why any[] not never[]: mirrors EventEmitter.off, which class implements-checks compare non-bivariantly.
off(event: string, listener: (...args: any[]) => void): unknown
disconnect(): void
unref(): void
}
export type LaunchedDaemonStderr = {
on(event: 'data', listener: (chunk: Buffer) => void): unknown
off(event: 'data', listener: (chunk: Buffer) => void): unknown
destroy(): void
}
/** The slice of Electron's UtilityProcess this module drives. */
export type UtilityProcessLike = {
pid?: number
postMessage(message: unknown): void
on(event: 'message', listener: (message: unknown) => void): unknown
on(event: 'spawn', listener: () => void): unknown
on(event: 'exit', listener: (code: number) => void): unknown
kill(): boolean
}
export type UtilityProcessForkFn = (
modulePath: string,
args?: string[],
options?: { stdio?: string; serviceName?: string }
) => UtilityProcessLike
/** How long the shim gets to spawn and report the daemon pid. */
const SHIM_HANDSHAKE_TIMEOUT_MS = 10_000
/** After release, how long the shim gets to exit on its own before a kill. */
const SHIM_RELEASE_KILL_DELAY_MS = 5_000
// Why a host port and not an electron import: this module sits in the daemon
// launcher's graph, which the Orca runtime must be able to load on plain Node
// (`orca serve`). The desktop installs the real utilityProcess.fork from
// src/main/host/ at bootstrap; a Node host installs nothing — its parent
// process has no Chromium descriptors, so the direct fork is already clean.
let installedUtilityProcessFork: UtilityProcessForkFn | null = null
export function setDaemonUtilityProcessFork(fork: UtilityProcessForkFn | null): void {
installedUtilityProcessFork = fork
}
export function canForkDaemonThroughUtilityProcess(
platform: NodeJS.Platform = process.platform
): boolean {
if (platform === 'darwin') {
return false
}
return installedUtilityProcessFork !== null
}
function getDaemonUtilityLauncherShimPath(): string {
// Why not the app.asar.unpacked redirect daemon-entry needs: the shim runs
// under the Electron runtime, which reads asar directly.
const appPath = getAppEnvironment().getAppPath()
const directPath = join(appPath, 'daemon-utility-launcher-shim.js')
return existsSync(directPath)
? directPath
: join(appPath, 'out', 'main', 'daemon-utility-launcher-shim.js')
}
class UtilityForkedDaemonStderr extends EventEmitter implements LaunchedDaemonStderr {
destroy(): void {
this.removeAllListeners()
}
}
class UtilityForkedDaemonChild extends EventEmitter implements LaunchedDaemonChild {
pid: number | undefined
exitCode: number | null = null
signalCode: NodeJS.Signals | null = null
connected = true
readonly stderr = new UtilityForkedDaemonStderr()
private daemonExited = false
private releasedShim = false
constructor(private readonly shim: UtilityProcessLike) {
super()
}
handleShimMessage(message: DaemonShimUpMessage): void {
switch (message.kind) {
case 'daemon-message':
this.emit('message', message.message)
break
case 'daemon-stderr':
this.stderr.emit('data', Buffer.from(message.text, 'utf8'))
break
case 'daemon-error':
// After release the launcher has dropped its listeners; an unlistened
// 'error' emission is an uncaught exception in the main process.
if (!this.releasedShim) {
this.emit('error', new Error(message.message))
}
break
case 'daemon-exit':
this.daemonExited = true
this.exitCode = message.code
this.signalCode = message.signal
this.emit('exit', message.code, message.signal)
// The shim has nothing left to relay.
this.shim.kill()
break
case 'shim-ready':
case 'spawned':
case 'spawn-error':
// Handshake messages; the launch promise consumes them before routing here.
break
}
}
handleShimExit(): void {
if (this.daemonExited || this.releasedShim) {
return
}
// The relay died while the launch still depended on it. The daemon may be
// fine, but readiness/exit can no longer be observed — surface it like a
// fork error so the launcher's failure path owns cleanup by pid.
this.emit('error', new Error('Daemon utility launcher exited before the daemon settled'))
}
disconnect(): void {
if (!this.connected) {
return
}
this.connected = false
this.releasedShim = true
const down: DaemonShimDownMessage = { kind: 'release' }
try {
this.shim.postMessage(down)
} catch {
// Shim already gone; the daemon is detached either way.
}
// Fallback if the shim ignores the release; timer must not hold the loop.
const killTimer = setTimeout(() => this.shim.kill(), SHIM_RELEASE_KILL_DELAY_MS)
killTimer.unref?.()
this.shim.on('exit', () => clearTimeout(killTimer))
}
unref(): void {
// The shim exits right after release and the daemon is already detached;
// there is no parent-side handle left to unref.
}
}
export async function forkDaemonThroughUtilityProcess(
spec: UtilityDaemonForkSpec,
forkUtilityProcess?: UtilityProcessForkFn
): Promise<LaunchedDaemonChild> {
const fork = forkUtilityProcess ?? installedUtilityProcessFork
if (!fork) {
throw new Error('No utility-process fork is installed on this host')
}
const shim = fork(getDaemonUtilityLauncherShimPath(), [], {
stdio: 'ignore',
serviceName: 'orca-daemon-launcher'
})
const child = new UtilityForkedDaemonChild(shim)
return await new Promise<LaunchedDaemonChild>((resolve, reject) => {
let settled = false
const timer = setTimeout(() => {
fail(new Error('Daemon utility launcher handshake timed out'))
}, SHIM_HANDSHAKE_TIMEOUT_MS)
function fail(error: Error): void {
if (settled) {
return
}
settled = true
clearTimeout(timer)
shim.kill()
reject(error)
}
shim.on('message', (raw) => {
const message = raw as DaemonShimUpMessage | null
if (!message || typeof message !== 'object') {
return
}
if (message.kind === 'shim-ready') {
const down: DaemonShimDownMessage = { kind: 'spawn', spec }
shim.postMessage(down)
return
}
if (message.kind === 'spawned') {
if (!settled) {
settled = true
clearTimeout(timer)
child.pid = message.pid
resolve(child)
}
return
}
if (message.kind === 'spawn-error') {
fail(new Error(`Daemon spawn failed in utility launcher: ${message.message}`))
return
}
child.handleShimMessage(message)
})
shim.on('exit', () => {
if (!settled) {
fail(new Error('Daemon utility launcher exited during the launch handshake'))
return
}
child.handleShimExit()
})
})
}
@@ -0,0 +1,14 @@
import { utilityProcess } from 'electron'
import type { UtilityProcessForkFn } from '../daemon/daemon-utility-process-fork'
/**
* The desktop implementation of the daemon launcher's utility-process port.
*
* Why it exists: on Linux and Windows a daemon forked directly from the Electron
* main process inherits Chromium descriptors (the CDP listener among them) for
* its whole detached lifetime. Chromium launches utility processes with a clean
* stdio-only descriptor grant, so the daemon launcher forks through one where a
* desktop host installs this.
*/
export const electronDaemonUtilityProcessFork: UtilityProcessForkFn = (modulePath, args, options) =>
utilityProcess.fork(modulePath, args ? [...args] : [], options)
+6
View File
@@ -27,6 +27,8 @@ import { ElectronAppEnvironment } from './host/electron-app-environment'
import { setPtyHostBindings } from './ipc/pty-host-bindings'
import { electronRuntimeDesktopSurface } from './host/electron-runtime-desktop-surface'
import { setRuntimeDesktopSurface } from './runtime/runtime-desktop-surface'
import { electronDaemonUtilityProcessFork } from './host/electron-daemon-utility-process-fork'
import { setDaemonUtilityProcessFork } from './daemon/daemon-utility-process-fork'
import { electronRuntimeBrowserCommandsFactory } from './host/electron-browser-commands'
import { setRuntimeBrowserCommandsFactory } from './runtime/runtime-browser-commands-factory'
import { electronHttpClient } from './host/electron-http-client'
@@ -949,6 +951,10 @@ if (hasSingleInstanceLock) {
// tab-create-reply channel are desktop-only. A Node host installs none and the
// runtime routes notifications to paired clients instead.
setRuntimeDesktopSurface(electronRuntimeDesktopSurface)
// Why: on Linux/Windows the daemon forks through a utility process so it does not
// inherit Chromium descriptors (CDP listener, crashpad channel, profile fds). A Node
// host installs nothing — its parent has no Chromium descriptors to leak.
setDaemonUtilityProcessFork(electronDaemonUtilityProcessFork)
// Why here: constructing RuntimeBrowserCommands is what pulls the Chromium browser
// cluster into the graph. The desktop installs it; a Node host installs none and every
// browser RPC rejects, which capability filtering already tells clients about.
@@ -24,6 +24,7 @@ describe('host port bootstrap wiring', () => {
'setSecretStore(new ElectronSecretStore())',
'setPtyHostBindings({',
'setRuntimeDesktopSurface(electronRuntimeDesktopSurface)',
'setDaemonUtilityProcessFork(electronDaemonUtilityProcessFork)',
'setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory)',
'setDefaultProxySessionResolver(',
'setMainHttpClient(electronHttpClient)',