fix(launcher): tolerate missing bin helpers on distro installs (#2255) (#2256)

* fix(launcher): tolerate missing bin helpers on distro installs (#2255)

* test(launcher): add heap fallback/canonical parity plus robust siblingless fixture

* test(launcher): isolate heap env in launcher fixture

---------

Co-authored-by: Gravirei <gravirei@users.noreply.github.com>
This commit is contained in:
Gravirei
2026-10-25 21:31:57 -07:00
committed by GitHub
co-authored by Gravirei
parent c86014337b
commit 0e441ba09a
3 changed files with 542 additions and 12 deletions
+211 -11
View File
@@ -11,17 +11,217 @@ 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'
import {
HEAP_SIZE_ENV,
HEAP_SIZE_FLAG,
formatPercentageUnavailableStderr,
hasHeapLimitFlag,
hasNodeFlag,
nodeOptionArgs,
resolveHeapSizeMb,
stripLauncherHeapArgs,
} from './heap-limit.mjs'
import os from 'node:os'
import * as nodeModule from 'node:module'
// Sibling helpers (./node-compile-cache.mjs, ./heap-limit.mjs) ship inside
// bin/ for npm installs, but non-npm layouts (e.g. distro packages that copy
// only the launcher file to /usr/lib/openclaude/bin/) may omit them. A static
// import would then fail with ERR_MODULE_NOT_FOUND before any code runs
// (issue #2255). Load them dynamically so a missing sibling degrades to a
// safe inline fallback instead of a hard boot crash. The fallbacks below are
// intentionally self-contained; keep them in sync with the canonical helpers
// in bin/node-compile-cache.mjs and bin/heap-limit.mjs.
const FALLBACK_DEFAULT_HEAP_SIZE_MB = 8192
const FALLBACK_HEAP_SIZE_ENV = 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB'
const FALLBACK_HEAP_PERCENTAGE_ENV =
'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE'
const FALLBACK_HEAP_SIZE_FLAG = '--max-old-space-size'
const FALLBACK_HEAP_PERCENTAGE_FLAG = '--max-old-space-size-percentage'
const FALLBACK_MAX_MEMORY_FLAG = '--max-memory'
function fallbackHasNodeFlag(args, flag) {
return args.some(arg => arg === flag || arg.startsWith(`${flag}=`))
}
function fallbackNodeOptionArgs(nodeOptions) {
return (nodeOptions || '').split(/\s+/).filter(Boolean)
}
function fallbackHasHeapLimitFlag(args) {
return (
fallbackHasNodeFlag(args, FALLBACK_HEAP_SIZE_FLAG) ||
fallbackHasNodeFlag(args, FALLBACK_HEAP_PERCENTAGE_FLAG)
)
}
function fallbackParsePositiveIntegerMb(raw) {
if (raw == null || raw === '') return null
const parsed = Number.parseInt(String(raw), 10)
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
}
function fallbackParsePercentage(raw) {
if (raw == null) return null
const trimmed = String(raw).trim()
if (!trimmed) return null
const withoutPercent = trimmed.endsWith('%')
? trimmed.slice(0, -1)
: trimmed
if (withoutPercent.trim() === '') return null
const parsed = Number(withoutPercent)
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) return null
return parsed
}
function fallbackGetAvailableMemoryBytes() {
try {
const constrained =
typeof process.constrainedMemory === 'function'
? process.constrainedMemory()
: undefined
if (
typeof constrained === 'number' &&
Number.isSafeInteger(constrained) &&
constrained > 0
) {
return constrained
}
} catch {
// Fall through to host total memory.
}
try {
const total = os.totalmem()
if (typeof total === 'number' && Number.isFinite(total) && total > 0) {
return total
}
} catch {
// Memory size unknown; callers fall back to the default heap.
}
return 0
}
function fallbackFindEqualsFlagValue(args, flag) {
const prefix = `${flag}=`
for (const arg of args) {
if (arg.startsWith(prefix)) return arg.slice(prefix.length)
}
return null
}
function fallbackFindEqualsOrNextFlagValue(args, flag) {
const prefix = `${flag}=`
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith(prefix)) return arg.slice(prefix.length)
if (arg === flag) {
const next = args[i + 1]
if (next && !next.startsWith('-')) return next
return ''
}
}
return null
}
function fallbackStripLauncherHeapArgs(args) {
const stripped = []
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (
arg === FALLBACK_MAX_MEMORY_FLAG ||
arg.startsWith(`${FALLBACK_MAX_MEMORY_FLAG}=`)
)
continue
if (arg.startsWith(`${FALLBACK_HEAP_PERCENTAGE_FLAG}=`)) continue
if (arg === FALLBACK_HEAP_PERCENTAGE_FLAG) {
const next = args[i + 1]
if (next && !next.startsWith('-') && fallbackParsePercentage(next) != null)
i += 1
continue
}
stripped.push(arg)
}
return stripped
}
function fallbackResolveHeapSizeMb({ argv = [], env = {} } = {}) {
const availableBytes = fallbackGetAvailableMemoryBytes()
const maxMem = fallbackParsePositiveIntegerMb(
fallbackFindEqualsFlagValue(argv, FALLBACK_MAX_MEMORY_FLAG),
)
if (maxMem != null) {
return { mb: maxMem, source: 'max-memory', setMaxMemoryEnv: true }
}
const argvPercentage = fallbackParsePercentage(
fallbackFindEqualsOrNextFlagValue(argv, FALLBACK_HEAP_PERCENTAGE_FLAG),
)
const envPercentage = fallbackParsePercentage(env[FALLBACK_HEAP_PERCENTAGE_ENV])
const percentage = argvPercentage ?? envPercentage
if (percentage != null) {
if (availableBytes > 0) {
const mb = Math.max(
1,
Math.floor((availableBytes * (percentage / 100)) / (1024 * 1024)),
)
return {
mb,
source: argvPercentage != null ? 'argv-percentage' : 'env-percentage',
percentage,
}
}
return {
mb: FALLBACK_DEFAULT_HEAP_SIZE_MB,
source: 'percentage-unavailable',
percentage,
}
}
const envMb = fallbackParsePositiveIntegerMb(env[FALLBACK_HEAP_SIZE_ENV])
if (envMb != null) return { mb: envMb, source: 'env-mb' }
return { mb: FALLBACK_DEFAULT_HEAP_SIZE_MB, source: 'default' }
}
function fallbackFormatPercentageUnavailableStderr(
percentage,
fallbackMb = FALLBACK_DEFAULT_HEAP_SIZE_MB,
) {
return `openclaude: could not convert heap percentage ${percentage} to megabytes because available memory is unknown; using ${fallbackMb}`
}
function fallbackEnableNodeCompileCacheIfAvailable() {
// Mirrors bin/node-compile-cache.mjs without the sibling file so a missing
// helper still warms the compile cache instead of losing the optimization.
const enable = nodeModule.enableCompileCache
if (typeof enable !== 'function') return
try {
enable()
} catch {
// Compile caching is optional and must never block startup.
}
}
async function loadSiblingModule(specifier) {
try {
return await import(new URL(specifier, import.meta.url).href)
} catch {
// Any load failure (missing file, unreadable, corrupt) falls back to the
// inline implementations above: booting the CLI outranks surfacing a
// packaging defect, and the fallback keeps stderr silent.
return null
}
}
const compileCacheModule = await loadSiblingModule('./node-compile-cache.mjs')
const heapLimitModule = await loadSiblingModule('./heap-limit.mjs')
const enableNodeCompileCacheIfAvailable =
compileCacheModule?.enableNodeCompileCacheIfAvailable ??
fallbackEnableNodeCompileCacheIfAvailable
const HEAP_SIZE_ENV =
heapLimitModule?.HEAP_SIZE_ENV ?? FALLBACK_HEAP_SIZE_ENV
const HEAP_SIZE_FLAG =
heapLimitModule?.HEAP_SIZE_FLAG ?? FALLBACK_HEAP_SIZE_FLAG
const formatPercentageUnavailableStderr =
heapLimitModule?.formatPercentageUnavailableStderr ??
fallbackFormatPercentageUnavailableStderr
const hasHeapLimitFlag =
heapLimitModule?.hasHeapLimitFlag ?? fallbackHasHeapLimitFlag
const hasNodeFlag = heapLimitModule?.hasNodeFlag ?? fallbackHasNodeFlag
const nodeOptionArgs =
heapLimitModule?.nodeOptionArgs ?? fallbackNodeOptionArgs
const resolveHeapSizeMb =
heapLimitModule?.resolveHeapSizeMb ?? fallbackResolveHeapSizeMb
const stripLauncherHeapArgs =
heapLimitModule?.stripLauncherHeapArgs ?? fallbackStripLauncherHeapArgs
const __dirname = dirname(fileURLToPath(import.meta.url))
const distPath = join(__dirname, '..', 'dist', 'cli.mjs')
+10 -1
View File
@@ -41,7 +41,16 @@ describe('openclaude launcher heap guard', () => {
expect(source).toContain("resolved.source === 'percentage-unavailable'")
expect(source).toContain('--expose-gc')
expect(source).toContain('spawnSync(process.execPath')
expect(source).toContain("from './heap-limit.mjs'")
// The launcher must not statically import its bin/*.mjs siblings: distro
// layouts that copy only the launcher file (issue #2255) would otherwise
// crash with ERR_MODULE_NOT_FOUND before any code runs. Siblings load
// dynamically with inline fallbacks instead.
expect(source).not.toContain("from './heap-limit.mjs'")
expect(source).not.toContain("from './node-compile-cache.mjs'")
expect(source).toContain("'./heap-limit.mjs'")
expect(source).toContain("'./node-compile-cache.mjs'")
expect(source).toContain('fallbackResolveHeapSizeMb')
expect(source).toContain('fallbackEnableNodeCompileCacheIfAvailable')
const importingBranch = source.slice(source.indexOf('if (existsSync(distPath))'))
const relaunchIndex = importingBranch.indexOf('relaunchWithLongSessionHeapIfNeeded()')
const compileCacheIndex = importingBranch.indexOf('enableNodeCompileCacheIfAvailable()')
@@ -0,0 +1,321 @@
import { spawnSync } from 'node:child_process'
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
const REPO_ROOT = join(import.meta.dir, '..')
const BIN_PATH = join(REPO_ROOT, 'bin', 'openclaude')
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
}
// Simulates distro layouts (e.g. Arch AUR /usr/lib/openclaude) that install
// only the launcher file without its bin/*.mjs siblings (issue #2255). The
// launcher must still boot via its inline fallbacks instead of crashing with
// ERR_MODULE_NOT_FOUND before any code runs.
function assertBuiltCli(): void {
const builtCli = join(REPO_ROOT, 'dist', 'cli.mjs')
if (!existsSync(builtCli)) {
throw new Error(
`dist/cli.mjs not found at ${builtCli} — run \`bun run build\` before these tests.`,
)
}
}
function makeSiblinglessLayout(): string {
assertBuiltCli()
const root = mkdtempSync(join(tmpdir(), 'openclaude-siblingless-'))
const binDir = join(root, 'bin')
mkdirSync(binDir, { recursive: true })
copyFileSync(BIN_PATH, join(binDir, 'openclaude'))
// 'junction' is ignored on POSIX and lets Windows create directory links
// without elevated privileges.
symlinkSync(join(REPO_ROOT, 'dist'), join(root, 'dist'), 'junction')
symlinkSync(
join(REPO_ROOT, 'node_modules'),
join(root, 'node_modules'),
'junction',
)
return root
}
function runLauncher(
root: string,
args: string[],
extraEnv: NodeJS.ProcessEnv = {},
): LauncherResult {
const env: NodeJS.ProcessEnv = {
...process.env,
CI: '1',
NO_COLOR: '1',
OPENCLAUDE_CONFIG_DIR: join(root, 'config'),
}
// Heap-related env must not leak in from the outer environment: the parity
// cases assert exact resolved values, and an inherited override (or a
// relaunch toggle) would silently change what the launcher resolves. Each
// case's env is applied after this cleanup, so explicit overrides still win.
for (const key of [
'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB',
'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE',
'OPENCLAUDE_MAX_MEMORY_MB',
'OPENCLAUDE_HEAP_RELAUNCHED',
'OPENCLAUDE_DISABLE_HEAP_RELAUNCH',
]) {
delete env[key]
}
if (!Object.hasOwn(extraEnv, 'NODE_OPTIONS')) delete env.NODE_OPTIONS
Object.assign(env, extraEnv)
const result = spawnSync('node', [join(root, 'bin', 'openclaude'), ...args], {
cwd: root,
encoding: 'utf8',
env,
timeout: 30_000,
})
return {
status: result.status,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
}
}
function runSiblingless(
root: string,
args: string[],
extraEnv: NodeJS.ProcessEnv = {},
): LauncherResult {
return runLauncher(root, args, extraEnv)
}
function withSiblinglessLayout(fn: (root: string) => void): void {
const root = makeSiblinglessLayout()
try {
fn(root)
} finally {
rmSync(root, { recursive: true, force: true })
}
}
// The inline fallbacks in bin/openclaude duplicate bin/heap-limit.mjs, so the
// same inputs must resolve to the same heap through both paths. The stub
// bundle below reports what the launcher resolved instead of starting the CLI,
// letting a full-sibling layout and a siblingless layout be compared
// observably without changing resolver behavior.
const HEAP_OBSERVER_STUB = `console.log(JSON.stringify({ heapMb: process.env.OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB ?? null, maxMemoryMb: process.env.OPENCLAUDE_MAX_MEMORY_MB ?? null, heapFlag: process.execArgv.find(a => a.startsWith('--max-old-space-size=')) ?? null }))\n`
function makeHeapObserverLayout(withSiblings: boolean): string {
assertBuiltCli()
const tag = withSiblings ? 'full' : 'siblingless'
const root = mkdtempSync(join(tmpdir(), `openclaude-heap-parity-${tag}-`))
const binDir = join(root, 'bin')
const distDir = join(root, 'dist')
mkdirSync(binDir, { recursive: true })
mkdirSync(distDir, { recursive: true })
copyFileSync(BIN_PATH, join(binDir, 'openclaude'))
if (withSiblings) {
for (const helper of ['heap-limit.mjs', 'node-compile-cache.mjs']) {
copyFileSync(join(REPO_ROOT, 'bin', helper), join(binDir, helper))
}
}
writeFileSync(join(distDir, 'cli.mjs'), HEAP_OBSERVER_STUB)
return root
}
type HeapParityCase = {
name: string
args: string[]
env: NodeJS.ProcessEnv
expectedMb: string | null
expectedMaxMemoryMb?: string
}
const HEAP_PARITY_CASES: HeapParityCase[] = [
{ name: 'default heap', args: [], env: {}, expectedMb: '8192' },
{
name: 'env MB override',
args: [],
env: { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB: '4096' },
expectedMb: '4096',
},
{
name: '--max-memory wins over percentage',
args: ['--max-memory=1536'],
env: { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE: '90' },
expectedMb: '1536',
expectedMaxMemoryMb: '1536',
},
// Percentage outcomes depend on host RAM, so these cases assert fallback /
// canonical equality rather than an exact value.
{
name: 'argv percentage',
args: ['--max-old-space-size-percentage=50'],
env: {},
expectedMb: null,
},
{
name: 'spaced argv percentage',
args: ['--max-old-space-size-percentage', '25'],
env: {},
expectedMb: null,
},
{
name: 'env percentage',
args: [],
env: { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE: '50' },
expectedMb: null,
},
{
name: 'invalid percentage falls back to env MB',
args: ['--max-old-space-size-percentage=0'],
env: { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB: '4096' },
expectedMb: '4096',
},
]
describe('openclaude launcher without bin/*.mjs siblings', () => {
test('boots --version with silent stderr', () => {
withSiblinglessLayout(root => {
const result = runSiblingless(root, ['--version'])
expect(result.status).toBe(0)
expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT)
expect(result.stderr).toBe('')
expect(`${result.stdout}${result.stderr}`).not.toContain(
'ERR_MODULE_NOT_FOUND',
)
})
})
test('boots --help with silent stderr', () => {
withSiblinglessLayout(root => {
const result = runSiblingless(root, ['--help'])
expect(result.status).toBe(0)
expect(result.stdout).toMatch(/usage/i)
expect(result.stderr).toBe('')
})
})
test('strips launcher-only percentage flags without siblings', () => {
withSiblinglessLayout(root => {
const result = runSiblingless(root, [
'--max-old-space-size-percentage=50',
'--version',
])
expect(result.status).toBe(0)
expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT)
expect(`${result.stdout}${result.stderr}`).not.toContain(
"unknown option '--max-old-space-size-percentage=50'",
)
})
})
test('strips launcher-only flags without siblings when relaunch is disabled', () => {
withSiblinglessLayout(root => {
const result = runSiblingless(
root,
['--max-old-space-size-percentage=50', '--version'],
{ OPENCLAUDE_DISABLE_HEAP_RELAUNCH: '1' },
)
expect(result.status).toBe(0)
expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT)
expect(`${result.stdout}${result.stderr}`).not.toContain(
"unknown option '--max-old-space-size-percentage=50'",
)
})
})
test('accepts --max-memory without siblings', () => {
withSiblinglessLayout(root => {
const result = runSiblingless(root, ['--max-memory=1024', '--version'])
expect(result.status).toBe(0)
expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT)
expect(result.stderr).toBe('')
})
})
test('honors heap env overrides without siblings', () => {
withSiblinglessLayout(root => {
const mbResult = runSiblingless(
root,
['--version'],
{ OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB: '4096' },
)
expect(mbResult.status).toBe(0)
expect(mbResult.stdout).toBe(EXPECTED_VERSION_OUTPUT)
expect(mbResult.stderr).toBe('')
const percentageResult = runSiblingless(
root,
['--version'],
{ OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE: '50' },
)
expect(percentageResult.status).toBe(0)
expect(percentageResult.stdout).toBe(EXPECTED_VERSION_OUTPUT)
expect(percentageResult.stderr).toBe('')
})
})
test('strips spaced launcher-only percentage flags without siblings', () => {
withSiblinglessLayout(root => {
const result = runSiblingless(root, [
'--max-old-space-size-percentage',
'50',
'--version',
])
expect(result.status).toBe(0)
expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT)
expect(`${result.stdout}${result.stderr}`).not.toContain('unknown option')
})
})
})
describe('openclaude launcher heap fallback/canonical parity', () => {
for (const parityCase of HEAP_PARITY_CASES) {
test(`resolves the same heap with and without siblings: ${parityCase.name}`, () => {
const full = makeHeapObserverLayout(true)
const siblingless = makeHeapObserverLayout(false)
try {
const canonical = runLauncher(full, parityCase.args, parityCase.env)
const fallback = runLauncher(
siblingless,
parityCase.args,
parityCase.env,
)
expect(canonical.status).toBe(0)
expect(fallback.status).toBe(0)
expect(fallback.stdout).toBe(canonical.stdout)
expect(fallback.stderr).toBe('')
if (parityCase.expectedMb !== null) {
const observed = JSON.parse(canonical.stdout)
expect(observed.heapMb).toBe(parityCase.expectedMb)
expect(observed.heapFlag).toBe(
`--max-old-space-size=${parityCase.expectedMb}`,
)
}
if (parityCase.expectedMaxMemoryMb !== undefined) {
expect(JSON.parse(canonical.stdout).maxMemoryMb).toBe(
parityCase.expectedMaxMemoryMb,
)
}
} finally {
rmSync(full, { recursive: true, force: true })
rmSync(siblingless, { recursive: true, force: true })
}
})
}
})