Skip to content
Merged
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
65 changes: 65 additions & 0 deletions bin/openclaude
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,76 @@
import { existsSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import { spawnSync } from 'child_process'

const __dirname = dirname(fileURLToPath(import.meta.url))
const distPath = join(__dirname, '..', 'dist', 'cli.mjs')

const HEAP_RELAUNCHED_ENV = 'OPENCLAUDE_HEAP_RELAUNCHED'
const DISABLE_HEAP_RELAUNCH_ENV = 'OPENCLAUDE_DISABLE_HEAP_RELAUNCH'
const HEAP_SIZE_ENV = 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB'
const DEFAULT_HEAP_SIZE_MB = 8192

function hasNodeFlag(args, flag) {
return args.some(arg => arg === flag || arg.startsWith(`${flag}=`))
}

function hasNodeOptionFlag(flag) {
return hasNodeFlag([
...process.execArgv,
...(process.env.NODE_OPTIONS || '').split(/\s+/).filter(Boolean),
], flag)
}

function getHeapSizeMb() {
const raw = process.env[HEAP_SIZE_ENV]
if (!raw) return DEFAULT_HEAP_SIZE_MB
const parsed = Number.parseInt(raw, 10)
return Number.isSafeInteger(parsed) && parsed > 0
? parsed
: DEFAULT_HEAP_SIZE_MB
}

function relaunchWithLongSessionHeapIfNeeded() {
if (process.env[DISABLE_HEAP_RELAUNCH_ENV] === '1') return
if (process.env[HEAP_RELAUNCHED_ENV] === '1') return
const hasHeapLimit = hasNodeOptionFlag('--max-old-space-size')
const hasExplicitGc = hasNodeOptionFlag('--expose-gc')
if (hasHeapLimit && hasExplicitGc) return

const execArgv = [...process.execArgv]
if (!hasHeapLimit) {
execArgv.push(`--max-old-space-size=${getHeapSizeMb()}`)
}

// Expose explicit GC for long interactive sessions. NODE_OPTIONS cannot
// carry --expose-gc, so the executable wrapper must add it before startup.
if (!hasExplicitGc) {
execArgv.push('--expose-gc')
}

const result = spawnSync(process.execPath, [
...execArgv,
fileURLToPath(import.meta.url),
...process.argv.slice(2),
], {
stdio: 'inherit',
env: {
...process.env,
[HEAP_RELAUNCHED_ENV]: '1',
},
})

if (result.error) {
console.error(`openclaude: failed to restart with long-session heap: ${result.error.message}`)
process.exit(1)
}

process.exit(result.status ?? 1)
}

if (existsSync(distPath)) {
relaunchWithLongSessionHeapIfNeeded()
await import(pathToFileURL(distPath).href)
} else {
console.error(`
Expand Down
27 changes: 27 additions & 0 deletions scripts/openclaude-bin-heap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'

const BIN_PATH = join(import.meta.dir, '..', 'bin', 'openclaude')

describe('openclaude launcher heap guard', () => {
test('raises the current Node heap before loading dist/cli.mjs', () => {
const source = readFileSync(BIN_PATH, 'utf-8')

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)"),
)
})

test('keeps user and troubleshooting escape hatches', () => {
const source = readFileSync(BIN_PATH, 'utf-8')

expect(source).toContain('OPENCLAUDE_DISABLE_HEAP_RELAUNCH')
expect(source).toContain('OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB')
expect(source).toContain('process.env.NODE_OPTIONS')
expect(source).toContain("hasNodeOptionFlag('--max-old-space-size')")
})
})
13 changes: 10 additions & 3 deletions src/cli/print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,9 +538,16 @@ export async function runHeadless(
proactiveModule.activateProactive('command')
}

// Periodically force a full GC to keep memory usage in check
if (typeof Bun !== 'undefined') {
const gcTimer = setInterval(Bun.gc, 1000)
// Periodically force a full GC to keep memory usage in check. The package
// launcher starts Node with --expose-gc; Bun exposes Bun.gc directly.
const forceGc =
typeof Bun !== 'undefined'
? Bun.gc
: typeof (globalThis as { gc?: () => void }).gc === 'function'
? (globalThis as { gc: () => void }).gc
: null
if (forceGc) {
const gcTimer = setInterval(forceGc, 1000)
gcTimer.unref()
}

Expand Down
13 changes: 4 additions & 9 deletions src/entrypoints/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,10 @@ process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS ??= 'true'
// eslint-disable-next-line custom-rules/no-top-level-side-effects
process.env.COREPACK_ENABLE_AUTO_PIN = '0';

// Set max heap size for child processes.
// Local runs get 8 GB so long agentic tasks (multi-file reads, large prompts,
// tool-heavy loops) do not hit V8's ~2 GB default ceiling. Only raise the cap
// when the user has not already set NODE_OPTIONS --max-old-space-size so we
// do not override an intentionally lower or higher personal setting.
// CCR (Claude Code Remote / container) environments are covered by the same
// unconditional assignment — the previous CLAUDE_CODE_REMOTE guard was too
// restrictive, preventing local users from benefiting from the raised limit.
// Closes: Gitlawb/openclaude#402 — JavaScript heap OOM during large tasks.
// Set max heap size for child processes. The current CLI process is already
// running by this point; the package launcher raises its heap before importing
// dist/cli.mjs. Keeping NODE_OPTIONS here preserves the larger cap for tools or
// subprocesses spawned after startup without overriding user-provided limits.
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level, custom-rules/safe-env-boolean-check
if (!process.env.NODE_OPTIONS?.includes('--max-old-space-size')) {
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
Expand Down