Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/scripts/vitest-node-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { SQLITE_RUNTIME_INCLUDE } from './vitest-sqlite-runtime-files.mjs'

// SQLite publication, native bindings, worker IPC, socket liveness, and V8 retention use Node.
export const NODE_RUNTIME_INCLUDE = [
'src/main/ipc/runtime-environment-cli-removal.electron.test.ts',
'config/scripts/vitest-sqlite-runtime-boundary.test.ts',
'config/scripts/cloud-security-history-workflow.test.mjs',
'config/scripts/skill-recipe-shell.test.mjs',
Expand Down
1 change: 1 addition & 0 deletions config/scripts/vitest-sqlite-database-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [
'src/main/runtime/claude-structured-session-integration.test.ts',
'src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts',
'src/main/runtime/exit-provenance-audit.test.ts',
'src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts',
'src/main/runtime/orchestration-cli-subprocess.test.ts',
'src/main/runtime/orchestration-dispatch-mailbox-delivery.test.ts',
'src/main/runtime/orchestration-mailbox-crash-recovery.test.ts',
Expand Down
229 changes: 229 additions & 0 deletions src/main/ipc/runtime-environment-cli-removal.electron.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import {
spawnProcess,
runProcess,
type ChildProcessHandle
} from '../../shared/child-process/run-process'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { builtinModules, createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { isAbsolute, join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { build as buildVite } from 'vite'
import { resolveElectronProbeLaunch } from '../browser/electron-probe-display-launch'
import { REMOTE_RUNTIME_SOCKET_PING_INTERVAL_MS } from '../../shared/remote-runtime-socket-liveness'
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version'
import { encodePairingOffer } from '../../shared/pairing'
import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store'
import {
closeSharedControlTestServers,
createSharedControlTestServer
} from '../../shared/remote-runtime-shared-control-test-server'

// The real CLI and a hidden Electron process exercise registration and socket retirement together.

const electronBinary = resolveElectronBinary()
const roots: string[] = []
let child: ChildProcessHandle | null = null

afterAll(async () => {
child?.kill('SIGKILL')
await closeSharedControlTestServers()
Comment on lines +36 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n 25,60p src/main/ipc/runtime-environment-cli-removal.electron.test.ts
rg -n "xvfb|spawn\(|detached|taskkill|treeKill|killProcessTree" src/main/ipc/runtime-environment-cli-removal.electron.test.ts
rg -ln "taskkill|killProcessTree|tree-kill" src config | head

Repository: stablyai/orca

Length of output: 1899


🏁 Script executed:

sed -n '1,260p' src/main/ipc/runtime-environment-cli-removal.electron.test.ts
printf '\\n--- process termination matches ---\\n'
rg -n -i "taskkill|killprocess(tree)?|kill.*tree|tree.*kill|terminate.*(child|process)|child.*(terminate|kill)|detached" src --glob '!**/*.test.ts' || test "$?" -eq 1
printf '\\n--- candidate helper sources ---\\n'
sed -n '1,240p' src/shared/ephemeral-vm-recipe-process.ts
sed -n '1,220p' src/cli/handlers/interactive-login-interruption.ts
sed -n '1,240p' src/main/pty-descendant-termination.test.ts

Repository: stablyai/orca

Length of output: 45655


🏁 Script executed:

printf '%s\\n' '--- test launch and teardown ---'
nl -ba src/main/ipc/runtime-environment-cli-removal.electron.test.ts | sed -n '20,42p;112,157p'
printf '%s\\n' '--- electron launch resolver ---'
nl -ba src/main/browser/electron-probe-display-launch.ts | sed -n '1,220p'
printf '%s\\n' '--- child-process run-process API ---'
nl -ba src/shared/child-process/run-process.ts | sed -n '1,260p'
printf '%s\\n' '--- child-process helper files ---'
rg --files src/shared/child-process
printf '%s\\n' '--- helper exports/usages ---'
rg -n "terminateWindowsLoginProcessTree|killRecipeProcess|process-tree-kill" src/shared src/cli src/main/ipc src/main/browser

Repository: stablyai/orca

Length of output: 20306


🏁 Script executed:

printf '%s\\n' '--- process tree termination ---'
nl -ba src/shared/child-process/process-tree-termination.ts | sed -n '1,300p'
printf '%s\\n' '--- process-tree kill gate ---'
nl -ba src/shared/child-process/process-tree-kill-gate.ts | sed -n '1,240p'
printf '%s\\n' '--- process spec ---'
nl -ba src/shared/child-process/process-spec.ts | sed -n '1,220p'
printf '%s\\n' '--- spawn resolution ---'
nl -ba src/shared/child-process/spawn-resolution.ts | sed -n '1,220p'
printf '%s\\n' '--- focused helper tests ---'
rg -n "forceTerminateProcessTree|signalProcessTree|taskkill|tree" src/shared/child-process/process-tree-termination.test.ts

Repository: stablyai/orca

Length of output: 20606


🏁 Script executed:

nl -ba src/main/ipc/runtime-environment-cli-removal.electron.test.ts | sed -n '32,50p;158,215p'
printf '%s\\n' '--- tree helper completion tests ---'
nl -ba src/shared/child-process/process-tree-termination.test.ts | sed -n '30,100p'

Repository: stablyai/orca

Length of output: 5881


Terminate the process tree before removing the fixture.

child.kill('SIGKILL') targets only the spawned process and does not wait for termination. When Linux uses xvfb-run because DISPLAY is unset, Electron is a child of the process this test kills. On Windows, Chromium descendants can retain profile handles while rmSync runs. Use the process-tree helper, and spawn detached on POSIX so the helper can terminate the whole group. Skip fixture removal if termination is not verified.

Suggested fix
+import { forceTerminateProcessTree } from '../../shared/child-process/process-tree-termination'
+
 afterAll(async () => {
-  child?.kill('SIGKILL')
+  const treeTerminated = child ? await forceTerminateProcessTree(child) : true
   await closeSharedControlTestServers()
   if (process.env.ORCA_KEEP_FIXTURE === '1') {
     return
   }
+  if (!treeTerminated) {
+    throw new Error('Could not verify Electron process-tree termination; fixture cleanup skipped')
+  }
   for (const root of roots) {
     rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
   }
 })
...
       env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' },
-      stdio: ['ignore', 'pipe', 'pipe']
+      stdio: ['ignore', 'pipe', 'pipe'],
+      detached: process.platform !== 'win32'
     })

if (process.env.ORCA_KEEP_FIXTURE === '1') {
return
}
for (const root of roots) {
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
})

function resolveElectronBinary(): string {
const resolved: unknown = createRequire(import.meta.url)('electron')
if (typeof resolved !== 'string') {
throw new Error('The electron package did not resolve to a binary path')
}
return resolved
}

function buildFixtureMain(bundlePath: string, logPath: string, userDataPath: string): string {
return [
`const { app } = require('electron')`,
`app.setPath('userData', ${JSON.stringify(userDataPath)})`,
`const { appendFileSync } = require('node:fs')`,
`const log = (message) => appendFileSync(${JSON.stringify(logPath)}, message + '\\n')`,
`process.on('uncaughtException', (error) => log('uncaught ' + (error && error.stack)))`,
`process.on('unhandledRejection', (error) => log('unhandled ' + (error && error.stack)))`,
`const runtime = require(${JSON.stringify(bundlePath)})`,
`runtime.applyBackgroundActivationPolicy()`,
`app.whenReady().then(() => {`,
` log('ready ' + app.getPath('userData'))`,
` runtime.registerRuntimeEnvironmentHandlers({ getSettings: () => ({}), getWorkspaceSessionHostIds: () => [] })`,
` log('registered')`,
`}).catch((error) => log('ready-failed ' + (error && error.stack)))`
].join('\n')
}

async function waitFor(
predicate: () => boolean,
timeoutMs: number,
what: () => string
): Promise<void> {
const startedAt = Date.now()
while (!predicate()) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`Timed out waiting for ${what()}`)
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
}

function readFixtureLog(root: string): string {
const path = join(root, 'fixture.log')
return existsSync(path) ? readFileSync(path, 'utf8') : '(no fixture log)'
}

const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))

