fix(db): prevent Windows native-driver hang from stalling requests (#10627) (#10709)

Merged via merge-train (release/v3.8.50, batch1 2026-08-20) — static gates (typecheck/file-size/complexity/cognitive/changelog) green on the combined tree; test:unit reds observed in the boarded run were verified pre-existing on the pure release tip (unrelated flake), not caused by this PR. Thanks for the contribution!
This commit is contained in:
Webman
2026-08-20 04:28:33 -05:00
committed by GitHub
parent 81b0ff46a3
commit 1accabeb4e
3 changed files with 213 additions and 3 deletions

View File

@@ -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<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>
@@ -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

View File

@@ -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 });
}