diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh index 1dfef84edb5..44dfd4afa96 100755 --- a/scripts/dev-setup.sh +++ b/scripts/dev-setup.sh @@ -15,6 +15,8 @@ WARN_COUNT=0 FAIL_COUNT=0 OUTPUT_FORMAT="human" JSON_RESULTS="" +NODE_HEAP_REMEDIATION_CLI="Run: NODE_OPTIONS=--max-old-space-size=5120 npm run typecheck:cli" +NODE_HEAP_REMEDIATION_PLUGIN="Run: NODE_OPTIONS=--max-old-space-size=5120 npm --prefix nemoclaw run build" usage() { cat <<'EOF' @@ -227,33 +229,40 @@ check_executable() { fi } -check_quiet_command() { +is_node_heap_oom_output() { + local output_file="$1" + + grep -Eq \ + "JavaScript heap out of memory|Ineffective mark-compacts near heap limit|Allocation failed - JavaScript heap out of memory" \ + "${output_file}" 2>/dev/null +} + +print_node_heap_oom_guidance() { + local remediation="$1" + + printf 'Node.js exhausted its V8 heap while running this type check.\n' >&2 + printf 'Next: %s\n' "${remediation}" >&2 +} + +check_quiet_command_with_heap_hint() { local label="$1" local remediation="$2" - local classification command_status matcher_status pipeline_result - local -a pipeline_status - shift 2 + local heap_remediation="$3" + local output_file + shift 3 - # Drain the complete stream and retain one classification word. This bounds - # shell memory and keeps command output out of the doctor report. - pipeline_result="$( - "$@" 2>&1 | awk ' - index($0, "JavaScript heap out of memory") { found = 1 } - END { printf "%s", found ? "heap" : "other" } - ' - pipeline_status=("${PIPESTATUS[@]}") - printf '|%s|%s' "${pipeline_status[0]}" "${pipeline_status[1]}" - )" - IFS='|' read -r classification command_status matcher_status <<<"${pipeline_result}" - - if [ "${command_status}" = "0" ] && [ "${matcher_status}" = "0" ]; then + output_file="$(mktemp "${TMPDIR:-/tmp}/nemoclaw-dev-doctor.XXXXXX")" || { + fail "${label}: failed" "${remediation}" + return + } + if "$@" >"${output_file}" 2>&1; then + rm -f "${output_file}" pass "${label}" - elif [ "${classification}" = "heap" ] && [ "${matcher_status}" = "0" ]; then - # Node.js derives its default old-space limit from host memory, so this - # reports the host size rather than a fault in the checked sources. - fail "${label}: ran out of Node.js heap" \ - "Raise the limit, then rerun: export NODE_OPTIONS=--max-old-space-size=5120" + elif is_node_heap_oom_output "${output_file}"; then + rm -f "${output_file}" + fail "${label}: ran out of Node.js heap" "${heap_remediation}" else + rm -f "${output_file}" fail "${label}: failed" "${remediation}" fi } @@ -332,6 +341,31 @@ run_setup_step() { return 1 } +run_setup_step_with_heap_hint() { + local label="$1" + local heap_remediation="$2" + local output_file + shift 2 + + printf '\n==> %s\n' "${label}" + output_file="$(mktemp "${TMPDIR:-/tmp}/nemoclaw-dev-setup.XXXXXX")" || { + printf 'Setup stopped while attempting: %s\n' "${label}" >&2 + return 1 + } + if "$@" >"${output_file}" 2>&1; then + rm -f "${output_file}" + return 0 + fi + if is_node_heap_oom_output "${output_file}"; then + print_node_heap_oom_guidance "${heap_remediation}" + else + cat "${output_file}" >&2 + fi + rm -f "${output_file}" + printf 'Setup stopped while attempting: %s\n' "${label}" >&2 + return 1 +} + repair_repository() { local setup_failed=0 hooks_path @@ -382,8 +416,10 @@ repair_repository() { run_setup_step "Build the CLI" npm run build:cli || return 1 run_setup_step "Build and type-check the plugin" npm --prefix nemoclaw run build || return 1 # Keep the explicit checks aligned with the broader pre-push and CI contracts. - run_setup_step "Type-check the CLI" npm run typecheck:cli || return 1 - run_setup_step "Type-check the plugin without emitting files" \ + run_setup_step_with_heap_hint "Type-check the CLI" "${NODE_HEAP_REMEDIATION_CLI}" \ + npm run typecheck:cli || return 1 + run_setup_step_with_heap_hint "Type-check the plugin without emitting files" \ + "${NODE_HEAP_REMEDIATION_PLUGIN}" \ "${REPO_ROOT}/nemoclaw/node_modules/.bin/tsc" --noEmit \ -p "${REPO_ROOT}/nemoclaw/tsconfig.json" || return 1 run_setup_step "Install repository Git hooks" "${REPO_ROOT}/node_modules/.bin/prek" install || return 1 @@ -612,11 +648,13 @@ run_doctor() { "Run: cd nemoclaw && npm run build" "${REPO_ROOT}/nemoclaw/src" \ "${REPO_ROOT}/nemoclaw/tsconfig.json" "${REPO_ROOT}/nemoclaw/package.json" if [ -x "${root_tsc}" ]; then - check_quiet_command "CLI type check" "Run: npm run typecheck:cli" \ + check_quiet_command_with_heap_hint "CLI type check" "Run: npm run typecheck:cli" \ + "${NODE_HEAP_REMEDIATION_CLI}" \ "${root_tsc}" -p "${REPO_ROOT}/tsconfig.cli.json" fi if [ -x "${plugin_tsc}" ]; then - check_quiet_command "Plugin type check" "Run: npm --prefix nemoclaw run build" \ + check_quiet_command_with_heap_hint "Plugin type check" "Run: npm --prefix nemoclaw run build" \ + "${NODE_HEAP_REMEDIATION_PLUGIN}" \ "${plugin_tsc}" --noEmit -p "${REPO_ROOT}/nemoclaw/tsconfig.json" fi diff --git a/test/dev-setup-doctor.test.ts b/test/dev-setup-doctor.test.ts index 4f1dbad0d7a..051c77069e5 100644 --- a/test/dev-setup-doctor.test.ts +++ b/test/dev-setup-doctor.test.ts @@ -40,6 +40,18 @@ ${body} ); } +function writeNodeHeapOomTool(filePath: string): void { + writeExecutable( + filePath, + `#!/usr/bin/env bash +printf '%s\\n' '<--- Last few GCs --->' >&2 +printf '%s\\n' 'FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory' >&2 +printf '%s\\n' '----- Native stack trace -----' >&2 +exit 134 +`, + ); +} + function writeManagedCliShim( fakeBin: string, repo: string, @@ -132,6 +144,11 @@ elif [ "\${FAKE_NPM_ROOT_INSTALL_FAIL:-}" = "1" ] && [ "\${1:-}" = "install" ]; exit 1 elif [ "\${FAKE_NPM_PLUGIN_INSTALL_FAIL:-}" = "1" ] && [ "\${1:-}" = "--prefix" ] && [ "\${2:-}" = "nemoclaw" ] && [ "\${3:-}" = "install" ]; then exit 1 +elif [ "\${FAKE_NPM_CLI_TYPECHECK_OOM:-}" = "1" ] && [ "\${1:-}" = "run" ] && [ "\${2:-}" = "typecheck:cli" ]; then + printf '%s\\n' '<--- Last few GCs --->' >&2 + printf '%s\\n' 'FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory' >&2 + printf '%s\\n' '----- Native stack trace -----' >&2 + exit 134 else echo "10.9.0" fi`, @@ -491,6 +508,34 @@ describe("contributor environment doctor", () => { expect(readCommandLog(fixture)).not.toMatch(/^npm .* exec(?: |$)/m); }); + it("reports an actionable heap-limit remedy when the CLI type check exhausts V8 memory", () => { + const fixture = createFixture(); + writeNodeHeapOomTool(path.join(fixture.repo, "node_modules", ".bin", "tsc")); + + const result = runDoctor(fixture); + + expect(result.status).toBe(1); + expect(result.output).toContain("CLI type check: ran out of Node.js heap"); + expect(result.output).toContain( + "Next: Run: NODE_OPTIONS=--max-old-space-size=5120 npm run typecheck:cli", + ); + expect(result.output).not.toContain("Native stack trace"); + }); + + it("reports an actionable heap-limit remedy when the plugin type check exhausts V8 memory", () => { + const fixture = createFixture(); + writeNodeHeapOomTool(path.join(fixture.repo, "nemoclaw", "node_modules", ".bin", "tsc")); + + const result = runDoctor(fixture); + + expect(result.status).toBe(1); + expect(result.output).toContain("Plugin type check: ran out of Node.js heap"); + expect(result.output).toContain( + "Next: Run: NODE_OPTIONS=--max-old-space-size=5120 npm --prefix nemoclaw run build", + ); + expect(result.output).not.toContain("Native stack trace"); + }); + it("rejects a foreign PATH CLI even when the global package links to this checkout", () => { const fixture = createFixture(); fs.rmSync(path.join(fixture.fakeBin, "nemoclaw")); @@ -740,6 +785,53 @@ describe("contributor repository setup", () => { expect(commands).not.toContain("npm --prefix nemoclaw install"); }); + it("stops CLI type-check setup failures with a heap-limit remedy instead of the V8 stack", () => { + const fixture = createFixture(); + + const result = runSetup(fixture, [], { FAKE_NPM_CLI_TYPECHECK_OOM: "1" }); + + expect(result.status).toBe(1); + expect(result.output).toContain("Node.js exhausted its V8 heap while running this type check."); + expect(result.output).toContain( + "Next: Run: NODE_OPTIONS=--max-old-space-size=5120 npm run typecheck:cli", + ); + expect(result.output).toContain("Setup stopped while attempting: Type-check the CLI"); + expect(result.output).not.toContain("Native stack trace"); + expect(result.output).not.toContain("Type-check the plugin without emitting files"); + expect(readCommandLog(fixture)).toContain("npm run typecheck:cli"); + }); + + it("stops plugin type-check setup failures with a heap-limit remedy instead of the V8 stack", () => { + const fixture = createFixture(); + writeNodeHeapOomTool(path.join(fixture.repo, "nemoclaw", "node_modules", ".bin", "tsc")); + + const result = runSetup(fixture); + + expect(result.status).toBe(1); + expect(result.output).toContain("Node.js exhausted its V8 heap while running this type check."); + expect(result.output).toContain( + "Next: Run: NODE_OPTIONS=--max-old-space-size=5120 npm --prefix nemoclaw run build", + ); + expect(result.output).toContain( + "Setup stopped while attempting: Type-check the plugin without emitting files", + ); + expect(result.output).not.toContain("Native stack trace"); + const commands = readCommandLog(fixture); + expect(commands).toContain("npm --prefix nemoclaw run build"); + expect(commands).not.toContain("prek install"); + }); + + it("names the setup step when the heap-check output capture cannot be created", () => { + const fixture = createFixture(); + writeTool(fixture.fakeBin, "mktemp", "exit 1"); + + const result = runSetup(fixture); + + expect(result.status).toBe(1); + expect(result.output).toContain("Setup stopped while attempting: Type-check the CLI"); + expect(result.output).not.toContain("Install repository Git hooks"); + }); + it.each([ ["default setup", []], ["repair", ["--repair"]],