Files
codegraph/__tests__/bundle-launcher.test.ts
T
Colby MchenryandClaude Opus 5.5 c4dbc126b0 fix(bundle): bundle Node 24.21.0 so Windows commands no longer abort as they exit (#2806)
Root cause: every release bundles the Node that scripts/build-bundle.sh
defaults to, since the release workflow passes only the target, and that
default was v24.16.0. On Windows that Node can abort just as the process
exits, after the command's work is done: a V8 worker posts a delayed task
after the platform's delayed-task scheduler has started closing its uv_async
handle, and libuv fails "Assertion failed: !(handle->flags &
UV_HANDLE_CLOSING), file src\win\async.c, line 94" with status 0xC0000409
(nodejs/node issue 56645). Node fixed it in 24.20.0 (nodejs/node PR 61999).

Fix: the default moves to v24.21.0, the newest 24.x LTS release that is at
least two weeks old (2026-09-07). It also carries the Windows 8.3 short-path
file-watcher fix (nodejs/node PR 65118). V8 13.6.233.17, libuv 1.52.1 and the
module ABI (137, N-API 10) are unchanged; SQLite goes from 3.53.0 to 3.53.4.
A comment at the default says why it must stay on 24.x at 24.20.0 or later,
and a test pins that, along with the workflow passing only the target.

Validation, on Windows 11 x64:
- Node's own regression test for the crash
  (test-process-exit-after-fetch-throw.js, from the v24.21.0 tag), 300 runs
  per arm, against the official win-x64 node.exe of each version (zips checked
  against nodejs.org's SHASUMS256.txt): v24.16.0 aborted 300/300 with
  0xC0000409 and that async.c line 94 assertion, and the test as written
  failed 300/300; v24.21.0 passed 300/300. With the launchers' --liftoff-only
  both passed 300/300, since that flag only removes this test's trigger.
- build-bundle.sh win32-x64 builds the bundle on this machine. Its node.exe is
  byte-identical to the official v24.21.0 one. --version through the Git Bash
  launcher, cmd and PowerShell, then init, status, query, callers and sync on
  a small TypeScript and Python project all exit 0, on the wasm path and with
  the native kernel bundled.
- The real CLI (index, status -j, sync) run 300 times per runtime on the
  bundled app: 0 nonzero exits on either.
- The runtime-facing test files (WASM flags, node:sqlite backend and open
  errors, launchers, worker teardown, version check) pass under Node 24.21.0:
  9 files, 65 passed, 3 skipped (POSIX-only).
- The new test fails on main's script (expected 16 to be greater than or
  equal to 20) and passes with the fix.

Fixes #2617

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:26:56 +00:00

