Files
codegraph/scripts/build-bundle.sh
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

170 lines
7.7 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Build a self-contained CodeGraph bundle: an official Node runtime + the
# compiled app + its production deps, so CodeGraph runs with NO system Node and
# NO native build — node:sqlite is built into the bundled Node. One archive per
# platform.
#
# Because dropping better-sqlite3 left zero native addons, the recipe is pure
# file-packaging (download the target's Node, copy the app, archive) — so any
# platform's bundle can be built on any OS. No cross-compile, no native runners.
#
# Usage:
# scripts/build-bundle.sh <target> [node-version]
# target: darwin-arm64 | darwin-x64 | linux-x64 | linux-arm64
# | win32-x64 | win32-arm64
# node-version: e.g. v24.21.0 (default below; pin for reproducible builds)
#
# Output:
# unix: release/codegraph-<target>.tar.gz (launcher: bin/codegraph)
# windows: release/codegraph-<target>.zip (launchers: bin/codegraph + .cmd)
set -euo pipefail
TARGET="${1:?usage: build-bundle.sh <target> [node-version]}"
# The release workflow passes no version, so this default is the Node every
# release bundles. Keep it on 24.x (the CLI refuses Node 25+) and at 24.20.0 or
# later: older 24.x releases can abort on Windows just as the process exits,
# after its work is done, with 0xC0000409 and "Assertion failed:
# !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c" (nodejs/node#56645,
# fixed by nodejs/node#61999 in 24.20.0; issue #2617).
NODE_VERSION="${2:-v24.21.0}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/release"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
ARCH="${TARGET##*-}" # x64 | arm64
OSFAM="${TARGET%-*}" # darwin | linux | win32
echo "[bundle] target=${TARGET} node=${NODE_VERSION}"
# 1. Download + extract the official Node runtime for the target platform.
if [ "$OSFAM" = "win32" ]; then
NODE_DIST="node-${NODE_VERSION}-win-${ARCH}"
NODE_URL="https://nodejs.org/dist/${NODE_VERSION}/${NODE_DIST}.zip"
echo "[bundle] downloading ${NODE_URL}"
curl -fsSL "$NODE_URL" -o "$WORK/node.zip"
if command -v unzip >/dev/null 2>&1; then
unzip -q "$WORK/node.zip" -d "$WORK"
else
tar -xf "$WORK/node.zip" -C "$WORK" # bsdtar can read zip
fi
NODE_BIN="$WORK/${NODE_DIST}/node.exe"
else
NODE_DIST="node-${NODE_VERSION}-${TARGET}"
NODE_URL="https://nodejs.org/dist/${NODE_VERSION}/${NODE_DIST}.tar.gz"
echo "[bundle] downloading ${NODE_URL}"
curl -fsSL "$NODE_URL" -o "$WORK/node.tar.gz"
tar -xzf "$WORK/node.tar.gz" -C "$WORK"
NODE_BIN="$WORK/${NODE_DIST}/bin/node"
fi
[ -f "$NODE_BIN" ] || { echo "[bundle] error: node binary not found ($NODE_BIN)" >&2; exit 1; }
# 2. Build the app (compiled JS + copied wasm/schema assets).
echo "[bundle] building app"
( cd "$ROOT" && npm run build >/dev/null )
# 3. Stage: app + production-only deps (pure JS/wasm → portable across platforms).
STAGE="$WORK/codegraph-${TARGET}"
mkdir -p "$STAGE/lib" "$STAGE/bin"
cp -R "$ROOT/dist" "$STAGE/lib/dist"
# The browser viewer rides along inside dist/viewer (built by `npm run build`
# above). Fail here rather than shipping a bundle whose `codegraph ui` serves
# a 404 — the copy is verified, not assumed.
node "$ROOT/scripts/check-ui-build.mjs" --root "$STAGE/lib"
cp "$ROOT/package.json" "$ROOT/package-lock.json" "$STAGE/lib/"
echo "[bundle] installing production dependencies"
# The staged package.json declares the `ui` workspace but the bundle carries
# no ui/ source — only its build output. That is fine: ui/ has dev
# dependencies only, so --omit=dev skips the workspace outright and no link
# is created. (If a future npm starts erroring on the absent folder, stage a
# stub ui/package.json before this line rather than editing the lock.)
( cd "$STAGE/lib" && npm ci --omit=dev --ignore-scripts >/dev/null 2>&1 )
rm -f "$STAGE/lib/package-lock.json"
# 3b. Native extraction kernel (optional). Included when a prebuilt .node for
# the target exists — release/kernel/<target>/codegraph-kernel.node (the
# release workflow's prebuild artifacts) or the locally staged
# codegraph-kernel/prebuilds/<target>/ (scripts/build-kernel.sh). Absent →
# the bundle simply runs the wasm extraction path; the kernel is a
# per-language speedup, never a requirement (see
# docs/design/rust-kernel-migration-plan.md).
KERNEL_NODE=""
for candidate in "$ROOT/release/kernel/${TARGET}/codegraph-kernel.node" \
"$ROOT/codegraph-kernel/prebuilds/${TARGET}/codegraph-kernel.node"; do
if [ -f "$candidate" ]; then KERNEL_NODE="$candidate"; break; fi
done
if [ -n "$KERNEL_NODE" ]; then
mkdir -p "$STAGE/lib/kernel"
cp "$KERNEL_NODE" "$STAGE/lib/kernel/codegraph-kernel.node"
echo "[bundle] native kernel included ($KERNEL_NODE)"
else
echo "[bundle] no native kernel for ${TARGET} — bundle uses the wasm extraction path"
fi
# 4. Vendored Node + launcher (the launcher uses the bundled Node by relative
# path, so no system Node is ever needed).
#
# `--liftoff-only`: keep tree-sitter's large WASM grammars on V8's Liftoff
# baseline compiler so they never reach the turboshaft optimizing tier, whose
# per-compilation Zone arena OOMs the whole process (`Fatal process out of
# memory: Zone`) on Node >= 22 — even with tens of GB free. The flag is read at
# V8 engine init so it must be on node's command line; the parse worker inherits
# it. See issues #293/#298 and src/extraction/wasm-runtime-flags.ts. (The CLI
# also self-relaunches with this flag when launched without it, so non-bundled
# runs are covered too; passing it here avoids that extra spawn.)
if [ "$OSFAM" = "win32" ]; then
cp "$NODE_BIN" "$STAGE/node.exe"
printf '@"%%~dp0..\\node.exe" --liftoff-only --disable-warning=ExperimentalWarning "%%~dp0..\\lib\\dist\\bin\\codegraph.js" %%*\r\n' \
> "$STAGE/bin/codegraph.cmd"
# Git Bash (including Claude Code hooks) does not resolve .cmd via PATHEXT.
cat > "$STAGE/bin/codegraph" <<'LAUNCH'
#!/bin/sh
DIR="$(cd "$(dirname "$0")/.." && pwd)"
# Preserve an inherited CODEGRAPH_HOST_PPID; do not replace it with MSYS's
# $PPID, which is not a native Windows PID usable by the orphan watchdog.
exec "$DIR/node.exe" --liftoff-only --disable-warning=ExperimentalWarning "$DIR/lib/dist/bin/codegraph.js" "$@"
LAUNCH
chmod +x "$STAGE/bin/codegraph"
else
cp "$NODE_BIN" "$STAGE/node"
cat > "$STAGE/bin/codegraph" <<'LAUNCH'
#!/bin/sh
# Resolve symlinks (e.g. the ~/.local/bin/codegraph link install.sh creates) so
# we find the real bundle dir, not the symlink's location.
SELF="$0"
while [ -L "$SELF" ]; do
target="$(readlink "$SELF")"
case "$target" in
/*) SELF="$target" ;;
*) SELF="$(dirname "$SELF")/$target" ;;
esac
done
DIR="$(cd "$(dirname "$SELF")/.." && pwd)"
# Thread the MCP host's pid to the server's orphan watchdog (issue #1185).
# $PPID is our parent — the host itself when it launched this script directly;
# an already-threaded value (the npm shim sets the true host pid) wins.
CODEGRAPH_HOST_PPID="${CODEGRAPH_HOST_PPID:-$PPID}"
export CODEGRAPH_HOST_PPID
# --liftoff-only: avoid the V8 turboshaft WASM Zone OOM (issues #293/#298).
# --disable-warning=ExperimentalWarning: mute node:sqlite's per-thread
# "experimental feature" warning that otherwise interleaves with the progress UI.
exec "$DIR/node" --liftoff-only --disable-warning=ExperimentalWarning "$DIR/lib/dist/bin/codegraph.js" "$@"
LAUNCH
chmod +x "$STAGE/bin/codegraph"
fi
# 5. Archive (.zip for Windows, .tar.gz otherwise).
mkdir -p "$OUT"
if [ "$OSFAM" = "win32" ]; then
ARCHIVE="$OUT/codegraph-${TARGET}.zip"
rm -f "$ARCHIVE"
( cd "$WORK" && zip -rqX "$ARCHIVE" "codegraph-${TARGET}" )
else
ARCHIVE="$OUT/codegraph-${TARGET}.tar.gz"
# --no-xattrs: don't embed macOS xattrs that make GNU tar warn on Linux.
tar --no-xattrs -czf "$ARCHIVE" -C "$WORK" "codegraph-${TARGET}"
fi
echo "[bundle] wrote ${ARCHIVE} ($(du -h "$ARCHIVE" | cut -f1))"