Skip to content
Open
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

[![npm version](https://img.shields.io/npm/v/@colbymchenry/codegraph.svg)](https://www.npmjs.com/package/@colbymchenry/codegraph)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/)
[![Node.js](https://img.shields.io/badge/Node.js-18%20%7C%2020%20%7C%2022-green.svg)](https://nodejs.org/)

[![Windows](https://img.shields.io/badge/Windows-supported-blue.svg)](#)
[![macOS](https://img.shields.io/badge/macOS-supported-blue.svg)](#)
Expand Down Expand Up @@ -144,6 +144,9 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by

### 1. Run the Installer

Requires Node.js 18, 20, or 22. Current Node.js 24+ releases are blocked because
their V8 WASM compiler can crash while CodeGraph loads tree-sitter grammars.

```bash
npx @colbymchenry/codegraph
```
Expand Down
67 changes: 58 additions & 9 deletions __tests__/node-version-check.test.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,91 @@
/**
* Pin the Node-25 block banner content. The banner replaced a soft
* Pin the unsafe Node block banner content. The banner replaced a soft
* `console.warn` because the warning was scrolling off-screen before
* the OOM crash 30 seconds later, generating duplicate bug reports
* (#54, #81, #140). The recipe and override env var below are
* load-bearing — if any of them get edited away, this test catches it.
*/

import { describe, it, expect } from 'vitest';
import { buildNode25BlockBanner } from '../src/bin/node-version-check';
import {
assertSupportedNodeVersion,
buildUnsupportedNodeBlockBanner,
getNodeMajor,
isUnsupportedNodeVersion,
shouldBlockUnsupportedNodeVersion,
} from '../src/bin/node-version-check';

describe('buildNode25BlockBanner', () => {
describe('getNodeMajor', () => {
it('parses the major from a Node version string', () => {
expect(getNodeMajor('24.13.0')).toBe(24);
});

it('returns null for malformed versions', () => {
expect(getNodeMajor('not-a-version')).toBeNull();
});
});

describe('isUnsupportedNodeVersion', () => {
it('allows supported LTS Node versions', () => {
expect(isUnsupportedNodeVersion('18.20.0')).toBe(false);
expect(isUnsupportedNodeVersion('20.19.4')).toBe(false);
expect(isUnsupportedNodeVersion('22.11.0')).toBe(false);
});

it('blocks Node 24 and newer before WASM compilation can crash', () => {
expect(isUnsupportedNodeVersion('24.13.0')).toBe(true);
expect(isUnsupportedNodeVersion('25.0.0')).toBe(true);
});
});

describe('shouldBlockUnsupportedNodeVersion', () => {
it('honors the explicit unsafe override', () => {
expect(shouldBlockUnsupportedNodeVersion('24.13.0', false)).toBe(true);
expect(shouldBlockUnsupportedNodeVersion('24.13.0', true)).toBe(false);
});
});

describe('assertSupportedNodeVersion', () => {
it('throws a recovery banner before unsafe runtimes can compile WASM', () => {
expect(() => assertSupportedNodeVersion('24.13.0', false)).toThrow(
/Unsupported Node.js version: 24\.13\.0/
);
});

it('allows unsafe runtimes when the override is active', () => {
expect(() => assertSupportedNodeVersion('24.13.0', true)).not.toThrow();
});
});

describe('buildUnsupportedNodeBlockBanner', () => {
it('embeds the reported Node version in the header', () => {
expect(buildNode25BlockBanner('25.9.0')).toContain(
'Unsupported Node.js version: 25.9.0'
expect(buildUnsupportedNodeBlockBanner('24.13.0')).toContain(
'Unsupported Node.js version: 24.13.0'
);
});

it('names the V8 turboshaft WASM root cause and the OOM symptom', () => {
const banner = buildNode25BlockBanner('25.7.0');
const banner = buildUnsupportedNodeBlockBanner('24.13.0');
expect(banner).toContain('V8 WASM JIT');
expect(banner).toContain('turboshaft');
expect(banner).toContain('Fatal process out of memory: Zone');
expect(banner).toContain('Node.js 24.x and 25.x');
});

it('points users to Node 22 LTS via nvm and Homebrew', () => {
const banner = buildNode25BlockBanner('25.7.0');
const banner = buildUnsupportedNodeBlockBanner('24.13.0');
expect(banner).toContain('Node.js 22 LTS');
expect(banner).toContain('nvm install 22');
expect(banner).toContain('brew install node@22');
});

it('documents the CODEGRAPH_ALLOW_UNSAFE_NODE override', () => {
const banner = buildNode25BlockBanner('25.7.0');
const banner = buildUnsupportedNodeBlockBanner('24.13.0');
expect(banner).toContain('CODEGRAPH_ALLOW_UNSAFE_NODE=1');
});

it('links to issue #81 for the root-cause writeup', () => {
expect(buildNode25BlockBanner('25.7.0')).toContain(
expect(buildUnsupportedNodeBlockBanner('24.13.0')).toContain(
'github.com/colbymchenry/codegraph/issues/81'
);
});
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,6 @@
"better-sqlite3": "^11.0.0"
},
"engines": {
"node": ">=18.0.0 <25.0.0"
"node": ">=18.0.0 <24.0.0"
}
}
20 changes: 11 additions & 9 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ import * as fs from 'fs';
import { getCodeGraphDir, isInitialized } from '../directory';
import { createShimmerProgress } from '../ui/shimmer-progress';

import { buildNode25BlockBanner } from './node-version-check';
import {
buildUnsupportedNodeBlockBanner,
isUnsupportedNodeVersion,
} from './node-version-check';

// Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
async function loadCodeGraph(): Promise<typeof import('../index')> {
Expand All @@ -46,17 +49,16 @@ async function loadCodeGraph(): Promise<typeof import('../index')> {
const importESM = new Function('specifier', 'return import(specifier)') as
(specifier: string) => Promise<typeof import('@clack/prompts')>;

// Block CodeGraph on Node.js 25.xV8's turboshaft WASM JIT has a Zone
// allocator bug that reliably crashes when compiling tree-sitter
// grammars (see #54, #81, #140). The previous behaviour was a soft
// console.warn that scrolls off-screen before the OOM crash 30 seconds
// later, leading to a steady stream of "what is this OOM" reports.
// Block CodeGraph on Node.js 24+current V8 turboshaft WASM JIT builds
// have a Zone allocator bug that reliably crashes when compiling
// tree-sitter grammars (see #54, #81, #140). The previous behaviour was
// a soft console.warn that scrolls off-screen before the OOM crash
// seconds later, leading to a steady stream of "what is this OOM" reports.
// Hard-exit before any WASM work; allow override via env var for users
// who patched V8 themselves or want to test a future fix.
const nodeVersion = process.versions.node;
const nodeMajor = parseInt(nodeVersion.split('.')[0] ?? '0', 10);
if (nodeMajor >= 25) {
process.stderr.write(buildNode25BlockBanner(nodeVersion) + '\n');
if (isUnsupportedNodeVersion(nodeVersion)) {
process.stderr.write(buildUnsupportedNodeBlockBanner(nodeVersion) + '\n');
if (!process.env.CODEGRAPH_ALLOW_UNSAFE_NODE) {
process.exit(1);
}
Expand Down
52 changes: 41 additions & 11 deletions src/bin/node-version-check.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,59 @@
/**
* Node.js version compatibility check.
*
* Node 25.x has a V8 turboshaft WASM JIT Zone allocator bug that
* reliably crashes CodeGraph with `Fatal process out of memory: Zone`
* during tree-sitter grammar compilation. This module owns the
* user-facing banner shown before exit. Kept side-effect-free so it's
* safe to import from tests without triggering CLI bootstrap.
* Current Node 24.x and 25.x releases have a V8 turboshaft WASM JIT
* Zone allocator bug that reliably crashes CodeGraph with
* `Fatal process out of memory: Zone` during tree-sitter grammar
* compilation. This module owns the user-facing banner shown before
* exit. Kept side-effect-free so it's safe to import from tests without
* triggering CLI bootstrap.
*/

export const MIN_UNSAFE_NODE_MAJOR = 24;

export function getNodeMajor(nodeVersion: string): number | null {
const major = Number.parseInt(nodeVersion.split('.')[0] ?? '', 10);
return Number.isFinite(major) ? major : null;
}

export function isUnsupportedNodeVersion(nodeVersion: string): boolean {
const major = getNodeMajor(nodeVersion);
return major !== null && major >= MIN_UNSAFE_NODE_MAJOR;
}

export function shouldBlockUnsupportedNodeVersion(
nodeVersion: string,
allowUnsafe: boolean = Boolean(process.env.CODEGRAPH_ALLOW_UNSAFE_NODE)
): boolean {
return isUnsupportedNodeVersion(nodeVersion) && !allowUnsafe;
}

export function assertSupportedNodeVersion(
nodeVersion: string = process.versions.node,
allowUnsafe?: boolean
): void {
if (shouldBlockUnsupportedNodeVersion(nodeVersion, allowUnsafe)) {
throw new Error(buildUnsupportedNodeBlockBanner(nodeVersion));
}
}

/**
* Build the bordered banner shown when CodeGraph detects an
* unsupported Node.js major version (currently 25+). Pinned via unit
* unsupported Node.js major version (currently 24+). Pinned via unit
* test so the recovery commands and override instructions can't be
* silently stripped by future edits.
*/
export function buildNode25BlockBanner(nodeVersion: string): string {
export function buildUnsupportedNodeBlockBanner(nodeVersion: string): string {
const sep = '─'.repeat(72);
return [
sep,
`[CodeGraph] Unsupported Node.js version: ${nodeVersion}`,
sep,
'Node.js 25.x has a V8 WASM JIT (turboshaft) Zone allocator bug that',
'crashes with `Fatal process out of memory: Zone` when CodeGraph',
'compiles tree-sitter grammars. CodeGraph WILL crash on this Node',
'version mid-indexing. See https://github.com/colbymchenry/codegraph/issues/81',
'Current Node.js 24.x and 25.x releases have a V8 WASM JIT',
'(turboshaft) Zone allocator bug that crashes with',
'`Fatal process out of memory: Zone` when CodeGraph compiles',
'tree-sitter grammars. CodeGraph WILL crash on this Node version',
'mid-indexing. See https://github.com/colbymchenry/codegraph/issues/81',
'',
'Fix: install Node.js 22 LTS:',
' nvm install 22 && nvm use 22 # nvm',
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { GraphTraverser, GraphQueryManager } from './graph';
import { ContextBuilder, createContextBuilder } from './context';
import { Mutex, FileLock } from './utils';
import { FileWatcher, WatchOptions } from './sync';
import { assertSupportedNodeVersion } from './bin/node-version-check';

// Re-export types for consumers
export * from './types';
Expand Down Expand Up @@ -151,6 +152,7 @@ export class CodeGraph {
config: CodeGraphConfig,
projectRoot: string
) {
assertSupportedNodeVersion();
this.db = db;
this.queries = queries;
this.config = config;
Expand Down Expand Up @@ -183,6 +185,7 @@ export class CodeGraph {
* @returns A new CodeGraph instance
*/
static async init(projectRoot: string, options: InitOptions = {}): Promise<CodeGraph> {
assertSupportedNodeVersion();
await initGrammars();
const resolvedRoot = path.resolve(projectRoot);

Expand Down Expand Up @@ -220,6 +223,7 @@ export class CodeGraph {
* Initialize synchronously (without indexing)
*/
static initSync(projectRoot: string, options: Omit<InitOptions, 'index' | 'onProgress'> = {}): CodeGraph {
assertSupportedNodeVersion();
const resolvedRoot = path.resolve(projectRoot);

// Check if already initialized
Expand Down Expand Up @@ -253,6 +257,7 @@ export class CodeGraph {
* @returns A CodeGraph instance
*/
static async open(projectRoot: string, options: OpenOptions = {}): Promise<CodeGraph> {
assertSupportedNodeVersion();
await initGrammars();
const resolvedRoot = path.resolve(projectRoot);

Expand Down Expand Up @@ -289,6 +294,7 @@ export class CodeGraph {
* Open synchronously (without sync)
*/
static openSync(projectRoot: string): CodeGraph {
assertSupportedNodeVersion();
const resolvedRoot = path.resolve(projectRoot);

// Check if initialized
Expand Down