145 lines
7.0 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const windows = process.platform === 'win32';
const bash = windows
? path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git/bin/bash.exe')
: 'bash';
const recipe = fs.readFileSync(path.resolve('scripts/build-bundle.sh'), 'utf8');
// Execute the release recipe itself, without downloading Node or rebuilding the UI.
const launcherRecipe = recipe.slice(recipe.indexOf('# 4. Vendored'), recipe.indexOf('# 5. Archive'));
const archiveRecipe = recipe.slice(recipe.indexOf('# 5. Archive'));
const roots: string[] = [];
const env = { ...process.env, CODEGRAPH_TELEMETRY: '0', DO_NOT_TRACK: '1', CODEGRAPH_NO_PROMPT_HOOK: '1' };
function shell(script: string, args: string[] = [], extraEnv: NodeJS.ProcessEnv = {}, input?: string) {
// A file avoids Windows command-line quoting altering the recipe's printf
// escapes or our argument fixtures before Bash even reads them.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-shell-'));
roots.push(root);
const file = path.join(root, 'test.sh');
const quote = (value: string) => "'" + value.replace(/'/g, "'\\''") + "'";
fs.writeFileSync(file, `set -- ${args.map(quote).join(' ')}\n${script}`);
return spawnSync(bash, ['--noprofile', '--norc', file], {
encoding: 'utf8', timeout: 30_000, env: { ...env, ...extraEnv }, input,
});
}
function stage(osfam = windows ? 'win32' : process.platform) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph bundle space '));
roots.push(root);
const bundle = path.join(root, `codegraph-${osfam}-x64`);
fs.mkdirSync(path.join(bundle, 'bin'), { recursive: true });
fs.mkdirSync(path.join(bundle, 'lib/dist/bin'), { recursive: true });
const convert = windows ? '$(cygpath -u "$1")' : '$1';
const node = windows ? '$(cygpath -u "$2")' : '$2';
const result = shell(`set -eu\nSTAGE="${convert}"\nNODE_BIN="${node}"\nOSFAM="$3"\n${launcherRecipe}`, [bundle, process.execPath, osfam]);
expect(result.status, result.stderr || String(result.error)).toBe(0);
return { root, bundle };
}
function invoke(bundle: string, command: string, args: string[] = [], extraEnv: NodeJS.ProcessEnv = {}, input?: string) {
const convert = windows ? '$(cygpath -u "$1")' : '$1';
// Exclude system Node: execution must use the runtime in the bundle.
return shell(`STAGE="${convert}"\nshift\nPATH="$STAGE/bin:/usr/bin:/bin"\nexport PATH\n${command}`,
[bundle, ...args], extraEnv, input);
}
function probe(bundle: string) {
fs.writeFileSync(path.join(bundle, 'lib/dist/bin/codegraph.js'), `
console.log(JSON.stringify({args: process.argv.slice(2), flags: process.execArgv,
host: process.env.CODEGRAPH_HOST_PPID, exe: process.execPath}));
process.exit(23);
`);
}
function checkForwarding(bundle: string) {
probe(bundle);
const args = ['two words', '', 'quote"value', "single'value", '$literal', '*.ts', 'a&b'];
const result = invoke(bundle, 'codegraph "$@"', args, { CODEGRAPH_HOST_PPID: '12345' });
expect(result.status, result.stderr).toBe(23);
const output = JSON.parse(result.stdout);
expect(output.args).toEqual(args);
expect(output.flags).toEqual(['--liftoff-only', '--disable-warning=ExperimentalWarning']);
expect(output.host).toBe('12345');
expect(fs.realpathSync(output.exe)).toBe(fs.realpathSync(path.join(bundle, windows ? 'node.exe' : 'node')));
}
afterEach(() => {
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
});
describe('bundled launchers (#1278)', () => {
it.runIf(!windows)('preserves POSIX arguments, exit status, runtime flags and host PID', () => {
checkForwarding(stage().bundle);
});
it.runIf(windows)('runs from Git Bash in a spaced path and forwards arguments, exit status, flags and host PID', () => {
checkForwarding(stage().bundle);
});
it.runIf(windows)('runs the real CLI and prompt hook in Git Bash, and keeps cmd and PowerShell working', () => {
const { bundle } = stage();
fs.rmSync(path.join(bundle, 'lib/dist'), { recursive: true });
fs.symlinkSync(path.resolve('dist'), path.join(bundle, 'lib/dist'), 'junction');
const version = require('../package.json').version;
const result = invoke(bundle, 'codegraph --version');
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim()).toBe(version);
const hook = invoke(bundle, 'codegraph prompt-hook', [], {}, '{"prompt":"test"}\n');
expect(hook.status, hook.stderr).toBe(0);
const bin = path.join(bundle, 'bin');
for (const [exe, args] of [
['cmd.exe', ['/d', '/s', '/c', 'codegraph --version']],
['powershell.exe', ['-NoProfile', '-Command', 'codegraph --version']],
] as const) {
const control = spawnSync(exe, [...args], {
cwd: bin, encoding: 'utf8', timeout: 30_000,
env: {
...Object.fromEntries(Object.entries(env).filter(([key]) => key.toLowerCase() !== 'path')),
Path: `${bin};${process.env.Path || process.env.PATH}`,
},
});
expect(control.status, control.stderr).toBe(0);
expect(control.stdout.trim(), exe).toBe(version);
}
});
it.runIf(!windows).each(['x64', 'arm64'])('ships both launchers in the Windows %s archive', (arch) => {
const { root, bundle } = stage('win32');
const target = `win32-${arch}`;
if (arch !== 'x64') fs.renameSync(bundle, path.join(root, `codegraph-${target}`));
const result = shell(`set -eu\nWORK="$1"\nOUT="$1/release"\nOSFAM=win32\nTARGET="$2"\n${archiveRecipe}`, [root, target]);
expect(result.status, result.stderr).toBe(0);
const list = spawnSync('unzip', ['-Z1', path.join(root, 'release', `codegraph-${target}.zip`)], { encoding: 'utf8' });
expect(list.status, list.stderr).toBe(0);
const entries = list.stdout.split('\n');
for (const file of ['bin/codegraph', 'bin/codegraph.cmd', 'node.exe']) {
expect(entries).toContain(`codegraph-${target}/${file}`);
}
});
});
describe('bundled Node runtime (#2617)', () => {
it('ships a Node 24 release with the fix for the Windows abort at exit', () => {
// The release workflow passes only the target, so the recipe's default
// version is the Node every release bundles.
const workflow = fs.readFileSync(path.resolve('.github/workflows/release.yml'), 'utf8');
const calls = workflow.match(/build-bundle\.sh[^\r\n]*/g) ?? [];
expect(calls.length).toBeGreaterThan(0);
for (const call of calls) expect(call.trim()).toBe('build-bundle.sh "$t"');
const pin = /^NODE_VERSION="\$\{2:-v(\d+)\.(\d+)\.(\d+)\}"\r?$/m.exec(recipe);
expect(pin, 'default NODE_VERSION in scripts/build-bundle.sh').not.toBeNull();
const [major, minor] = pin!.slice(1, 3).map(Number);
// The CLI refuses to run on Node 25 and later.
expect(major).toBe(24);
// Node 24 before 24.20.0 can abort on Windows as the process exits, after
// the command's work is done (0xC0000409, nodejs/node#56645).
expect(minor).toBeGreaterThanOrEqual(20);
});
});