mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* fix(rate-limits): spawn the Codex usage probe off the main thread On Windows, creating a cold standalone codex.exe can spend seconds in code integrity checks on the calling thread. The usage probe runs on window focus, so that froze the UI. The probe's child is now created and piped on a worker thread and proxied back to the main thread. Fixes #27362 * refactor(rate-limits): type the worker's children through @orca/process-host * fix(rate-limits): kill an orphaned Codex probe by pid when its worker dies Once the worker thread that owns the probe's child is gone, kill() can no longer reach the child through it. The proxy now falls back to a pid-addressed kill; the Codex probe uses the app-server tree kill so a node launcher's codex.exe child goes too, while SIGTERM stays a graceful root signal. * refactor(build): move the main build's entry map out of electron.vite.config.ts The new worker entry pushed electron.vite.config.ts past the 300-line limit. The Rollup input map for the main build now lives in its own module. * refactor(rate-limits): drop the orphan pid kill and trim the worker child-process stand-in A worker thread's death closes its children's pipes, so an abandoned Codex probe already gets stdin EOF, its graceful stop. The pid-addressed orphan kill was redundant with that and with terminateCodexProbeChild's own gated Windows tree kill. Its hard kill also bypassed the tree-kill gate's recycled-pid refusal by turning the handle-addressed fallback into a pid kill. - Remove orphan-pid-kill, killOrphanedCodexProbe, the injected killOrphan, and the orphaned flag. kill() now reports false once a child is no longer tracked. - Process-creation failure is one 'spawn-error' event that the stand-in expands into error/exit/close, replacing the duplicated triple and the exported exit code. Stdin errors arrive as child errors, as the probe already treated them. - Fold both suites into one over a real MessageChannel and real children. Add a real-worker test showing that terminating or crashing the worker lets the child exit on stdin EOF. * test(rate-limits): run the worker child-process suite under Node The suite asserts Node behavior that Electron main relies on: a failed spawn closes with a null signal, and a dead worker thread closes its children's pipes. Bun reports an undefined signal and, on Linux, leaves the pipes open, so the Bun lane failed on runtime differences, not on the product code. * test(rate-limits): count a reaped-by-nobody zombie as an exited child On Linux and macOS a child whose worker thread died gets stdin EOF and exits, but libuv reaps a child only through the handle on the loop that spawned it. Once that loop is gone, nothing waits on the pid, so it stays a zombie and kill(pid, 0) still succeeds. The real-worker tests now treat a zombie as exited, and still require the child's EOF marker. --------- Co-authored-by: Orca Worker <orca-worker@localhost>
368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
import { spawn } from 'node:child_process'
|
|
import { isBuiltin } from 'node:module'
|
|
import { join } from 'node:path'
|
|
import type { Plugin, Rollup } from 'vite'
|
|
|
|
type NormalizedInputOptions = Rollup.NormalizedInputOptions
|
|
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
|
|
type OutputBundle = Rollup.OutputBundle
|
|
type OutputChunk = Rollup.OutputChunk
|
|
|
|
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
|
|
// chunk gained `require("electron")` (an import edge added in #7642), and the
|
|
// daemon is forked as a plain-Node process where electron cannot be required.
|
|
// Nothing in CI executes the built daemon-entry under plain Node, so the leak
|
|
// stayed invisible until an adopted old daemon died. This guard fails the
|
|
// build when any chunk reachable from a plain-Node fork entry requires
|
|
// electron, and smoke-loads daemon-entry under plain Node to prove its module
|
|
// graph still resolves.
|
|
|
|
// The CLI loads these paths after electron-vite replaces out/main.
|
|
export const CLI_MAIN_ENTRY_NAMES = [
|
|
'agent-hooks/managed-agent-hook-controls',
|
|
'gitlab/project-ref-parser',
|
|
'orca-profiles/profile-index-store',
|
|
'claude-accounts/keychain',
|
|
...[
|
|
'access',
|
|
'active-location',
|
|
'storage-classification',
|
|
'offline-settings',
|
|
'backup-path',
|
|
'database-recovery',
|
|
'domain-reader',
|
|
'recovery-command'
|
|
].map((module) => `persistence/profile-state/profile-state-${module}`),
|
|
'persistence/profile-state/legacy-json/profile-state-export-path',
|
|
'persistence/profile-state/legacy-json/profile-state-recovery',
|
|
'startup/http1-compatibility-marker'
|
|
] as const
|
|
|
|
// Plain-Node processes and CLI modules cannot load Electron's API.
|
|
const PLAIN_NODE_ENTRY_NAMES = [
|
|
'daemon-entry',
|
|
'parcel-watcher-process-entry',
|
|
'session-scanner-service-entry',
|
|
'computer-sidecar',
|
|
'wsl-transcript-fs-process-entry',
|
|
'orcad/orcad-local-serve-selection-entry',
|
|
...CLI_MAIN_ENTRY_NAMES
|
|
] as const
|
|
|
|
// Entries executed as worker threads of the main process. Electron's module is
|
|
// not registered on worker threads, so require("electron") throws
|
|
// "Cannot find module 'electron'" there too (verified on Electron 43) and kills
|
|
// the worker at startup. These carry hand-written "must stay electron-free"
|
|
// comments, which is convention, not enforcement — and the port-scan worker in
|
|
// particular sits one import away from a client module that deliberately does
|
|
// require electron.
|
|
const WORKER_THREAD_ENTRY_NAMES = [
|
|
'stt-worker',
|
|
'warp-theme-parser-worker',
|
|
'foreign-sqlite-reader-entry',
|
|
'main-thread-hang-watchdog-entry',
|
|
'port-scan-command-worker-entry',
|
|
'worker-thread-child-process-entry',
|
|
'usage-scan-worker-entry',
|
|
'claude-profile-setup-worker-entry',
|
|
'profile-state-backup-worker-entry',
|
|
'profile-state-writer-worker-entry'
|
|
] as const
|
|
|
|
export const GUARDED_ENTRY_NAMES = [
|
|
...PLAIN_NODE_ENTRY_NAMES,
|
|
...WORKER_THREAD_ENTRY_NAMES
|
|
] as const
|
|
|
|
type EntryRuntime = 'plain-Node process' | 'worker thread'
|
|
|
|
// Subpaths (electron/main) are as unloadable as the bare module under plain Node.
|
|
const ELECTRON_REQUIRE_RE = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/
|
|
|
|
// Why: writeBundle skips any name missing from the bundle, so a renamed or
|
|
// removed rollup input would silently drop that entry from the guard and let the
|
|
// regression back in. Pin the lists to the input keys at build start instead.
|
|
function assertEntryNamesAreRollupInputs(input: NormalizedInputOptions['input']): void {
|
|
if (typeof input === 'string' || Array.isArray(input)) {
|
|
return
|
|
}
|
|
const inputNames = new Set(Object.keys(input))
|
|
const missing = GUARDED_ENTRY_NAMES.filter((name) => !inputNames.has(name))
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] guarded ${missing.map((name) => `"${name}"`).join(', ')} ` +
|
|
`${missing.length === 1 ? 'is not a rollup input' : 'are not rollup inputs'} anymore. ` +
|
|
`Update PLAIN_NODE_ENTRY_NAMES/WORKER_THREAD_ENTRY_NAMES in plain-node-entry-guard.ts to ` +
|
|
`the current entry names — a stale name silently stops guarding that entry.`
|
|
)
|
|
}
|
|
}
|
|
|
|
function collectReachableChunks(
|
|
entry: OutputChunk,
|
|
byFileName: Map<string, OutputChunk>
|
|
): OutputChunk[] {
|
|
const seen = new Set<string>()
|
|
const reachable: OutputChunk[] = []
|
|
const stack = [entry.fileName]
|
|
while (stack.length > 0) {
|
|
const fileName = stack.pop() as string
|
|
if (seen.has(fileName)) {
|
|
continue
|
|
}
|
|
seen.add(fileName)
|
|
const chunk = byFileName.get(fileName)
|
|
if (!chunk) {
|
|
continue
|
|
}
|
|
reachable.push(chunk)
|
|
for (const imported of [...chunk.imports, ...chunk.dynamicImports]) {
|
|
stack.push(imported)
|
|
}
|
|
}
|
|
return reachable
|
|
}
|
|
|
|
function assertNoElectronRequire(
|
|
entryName: string,
|
|
entry: OutputChunk,
|
|
byFileName: Map<string, OutputChunk>,
|
|
electronFreeChunkCode: Map<OutputChunk, string>,
|
|
runtime: EntryRuntime = 'plain-Node process'
|
|
): void {
|
|
for (const chunk of collectReachableChunks(entry, byFileName)) {
|
|
const code = chunk.code
|
|
if (electronFreeChunkCode.get(chunk) === code) {
|
|
continue
|
|
}
|
|
if (ELECTRON_REQUIRE_RE.test(code)) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
|
|
`requires electron. "${entryName}" runs as a ${runtime}, where ` +
|
|
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
|
|
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
|
|
)
|
|
}
|
|
electronFreeChunkCode.set(chunk, code)
|
|
}
|
|
}
|
|
|
|
// Why: on packaged Windows the daemon runs from a relocated daemon-host copy that mirrors only
|
|
// these packages (src/main/daemon/daemon-host-manifest.ts). Any other bare require in its graph
|
|
// resolves in the install dir but throws MODULE_NOT_FOUND in the copy, and the desktop silently
|
|
// falls back to an unrelocated daemon that the next update kills.
|
|
const DAEMON_HOST_MIRRORED_PACKAGES = [
|
|
'@orca/process-host',
|
|
'node-pty',
|
|
'@vscode/windows-process-tree'
|
|
]
|
|
const BARE_REQUIRE_RE = /require\(\s*["'`]([^"'`./][^"'`]*)["'`]\s*\)/g
|
|
|
|
function packageOfSpecifier(specifier: string): string {
|
|
const parts = specifier.split('/')
|
|
return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]!
|
|
}
|
|
|
|
function assertDaemonExternalsAreMirrored(
|
|
entry: OutputChunk,
|
|
byFileName: Map<string, OutputChunk>
|
|
): void {
|
|
for (const chunk of collectReachableChunks(entry, byFileName)) {
|
|
for (const match of chunk.code.matchAll(BARE_REQUIRE_RE)) {
|
|
const specifier = match[1]!
|
|
if (specifier.startsWith('node:') || isBuiltin(specifier)) {
|
|
continue
|
|
}
|
|
if (!DAEMON_HOST_MIRRORED_PACKAGES.includes(packageOfSpecifier(specifier))) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] "daemon-entry" reaches chunk "${chunk.fileName}" that ` +
|
|
`requires "${specifier}", which the relocated Windows daemon-host does not mirror ` +
|
|
`(daemon-host-manifest.ts). Keep it out of the daemon graph, or mirror it there.`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Owned by the argv parser in src/main/daemon/daemon-entry.ts — keep in sync.
|
|
const DAEMON_USAGE_PREFIX = 'Usage: daemon-entry'
|
|
|
|
export type SmokeTimings = {
|
|
timeoutMs: number
|
|
// daemon-entry traps SIGTERM and awaits a native shutdown, so the deadline
|
|
// needs an uncatchable follow-up to stay a deadline.
|
|
killGraceMs: number
|
|
}
|
|
|
|
const DEFAULT_SMOKE_TIMINGS: SmokeTimings = { timeoutMs: 15_000, killGraceMs: 2_000 }
|
|
|
|
// Bound the wait for stderr to flush after exit; a grandchild inheriting stdio
|
|
// can hold the pipes open long after the child is gone.
|
|
const SMOKE_STDERR_DRAIN_MS = 250
|
|
|
|
type SmokeResult = {
|
|
status: number | null
|
|
signal: NodeJS.Signals | null
|
|
stderr: string
|
|
error?: Error
|
|
timedOut: boolean
|
|
}
|
|
|
|
// Why not spawnSync({ timeout }): its timeout only sends killSignal and then
|
|
// keeps blocking until the child exits, so a child that traps SIGTERM hangs the
|
|
// build forever. Escalate to SIGKILL instead.
|
|
function runDaemonEntry(entryPath: string, timings: SmokeTimings): Promise<SmokeResult> {
|
|
return new Promise((resolve) => {
|
|
const child = spawn(process.execPath, [entryPath], { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
let stderr = ''
|
|
let timedOut = false
|
|
let settled = false
|
|
let forceKillTimer: NodeJS.Timeout | undefined
|
|
let drainTimer: NodeJS.Timeout | undefined
|
|
|
|
child.stderr.setEncoding('utf8')
|
|
child.stderr.on('data', (chunk: string) => {
|
|
stderr += chunk
|
|
})
|
|
|
|
const deadlineTimer = setTimeout(() => {
|
|
timedOut = true
|
|
child.kill('SIGTERM')
|
|
forceKillTimer = setTimeout(() => child.kill('SIGKILL'), timings.killGraceMs)
|
|
}, timings.timeoutMs)
|
|
|
|
const finish = (status: number | null, signal: NodeJS.Signals | null, error?: Error): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
clearTimeout(deadlineTimer)
|
|
clearTimeout(forceKillTimer)
|
|
clearTimeout(drainTimer)
|
|
resolve({ status, signal, stderr, error, timedOut })
|
|
}
|
|
|
|
child.on('error', (error: Error) => finish(null, null, error))
|
|
// 'close' gives the full stderr; 'exit' is the fallback so a held-open pipe
|
|
// cannot outlast the process itself.
|
|
child.on('close', (status, signal) => finish(status, signal))
|
|
child.on('exit', (status, signal) => {
|
|
drainTimer = setTimeout(() => finish(status, signal), SMOKE_STDERR_DRAIN_MS)
|
|
})
|
|
})
|
|
}
|
|
|
|
// Why: proves the whole daemon-entry graph resolves under plain Node (no
|
|
// unresolved requires). require("electron") does not throw in a dev tree with
|
|
// node_modules present, so the static scan above — not this smoke — is the
|
|
// electron regression guard; this only catches gross load failures.
|
|
async function smokeLoadDaemonEntry(outputDir: string, timings: SmokeTimings): Promise<void> {
|
|
const entryPath = join(outputDir, 'daemon-entry.js')
|
|
const result = await runDaemonEntry(entryPath, timings)
|
|
if (result.error) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
|
|
`${result.error.message}`
|
|
)
|
|
}
|
|
// Almost always means the daemon stopped rejecting an empty argv and started
|
|
// listening instead.
|
|
if (result.timedOut) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] daemon-entry.js did not exit within ${timings.timeoutMs}ms on an ` +
|
|
`empty argv under plain Node, so the smoke killed it.`
|
|
)
|
|
}
|
|
if (result.signal) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] daemon-entry.js was killed by ${result.signal} under plain Node.`
|
|
)
|
|
}
|
|
const stderr = result.stderr
|
|
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
|
|
)
|
|
}
|
|
if (result.status === 0 || !stderr.includes(DAEMON_USAGE_PREFIX)) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] daemon-entry.js did not reject an empty argv under plain Node ` +
|
|
`(expected a non-zero exit and the "${DAEMON_USAGE_PREFIX}" error, got exit ` +
|
|
`${result.status}). stderr:\n${stderr}`
|
|
)
|
|
}
|
|
}
|
|
|
|
export function createPlainNodeEntryGuardPlugin(
|
|
smokeTimings: SmokeTimings = DEFAULT_SMOKE_TIMINGS
|
|
): Plugin {
|
|
let daemonOutputDir: string | undefined
|
|
|
|
return {
|
|
name: 'orca-plain-node-entry-guard',
|
|
buildStart(options: NormalizedInputOptions) {
|
|
assertEntryNamesAreRollupInputs(options.input)
|
|
},
|
|
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
|
|
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
|
|
// every rebuild, and the guard only needs to gate produced builds.
|
|
if (this.meta.watchMode) {
|
|
return
|
|
}
|
|
const chunks = Object.values(bundle).filter(
|
|
(item): item is OutputChunk => item.type === 'chunk'
|
|
)
|
|
const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]))
|
|
const entryByName = new Map<string, OutputChunk>()
|
|
for (const chunk of chunks) {
|
|
if (chunk.isEntry && chunk.name) {
|
|
entryByName.set(chunk.name, chunk)
|
|
}
|
|
}
|
|
|
|
const electronFreeChunkCode = new Map<OutputChunk, string>()
|
|
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
|
|
const entry = entryByName.get(entryName)
|
|
if (entry) {
|
|
assertNoElectronRequire(
|
|
entryName,
|
|
entry,
|
|
byFileName,
|
|
electronFreeChunkCode,
|
|
'plain-Node process'
|
|
)
|
|
}
|
|
}
|
|
|
|
for (const entryName of WORKER_THREAD_ENTRY_NAMES) {
|
|
const entry = entryByName.get(entryName)
|
|
if (entry) {
|
|
assertNoElectronRequire(
|
|
entryName,
|
|
entry,
|
|
byFileName,
|
|
electronFreeChunkCode,
|
|
'worker thread'
|
|
)
|
|
}
|
|
}
|
|
|
|
const daemonEntry = entryByName.get('daemon-entry')
|
|
if (daemonEntry) {
|
|
assertDaemonExternalsAreMirrored(daemonEntry, byFileName)
|
|
}
|
|
|
|
if (entryByName.has('daemon-entry') && options.dir) {
|
|
daemonOutputDir = options.dir
|
|
}
|
|
},
|
|
async closeBundle() {
|
|
if (daemonOutputDir) {
|
|
const outputDir = daemonOutputDir
|
|
daemonOutputDir = undefined
|
|
await smokeLoadDaemonEntry(outputDir, smokeTimings)
|
|
}
|
|
}
|
|
}
|
|
}
|