From 3633c1b074dfcca312d58e5960efc4605aae98ca Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:24:01 -0700 Subject: [PATCH] test(orcad): guard an idle WSL-host orcad tree's resident memory in the node-server lane (#35772) Boot the built orcad the way a WSL host launch does, open a repo and two terminals, idle 90 s, and assert the whole process tree stays under 400 MB of RSS (about 251 MB today). It catches a new always-on worker or a retained buffer between device runs of the 1 GB memory gate. --- config/scripts/node-server-test-paths.mjs | 1 + ...cad-idle-memory-budget.integration.test.ts | 251 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 src/main/orcad/orcad-idle-memory-budget.integration.test.ts diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs index 7dc21a1e530..e6e806eb8b8 100644 --- a/config/scripts/node-server-test-paths.mjs +++ b/config/scripts/node-server-test-paths.mjs @@ -47,6 +47,7 @@ export function nodeServerTestPaths({ 'src/main/orcad/orcad-stop-request-shutdown.integration.test.ts', 'src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts', 'src/main/orcad/orcad-serve-parity.integration.test.ts', + 'src/main/orcad/orcad-idle-memory-budget.integration.test.ts', 'config/scripts/zip-extractor-command.test.mjs' ] : []), diff --git a/src/main/orcad/orcad-idle-memory-budget.integration.test.ts b/src/main/orcad/orcad-idle-memory-budget.integration.test.ts new file mode 100644 index 00000000000..d39f960051a --- /dev/null +++ b/src/main/orcad/orcad-idle-memory-budget.integration.test.ts @@ -0,0 +1,251 @@ +// Regression guard for the WSL host memory gate: a built orcad, launched the way a WSL host launch does, +// must keep its whole process tree under a resident budget once idle with two terminals open. +import { existsSync, mkdtempSync, readdirSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { z } from 'zod' +import { runProcess, spawnProcess } from '@orca/process-host' +import { + ORCAD_CLI_ENTRY_FILENAME, + ORCAD_NODE_RUNTIME_MARKER_FILENAME +} from '../../shared/orcad-artifacts' +import { ORCAD_WSL_HOST_ENV } from '../ipc/wsl-guest-windows-drive-path' +import { ORCAD_RUNTIME_V8_FLAGS } from '../managed-server/install/orcad-remote-launch' +import { hostMallocTunablesLaunchEnv } from '../memory/host-malloc-tunables' +import { resolveBundledOrcadRuntime } from './orcad-bundled-runtime' +import { skipForMissingInputs } from './orcad-node-slot-fixture' +import { + killProfileDaemons, + recordProfileDaemons, + removeTestRoot +} from './orcad-daemon-teardown-fixture' + +// About 255 MB today (linux-arm64, cold compile); the headroom absorbs runner noise, not a new resident cost. +const ORCAD_TREE_RSS_BUDGET_MB = 400 +const IDLE_MS = 90_000 + +const slotDir = resolve('out/orcad') +const cliEntry = join(slotDir, ...ORCAD_CLI_ENTRY_FILENAME.split('/')) +const runtime = existsSync(join(slotDir, ORCAD_NODE_RUNTIME_MARKER_FILENAME)) + ? resolveBundledOrcadRuntime(slotDir) + : null +// smaps_rollup is Linux-only, and Linux is the host OS a WSL distro runs. +const skip = + process.platform !== 'linux' || + skipForMissingInputs( + 'artifact', + runtime && existsSync(cliEntry) ? [] : ['a Node orcad slot in out/orcad'] + ) + +const roots: string[] = [] +const servers: ReturnType[] = [] + +afterEach(async () => { + for (const server of servers.splice(0)) { + if (server.exitCode === null) { + const exited = new Promise((settle) => server.once('exit', settle)) + server.kill('SIGTERM') + await exited + } + } + for (const root of roots.splice(0)) { + await killProfileDaemons(join(root, 'user-data')) + await removeTestRoot(root) + } +}) + +/** Every process below `rootPid`, including it, read from /proc. */ +function processTree(rootPid: number): number[] { + const parents = new Map() + for (const entry of readdirSync('/proc')) { + if (!/^\d+$/.test(entry)) { + continue + } + try { + const stat = readFileSync(`/proc/${entry}/stat`, 'utf8') + parents.set(Number(entry), Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1])) + } catch { + // Exited while listing. + } + } + const tree = new Set([rootPid]) + for (let grew = true; grew;) { + grew = false + for (const [pid, parent] of parents) { + if (tree.has(parent) && !tree.has(pid)) { + tree.add(pid) + grew = true + } + } + } + return [...tree] +} + +function residentMb(pid: number): number { + try { + const rollup = readFileSync(`/proc/${pid}/smaps_rollup`, 'utf8') + return Number(/^Rss:\s+(\d+)/m.exec(rollup)?.[1] ?? 0) / 1024 + } catch { + return 0 + } +} + +function commandOf(pid: number): string { + try { + const argv = readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0') + // Name Node processes by their entry script; the runtime path alone says nothing. + const entry = argv.find((arg) => arg.endsWith('.js')) + return entry ? entry.split('/').pop()! : argv.join(' ').slice(0, 80) + } catch { + return '(exited)' + } +} + +async function cli( + env: NodeJS.ProcessEnv, + pairingCode: string, + args: string[], + schema: z.ZodType +): Promise { + const result = await runProcess({ + program: runtime!, + args: [cliEntry, ...args, '--pairing-code', pairingCode, '--json'], + env, + timeoutMs: 60_000 + }) + expect(result.code, result.stderr).toBe(0) + return schema.parse(JSON.parse(result.stdout.trim())) +} + +const RepoAdded = z.object({ result: z.object({ repo: z.object({ id: z.string() }) }) }) +const WorktreeCreated = z.object({ result: z.object({ worktree: z.object({ id: z.string() }) }) }) +const Anything = z.unknown() +const Readiness = z.object({ pairing: z.object({ url: z.string() }) }) + +async function startOrcad(env: NodeJS.ProcessEnv): Promise<{ pid: number; pairingCode: string }> { + const server = spawnProcess({ + program: runtime!, + args: [ + ...ORCAD_RUNTIME_V8_FLAGS.split(' '), + join(slotDir, 'orcad.js'), + '--json', + '--bind', + '127.0.0.1', + '--port', + '0' + ], + env, + timeoutMs: null + }) + servers.push(server) + let stdout = '' + let stderr = '' + server.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8'))) + const ready = await new Promise((resolveLine, reject) => { + const timer = setTimeout(() => reject(new Error(`no readiness: ${stderr}`)), 120_000) + server.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8') + const newline = stdout.indexOf('\n') + if (newline !== -1) { + clearTimeout(timer) + resolveLine(stdout.slice(0, newline)) + } + }) + }) + const pairingCode = new URL(Readiness.parse(JSON.parse(ready)).pairing.url).searchParams.get( + 'code' + ) + if (!pairingCode || server.pid === undefined) { + throw new Error(`orcad readiness carried no pairing offer: ${ready}`) + } + return { pid: server.pid, pairingCode } +} + +it.skipIf(skip)( + 'keeps an idle WSL-host orcad tree with two terminals under its resident budget', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orcad-idle-memory-')) + roots.push(root) + const env: NodeJS.ProcessEnv = { + ...Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.startsWith('VITEST') && key !== 'NODE_ENV' + ) + ), + HOME: root, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_USER_DATA: join(root, 'user-data'), + // The WSL host launch: its malloc tunables, and the marker that turns on the idle writer stop. + ...Object.fromEntries(hostMallocTunablesLaunchEnv().map(([name, value]) => [name, value])), + [ORCAD_WSL_HOST_ENV]: '1', + WSL_DISTRO_NAME: 'orca-memory-lane' + } + const { pid, pairingCode } = await startOrcad(env) + + const repo = join(root, 'repo') + for (const args of [ + ['init', '-q', '-b', 'main', repo], + [ + '-C', + repo, + '-c', + 'user.email=lane@orca.test', + '-c', + 'user.name=lane', + 'commit', + '-q', + '--allow-empty', + '-m', + 'seed' + ] + ]) { + const git = await runProcess({ program: 'git', args, timeoutMs: 30_000 }) + expect(git.code, git.stderr).toBe(0) + } + const added = await cli(env, pairingCode, ['repo', 'add', '--path', repo], RepoAdded) + const created = await cli( + env, + pairingCode, + [ + 'worktree', + 'create', + '--repo', + `id:${added.result.repo.id}`, + '--name', + 'lane', + '--setup', + 'skip' + ], + WorktreeCreated + ) + for (let terminal = 0; terminal < 2; terminal += 1) { + await cli( + env, + pairingCode, + ['terminal', 'create', '--worktree', `id:${created.result.worktree.id}`], + Anything + ) + } + + // ast-grep-ignore: test-no-real-time-wait -- idle memory is the thing under test; only real idle time shows it + await new Promise((settle) => setTimeout(settle, IDLE_MS)) + + await recordProfileDaemons(env.ORCA_USER_DATA!) + const tree = processTree(pid).map((member) => ({ + pid: member, + mb: residentMb(member), + command: commandOf(member) + })) + const totalMb = tree.reduce((sum, member) => sum + member.mb, 0) + const breakdown = tree + .sort((left, right) => right.mb - left.mb) + .map((member) => `${member.mb.toFixed(1)} MB ${member.command}`) + .join('\n') + expect(totalMb, `orcad tree RSS ${totalMb.toFixed(1)} MB:\n${breakdown}`).toBeLessThan( + ORCAD_TREE_RSS_BUDGET_MB + ) + }, + // Boot, a repo and two terminals, then a 90 s idle: well past the default per-test timeout. + 240_000 +)