Compare commits

...

3 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
91ab21a9ce chore: sync release/v3.8.51 into fix/13306-windows-libuv-abort-sqljs-exit (base-red fix #13747) 2026-09-15 23:24:33 -03:00
diegosouzapw
9fab8da48d Merge commit '8f55d85d221e8df0b788eab0e598935a1514536a' into fix/13306-windows-libuv-abort-sqljs-exit 2026-09-15 23:19:37 -03:00
diegosouzapw
ddae648fec fix(db): defer process.exit(0) by a macrotask on graceful shutdown (#13306)
sql.js's Emscripten WASM build leaves pending libuv async-handle teardown
work in flight after a statement has run. gracefulShutdown.ts's shutdown
closure called process.exit(0) in the same tick as the cleanup promise
resolving, tearing the event loop down before that teardown settled. On
Windows, libuv's async-handle close path asserts
!(handle->flags & UV_HANDLE_CLOSING) when this happens, aborting the
process (exit 127); Linux's unix backend has no equivalent assertion,
which is why this was invisible on CI. Deferring process.exit(0) by one
macrotask (setTimeout(..., 0)) mirrors the pattern already used
throughout 9router's own shutdown call sites and gives sql.js's pending
libuv work a chance to settle first.

Regression test: tests/unit/graceful-shutdown-deferred-exit-13306.test.ts
pins the ordering contract (process.exit(0) must not fire in the same
microtask turn cleanup() resolves in). The Windows abort itself cannot
be reproduced on Linux CI — see the PR body for the required live
Windows validation.
2026-09-15 15:17:33 -03:00
3 changed files with 101 additions and 1 deletions

View File

@@ -0,0 +1 @@
- **fix(db):** defer `process.exit(0)` on graceful shutdown by one macrotask, avoiding a Windows-only libuv abort when the sql.js fallback driver has a statement in flight (#13306) — thanks @anhtahaylove

View File

@@ -200,7 +200,16 @@ export function initGracefulShutdown(): void {
}
const shutdown = (signal: string) => {
void globalThis.__omnirouteRequestShutdown?.(signal).then(() => process.exit(0));
void globalThis.__omnirouteRequestShutdown?.(signal).then(() => {
// #13306: on Windows, sql.js's Emscripten WASM build leaves pending libuv
// async-handle teardown work in flight after a statement has run. Calling
// process.exit() in the same tick as cleanup() resolving tears the event loop
// down before that teardown settles, and libuv's Windows async-handle close path
// asserts `!(handle->flags & UV_HANDLE_CLOSING)` -> hard abort. Deferring by one
// macrotask (mirrors 9router's own shutdown call sites, e.g.
// appUpdater.js:199, cli/cli.js:675) gives that teardown work a chance to run.
setTimeout(() => process.exit(0), 0);
});
};
process.on("SIGTERM", () => void shutdown("SIGTERM"));

View File

@@ -0,0 +1,90 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
type GracefulShutdownModule = typeof import("../../src/lib/gracefulShutdown.ts");
const gracefulShutdownUrl = pathToFileURL(join(process.cwd(), "src/lib/gracefulShutdown.ts")).href;
// #13306: on Windows, sql.js's Emscripten WASM build leaves pending libuv async-handle
// teardown work in flight after a statement has run (db.run()/adapter.exec()). Calling
// process.exit() tears the event loop down synchronously, and libuv's Windows async-handle
// close path asserts `!(handle->flags & UV_HANDLE_CLOSING)` while that teardown work is
// still pending -> hard abort (exit 127). The fix defers `process.exit(0)` by one macrotask
// after the shutdown cleanup promise resolves (mirrors the `setTimeout(() =>
// process.exit(0), n)` pattern already used throughout 9router's own shutdown call sites),
// giving sql.js's pending libuv work a chance to settle before the event loop tears down.
//
// The Windows abort itself cannot be reproduced here (Linux's unix libuv backend has no
// equivalent assertion, matching the reporter's own cross-platform matrix) — this test pins
// the *ordering* contract the fix depends on: process.exit(0) must not fire in the same
// microtask turn the cleanup promise resolves in, it must be deferred to a later macrotask.
test("graceful shutdown defers process.exit(0) to a macrotask after cleanup resolves (#13306)", async () => {
const previousState = globalThis.__omnirouteShutdown;
const previousRequestShutdown = globalThis.__omnirouteRequestShutdown;
const previousCustomServerOwner = globalThis.__omnirouteCustomServerOwnsShutdown;
const previousExit = process.exit;
const listenersBefore = process.listeners("SIGTERM");
delete globalThis.__omnirouteShutdown;
delete globalThis.__omnirouteCustomServerOwnsShutdown;
const exitCalls: Array<number | undefined> = [];
process.exit = ((code?: number) => {
exitCalls.push(code);
return undefined as never;
}) as typeof process.exit;
let resolveCleanup!: () => void;
const cleanupPromise = new Promise<void>((resolve) => {
resolveCleanup = resolve;
});
globalThis.__omnirouteRequestShutdown = () => cleanupPromise;
try {
const shutdownModule = (await import(
`${gracefulShutdownUrl}?issue13306=${Date.now()}`
)) as GracefulShutdownModule;
shutdownModule.initGracefulShutdown();
const addedListener = process
.listeners("SIGTERM")
.find((listener) => !listenersBefore.includes(listener));
assert.ok(addedListener, "initGracefulShutdown() must register a new SIGTERM listener");
// Trigger the shutdown closure directly — do NOT emit a real SIGTERM in the test process.
(addedListener as () => void)();
resolveCleanup();
// Let the shutdown closure's own `.then()` continuation run: it was attached to
// `cleanupPromise` before this `await`, so it settles first on the microtask queue.
await cleanupPromise;
await Promise.resolve();
assert.deepEqual(
exitCalls,
[],
"process.exit(0) must not fire in the same microtask turn the cleanup promise resolves in"
);
// Now let a macrotask elapse — this is where the deferred process.exit(0) must land.
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(exitCalls, [0], "process.exit(0) must still run, deferred by one macrotask");
} finally {
process.exit = previousExit;
for (const listener of process.listeners("SIGTERM")) {
if (!listenersBefore.includes(listener)) process.removeListener("SIGTERM", listener);
}
if (previousState === undefined) delete globalThis.__omnirouteShutdown;
else globalThis.__omnirouteShutdown = previousState;
if (previousRequestShutdown === undefined) delete globalThis.__omnirouteRequestShutdown;
else globalThis.__omnirouteRequestShutdown = previousRequestShutdown;
if (previousCustomServerOwner === undefined) {
delete globalThis.__omnirouteCustomServerOwnsShutdown;
} else {
globalThis.__omnirouteCustomServerOwnsShutdown = previousCustomServerOwner;
}
}
});