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
65 changes: 38 additions & 27 deletions hooks/ensure-deps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { existsSync, copyFileSync } from "node:fs";
import { execSync } from "node:child_process";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";

const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
Expand Down Expand Up @@ -59,18 +58,28 @@ export function ensureDeps() {
}

/**
* ABI-aware native binary caching for better-sqlite3 (#148, #203).
* Probe-load better-sqlite3 in a child process to verify the binary on disk
* is compatible with the current Node ABI. In-process require() caches native
* modules at the dlopen level, so it can't detect on-disk binary changes.
* A child process gets a fresh dlopen cache.
*
* Users with mise/asdf/volta/fnm may run sessions with different Node
* versions. Each ABI needs its own compiled binary — cache them
* side-by-side so switching Node versions doesn't require a rebuild
* every time.
*
* Flow:
* 1. Check if ABI-specific cache exists → swap in
* 2. Probe-load better-sqlite3 → if OK, cache current binary
* 3. If ABI mismatch → npm rebuild, then cache the new binary
* Note: require('better-sqlite3') only loads the JS wrapper — the native
* binary is lazy-loaded when instantiating a Database. We must create an
* in-memory DB to actually trigger dlopen.
*/
function probeNativeInChildProcess(pluginRoot) {
try {
execSync(`node -e "new (require('better-sqlite3'))(':memory:').close()"`, {
cwd: pluginRoot,
stdio: "pipe",
timeout: 10000,
});
return true;
} catch {
return false;
}
}

