Files
OmniRoute/src/lib/gracefulShutdown.ts
Xiangzhe 100c9dd3fa perf(logging): offload call-log artifacts to a worker (#10123)
* perf(logging): offload call-log artifacts to a worker

* test(call-log): raise drain wait timeout for cold worker spawn

The first cold spawn of the worker_threads artifact worker can take ~2.4s
before queued artifact writes start draining, so a 2s wait in
call-log-save-drain.test.ts flakes on cold runs. Raise it to 10s.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: xz-dev <xz-dev@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:49:15 -03:00

188 lines
5.5 KiB
TypeScript

/**
* Graceful Shutdown — E-2 Critical Fix
*
* Handles SIGTERM / SIGINT to drain in-flight requests before exit.
* Critical for Docker containers and Kubernetes pods where hard kills
* can drop active SSE streams.
*
* Usage:
* import { initGracefulShutdown } from "@/lib/gracefulShutdown";
* initGracefulShutdown();
*
* @module lib/gracefulShutdown
*/
import { markServerStopping } from "@/lib/serverLifecycle";
/** Grace period before forced exit (default 30s, configurable) */
const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "30000", 10);
declare global {
var __omnirouteShutdown:
{ init: boolean; shuttingDown: boolean; activeRequests: number } | undefined;
}
function getShutdownState() {
if (!globalThis.__omnirouteShutdown) {
globalThis.__omnirouteShutdown = { init: false, shuttingDown: false, activeRequests: 0 };
}
return globalThis.__omnirouteShutdown;
}
/**
* Check if the server is currently shutting down.
* Route handlers can use this to reject new requests.
*/
export function isDraining(): boolean {
return getShutdownState().shuttingDown;
}
/**
* Track a new in-flight request. Call `done()` when it completes.
* Returns a done callback.
*/
export function trackRequest(): () => void {
const state = getShutdownState();
state.activeRequests++;
let called = false;
return () => {
if (!called) {
called = true;
state.activeRequests--;
}
};
}
/**
* Get current active request count (for monitoring/health endpoints).
*/
export function getActiveRequestCount(): number {
return getShutdownState().activeRequests;
}
/**
* Wait for all in-flight requests to complete, with timeout.
*/
async function waitForDrain(): Promise<void> {
const state = getShutdownState();
const start = Date.now();
const CHECK_INTERVAL_MS = 250;
return new Promise((resolve) => {
const check = () => {
if (state.activeRequests <= 0) {
console.log("[Shutdown] All in-flight requests drained.");
resolve();
return;
}
if (Date.now() - start > SHUTDOWN_TIMEOUT_MS) {
console.warn(
`[Shutdown] Timeout after ${SHUTDOWN_TIMEOUT_MS}ms with ${state.activeRequests} active requests. Forcing exit.`
);
resolve();
return;
}
console.log(`[Shutdown] Waiting for ${state.activeRequests} in-flight request(s)...`);
setTimeout(check, CHECK_INTERVAL_MS);
};
check();
});
}
/**
* Perform cleanup: close DB connections, flush logs.
*/
async function cleanup(): Promise<void> {
try {
const [
{ closeAuditDb },
{ closeDbInstance },
{ flushSpendBatchWriter },
{ closeLogRotation },
{ closeCallLogSaves },
] = await Promise.all([
import("@omniroute/open-sse/mcp-server/audit.ts"),
import("@/lib/db/core"),
import("@/lib/spend/batchWriter"),
import("@/lib/logRotation"),
import("@/lib/usage/callLogs"),
]);
const flushResult = await flushSpendBatchWriter();
if (flushResult.flushedEntries > 0) {
console.log(
`[Shutdown] Spend batch writer flushed ${flushResult.flushedEntries} pending entry(ies).`
);
}
await closeCallLogSaves();
if (closeAuditDb()) {
console.log("[Shutdown] MCP audit database checkpointed and closed.");
}
if (closeDbInstance()) {
console.log("[Shutdown] SQLite database checkpointed and closed.");
}
closeLogRotation();
console.log("[Shutdown] Log rotation timer stopped.");
// Tear down any persistent VNC login browser containers so they don't leak
// past the server process. Best-effort; no-op if the feature was never used
// or the docker CLI is unavailable.
try {
const { stopAllSessions, listSessions } = await import("@/lib/vncSession/service");
if (listSessions().length > 0) {
await stopAllSessions();
console.log("[Shutdown] VNC login sessions stopped.");
}
} catch {
/* feature unused / docker missing */
}
try {
const { stopChatGptWebCodexRuntime } =
await import("@omniroute/open-sse/executors/chatgpt-web-codex/runtime.ts");
await stopChatGptWebCodexRuntime();
console.log("[Shutdown] ChatGPT Web (Codex) runtime stopped.");
} catch {
/* feature unused */
}
} catch (err) {
console.error("[Shutdown] Error during cleanup:", (err as Error).message);
}
}
/**
* Initialize graceful shutdown handlers.
* Should be called once during server startup.
*/
export function initGracefulShutdown(): void {
const state = getShutdownState();
if (state.init) return;
state.init = true;
const shutdown = async (signal: string) => {
if (state.shuttingDown) return;
state.shuttingDown = true;
markServerStopping();
console.log(`\n[Shutdown] Received ${signal}. Draining ${state.activeRequests} request(s)...`);
await waitForDrain();
await cleanup();
console.log("[Shutdown] Bye.");
process.exit(0);
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
// #8045: on Windows, closing the console window delivers CTRL_CLOSE_EVENT, which
// Node/libuv maps to a JS-visible "SIGHUP" event — without this listener, closing
// the window never runs cleanup() (WAL checkpoint + closeDbInstance()), leaving
// storage.sqlite's WAL un-checkpointed for the next launch.
process.on("SIGHUP", () => shutdown("SIGHUP"));
console.log("[Shutdown] Graceful shutdown handlers registered.");
}