diff --git a/src/lib/db/adapters/driverFactory.ts b/src/lib/db/adapters/driverFactory.ts index d696d1388d..134e984c28 100644 --- a/src/lib/db/adapters/driverFactory.ts +++ b/src/lib/db/adapters/driverFactory.ts @@ -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 { @@ -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. * @@ -137,7 +202,12 @@ function getSqlJsPendingCache(): Map> { * 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 @@ -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; @@ -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 diff --git a/src/proxy.ts b/src/proxy.ts index 93de5e149f..153785efdf 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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 }); } diff --git a/tests/unit/db-adapters/driverFactory.test.ts b/tests/unit/db-adapters/driverFactory.test.ts index f5fc774e3f..b73ea87a6f 100644 --- a/tests/unit/db-adapters/driverFactory.test.ts +++ b/tests/unit/db-adapters/driverFactory.test.ts @@ -10,6 +10,7 @@ import { runtimeRequire } from "../../../src/lib/db/adapters/runtimeRequire.ts"; const { createSyncDriverFactory, + createBetterSqliteProbe, isPackBootForcedSqlJsSmoke, tryOpenSync, openDatabaseAsync, @@ -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() {}, @@ -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");