perf(cli): enable Node module compile cache (#2092)

* perf(cli): enable Node module compile cache

Warm CLI invocations spend substantial time compiling the bundled ESM entrypoint. Enable Node's optional on-disk compile cache only in the process that imports the bundle, while preserving early Node 22 compatibility and making cache failures non-fatal.

Add deterministic launcher coverage, packaging checks, and a reproducible benchmark procedure so the startup benefit can be measured without flaky CI thresholds.

* fix(ci): isolate minimum Node launcher check

The full validation suite depends on knip and oxc-parser behavior unavailable in Node 22.0.0. Keep full CI on the active Node 22 line and exercise the declared runtime floor in a dedicated build-and-launch job.

* fix(benchmark): harden startup measurements

Keep environment setup outside the timed process window, document the API's Node 22.8 floor, and preserve completed benchmark results when git metadata is unavailable.

* test(cli): verify compile cache disable behavior

Pair NODE_DISABLE_COMPILE_CACHE with a temporary cache directory and assert that supported Node releases leave it empty while preserving normal launcher output.
This commit is contained in:
Bogdan
2026-08-07 09:55:01 +08:00
committed by GitHub
parent bac012f70b
commit d427a4b2bb
10 changed files with 529 additions and 4 deletions
+34 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22, "24.11.x"]
node-version: ["22", "24.11.x"]
steps:
- name: Check out repository
@@ -38,6 +38,11 @@ jobs:
- name: Smoke and full unit test suite
run: bun run check
- name: Launcher compatibility
run: |
node bin/openclaude --version
NODE_DISABLE_COMPILE_CACHE=1 node bin/openclaude --version
- name: Suspicious PR intent scan
env:
PR_SCAN_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || 'origin/main' }}
@@ -49,6 +54,34 @@ jobs:
- name: Provider recommendation tests
run: npm run test:provider-recommendation
launcher-node-floor:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up minimum supported Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.0.0"
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build
run: bun run build
- name: Launch on the minimum supported Node.js
run: node bin/openclaude --version
typecheck:
runs-on: ubuntu-latest
+24
View File
@@ -400,6 +400,30 @@ Day-to-day commands:
Focused suites: `bun run test:provider`, `bun run test:provider-recommendation`.
To benchmark the launcher module compile cache, build the CLI and run:
```bash
bun run build
bun run benchmark:startup
```
The benchmark requires Node `>=22.8.0`, where the compile-cache API was added;
the built OpenClaude launcher continues to support the declared Node `>=22.0.0`
runtime range.
The benchmark defaults to 30 separate-process warm runs and 10 isolated
empty-cache runs. It reports the median, IQR, MAD, first cache-populating run,
first warm-up, Node/OS/CPU details, bundle size, and commit. Direct bundle
timings are included only as a secondary diagnostic; the full launcher result
is the decision signal. Use
`bun run benchmark:startup -- --warm-runs 40 --cold-runs 10` to request a
larger sample set. The benchmark records results without enforcing a timing
threshold in CI.
OpenClaude leaves Node's standard compile-cache controls authoritative. Set
`NODE_DISABLE_COMPILE_CACHE=1` to disable the optimization, including for V8
coverage runs that require uncached compilation.
Recommended validation before opening a PR:
- `bun run build`
+17
View File
@@ -0,0 +1,17 @@
import * as nodeModule from 'node:module'
/** @typedef {{ enableCompileCache?: () => unknown }} CompileCacheModule */
/**
* @param {CompileCacheModule} [module=nodeModule]
*/
export function enableNodeCompileCacheIfAvailable(module = nodeModule) {
const enable = module.enableCompileCache
if (typeof enable !== 'function') return
try {
enable()
} catch {
// Compile caching is optional and must never block startup.
}
}
+2
View File
@@ -11,6 +11,7 @@ import { existsSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import { spawnSync } from 'child_process'
import { enableNodeCompileCacheIfAvailable } from './node-compile-cache.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const distPath = join(__dirname, '..', 'dist', 'cli.mjs')
@@ -107,6 +108,7 @@ function relaunchWithLongSessionHeapIfNeeded() {
if (existsSync(distPath)) {
relaunchWithLongSessionHeapIfNeeded()
enableNodeCompileCacheIfAvailable()
await import(pathToFileURL(distPath).href)
} else {
console.error(`
+1
View File
@@ -27,6 +27,7 @@
],
"scripts": {
"build": "bun run scripts/build.ts",
"benchmark:startup": "node scripts/benchmark-openclaude-startup.mjs",
"integrations:generate": "bun run scripts/generate-integrations-artifacts.ts",
"integrations:check": "bun run scripts/generate-integrations-artifacts.ts --check",
"dev": "bun run build && node bin/openclaude",
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
import * as nodeModule from 'node:module'
import { cpus, platform, release, tmpdir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
const MIN_WARM_RUNS = 20
const DEFAULT_WARM_RUNS = 30
const DEFAULT_COLD_RUNS = 10
const REPO_ROOT = join(import.meta.dirname, '..')
const LAUNCHER_PATH = join(REPO_ROOT, 'bin', 'openclaude')
const BUNDLE_PATH = join(REPO_ROOT, 'dist', 'cli.mjs')
const PACKAGE_VERSION = JSON.parse(
readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'),
).version
const EXPECTED_VERSION_OUTPUT = `${PACKAGE_VERSION} (OpenClaude)`
function readPositiveInteger(name, fallback) {
const index = process.argv.indexOf(name)
if (index === -1) return fallback
const parsed = Number.parseInt(process.argv[index + 1] ?? '', 10)
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer`)
}
return parsed
}
const warmRuns = readPositiveInteger('--warm-runs', DEFAULT_WARM_RUNS)
const coldRuns = readPositiveInteger('--cold-runs', DEFAULT_COLD_RUNS)
if (warmRuns < MIN_WARM_RUNS) {
throw new Error(`--warm-runs must be at least ${MIN_WARM_RUNS}`)
}
if (typeof nodeModule.enableCompileCache !== 'function') {
throw new Error(`Node ${process.version} does not expose module.enableCompileCache; the benchmark requires Node >=22.8.0`)
}
if (!existsSync(BUNDLE_PATH) || !statSync(BUNDLE_PATH).isFile()) {
throw new Error('dist/cli.mjs is missing; run `bun run build` first')
}
const scratch = mkdtempSync(join(tmpdir(), 'openclaude-startup-benchmark-'))
function childEnv(tempRoot, cacheMode) {
mkdirSync(tempRoot, { recursive: true })
const env = {
...process.env,
CI: '1',
NO_COLOR: '1',
OPENCLAUDE_CONFIG_DIR: join(scratch, 'config'),
TEMP: tempRoot,
TMP: tempRoot,
TMPDIR: tempRoot,
}
delete env.NODE_COMPILE_CACHE
delete env.NODE_DISABLE_COMPILE_CACHE
delete env.OPENCLAUDE_HEAP_RELAUNCHED
delete env.OPENCLAUDE_DISABLE_HEAP_RELAUNCH
if (cacheMode === 'disabled') {
env.NODE_DISABLE_COMPILE_CACHE = '1'
} else if (cacheMode === 'environment') {
env.NODE_COMPILE_CACHE = join(tempRoot, 'node-compile-cache')
}
return env
}
function sample(target, tempRoot, cacheMode) {
const path = target === 'launcher' ? LAUNCHER_PATH : BUNDLE_PATH
const env = childEnv(tempRoot, cacheMode)
const started = performance.now()
const result = spawnSync(process.execPath, [path, '--version'], {
cwd: REPO_ROOT,
encoding: 'utf8',
env,
timeout: 30_000,
})
const elapsedMs = performance.now() - started
if (
result.status !== 0
|| result.stdout.trim() !== EXPECTED_VERSION_OUTPUT
|| result.stderr !== ''
) {
throw new Error(`benchmark command failed: ${JSON.stringify({
target,
cacheMode,
status: result.status,
stdout: result.stdout,
stderr: result.stderr,
})}`)
}
return elapsedMs
}
function percentile(sorted, fraction) {
return sorted[Math.floor((sorted.length - 1) * fraction)]
}
function summarize(values) {
const sorted = [...values].sort((a, b) => a - b)
const median = percentile(sorted, 0.5)
const deviations = sorted
.map(value => Math.abs(value - median))
.sort((a, b) => a - b)
const p25 = percentile(sorted, 0.25)
const p75 = percentile(sorted, 0.75)
return {
samples: sorted.length,
medianMs: Number(median.toFixed(1)),
p25Ms: Number(p25.toFixed(1)),
p75Ms: Number(p75.toFixed(1)),
iqrMs: Number((p75 - p25).toFixed(1)),
madMs: Number(percentile(deviations, 0.5).toFixed(1)),
minMs: Number(sorted[0].toFixed(1)),
maxMs: Number(sorted.at(-1).toFixed(1)),
}
}
function measureWarmPair(target, enabledCacheMode, prefix) {
const enabledRoot = join(scratch, `${prefix}-enabled`)
const disabledRoot = join(scratch, `${prefix}-disabled`)
const populationMs = sample(target, enabledRoot, enabledCacheMode)
const firstWarmupMs = sample(target, enabledRoot, enabledCacheMode)
const enabled = []
const disabled = []
for (let index = 0; index < warmRuns; index++) {
if (index % 2 === 0) {
disabled.push(sample(target, disabledRoot, 'disabled'))
enabled.push(sample(target, enabledRoot, enabledCacheMode))
} else {
enabled.push(sample(target, enabledRoot, enabledCacheMode))
disabled.push(sample(target, disabledRoot, 'disabled'))
}
}
return {
cachePopulationMs: Number(populationMs.toFixed(1)),
firstWarmupMs: Number(firstWarmupMs.toFixed(1)),
disabled: summarize(disabled),
enabledWarm: summarize(enabled),
}
}
function measureColdLauncher() {
const samples = []
for (let index = 0; index < coldRuns; index++) {
samples.push(sample('launcher', join(scratch, `launcher-cold-${index}`), 'default'))
}
return summarize(samples)
}
function comparison(disabled, enabled) {
const savedMs = disabled.medianMs - enabled.medianMs
return {
savedMs: Number(savedMs.toFixed(1)),
improvementPercent: Number((savedMs / disabled.medianMs * 100).toFixed(1)),
}
}
try {
const launcher = measureWarmPair('launcher', 'default', 'launcher-warm')
const launcherCold = measureColdLauncher()
const directBundle = measureWarmPair('bundle', 'environment', 'bundle-warm')
const gitResult = spawnSync('git', ['rev-parse', 'HEAD'], {
cwd: REPO_ROOT,
encoding: 'utf8',
})
const gitCommit = gitResult.status === 0 && typeof gitResult.stdout === 'string'
? gitResult.stdout.trim() || 'unknown'
: 'unknown'
console.log(JSON.stringify({
environment: {
node: process.version,
os: `${platform()} ${release()}`,
arch: process.arch,
cpu: cpus()[0]?.model,
logicalCpus: cpus().length,
bundleBytes: statSync(BUNDLE_PATH).size,
commit: gitCommit,
},
methodology: {
coldRuns,
warmRuns,
separateProcesses: true,
coldDefinition: 'empty Node compile-cache root; filesystem caches are not flushed',
launcherEnabledMode: 'zero-argument launcher API with default cache location under isolated temp roots',
launcherDisabledMode: 'NODE_DISABLE_COMPILE_CACHE=1',
directBundleRole: 'secondary diagnostic using NODE_COMPILE_CACHE for explicit activation',
performanceThresholdEnforced: false,
},
launcher: {
compileCacheCold: launcherCold,
...launcher,
comparison: comparison(launcher.disabled, launcher.enabledWarm),
},
directBundle: {
...directBundle,
comparison: comparison(directBundle.disabled, directBundle.enabledWarm),
},
}, null, 2))
} finally {
rmSync(scratch, { recursive: true, force: true })
}
@@ -0,0 +1,24 @@
import { appendFileSync } from 'node:fs'
import { createRequire, syncBuiltinESMExports } from 'node:module'
const markerPath = process.env.OPENCLAUDE_TEST_COMPILE_CACHE_MARKER
const behavior = process.env.OPENCLAUDE_TEST_COMPILE_CACHE_BEHAVIOR
const builtinModule = createRequire(import.meta.url)('node:module')
if (behavior === 'absent') {
builtinModule.enableCompileCache = undefined
} else if (typeof builtinModule.enableCompileCache === 'function') {
builtinModule.enableCompileCache = () => {
if (markerPath) {
appendFileSync(markerPath, `${JSON.stringify({
pid: process.pid,
heapRelaunched: process.env.OPENCLAUDE_HEAP_RELAUNCHED === '1',
})}\n`)
}
if (behavior === 'throw') throw new Error('injected compile-cache failure')
if (behavior === 'failed-status') return { status: 0, message: 'injected failure' }
return { status: 1, directory: '/injected/cache' }
}
}
syncBuiltinESMExports()
@@ -0,0 +1,204 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
import { enableNodeCompileCacheIfAvailable } from '../bin/node-compile-cache.mjs'
const REPO_ROOT = join(import.meta.dir, '..')
const BIN_PATH = join(REPO_ROOT, 'bin', 'openclaude')
const INSTRUMENTATION_PATH = join(
import.meta.dir,
'fixtures',
'instrument-node-compile-cache.mjs',
)
const PACKAGE_VERSION = JSON.parse(
readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'),
).version
const EXPECTED_VERSION_OUTPUT = `${PACKAGE_VERSION} (OpenClaude)\n`
type LauncherResult = {
status: number | null
stdout: string
stderr: string
}
function launcherEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
const env = {
...process.env,
CI: '1',
NO_COLOR: '1',
...overrides,
}
delete env.OPENCLAUDE_HEAP_RELAUNCHED
delete env.OPENCLAUDE_DISABLE_HEAP_RELAUNCH
if (!Object.hasOwn(overrides, 'NODE_OPTIONS')) delete env.NODE_OPTIONS
if (!Object.hasOwn(overrides, 'NODE_COMPILE_CACHE')) delete env.NODE_COMPILE_CACHE
if (!Object.hasOwn(overrides, 'NODE_DISABLE_COMPILE_CACHE')) delete env.NODE_DISABLE_COMPILE_CACHE
return env
}
function runLauncher(
env: NodeJS.ProcessEnv,
nodeArgs: string[] = [],
): LauncherResult {
const result = spawnSync('node', [...nodeArgs, BIN_PATH, '--version'], {
cwd: REPO_ROOT,
encoding: 'utf8',
env,
timeout: 30_000,
})
return {
status: result.status,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
}
}
function expectNormalVersion(result: LauncherResult): void {
expect(result.status).toBe(0)
expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT)
expect(result.stderr).toBe('')
}
function nodeSupportsCompileCache(): boolean {
return spawnSync(
'node',
['-e', "process.exit(typeof require('node:module').enableCompileCache === 'function' ? 0 : 1)"],
).status === 0
}
function hasFileContent(path: string): boolean {
if (!existsSync(path)) return false
for (const entry of readdirSync(path, { withFileTypes: true })) {
const child = join(path, entry.name)
if (entry.isDirectory()) {
if (hasFileContent(child)) return true
} else {
return true
}
}
return false
}
describe('enableNodeCompileCacheIfAvailable', () => {
test('leaves startup unchanged when the API is absent', () => {
expect(() => enableNodeCompileCacheIfAvailable({})).not.toThrow()
})
test('invokes an available API exactly once without a directory', () => {
const calls: unknown[][] = []
enableNodeCompileCacheIfAvailable({
enableCompileCache: (...args: unknown[]) => {
calls.push(args)
return { status: 1, directory: '/tmp/cache' }
},
})
expect(calls).toEqual([[]])
})
test('ignores a failed status result', () => {
expect(() => enableNodeCompileCacheIfAvailable({
enableCompileCache: () => ({ status: 0, message: 'not writable' }),
})).not.toThrow()
})
test('swallows unexpected implementation throws', () => {
expect(() => enableNodeCompileCacheIfAvailable({
enableCompileCache: () => {
throw new Error('unexpected')
},
})).not.toThrow()
})
})
describe('openclaude launcher compile cache', () => {
test('does not inherit ambient NODE_OPTIONS into boundary launches', () => {
const originalNodeOptions = process.env.NODE_OPTIONS
try {
process.env.NODE_OPTIONS = '--max-old-space-size=256 --expose-gc'
expect(launcherEnv().NODE_OPTIONS).toBeUndefined()
} finally {
if (originalNodeOptions === undefined) delete process.env.NODE_OPTIONS
else process.env.NODE_OPTIONS = originalNodeOptions
}
})
test('the real absent binding remains non-fatal and silent', () => {
const scratch = mkdtempSync(join(tmpdir(), 'openclaude-compile-cache-absent-'))
try {
const result = runLauncher(
launcherEnv({
OPENCLAUDE_CONFIG_DIR: join(scratch, 'config'),
OPENCLAUDE_TEST_COMPILE_CACHE_BEHAVIOR: 'absent',
}),
['--import', INSTRUMENTATION_PATH],
)
expectNormalVersion(result)
} finally {
rmSync(scratch, { recursive: true, force: true })
}
})
for (const behavior of ['success', 'failed-status', 'throw'] as const) {
test(`the final importing process survives ${behavior} setup`, () => {
if (!nodeSupportsCompileCache()) return
const scratch = mkdtempSync(join(tmpdir(), `openclaude-compile-cache-${behavior}-`))
const markerPath = join(scratch, 'calls.jsonl')
try {
const result = runLauncher(
launcherEnv({
OPENCLAUDE_CONFIG_DIR: join(scratch, 'config'),
OPENCLAUDE_TEST_COMPILE_CACHE_BEHAVIOR: behavior,
OPENCLAUDE_TEST_COMPILE_CACHE_MARKER: markerPath,
}),
['--import', INSTRUMENTATION_PATH],
)
expectNormalVersion(result)
const calls = readFileSync(markerPath, 'utf8')
.trim()
.split('\n')
.map(line => JSON.parse(line))
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({ heapRelaunched: true })
} finally {
rmSync(scratch, { recursive: true, force: true })
}
})
}
test('NODE_DISABLE_COMPILE_CACHE remains authoritative', () => {
const scratch = mkdtempSync(join(tmpdir(), 'openclaude-compile-cache-disabled-'))
const cacheDir = join(scratch, 'cache')
try {
expectNormalVersion(runLauncher(launcherEnv({
NODE_COMPILE_CACHE: cacheDir,
NODE_DISABLE_COMPILE_CACHE: '1',
OPENCLAUDE_CONFIG_DIR: join(scratch, 'config'),
})))
if (nodeSupportsCompileCache()) expect(hasFileContent(cacheDir)).toBe(false)
} finally {
rmSync(scratch, { recursive: true, force: true })
}
})
test('a temporary NODE_COMPILE_CACHE gains content without changing output', () => {
if (!nodeSupportsCompileCache()) return
const scratch = mkdtempSync(join(tmpdir(), 'openclaude-compile-cache-real-'))
const cacheDir = join(scratch, 'cache')
try {
const env = launcherEnv({
NODE_COMPILE_CACHE: cacheDir,
OPENCLAUDE_CONFIG_DIR: join(scratch, 'config'),
})
expectNormalVersion(runLauncher(env))
expectNormalVersion(runLauncher(env))
expect(hasFileContent(cacheDir)).toBe(true)
} finally {
rmSync(scratch, { recursive: true, force: true })
}
})
})
+16 -3
View File
@@ -3,6 +3,7 @@ import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
const BIN_PATH = join(import.meta.dir, '..', 'bin', 'openclaude')
const COMPILE_CACHE_PATH = join(import.meta.dir, '..', 'bin', 'node-compile-cache.mjs')
describe('openclaude launcher heap guard', () => {
test('raises the current Node heap before loading dist/cli.mjs', () => {
@@ -11,9 +12,14 @@ describe('openclaude launcher heap guard', () => {
expect(source).toContain('--max-old-space-size=')
expect(source).toContain('--expose-gc')
expect(source).toContain('spawnSync(process.execPath')
expect(source.indexOf('relaunchWithLongSessionHeapIfNeeded()')).toBeLessThan(
source.indexOf("await import(pathToFileURL(distPath).href)"),
)
const importingBranch = source.slice(source.indexOf('if (existsSync(distPath))'))
const relaunchIndex = importingBranch.indexOf('relaunchWithLongSessionHeapIfNeeded()')
const compileCacheIndex = importingBranch.indexOf('enableNodeCompileCacheIfAvailable()')
const importIndex = importingBranch.indexOf("await import(pathToFileURL(distPath).href)")
expect(relaunchIndex).toBeGreaterThanOrEqual(0)
expect(compileCacheIndex).toBeGreaterThan(relaunchIndex)
expect(importIndex).toBeGreaterThan(compileCacheIndex)
})
test('keeps user and troubleshooting escape hatches', () => {
@@ -24,4 +30,11 @@ describe('openclaude launcher heap guard', () => {
expect(source).toContain('process.env.NODE_OPTIONS')
expect(source).toContain("hasNodeOptionFlag('--max-old-space-size')")
})
test('feature-detects the compile-cache API without a named builtin import', () => {
const source = readFileSync(COMPILE_CACHE_PATH, 'utf-8')
expect(source).toContain("import * as nodeModule from 'node:module'")
expect(source).not.toMatch(/import\s*\{[^}]*enableCompileCache[^}]*\}\s*from\s*['"]node:module['"]/s)
})
})
+1
View File
@@ -298,6 +298,7 @@ function checkTarballContents(tarballPath: string): void {
const required = [
'package/package.json',
'package/bin/openclaude',
'package/bin/node-compile-cache.mjs',
'package/dist/cli.mjs',
'package/dist/sdk.mjs',
'package/src/entrypoints/sdk.d.ts',