mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 14:22:14 +03:00
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.
This commit is contained in:
@@ -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
|
||||
@@ -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"));
|
||||
|
||||
90
tests/unit/graceful-shutdown-deferred-exit-13306.test.ts
Normal file
90
tests/unit/graceful-shutdown-deferred-exit-13306.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user