export function ensureNativeCompat(pluginRoot) {
try {
const abi = process.versions.modules;
Expand All @@ -80,32 +89,34 @@ export function ensureNativeCompat(pluginRoot) {

if (!existsSync(nativeDir)) return;

// Fast path: cached binary for this ABI already exists
// Fast path: cached binary for this ABI already exists — swap in and verify
if (existsSync(abiCachePath)) {
copyFileSync(abiCachePath, binaryPath);
codesignBinary(binaryPath);
return;
// Validate via child process — dlopen cache is per-process, so in-process
// require() can't detect a swapped binary on disk (#148)
if (probeNativeInChildProcess(pluginRoot)) {
return; // Cache hit validated
}
// Cached binary is stale/corrupt — fall through to rebuild
}

if (!existsSync(binaryPath)) return;

// Probe: try loading better-sqlite3 with current Node
try {
const req = createRequire(resolve(pluginRoot, "package.json"));
req("better-sqlite3");
if (probeNativeInChildProcess(pluginRoot)) {
// Load succeeded — cache the working binary for this ABI
copyFileSync(binaryPath, abiCachePath);
} catch (probeErr) {
if (probeErr?.message?.includes("NODE_MODULE_VERSION")) {
// ABI mismatch — rebuild for current Node version
execSync("npm rebuild better-sqlite3", {
cwd: pluginRoot,
stdio: "pipe",
timeout: 60000,
});
if (existsSync(binaryPath)) {
copyFileSync(binaryPath, abiCachePath);
}
} else {
// ABI mismatch — rebuild for current Node version
execSync("npm rebuild better-sqlite3", {
cwd: pluginRoot,
stdio: "pipe",
timeout: 60000,
});
codesignBinary(binaryPath);
if (existsSync(binaryPath)) {
copyFileSync(binaryPath, abiCachePath);
}
}
} catch {
Expand Down
168 changes: 167 additions & 1 deletion tests/hooks/ensure-deps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,179 @@ describe("ensure-deps: native binary detection (#206)", () => {
});
});

// ── Shared path to the real ensure-deps.mjs (used by ABI + codesign tests) ──
const ensureDepsAbsPath = join(fileURLToPath(import.meta.url), "..", "..", "..", "hooks", "ensure-deps.mjs");

// ═══════════════════════════════════════════════════════════════════════
// RED-GREEN tests for ABI cache validation (#148 follow-up)
// ═══════════════════════════════════════════════════════════════════════

// Subprocess harness that replicates ensureNativeCompat's decision logic
// using a simulated probe (binary is "valid" if content starts with "VALID").
// This avoids needing a real better-sqlite3 install in the temp dir.
const ABI_HARNESS = `
import { existsSync, copyFileSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";

const pluginRoot = process.argv[2];
const abi = "137"; // arbitrary ABI value for testing — not tied to any real Node version
const captured = [];

const nativeDir = resolve(pluginRoot, "node_modules", "better-sqlite3", "build", "Release");
const binaryPath = resolve(nativeDir, "better_sqlite3.node");
const abiCachePath = resolve(nativeDir, "better_sqlite3.abi" + abi + ".node");

function probeNative() {
if (!existsSync(binaryPath)) return false;
const buf = readFileSync(binaryPath);
return buf.length >= 5 && buf.toString("utf-8", 0, 5) === "VALID";
}

if (!existsSync(nativeDir)) {
console.log(JSON.stringify(captured));
process.exit(0);
}

if (existsSync(abiCachePath)) {
copyFileSync(abiCachePath, binaryPath);
captured.push("cache-swap");
if (probeNative()) {
captured.push("cache-valid");
console.log(JSON.stringify(captured));
process.exit(0);
}
captured.push("cache-invalid");
}

if (!existsSync(binaryPath)) {
console.log(JSON.stringify(captured));
process.exit(0);
}

if (probeNative()) {
captured.push("probe-ok");
copyFileSync(binaryPath, abiCachePath);
captured.push("cached");
} else {
captured.push("probe-fail");
writeFileSync(binaryPath, "VALID-rebuilt-binary");
captured.push("rebuilt");
copyFileSync(binaryPath, abiCachePath);
captured.push("cached");
}

console.log(JSON.stringify(captured));
`;

describe("ensure-deps: ABI cache validation (#148 follow-up)", () => {
function runAbiHarness(root: string): string[] {
const harnessPath = join(root, "_abi-harness.mjs");
writeFileSync(harnessPath, ABI_HARNESS, "utf-8");
const result = spawnSync("node", [harnessPath, root], {
encoding: "utf-8",
timeout: 30_000,
});
if (result.error) throw result.error;
return JSON.parse(result.stdout.trim());
}

test("corrupted ABI cache: detects invalid binary, rebuilds, and re-caches", () => {
const root = createTempRoot();
const releaseDir = join(root, "node_modules", "better-sqlite3", "build", "Release");
mkdirSync(releaseDir, { recursive: true });
// Valid binary on disk
writeFileSync(join(releaseDir, "better_sqlite3.node"), "VALID-original");
// Corrupted cache (wrong ABI binary saved under current ABI label)
writeFileSync(join(releaseDir, "better_sqlite3.abi137.node"), "WRONG-abi115-binary");

const actions = runAbiHarness(root);
expect(actions).toEqual(["cache-swap", "cache-invalid", "probe-fail", "rebuilt", "cached"]);
});

test("valid ABI cache: uses fast path without rebuild", () => {
const root = createTempRoot();
const releaseDir = join(root, "node_modules", "better-sqlite3", "build", "Release");
mkdirSync(releaseDir, { recursive: true });
writeFileSync(join(releaseDir, "better_sqlite3.node"), "VALID-original");
writeFileSync(join(releaseDir, "better_sqlite3.abi137.node"), "VALID-cached-binary");

const actions = runAbiHarness(root);
expect(actions).toEqual(["cache-swap", "cache-valid"]);
});

test("missing ABI cache with valid binary: probes and creates cache", () => {
const root = createTempRoot();
const releaseDir = join(root, "node_modules", "better-sqlite3", "build", "Release");
mkdirSync(releaseDir, { recursive: true });
writeFileSync(join(releaseDir, "better_sqlite3.node"), "VALID-original");
// No abi137.node cache file

const actions = runAbiHarness(root);
expect(actions).toEqual(["probe-ok", "cached"]);
});

test("missing ABI cache with incompatible binary: rebuilds and caches", () => {
const root = createTempRoot();
const releaseDir = join(root, "node_modules", "better-sqlite3", "build", "Release");
mkdirSync(releaseDir, { recursive: true });
writeFileSync(join(releaseDir, "better_sqlite3.node"), "WRONG-different-abi");
// No cache file

const actions = runAbiHarness(root);
expect(actions).toEqual(["probe-fail", "rebuilt", "cached"]);
});

test("corrupted cache with missing binary: early return after cache swap fails", () => {
const root = createTempRoot();
const releaseDir = join(root, "node_modules", "better-sqlite3", "build", "Release");
mkdirSync(releaseDir, { recursive: true });
// No better_sqlite3.node on disk, only a corrupted cache
writeFileSync(join(releaseDir, "better_sqlite3.abi137.node"), "WRONG-corrupt");

const actions = runAbiHarness(root);
// Cache swap copies corrupt → binaryPath, probe fails, then falls through.
// binaryPath now exists (from the copy), so it won't hit the early return.
// Instead it probes again, fails, and rebuilds.
expect(actions).toEqual(["cache-swap", "cache-invalid", "probe-fail", "rebuilt", "cached"]);
});

test("graceful degradation: does not throw when probe and rebuild both fail", () => {
// Exercise the real ensureNativeCompat on a fake plugin root where
// better-sqlite3 exists but has no valid binary and npm rebuild will fail.
// The outer try/catch must swallow all errors.
const root = createTempRoot();
const releaseDir = join(root, "node_modules", "better-sqlite3", "build", "Release");
mkdirSync(releaseDir, { recursive: true });
writeFileSync(join(releaseDir, "better_sqlite3.node"), "CORRUPT-binary");

const harness = `
import { ensureNativeCompat } from ${JSON.stringify("file://" + ensureDepsAbsPath.replace(/\\/g, "/"))};
try {
ensureNativeCompat(${JSON.stringify(root)});
console.log(JSON.stringify({ threw: false }));
} catch (e) {
console.log(JSON.stringify({ threw: true, error: e.message }));
}
`;
const harnessPath = join(root, "_degrade-harness.mjs");
writeFileSync(harnessPath, harness, "utf-8");
const result = spawnSync("node", [harnessPath], {
encoding: "utf-8",
timeout: 30_000,
cwd: join(fileURLToPath(import.meta.url), "..", ".."),
});
if (result.error) throw result.error;
const out = JSON.parse(result.stdout.trim());
expect(out).toEqual({ threw: false });
});
});

// ═══════════════════════════════════════════════════════════════════════
// RED-GREEN tests for macOS codesign after binary copy (#SIGKILL fix)
// ═══════════════════════════════════════════════════════════════════════

// Subprocess harness that imports codesignBinary from ensure-deps.mjs and
// exercises it with mocked execSync to verify codesign behavior.
const ensureDepsAbsPath = join(fileURLToPath(import.meta.url), "..", "..", "..", "hooks", "ensure-deps.mjs");
const CODESIGN_HARNESS = `
import { codesignBinary } from ${JSON.stringify("file://" + ensureDepsAbsPath.replace(/\\/g, "/"))};

Expand Down
Loading