263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
import { spawn, spawnSync } from "bun";
|
|
import { existsSync, renameSync, statSync } from "node:fs";
|
|
import { mkdir } from "node:fs/promises";
|
|
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { bunExe, bunEnv, isCI, isMusl } from "../../harness";
|
|
|
|
// Tests that intentionally abort and should not generate core dumps when they abort
|
|
// due to a Node-API error
|
|
const abortingJsNativeApiTests = ["test_finalizer/test_fatal_finalize.js"];
|
|
|
|
// Must match the npm_config_target passed to node-gyp below (without the leading "v").
|
|
const NODE_HEADERS_VERSION = "26.3.0";
|
|
|
|
const linuxClang = !isMusl ? "/usr/lib/llvm-21/bin/clang" : "/usr/lib/llvm21/bin/clang";
|
|
|
|
interface GypTarget {
|
|
target_name: string;
|
|
sources: string[];
|
|
defines?: string[];
|
|
}
|
|
|
|
// Every binding.gyp in this suite is `{ targets: [{ target_name, sources[, defines] }] }`.
|
|
// Returns null for anything else so build() falls back to real node-gyp.
|
|
function parseSimpleBindingGyp(text: string): GypTarget[] | null {
|
|
let parsed: any;
|
|
try {
|
|
// gyp files are python literals: allow comments, single quotes, and trailing commas.
|
|
const json = text
|
|
.split("\n")
|
|
.filter(line => !/^\s*#/.test(line))
|
|
.join("\n")
|
|
.replace(/'/g, '"')
|
|
.replace(/,(\s*[\]}])/g, "$1");
|
|
parsed = JSON.parse(json);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (typeof parsed !== "object" || parsed === null) return null;
|
|
if (Object.keys(parsed).length !== 1 || !Array.isArray(parsed.targets) || parsed.targets.length === 0) return null;
|
|
for (const target of parsed.targets) {
|
|
for (const key of Object.keys(target)) {
|
|
if (key !== "target_name" && key !== "sources" && key !== "defines") return null;
|
|
}
|
|
if (typeof target.target_name !== "string" || target.target_name.length === 0) return null;
|
|
if (!Array.isArray(target.sources) || target.sources.length === 0) return null;
|
|
const isC = (s: unknown) => typeof s === "string" && s.endsWith(".c");
|
|
const isCxx = (s: unknown) => typeof s === "string" && s.endsWith(".cc");
|
|
// A target must be all C or all C++ so one compiler driver handles it.
|
|
if (!(target.sources.every(isC) || target.sources.every(isCxx))) return null;
|
|
if (target.defines !== undefined) {
|
|
if (!Array.isArray(target.defines) || !target.defines.every((d: unknown) => typeof d === "string")) return null;
|
|
}
|
|
}
|
|
return parsed.targets;
|
|
}
|
|
|
|
// Read-only lookup of the header tree node-gyp itself installs. Never downloads or
|
|
// mutates the cache; if it isn't complete we fall back to node-gyp (which fills it).
|
|
function findNodeHeaders(): string | null {
|
|
const candidates: string[] = [];
|
|
if (process.env.npm_config_devdir) candidates.push(process.env.npm_config_devdir);
|
|
if (process.platform === "darwin") {
|
|
candidates.push(join(homedir(), "Library", "Caches", "node-gyp"));
|
|
} else {
|
|
if (process.env.XDG_CACHE_HOME) candidates.push(join(process.env.XDG_CACHE_HOME, "node-gyp"));
|
|
candidates.push(join(homedir(), ".cache", "node-gyp"));
|
|
}
|
|
for (const devDir of candidates) {
|
|
const base = join(devDir, NODE_HEADERS_VERSION);
|
|
const include = join(base, "include", "node");
|
|
// node-gyp writes `installVersion` last, only after the headers finish extracting.
|
|
if (existsSync(join(base, "installVersion")) && existsSync(join(include, "node_api.h"))) {
|
|
return include;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function compilerFor(cxx: boolean): string {
|
|
if (process.platform === "linux" && isCI) return cxx ? `${linuxClang}++` : linuxClang;
|
|
if (cxx) return process.env.CXX || "c++";
|
|
return process.env.CC || "cc";
|
|
}
|
|
|
|
// node-gyp's fixed per-addon bootstrap (bun x + python gyp configure + make) costs several
|
|
// seconds per directory. When binding.gyp is the trivial shape used by every addon in this
|
|
// suite and the node headers are already cached, one direct compiler invocation produces
|
|
// the same build/Debug/<target>.node in well under a second. Returns false to fall back.
|
|
async function tryBuildFast(dir: string): Promise<boolean> {
|
|
if (process.platform === "win32") return false;
|
|
const gypPath = join(dir, "binding.gyp");
|
|
if (!existsSync(gypPath)) return false;
|
|
const targets = parseSimpleBindingGyp(await Bun.file(gypPath).text());
|
|
if (targets === null) return false;
|
|
const nodeInclude = findNodeHeaders();
|
|
if (nodeInclude === null) return false;
|
|
|
|
const outDir = join(dir, "build", "Debug");
|
|
await mkdir(outDir, { recursive: true });
|
|
|
|
const results = await Promise.all(
|
|
targets.map(async target => {
|
|
const cxx = target.sources[0].endsWith(".cc");
|
|
// Compile to a temporary name and rename so a partial artifact is never loadable.
|
|
const output = join(outDir, `${target.target_name}.node`);
|
|
const tmpOutput = join(outDir, `.${target.target_name}.${process.pid}.tmp.node`);
|
|
try {
|
|
const child = spawn({
|
|
cmd: [
|
|
compilerFor(cxx),
|
|
...(cxx ? ["-std=gnu++20"] : []),
|
|
"-shared",
|
|
"-fPIC",
|
|
"-g",
|
|
"-O0",
|
|
...(process.platform === "darwin" ? ["-undefined", "dynamic_lookup"] : []),
|
|
`-I${nodeInclude}`,
|
|
`-DNODE_GYP_MODULE_NAME=${target.target_name}`,
|
|
"-DBUILDING_NODE_EXTENSION",
|
|
"-DDEBUG",
|
|
"-D_DEBUG",
|
|
...(target.defines ?? []).map(define => `-D${define}`),
|
|
...target.sources,
|
|
"-o",
|
|
tmpOutput,
|
|
],
|
|
cwd: dir,
|
|
stderr: "pipe",
|
|
stdout: "ignore",
|
|
stdin: "ignore",
|
|
env: bunEnv,
|
|
});
|
|
const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]);
|
|
if (exitCode !== 0) {
|
|
console.warn(
|
|
`direct compile of ${target.target_name} in ${dir} failed, falling back to node-gyp:\n${stderr}`,
|
|
);
|
|
return false;
|
|
}
|
|
renameSync(tmpOutput, output);
|
|
return true;
|
|
} catch (error) {
|
|
console.warn(`direct compile of ${target.target_name} in ${dir} failed, falling back to node-gyp: ${error}`);
|
|
return false;
|
|
}
|
|
}),
|
|
);
|
|
return results.every(Boolean);
|
|
}
|
|
|
|
// `bun x node-gyp@11` installs node-gyp into the shared bunx cache on first use and
|
|
// links its bin dependencies (nopt, which, glob, semver) into the shared bin dir.
|
|
// Several addon builds doing that at once race on those links (EEXIST) and fail,
|
|
// so callers that build concurrently warm the cache once, serially, first.
|
|
export async function warmNodeGyp() {
|
|
// Best effort: a failed warm-up must never stop the addon builds, which
|
|
// install node-gyp themselves if the cache is cold.
|
|
try {
|
|
const child = spawn({
|
|
cmd: [bunExe(), "--bun", "x", "node-gyp@11", "--version"],
|
|
stderr: "pipe",
|
|
stdout: "ignore",
|
|
stdin: "ignore",
|
|
env: bunEnv,
|
|
});
|
|
const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]);
|
|
if (exitCode !== 0) console.warn(`warming node-gyp failed (builds will install it themselves):\n${stderr}`);
|
|
} catch (error) {
|
|
console.warn(`warming node-gyp failed (builds will install it themselves): ${error}`);
|
|
}
|
|
}
|
|
|
|
export async function buildWithNodeGyp(dir: string) {
|
|
const child = spawn({
|
|
// `configure build` instead of `rebuild`: `clean` is pure waste (CI checkouts are always
|
|
// cold) and skipping it keeps local re-runs incremental. No `--verbose`/`-j max`: every
|
|
// addon here has 1-2 source files, and many of these build concurrently on one runner.
|
|
cmd: [bunExe(), "--bun", "x", "node-gyp@11", "configure", "build", "--debug"],
|
|
cwd: dir,
|
|
stderr: "pipe",
|
|
stdout: "ignore",
|
|
stdin: "inherit",
|
|
env: {
|
|
...bunEnv,
|
|
npm_config_target: `v${NODE_HEADERS_VERSION}`,
|
|
CXXFLAGS: (bunEnv.CXXFLAGS ?? "") + (process.platform == "win32" ? " -std=c++20" : " -std=gnu++20"),
|
|
// on linux CI, node-gyp will default to g++ and the version installed there is very old,
|
|
// so we make it use clang instead
|
|
...(process.platform == "linux" && isCI
|
|
? {
|
|
CC: linuxClang,
|
|
CXX: `${linuxClang}++`,
|
|
}
|
|
: {}),
|
|
},
|
|
});
|
|
const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]);
|
|
if (exitCode !== 0) {
|
|
throw new Error(`node-gyp build in ${dir} failed:\n${stderr}`);
|
|
}
|
|
}
|
|
|
|
export async function parseBindingGyp(dir: string): Promise<GypTarget[] | null> {
|
|
const gypPath = join(dir, "binding.gyp");
|
|
if (!existsSync(gypPath)) return null;
|
|
return parseSimpleBindingGyp(await Bun.file(gypPath).text());
|
|
}
|
|
|
|
export async function isBuilt(dir: string): Promise<boolean> {
|
|
const targets = await parseBindingGyp(dir);
|
|
if (targets === null) return false;
|
|
const mtime = (path: string) => (existsSync(path) ? statSync(path).mtimeMs : Infinity);
|
|
const gyp = statSync(join(dir, "binding.gyp")).mtimeMs;
|
|
return targets.every(target => {
|
|
const artifact = join(dir, "build", "Debug", `${target.target_name}.node`);
|
|
if (!existsSync(artifact)) return false;
|
|
const built = statSync(artifact).mtimeMs;
|
|
return built >= gyp && target.sources.every(source => built >= mtime(join(dir, source)));
|
|
});
|
|
}
|
|
|
|
|
|
export async function build(dir: string) {
|
|
if (await isBuilt(dir)) return;
|
|
if (await tryBuildFast(dir)) return;
|
|
await buildWithNodeGyp(dir);
|
|
}
|
|
|
|
function envFor(test: string) {
|
|
return abortingJsNativeApiTests.includes(test)
|
|
? { ...bunEnv, BUN_INTERNAL_SUPPRESS_CRASH_ON_NAPI_ABORT: "1" }
|
|
: bunEnv;
|
|
}
|
|
|
|
export function run(dir: string, test: string) {
|
|
const result = spawnSync({
|
|
cmd: [bunExe(), "run", test],
|
|
cwd: dir,
|
|
stderr: "inherit",
|
|
stdout: "ignore",
|
|
stdin: "inherit",
|
|
env: envFor(test),
|
|
});
|
|
expect(result.success).toBeTrue();
|
|
expect(result.exitCode).toBe(0);
|
|
}
|
|
|
|
// Non-blocking variant of run() so callers can execute the addon's .js tests concurrently.
|
|
export async function runAsync(dir: string, test: string) {
|
|
const child = spawn({
|
|
cmd: [bunExe(), "run", test],
|
|
cwd: dir,
|
|
stderr: "inherit",
|
|
stdout: "ignore",
|
|
stdin: "inherit",
|
|
env: envFor(test),
|
|
});
|
|
const exitCode = await child.exited;
|
|
expect(child.signalCode).toBeNull();
|
|
expect(exitCode).toBe(0);
|
|
}
|