mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 12:50:54 -04:00
* 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:
+211
-11
@@ -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')
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user