mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 20:02:45 +03:00
fix(dev): make logging resources HMR-singleton (#12079)
Co-authored-by: backryun <backryun@daonlab.local>
This commit is contained in:
@@ -19,7 +19,15 @@ const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "30000",
|
||||
|
||||
declare global {
|
||||
var __omnirouteShutdown:
|
||||
{ init: boolean; shuttingDown: boolean; activeRequests: number } | undefined;
|
||||
| {
|
||||
init: boolean;
|
||||
shuttingDown: boolean;
|
||||
activeRequests: number;
|
||||
shutdownPromise?: Promise<void>;
|
||||
}
|
||||
| undefined;
|
||||
var __omnirouteRequestShutdown: ((signal: string) => Promise<void>) | undefined;
|
||||
var __omnirouteCustomServerOwnsShutdown: boolean | undefined;
|
||||
}
|
||||
|
||||
function getShutdownState() {
|
||||
@@ -102,12 +110,14 @@ async function cleanup(): Promise<void> {
|
||||
{ closeDbInstance },
|
||||
{ flushSpendBatchWriter },
|
||||
{ closeLogRotation },
|
||||
{ closeSharedLoggerResource },
|
||||
{ closeCallLogSaves },
|
||||
] = await Promise.all([
|
||||
import("@omniroute/open-sse/mcp-server/audit.ts"),
|
||||
import("@/lib/db/core"),
|
||||
import("@/lib/spend/batchWriter"),
|
||||
import("@/lib/logRotation"),
|
||||
import("@/shared/utils/loggerResource"),
|
||||
import("@/lib/usage/callLogs"),
|
||||
]);
|
||||
const flushResult = await flushSpendBatchWriter();
|
||||
@@ -123,9 +133,6 @@ async function cleanup(): Promise<void> {
|
||||
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.
|
||||
@@ -147,41 +154,62 @@ async function cleanup(): Promise<void> {
|
||||
} catch {
|
||||
/* feature unused */
|
||||
}
|
||||
|
||||
await closeSharedLoggerResource();
|
||||
closeLogRotation();
|
||||
console.log("[Shutdown] Logger transport and log rotation stopped.");
|
||||
} catch (err) {
|
||||
console.error("[Shutdown] Error during cleanup:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the process-wide shutdown sequence, or join the sequence already in progress.
|
||||
*/
|
||||
export function requestGracefulShutdown(signal: string): Promise<void> {
|
||||
const state = getShutdownState();
|
||||
if (state.shutdownPromise) return state.shutdownPromise;
|
||||
|
||||
state.shuttingDown = true;
|
||||
markServerStopping();
|
||||
state.shutdownPromise = (async () => {
|
||||
console.log(`\n[Shutdown] Received ${signal}. Draining ${state.activeRequests} request(s)...`);
|
||||
|
||||
await waitForDrain();
|
||||
await cleanup();
|
||||
|
||||
console.log("[Shutdown] Bye.");
|
||||
})();
|
||||
|
||||
return state.shutdownPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize graceful shutdown handlers.
|
||||
* Should be called once during server startup.
|
||||
*/
|
||||
export function initGracefulShutdown(): void {
|
||||
const state = getShutdownState();
|
||||
globalThis.__omnirouteRequestShutdown ??= requestGracefulShutdown;
|
||||
if (state.init) return;
|
||||
state.init = true;
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
if (state.shuttingDown) return;
|
||||
state.shuttingDown = true;
|
||||
markServerStopping();
|
||||
if (globalThis.__omnirouteCustomServerOwnsShutdown) {
|
||||
console.log("[Shutdown] Cleanup registered with the custom server shutdown owner.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n[Shutdown] Received ${signal}. Draining ${state.activeRequests} request(s)...`);
|
||||
|
||||
await waitForDrain();
|
||||
await cleanup();
|
||||
|
||||
console.log("[Shutdown] Bye.");
|
||||
process.exit(0);
|
||||
const shutdown = (signal: string) => {
|
||||
void globalThis.__omnirouteRequestShutdown?.(signal).then(() => process.exit(0));
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => void 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"));
|
||||
process.on("SIGHUP", () => void shutdown("SIGHUP"));
|
||||
|
||||
console.log("[Shutdown] Graceful shutdown handlers registered.");
|
||||
}
|
||||
|
||||
@@ -42,8 +42,18 @@ export function getAppLogRotationCheckInterval(): number {
|
||||
);
|
||||
}
|
||||
|
||||
/** Module-level timer handle — cleared by closeLogRotation(). */
|
||||
let rotationTimer: ReturnType<typeof setInterval> | null = null;
|
||||
interface LogRotationState {
|
||||
timer: ReturnType<typeof setInterval> | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __omnirouteLogRotationState: LogRotationState | undefined;
|
||||
}
|
||||
|
||||
/** Process-wide state survives Next.js development HMR and split server chunks. */
|
||||
function getLogRotationState(): LogRotationState {
|
||||
return (globalThis.__omnirouteLogRotationState ??= { timer: null });
|
||||
}
|
||||
|
||||
export function getLogConfig() {
|
||||
const logToFile = getAppLogToFile();
|
||||
@@ -172,6 +182,9 @@ export function cleanupOverflowLogs(logFilePath: string, maxFiles: number): void
|
||||
* Call closeLogRotation() during application shutdown to clear the timer.
|
||||
*/
|
||||
export function initLogRotation(): void {
|
||||
const state = getLogRotationState();
|
||||
if (state.timer !== null) return;
|
||||
|
||||
const config = getLogConfig();
|
||||
if (!config.logToFile) return;
|
||||
|
||||
@@ -181,7 +194,7 @@ export function initLogRotation(): void {
|
||||
cleanupOverflowLogs(config.logFilePath, config.maxFiles);
|
||||
|
||||
const intervalMs = getAppLogRotationCheckInterval();
|
||||
rotationTimer = setInterval(
|
||||
state.timer = setInterval(
|
||||
(filePath: string, maxSize: number, maxFiles: number) => {
|
||||
rotateIfNeeded(filePath, maxSize);
|
||||
cleanupOverflowLogs(filePath, maxFiles);
|
||||
@@ -191,7 +204,7 @@ export function initLogRotation(): void {
|
||||
config.maxFileSize,
|
||||
config.maxFiles
|
||||
);
|
||||
rotationTimer.unref?.();
|
||||
state.timer.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,8 +212,9 @@ export function initLogRotation(): void {
|
||||
* Idempotent — safe to call multiple times.
|
||||
*/
|
||||
export function closeLogRotation(): void {
|
||||
if (rotationTimer !== null) {
|
||||
clearInterval(rotationTimer);
|
||||
rotationTimer = null;
|
||||
const state = getLogRotationState();
|
||||
if (state.timer !== null) {
|
||||
clearInterval(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ import { resolve } from "path";
|
||||
import { getLogConfig, initLogRotation } from "@/lib/logRotation";
|
||||
import { getAppLogLevel } from "@/lib/logEnv";
|
||||
import { redactLogArgs } from "@/shared/utils/logRedaction";
|
||||
import {
|
||||
getOrCreateSharedLoggerResource,
|
||||
type SharedLoggerResource,
|
||||
} from "@/shared/utils/loggerResource";
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
|
||||
@@ -61,7 +65,7 @@ function getTransportCompatibleConfig(): pino.LoggerOptions {
|
||||
* vanished, so failed writes are dropped (best-effort stderr notice) instead of
|
||||
* escalating.
|
||||
*/
|
||||
function buildFileTransportStream(targets: NonNullable<pino.TransportMultiOptions["targets"]>) {
|
||||
function buildTransportStream(targets: NonNullable<pino.TransportMultiOptions["targets"]>) {
|
||||
const stream = pino.transport({ targets });
|
||||
stream.on("error", (err: unknown) => {
|
||||
try {
|
||||
@@ -75,11 +79,65 @@ function buildFileTransportStream(targets: NonNullable<pino.TransportMultiOption
|
||||
return stream;
|
||||
}
|
||||
|
||||
interface OwnedLogStream {
|
||||
flushSync?: () => void;
|
||||
end?: () => void;
|
||||
once?: (event: string, listener: () => void) => unknown;
|
||||
}
|
||||
|
||||
async function closeOwnedStream(stream: OwnedLogStream | null): Promise<void> {
|
||||
if (!stream) return;
|
||||
|
||||
try {
|
||||
stream.flushSync?.();
|
||||
} catch {
|
||||
// Best-effort shutdown: a missing log destination must not block process exit.
|
||||
}
|
||||
|
||||
if (!stream.end) return;
|
||||
|
||||
await new Promise<void>((resolveClose) => {
|
||||
let resolved = false;
|
||||
const finish = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
resolveClose();
|
||||
};
|
||||
const fallback = setTimeout(finish, 1_000);
|
||||
|
||||
stream.once?.("close", () => {
|
||||
clearTimeout(fallback);
|
||||
finish();
|
||||
});
|
||||
|
||||
try {
|
||||
stream.end?.();
|
||||
if (!stream.once) {
|
||||
clearTimeout(fallback);
|
||||
finish();
|
||||
}
|
||||
} catch {
|
||||
clearTimeout(fallback);
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createLoggerResource(
|
||||
logger: pino.Logger,
|
||||
stream: OwnedLogStream | null
|
||||
): SharedLoggerResource {
|
||||
return {
|
||||
logger,
|
||||
close: () => closeOwnedStream(stream),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the logger with optional file transport.
|
||||
* Uses pino transport targets for all destinations.
|
||||
*/
|
||||
function buildLogger(): pino.Logger {
|
||||
function buildLoggerResource(): SharedLoggerResource {
|
||||
const logConfig = getLogConfig();
|
||||
const logLevel = (baseConfig.level as string) || "info";
|
||||
const transportConfig = getTransportCompatibleConfig();
|
||||
@@ -95,7 +153,7 @@ function buildLogger(): pino.Logger {
|
||||
|
||||
if (isDev) {
|
||||
// Dev: pino-pretty → stdout, JSON → file
|
||||
const stream = buildFileTransportStream([
|
||||
const stream = buildTransportStream([
|
||||
{
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
@@ -113,12 +171,12 @@ function buildLogger(): pino.Logger {
|
||||
level: logLevel,
|
||||
},
|
||||
]);
|
||||
return pino(transportConfig, stream);
|
||||
return createLoggerResource(pino(transportConfig, stream), stream);
|
||||
}
|
||||
|
||||
// Production: JSON → stdout + JSON → file
|
||||
{
|
||||
const stream = buildFileTransportStream([
|
||||
const stream = buildTransportStream([
|
||||
{
|
||||
target: "pino/file",
|
||||
options: { destination: 1 }, // stdout
|
||||
@@ -130,7 +188,7 @@ function buildLogger(): pino.Logger {
|
||||
level: logLevel,
|
||||
},
|
||||
]);
|
||||
return pino(transportConfig, stream);
|
||||
return createLoggerResource(pino(transportConfig, stream), stream);
|
||||
}
|
||||
} catch (err) {
|
||||
// Log the actual error for diagnostics (issue #165)
|
||||
@@ -156,12 +214,15 @@ function buildLogger(): pino.Logger {
|
||||
});
|
||||
|
||||
// Production fallback: JSON to both stdout and file via multistream
|
||||
return pino(
|
||||
baseConfig,
|
||||
pino.multistream([
|
||||
{ stream: process.stdout, level: logLevel as pino.Level },
|
||||
{ stream: fileDestination, level: logLevel as pino.Level },
|
||||
])
|
||||
return createLoggerResource(
|
||||
pino(
|
||||
baseConfig,
|
||||
pino.multistream([
|
||||
{ stream: process.stdout, level: logLevel as pino.Level },
|
||||
{ stream: fileDestination, level: logLevel as pino.Level },
|
||||
])
|
||||
),
|
||||
fileDestination
|
||||
);
|
||||
} catch (fallbackErr) {
|
||||
try {
|
||||
@@ -175,9 +236,8 @@ function buildLogger(): pino.Logger {
|
||||
|
||||
// Console-only (no file logging)
|
||||
if (isDev) {
|
||||
return pino({
|
||||
...baseConfig,
|
||||
transport: {
|
||||
const stream = buildTransportStream([
|
||||
{
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
@@ -185,14 +245,18 @@ function buildLogger(): pino.Logger {
|
||||
ignore: "pid,hostname,service",
|
||||
messageFormat: "[{module}] {msg}",
|
||||
},
|
||||
level: logLevel,
|
||||
},
|
||||
});
|
||||
]);
|
||||
return createLoggerResource(pino(transportConfig, stream), stream);
|
||||
}
|
||||
|
||||
return pino(baseConfig);
|
||||
return createLoggerResource(pino(baseConfig), null);
|
||||
}
|
||||
|
||||
export const logger = buildLogger();
|
||||
const sharedLoggerResource = getOrCreateSharedLoggerResource(buildLoggerResource);
|
||||
|
||||
export const logger = sharedLoggerResource.logger;
|
||||
|
||||
/**
|
||||
* Create a child logger with a module tag.
|
||||
|
||||
32
src/shared/utils/loggerResource.ts
Normal file
32
src/shared/utils/loggerResource.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
export interface SharedLoggerResource {
|
||||
logger: Logger;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __omnirouteLoggerResource: SharedLoggerResource | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the process-wide logger resource, creating it only once.
|
||||
*
|
||||
* Next.js development HMR can evaluate logger.ts in more than one server chunk.
|
||||
* Keeping the resource on globalThis prevents each evaluation from spawning a
|
||||
* new pino worker transport.
|
||||
*/
|
||||
export function getOrCreateSharedLoggerResource(
|
||||
create: () => SharedLoggerResource
|
||||
): SharedLoggerResource {
|
||||
return (globalThis.__omnirouteLoggerResource ??= create());
|
||||
}
|
||||
|
||||
/** Close and forget the shared transport. Idempotent across HMR module copies. */
|
||||
export async function closeSharedLoggerResource(): Promise<void> {
|
||||
const resource = globalThis.__omnirouteLoggerResource;
|
||||
if (!resource) return;
|
||||
|
||||
delete globalThis.__omnirouteLoggerResource;
|
||||
await resource.close();
|
||||
}
|
||||
Reference in New Issue
Block a user