describe('runtime environment removed by the CLI', () => {
it('drops the live socket the desktop app was holding and never reconnects', async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-cli-removal-'))
roots.push(root)
const userDataPath = join(root, 'profile')
mkdirSync(userDataPath, { recursive: true })
const fixtureDir = join(root, 'fixture')
mkdirSync(fixtureDir, { recursive: true })
symlinkSync(
join(process.cwd(), 'node_modules'),
join(fixtureDir, 'node_modules'),
process.platform === 'win32' ? 'junction' : 'dir'
)

// The app only keeps a standing shared-control socket for a host that advertises the capability.
const server = await createSharedControlTestServer({
resultForRequest: (method) =>
method === 'status.get'
? { capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY], runtimeId: 'runtime-test' }
: { method }
})
const environment = addEnvironmentFromPairingCode(userDataPath, {
name: 'removal-host',
pairingCode: encodePairingOffer(server.pairing)
})

const entryPath = join(fixtureDir, 'entry.ts')
writeFileSync(
entryPath,
`export { registerRuntimeEnvironmentHandlers } from ${JSON.stringify(join(process.cwd(), 'src/main/ipc/runtime-environments.ts'))}\nexport { applyBackgroundActivationPolicy } from ${JSON.stringify(join(process.cwd(), 'src/main/window/foreground-activation-policy.ts'))}`
)
await buildVite({
configFile: false,
logLevel: 'silent',
// Why `node` conditions and the bare-builtin externals: the default browser resolution stubs
// `crypto` and the bundle throws on load instead of running the shipped code.
resolve: { conditions: ['node'] },
build: {
emptyOutDir: false,
lib: { entry: entryPath, formats: ['cjs'], fileName: () => 'runtime-environments.cjs' },
outDir: fixtureDir,
target: 'node20',
rollupOptions: { external: ['electron', 'ssh2', /^node:/, ...builtinModules] }
}
})

await buildVite({
configFile: false,
logLevel: 'silent',
resolve: { conditions: ['node'] },
build: {
emptyOutDir: false,
lib: {
entry: join(process.cwd(), 'src/cli/index.ts'),
formats: ['cjs'],
fileName: () => 'orca-cli.cjs'
},
outDir: fixtureDir,
target: 'node20',
rollupOptions: { external: (id) => !isAbsolute(id) && !id.startsWith('.') }
}
})

writeFileSync(
join(fixtureDir, 'package.json'),
'{ "name": "orca-cli-removal-fixture", "main": "main.js" }'
)
writeFileSync(
join(fixtureDir, 'main.js'),
buildFixtureMain(
join(fixtureDir, 'runtime-environments.cjs'),
join(root, 'fixture.log'),
userDataPath
)
)

const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = process.env
const electronArgs = [fixtureDir, `--user-data-dir=${userDataPath}`]
const { executable, args } = resolveElectronProbeLaunch({
electronBinary,
electronArgs,
platform: process.platform,
display: env.DISPLAY
})
child = spawnProcess({
program: executable,
args,
env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' },
stdio: ['ignore', 'pipe', 'pipe']
})
let stderr = ''
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString()
})

