mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
fix(daemon): fork the detached daemon through a utility process so it stops inheriting Chromium descriptors
This commit is contained in:
committed by
Merge Sim
parent
f7d8d7f77a
commit
1d8153b42e
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user