fix(dev): make logging resources HMR-singleton (#12079)

Co-authored-by: backryun <backryun@daonlab.local>
This commit is contained in:
backryun
2026-09-01 02:13:53 +09:00
committed by GitHub
parent e12fb110f9
commit f8b01c966e
8 changed files with 439 additions and 54 deletions

View File

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

View File

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

View File

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

View 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();
}