// The app connects on its own at startup, exactly as it does for a stored environment.
await waitFor(
() =>
server.connectionCount() >= 2 && server.openClientCount() === 1 && server.auths.length >= 2,
60_000,
() =>
`the app to connect (connections=${server.connectionCount()} open=${server.openClientCount()} methods=${server.requests.map((request) => request.method).join(',')} stderr=${stderr} fixture=${readFixtureLog(root)})`
)
const connectionsWhileStored = server.connectionCount()

// What `orca environment rm` does, from a process the app knows nothing about.
const cli = await runProcess({
program: process.execPath,
args: [
join(fixtureDir, 'orca-cli.cjs'),
'environment',
'rm',
'--environment',
environment.id,
'--json'
],
env: { ...process.env, ORCA_USER_DATA_PATH: userDataPath },
timeoutMs: 30_000
})
expect(cli.code, cli.stderr || cli.stdout).toBe(0)

await waitFor(
() => server.openClientCount() === 0,
REMOTE_RUNTIME_SOCKET_PING_INTERVAL_MS * 3,
() => `the removed environment to lose its socket (open=${server.openClientCount()})`
)
await delay(REMOTE_RUNTIME_SOCKET_PING_INTERVAL_MS + 2_000)
expect(server.openClientCount()).toBe(0)
expect(server.connectionCount()).toBe(connectionsWhileStored)
console.info('CLI removal socket proof', {
before: connectionsWhileStored,
openAfter: server.openClientCount(),
totalAfter: server.connectionCount()
})
}, 180_000)
})
Loading
Loading