Skip to content
Merged
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
79 changes: 76 additions & 3 deletions src/lib/db/adapters/driverFactory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { runtimeRequire as _require } from "./runtimeRequire";
import { existsSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { createBetterSqliteAdapter } from "./betterSqliteAdapter";
import { createBunSqliteAdapter, type BunSqliteDatabaseLike } from "./bunSqliteAdapter";
import {
Expand All @@ -11,6 +12,70 @@ import type { SqliteAdapter } from "./types";

type DriverLoader = (moduleName: string) => unknown;

type SpawnSyncLike = (
command: string,
args: string[],
options: { timeout: number; stdio: "ignore"; cwd: string; windowsHide: boolean }
) => { status: number | null };

/** Returns whether better-sqlite3 may be loaded in this process. */
export type DriverProbe = () => boolean;

/**
* #10627 — Windows driver-hang guard.
*
* The sync cascade's try/catch only covers drivers that THROW on load
* (ERR_DLOPEN_FAILED, "Module did not self-register", ...). On Windows, a
* mismatched-ABI native addon can HANG inside DllMain (loader lock) instead of
* throwing — a hang never reaches the catch, so the fallback to node:sqlite /
* sql.js never runs and the first DB touch in a runtime stalls forever at ~0%
* CPU (the exact #10627 symptom: every request hangs, 0 bytes, no logs).
*
* The probe answers "can better-sqlite3 load AND open a database?" by loading
* it in a CHILD PROCESS with a bounded timeout, so a hang becomes a timed-out
* probe (verdict "bad") instead of a process-level deadlock. The verdict is
* cached per process — the child spawn happens at most once.
*
* On POSIX this is a no-op returning true: broken addons throw there, which
* the existing cascade already handles, and we don't want to pay a subprocess
* spawn on every Linux/CI boot.
*/
export function createBetterSqliteProbe(options: {
platform?: string;
execPath?: string;
spawn?: SpawnSyncLike;
timeoutMs?: number;
}): DriverProbe {
const {
platform = process.platform,
execPath = process.execPath,
spawn = spawnSync as unknown as SpawnSyncLike,
timeoutMs = 5_000,
} = options;

let verdict: boolean | null = null;
return () => {
if (verdict !== null) return verdict;
if (platform !== "win32") {
verdict = true;
return verdict;
}
try {
const result = spawn(execPath, ["-e", "require('better-sqlite3')(':memory:')"], {
timeout: timeoutMs,
stdio: "ignore",
cwd: process.cwd(),
windowsHide: true,
});
// status === null means the child was killed by the timeout — a hang.
verdict = result.status === 0;
} catch {
verdict = false;
}
return verdict;
};
}

/**
* The production loader for the sync driver cascade.
*
Expand Down Expand Up @@ -137,7 +202,12 @@ function getSqlJsPendingCache(): Map<string, Promise<SqliteAdapter>> {
* Builds the synchronous driver cascade. Keeping the loader injectable makes
* the real node:sqlite branch testable without changing the public adapter API.
*/
export function createSyncDriverFactory(load: DriverLoader) {
export function createSyncDriverFactory(load: DriverLoader, betterSqliteProbe?: DriverProbe) {
// #10627: when a probe is supplied, the better-sqlite3 branch is gated on it
// so a Windows DllMain hang (which never throws, so never hits the catch)
// cannot stall the request path. Default: no probe — existing callers/tests
// keep the historical throw-only behavior.
const mayLoadBetterSqlite = betterSqliteProbe ?? (() => true);
return function tryOpenSync(
filePath: string,
options?: Record<string, unknown>
Expand All @@ -164,7 +234,7 @@ export function createSyncDriverFactory(load: DriverLoader) {
}

// better-sqlite3: rápido, nativo — skip em Bun
if (!process.versions.bun) {
if (!process.versions.bun && mayLoadBetterSqlite()) {
try {
const BetterSqlite = load("better-sqlite3") as {
new (p: string, o?: object): import("better-sqlite3").Database;
Expand Down Expand Up @@ -204,7 +274,10 @@ export function createSyncDriverFactory(load: DriverLoader) {
};
}

const openSyncDriver = createSyncDriverFactory(requireSqliteDriver);
// Production wiring: the real probe (child-process, timed, cached) guards the
// better-sqlite3 branch so a hang on Windows degrades to a failover instead of
// a request-path deadlock (#10627).
const openSyncDriver = createSyncDriverFactory(requireSqliteDriver, createBetterSqliteProbe({}));

/**
* The installed-tarball smoke uses this paired marker to exercise the sql.js tier
Expand Down
19 changes: 19 additions & 0 deletions src/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
import type { NextRequest } from "next/server";
import { runAuthzPipeline } from "./server/authz/pipeline";

// #10627: the proxy runs in its own Next.js runtime and never executes
// instrumentation-node.ts's startup warm-ups, so its FIRST request used to
// trigger a cold `import("@/lib/db/settings")` → native SQLite driver load ON
// the request path. If that addon hangs (see driverFactory's #10627 probe),
// every proxied request stalled indefinitely with 0 bytes and no logs.
// Warm the settings cache here at boot instead: a driver failure now surfaces
// as a logged startup error, and real requests start with a hot cache.
// Fire-and-forget — never blocks proxy initialization, never rejects the
// module (mirrors the `void warmModelCatalogCache()` pattern in
// instrumentation-node.ts).
void import("./lib/db/readCache")
.then(({ getCachedSettings }) => getCachedSettings())
.catch((err: unknown) => {
console.error(
"[proxy] DB settings warm failed; requests will use default limits:",
err instanceof Error ? err.message : err
);
});

export async function proxy(request: NextRequest) {
return runAuthzPipeline(request, { enforce: true });
}
Expand Down
118 changes: 118 additions & 0 deletions tests/unit/db-adapters/driverFactory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { runtimeRequire } from "../../../src/lib/db/adapters/runtimeRequire.ts";

const {
createSyncDriverFactory,
createBetterSqliteProbe,
isPackBootForcedSqlJsSmoke,
tryOpenSync,
openDatabaseAsync,
Expand Down Expand Up @@ -81,6 +82,55 @@ describe("driverFactory", () => {
}
);

test("rejected probe skips better-sqlite3 and falls through to node:sqlite", (t) => {
const databasePath = createTempDatabasePath(t);
const openWithoutBrokenAddon = createSyncDriverFactory(
(moduleName: string) => {
if (moduleName === "better-sqlite3") {
throw new Error("better-sqlite3 must not load when the probe rejects it");
}
return require(moduleName);
},
() => false
);

const adapter = openWithoutBrokenAddon(databasePath);
assert.ok(adapter);
assert.equal(adapter.driver, "node:sqlite");
adapter.exec("CREATE TABLE items (value TEXT)");
adapter.prepare("INSERT INTO items VALUES (?)").run("ok");
assert.equal(
(adapter.prepare("SELECT value FROM items").get() as { value: string }).value,
"ok"
);
adapter.close();
});

test("passed probe still prefers better-sqlite3 in the cascade", () => {
let betterSqliteRequested = false;
const openWithPassedProbe = createSyncDriverFactory(
(moduleName: string) => {
if (moduleName === "better-sqlite3") {
betterSqliteRequested = true;
return function FakeBetterSqlite() {
return { close() {}, name: ":memory:", open: true };
};
}
if (moduleName === "node:sqlite") {
throw new Error("node:sqlite must not load when better-sqlite3 passes the probe");
}
throw new Error(`unexpected driver load: ${moduleName}`);
},
() => true
);

const adapter = openWithPassedProbe(":memory:");
assert.ok(adapter);
assert.equal(adapter.driver, "better-sqlite3");
assert.equal(betterSqliteRequested, true);
adapter.close();
});

test("prefers better-sqlite3 before node:sqlite in the driver cascade", () => {
const fakeBetterSqlite = {
close() {},
Expand Down Expand Up @@ -337,6 +387,74 @@ describe("driverFactory", () => {
});
}

// #10627 — the Windows driver-hang guard. On Windows a mismatched-ABI
// better-sqlite3 addon can HANG inside DllMain instead of throwing, so the
// cascade's try/catch never fires and the fallback never runs. The probe
// loads the addon in a child process with a bounded timeout, turning a hang
// into a cached "bad" verdict that skips the branch.
test("probe: non-Windows platforms skip the child probe and report ok", () => {
let spawned = 0;
const probe = createBetterSqliteProbe({
platform: "linux",
execPath: "node",
spawn: () => {
spawned += 1;
return { status: 0 };
},
});
assert.equal(probe(), true);
assert.equal(probe(), true);
assert.equal(spawned, 0, "POSIX must not spawn a probe child process");
});

test("probe: successful child probe is cached (spawned at most once)", () => {
let spawned = 0;
const probe = createBetterSqliteProbe({
platform: "win32",
execPath: "node",
spawn: () => {
spawned += 1;
return { status: 0 };
},
});
assert.equal(probe(), true);
assert.equal(probe(), true);
assert.equal(probe(), true);
assert.equal(spawned, 1, "verdict must be cached per process");
});

test("probe: non-zero child exit rejects better-sqlite3", () => {
const probe = createBetterSqliteProbe({
platform: "win32",
execPath: "node",
spawn: () => ({ status: 1 }),
});
assert.equal(probe(), false);
assert.equal(probe(), false);
});

test("probe: child spawn throw rejects better-sqlite3", () => {
const probe = createBetterSqliteProbe({
platform: "win32",
execPath: "node",
spawn: () => {
throw new Error("spawn failed");
},
});
assert.equal(probe(), false);
});

test("probe: timed-out child (status null) rejects better-sqlite3 — the #10627 hang case", () => {
// status === null is exactly what spawnSync returns when the child is
// killed by the timeout — i.e. the DllMain hang that never throws.
const probe = createBetterSqliteProbe({
platform: "win32",
execPath: "node",
spawn: () => ({ status: null }),
});
assert.equal(probe(), false);
});

test("retains the existing cascade when native drivers are unavailable", () => {
const openWithoutNativeDrivers = createSyncDriverFactory(() => {
throw new Error("forced driver load failure");
Expand Down