From 4231c3c0ff09747e10b17ffec77ab22f50bbbdea Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:12:15 -0700 Subject: [PATCH] perf(main): add watchdog boundary memory benchmark Add a repeatable Electron 43 RSS harness that measures the production-built watchdog entry across the child-process and worker-thread boundaries. Record per-trial samples, the median, revision, runtime, and settling procedure for reproducible PR evidence. --- .../hang-watchdog-memory-benchmark.mjs | 443 ++++++++++++++++++ .../scripts/hang-watchdog-process-metrics.mjs | 108 +++++ .../hang-watchdog-process-metrics.test.mjs | 45 ++ package.json | 1 + 4 files changed, 597 insertions(+) create mode 100644 config/scripts/hang-watchdog-memory-benchmark.mjs create mode 100644 config/scripts/hang-watchdog-process-metrics.mjs create mode 100644 config/scripts/hang-watchdog-process-metrics.test.mjs diff --git a/config/scripts/hang-watchdog-memory-benchmark.mjs b/config/scripts/hang-watchdog-memory-benchmark.mjs new file mode 100644 index 00000000000..49a2dd63136 --- /dev/null +++ b/config/scripts/hang-watchdog-memory-benchmark.mjs @@ -0,0 +1,443 @@ +#!/usr/bin/env node +import { execFileSync, fork, spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Worker } from 'node:worker_threads' +import { + childRssBytes, + median, + physicalFootprintBytes, + sampleMemory, + sampleProductionPerformance +} from './hang-watchdog-process-metrics.mjs' + +const INTERNAL_ENV = 'ORCA_HANG_WATCHDOG_BENCH_INTERNAL' +const BOUNDARY_ENV = 'ORCA_HANG_WATCHDOG_BENCH_BOUNDARY' +const RESULT_PREFIX = 'ORCA_HANG_WATCHDOG_BENCH_RESULT=' +const DEFAULT_TRIALS = 7 +const SETTLE_MS = 2_000 +const SAMPLE_COUNT = 5 +const SAMPLE_INTERVAL_MS = 200 +const VERIFY_TIMEOUT_MS = 500 +const VERIFY_CHECK_INTERVAL_MS = 50 +const VERIFY_BLOCK_MS = 1_200 +const PRODUCTION_HEARTBEAT_INTERVAL_MS = 2_000 +const PRODUCTION_TIMEOUT_MS = 45_000 +const PRODUCTION_CHECK_INTERVAL_MS = 5_000 +const PRODUCTION_SAMPLE_MS = 30_000 +const MAX_LAUNCH_ATTEMPTS = 3 +const MIB = 1024 * 1024 +const scriptPath = import.meta.filename +const repoRoot = path.resolve(import.meta.dirname, '..', '..') +const entryPath = path.join(repoRoot, 'out', 'main', 'main-thread-hang-watchdog-entry.js') + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function forceGc() { + if (typeof global.gc !== 'function') { + throw new Error('Electron did not expose GC; keep --js-flags=--expose-gc in the harness') + } + global.gc() + global.gc() +} + +function blockMainThread(ms) { + const startedAt = Date.now() + while (Date.now() - startedAt < ms) { + // Intentional synchronous stall. + } + return { startedAt, endedAt: Date.now() } +} + +function readMarker(markerPath) { + try { + return JSON.parse(readFileSync(markerPath, 'utf8')) + } catch { + return null + } +} + +async function verifyBlockedMainDetection(markerPath, sendHeartbeat) { + const block = blockMainThread(VERIFY_BLOCK_MS) + const detected = readMarker(markerPath) + sendHeartbeat() + const deadline = Date.now() + VERIFY_TIMEOUT_MS + let resolved + do { + resolved = readMarker(markerPath) + if (resolved?.selfRecovered === true) { + break + } + await sleep(VERIFY_CHECK_INTERVAL_MS) + } while (Date.now() < deadline) + const verified = + detected?.detectedAt >= block.startedAt && + detected.detectedAt <= block.endedAt && + detected.selfRecovered === false && + resolved?.selfRecovered === true + if (!verified) { + throw new Error( + `Built watchdog failed blocked-main verification: ${JSON.stringify({ detected, resolved })}` + ) + } + return true +} + +function startChild(markerPath, timeoutMs, checkIntervalMs) { + const startedAt = process.hrtime.bigint() + const child = fork(entryPath, [], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + ORCA_HANG_WATCHDOG_PARENT_PID: String(process.pid), + ORCA_HANG_WATCHDOG_MARKER_PATH: markerPath, + ORCA_HANG_WATCHDOG_TIMEOUT_MS: String(timeoutMs), + ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS: String(checkIntervalMs) + } + }) + const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6 + return { + pids: [process.pid, child.pid], + startupMs, + sendHeartbeat: () => child.send?.({ type: 'heartbeat' }), + shutdown: async () => { + if (child.exitCode !== null) { + return + } + const exitPromise = new Promise((resolve) => child.once('exit', resolve)) + child.send?.({ type: 'shutdown' }) + if (child.connected) { + child.disconnect() + } + await exitPromise + } + } +} + +function startWorker(markerPath, timeoutMs, checkIntervalMs) { + const startedAt = process.hrtime.bigint() + const worker = new Worker(entryPath, { + workerData: { + parentPid: process.pid, + markerPath, + timeoutMs, + checkIntervalMs + } + }) + const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6 + return { + pids: [process.pid], + startupMs, + sendHeartbeat: () => worker.postMessage({ type: 'heartbeat' }), + shutdown: async () => { + if (worker.threadId === -1) { + return + } + const exitPromise = new Promise((resolve) => worker.once('exit', resolve)) + worker.postMessage({ type: 'shutdown' }) + await exitPromise + } + } +} + +async function verifyBoundary(markerPath, startBoundary) { + const boundary = startBoundary(markerPath, VERIFY_TIMEOUT_MS, VERIFY_CHECK_INTERVAL_MS) + const heartbeat = setInterval(boundary.sendHeartbeat, 100) + try { + await sleep(SETTLE_MS) + return await verifyBlockedMainDetection(markerPath, boundary.sendHeartbeat) + } finally { + clearInterval(heartbeat) + await boundary.shutdown() + } +} + +async function measureChild(markerPath) { + forceGc() + await sleep(SETTLE_MS) + forceGc() + const before = await sampleMemory( + () => process.memoryUsage().rss, + () => physicalFootprintBytes([process.pid]), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const child = startChild(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS) + let measurements + try { + await sleep(SETTLE_MS) + forceGc() + const childRss = await sampleMemory( + () => childRssBytes(child.pids[1]), + () => physicalFootprintBytes([child.pids[1]]), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const total = await sampleMemory( + () => process.memoryUsage().rss + childRssBytes(child.pids[1]), + () => physicalFootprintBytes(child.pids), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const performance = await sampleProductionPerformance(child, { + heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS, + sampleMs: PRODUCTION_SAMPLE_MS, + sleep + }) + measurements = { + rssBytes: childRss.rssBytes, + summedProcessRssDeltaBytes: Math.max(0, total.rssBytes - before.rssBytes), + physicalFootprintDeltaBytes: Math.max( + 0, + total.physicalFootprintBytes - before.physicalFootprintBytes + ), + startupMs: child.startupMs, + ...performance + } + } finally { + await child.shutdown() + } + rmSync(markerPath, { force: true }) + return { + ...measurements, + blockedMainThreadVerified: await verifyBoundary(markerPath, startChild) + } +} + +async function measureWorker(markerPath) { + forceGc() + await sleep(SETTLE_MS) + forceGc() + const before = await sampleMemory( + () => process.memoryUsage().rss, + () => physicalFootprintBytes([process.pid]), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const worker = startWorker(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS) + let measurements + try { + await sleep(SETTLE_MS) + forceGc() + const after = await sampleMemory( + () => process.memoryUsage().rss, + () => physicalFootprintBytes([process.pid]), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const performance = await sampleProductionPerformance(worker, { + heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS, + sampleMs: PRODUCTION_SAMPLE_MS, + sleep + }) + const rssBytes = Math.max(0, after.rssBytes - before.rssBytes) + measurements = { + rssBytes, + summedProcessRssDeltaBytes: rssBytes, + physicalFootprintDeltaBytes: Math.max( + 0, + after.physicalFootprintBytes - before.physicalFootprintBytes + ), + startupMs: worker.startupMs, + ...performance + } + } finally { + await worker.shutdown() + } + rmSync(markerPath, { force: true }) + return { + ...measurements, + blockedMainThreadVerified: await verifyBoundary(markerPath, startWorker) + } +} + +async function runInternal() { + if (process.platform !== 'darwin') { + throw new Error('The production watchdog is macOS-only; run this benchmark on macOS') + } + const { app } = await import('electron') + const boundary = process.env[BOUNDARY_ENV] + const profileDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-')) + app.setPath('userData', profileDir) + try { + await app.whenReady() + const markerPath = path.join(profileDir, 'main-thread-hang.json') + const result = + boundary === 'child' + ? await measureChild(markerPath) + : boundary === 'worker' + ? await measureWorker(markerPath) + : (() => { + throw new Error(`Unsupported boundary: ${boundary}`) + })() + process.stdout.write(`${RESULT_PREFIX}${JSON.stringify(result)}\n`) + } finally { + app.quit() + rmSync(profileDir, { recursive: true, force: true }) + } +} + +function parseArgs(argv) { + const options = { boundary: '', trials: DEFAULT_TRIALS, output: '' } + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const value = argv[index + 1] + if (arg === '--boundary' || arg === '--trials' || arg === '--output') { + if (!value) { + throw new Error(`Missing value for ${arg}`) + } + options[arg.slice(2)] = arg === '--trials' ? Number(value) : value + index += 1 + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + if (!['child', 'worker'].includes(options.boundary)) { + throw new Error('--boundary must be child or worker') + } + if (!Number.isInteger(options.trials) || options.trials < 1) { + throw new Error('--trials must be a positive integer') + } + return options +} + +function electronPath() { + const requirePath = import.meta.resolve('electron') + const electronModulePath = fileURLToPath(requirePath) + return execFileSync( + process.execPath, + ['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`], + { + encoding: 'utf8' + } + ) +} + +function runTrial(executable, boundary) { + for (let attempt = 1; attempt <= MAX_LAUNCH_ATTEMPTS; attempt += 1) { + const env = { ...process.env, [INTERNAL_ENV]: '1', [BOUNDARY_ENV]: boundary } + delete env.ELECTRON_RUN_AS_NODE + const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-launcher-')) + writeFileSync( + path.join(launcherDir, 'package.json'), + JSON.stringify({ name: 'orca-watchdog-benchmark', main: 'main.cjs' }) + ) + writeFileSync( + path.join(launcherDir, 'main.cjs'), + `import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => { + console.error(error) + process.exitCode = 1 +})\n` + ) + let result + try { + result = spawnSync(executable, ['--js-flags=--expose-gc', launcherDir], { + cwd: repoRoot, + env, + encoding: 'utf8', + timeout: 90_000 + }) + } finally { + rmSync(launcherDir, { recursive: true, force: true }) + } + if (result.status !== 0) { + throw new Error( + `Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n` + + `${result.stderr || result.stdout}` + ) + } + const line = result.stdout.split('\n').find((candidate) => candidate.startsWith(RESULT_PREFIX)) + if (line) { + return { ...JSON.parse(line.slice(RESULT_PREFIX.length)), launchAttempts: attempt } + } + if (attempt === MAX_LAUNCH_ATTEMPTS || result.stderr || result.stdout) { + throw new Error(`Electron trial did not report a result (status ${result.status})`) + } + } + throw new Error('Electron trial exhausted launcher attempts') +} + +function runBenchmark() { + if (process.platform !== 'darwin') { + throw new Error('The production watchdog is macOS-only; run this benchmark on macOS') + } + if (!existsSync(entryPath)) { + throw new Error(`Missing ${entryPath}; run pnpm exec electron-vite build first`) + } + const options = parseArgs(process.argv.slice(2)) + const builtEntry = readFileSync(entryPath, 'utf8') + const hasChildContract = builtEntry.includes('ORCA_HANG_WATCHDOG_PARENT_PID') + const hasWorkerContract = builtEntry.includes('workerData') && builtEntry.includes('parentPort') + if ( + (options.boundary === 'child' && !hasChildContract) || + (options.boundary === 'worker' && !hasWorkerContract) + ) { + throw new Error( + `Built watchdog does not implement the requested ${options.boundary} boundary; rebuild the matching revision` + ) + } + const executable = electronPath() + const results = Array.from({ length: options.trials }, () => + runTrial(executable, options.boundary) + ) + const rssBytes = results.map((result) => result.rssBytes) + const summedProcessRssDeltaBytes = results.map((result) => result.summedProcessRssDeltaBytes) + const physicalFootprintDeltaBytes = results.map((result) => result.physicalFootprintDeltaBytes) + const startupMs = results.map((result) => result.startupMs) + const cpuMs = results.map((result) => result.cpuMs) + const eventLoopDelayP95Ms = results.map((result) => result.eventLoopDelayP95Ms) + const eventLoopDelayP99Ms = results.map((result) => result.eventLoopDelayP99Ms) + const eventLoopDelayMaxMs = results.map((result) => result.eventLoopDelayMaxMs) + const report = { + benchmark: 'hang-watchdog-memory', + boundary: options.boundary, + revision: execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8' + }).trim(), + electron: execFileSync(executable, ['-e', 'process.stdout.write(process.versions.electron)'], { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + encoding: 'utf8' + }).trim(), + settleMs: SETTLE_MS, + samplesPerTrial: SAMPLE_COUNT, + productionHeartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS, + productionCheckIntervalMs: PRODUCTION_CHECK_INTERVAL_MS, + productionSampleMs: PRODUCTION_SAMPLE_MS, + trials: options.trials, + rssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))), + medianRssMiB: Number((median(rssBytes) / MIB).toFixed(2)), + summedProcessRssDeltaMiB: summedProcessRssDeltaBytes.map((value) => + Number((value / MIB).toFixed(2)) + ), + medianSummedProcessRssDeltaMiB: Number((median(summedProcessRssDeltaBytes) / MIB).toFixed(2)), + physicalFootprintDeltaMiB: physicalFootprintDeltaBytes.map((value) => + Number((value / MIB).toFixed(2)) + ), + medianPhysicalFootprintDeltaMiB: Number((median(physicalFootprintDeltaBytes) / MIB).toFixed(2)), + startupMs: startupMs.map((value) => Number(value.toFixed(3))), + medianStartupMs: Number(median(startupMs).toFixed(3)), + cpuMs: cpuMs.map((value) => Number(value.toFixed(2))), + medianCpuMs: Number(median(cpuMs).toFixed(2)), + eventLoopDelayP95Ms: eventLoopDelayP95Ms.map((value) => Number(value.toFixed(3))), + medianEventLoopDelayP95Ms: Number(median(eventLoopDelayP95Ms).toFixed(3)), + eventLoopDelayP99Ms: eventLoopDelayP99Ms.map((value) => Number(value.toFixed(3))), + medianEventLoopDelayP99Ms: Number(median(eventLoopDelayP99Ms).toFixed(3)), + eventLoopDelayMaxMs: eventLoopDelayMaxMs.map((value) => Number(value.toFixed(3))), + medianEventLoopDelayMaxMs: Number(median(eventLoopDelayMaxMs).toFixed(3)), + heartbeatCounts: results.map((result) => result.heartbeatCount), + launchAttempts: results.map((result) => result.launchAttempts), + blockedMainThreadVerified: results.every((result) => result.blockedMainThreadVerified) + } + const serialized = `${JSON.stringify(report, null, 2)}\n` + process.stdout.write(serialized) + if (options.output) { + writeFileSync(path.resolve(options.output), serialized) + } +} + +if (process.env[INTERNAL_ENV] === '1') { + await runInternal() +} else { + runBenchmark() +} diff --git a/config/scripts/hang-watchdog-process-metrics.mjs b/config/scripts/hang-watchdog-process-metrics.mjs new file mode 100644 index 00000000000..7e5ee4de53f --- /dev/null +++ b/config/scripts/hang-watchdog-process-metrics.mjs @@ -0,0 +1,108 @@ +import { execFileSync } from 'node:child_process' +import { monitorEventLoopDelay } from 'node:perf_hooks' + +export function median(values) { + const sorted = [...values].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +export async function sampleMemory(readRss, readPhysicalFootprint, options) { + const rssSamples = [] + const physicalFootprintSamples = [] + for (let index = 0; index < options.sampleCount; index += 1) { + rssSamples.push(readRss()) + physicalFootprintSamples.push(readPhysicalFootprint()) + await options.sleep(options.sampleIntervalMs) + } + return { + rssBytes: median(rssSamples), + physicalFootprintBytes: median(physicalFootprintSamples) + } +} + +export function childRssBytes(pid) { + const raw = execFileSync('ps', ['-o', 'rss=', '-p', String(pid)], { + encoding: 'utf8' + }).trim() + const rssKiB = Number(raw) + if (!Number.isFinite(rssKiB) || rssKiB <= 0) { + throw new Error(`Could not read watchdog child RSS for PID ${pid}`) + } + return rssKiB * 1024 +} + +export function parsePhysicalFootprintBytes(output, processCount) { + const match = + processCount > 1 + ? output.match(/^Summary Footprint:\s+(\d+) B$/m) + : output.match(/^[^\s].*\sFootprint:\s+(\d+) B/m) + const bytes = Number(match?.[1]) + return Number.isFinite(bytes) && bytes > 0 ? bytes : null +} + +export function physicalFootprintBytes(pids) { + const pidArgs = pids.flatMap((pid) => ['--pid', String(pid)]) + const output = execFileSync( + '/usr/bin/footprint', + [...pidArgs, '--format', 'bytes', '--noCategories'], + { encoding: 'utf8' } + ) + const bytes = parsePhysicalFootprintBytes(output, pids.length) + if (bytes === null) { + throw new Error(`Could not read physical footprint for PIDs ${pids.join(', ')}`) + } + return bytes +} + +export function parseProcessCpuTimeMs(raw) { + if (!raw.trim()) { + return null + } + const parts = raw.split(':').map(Number) + if (!parts.length || parts.some((part) => !Number.isFinite(part))) { + return null + } + const seconds = parts.reduce((total, part) => total * 60 + part, 0) + const milliseconds = seconds * 1_000 + return milliseconds >= 0 ? milliseconds : null +} + +function processCpuTimeMs(pid) { + const raw = execFileSync('ps', ['-o', 'time=', '-p', String(pid)], { + encoding: 'utf8' + }).trim() + const milliseconds = parseProcessCpuTimeMs(raw) + if (milliseconds === null) { + throw new Error(`Could not read CPU time for PID ${pid}`) + } + return milliseconds +} + +function combinedCpuTimeMs(pids) { + return pids.reduce((total, pid) => total + processCpuTimeMs(pid), 0) +} + +export async function sampleProductionPerformance(boundary, options) { + const loopDelay = monitorEventLoopDelay({ resolution: 10 }) + let heartbeatCount = 0 + const heartbeat = setInterval(() => { + heartbeatCount += 1 + boundary.sendHeartbeat() + }, options.heartbeatIntervalMs) + const cpuBefore = combinedCpuTimeMs(boundary.pids) + loopDelay.enable() + try { + await options.sleep(options.sampleMs) + } finally { + loopDelay.disable() + clearInterval(heartbeat) + } + return { + cpuMs: Math.max(0, combinedCpuTimeMs(boundary.pids) - cpuBefore), + heartbeatCount, + eventLoopDelayP95Ms: loopDelay.percentile(95) / 1e6, + eventLoopDelayP99Ms: loopDelay.percentile(99) / 1e6, + eventLoopDelayMaxMs: loopDelay.max / 1e6 + } +} diff --git a/config/scripts/hang-watchdog-process-metrics.test.mjs b/config/scripts/hang-watchdog-process-metrics.test.mjs new file mode 100644 index 00000000000..2d54e691d00 --- /dev/null +++ b/config/scripts/hang-watchdog-process-metrics.test.mjs @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + parsePhysicalFootprintBytes, + parseProcessCpuTimeMs +} from './hang-watchdog-process-metrics.mjs' + +describe('hang watchdog process metrics', () => { + it('uses the de-duplicated summary for multiple processes', () => { + const output = ` +Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page) + phys_footprint: 5100000 B +Electron Helper [102]: 64-bit Footprint: 2000000 B (16384 bytes per page) + phys_footprint: 2100000 B +Summary Footprint: 6259264 B +` + expect(parsePhysicalFootprintBytes(output, 2)).toBe(6_259_264) + }) + + it('uses the process footprint rather than auxiliary accounting for one process', () => { + const output = ` +Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page) + phys_footprint: 5100000 B +` + expect(parsePhysicalFootprintBytes(output, 1)).toBe(5_000_000) + }) + + it('rejects missing or zero footprint summaries', () => { + expect(parsePhysicalFootprintBytes('phys_footprint: 100 B', 2)).toBeNull() + expect(parsePhysicalFootprintBytes('Summary Footprint: 0 B', 2)).toBeNull() + }) + + it.each([ + ['0:00.04', 40], + ['1:02.50', 62_500], + ['2:01:02.50', 7_262_500] + ])('parses ps CPU time %s', (value, expected) => { + expect(parseProcessCpuTimeMs(value)).toBe(expected) + }) + + it('rejects invalid CPU times', () => { + expect(parseProcessCpuTimeMs('')).toBeNull() + expect(parseProcessCpuTimeMs('not-a-time')).toBeNull() + expect(parseProcessCpuTimeMs('-1:00')).toBeNull() + }) +}) diff --git a/package.json b/package.json index fe23f109180..8b124f8ae44 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs", "bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs", "bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs", + "bench:hang-watchdog-memory": "pnpm run ensure:electron-runtime && node config/scripts/hang-watchdog-memory-benchmark.mjs", "bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs", "bench:worktree-deletion": "node tools/benchmarks/worktree-deletion-dev-bench.mjs", "bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs",