mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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(`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user