plan/react-rewrite #1
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { registerGracefulShutdown } from "./shutdown.js";
|
||||
import type { Logger } from "@/observability/logger/types.js";
|
||||
|
||||
function createMockLogger(): Logger & { flush: ReturnType<typeof vi.fn> } {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn(),
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
logger.child.mockReturnValue(logger);
|
||||
return logger as Logger & { flush: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function createMockServer() {
|
||||
return {
|
||||
close: vi.fn((cb: () => void) => cb()),
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerGracefulShutdown", () => {
|
||||
let processOnSpy: ReturnType<typeof vi.spyOn>;
|
||||
let processExitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
processOnSpy = vi.spyOn(process, "on");
|
||||
processExitSpy = vi
|
||||
.spyOn(process, "exit")
|
||||
.mockImplementation(() => undefined as never);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
processOnSpy.mockRestore();
|
||||
processExitSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("registers a SIGTERM handler", () => {
|
||||
const logger = createMockLogger();
|
||||
const server = createMockServer();
|
||||
|
||||
registerGracefulShutdown({ logger, server });
|
||||
|
||||
expect(processOnSpy).toHaveBeenCalledWith("SIGTERM", expect.any(Function));
|
||||
});
|
||||
|
||||
it("calls server.close on SIGTERM", async () => {
|
||||
const logger = createMockLogger();
|
||||
const server = createMockServer();
|
||||
|
||||
registerGracefulShutdown({ logger, server });
|
||||
|
||||
// Extract and call the SIGTERM handler
|
||||
const sigtermCall = processOnSpy.mock.calls.find(
|
||||
(call) => call[0] === "SIGTERM",
|
||||
);
|
||||
expect(sigtermCall).toBeDefined();
|
||||
const handler = sigtermCall![1] as () => void;
|
||||
handler();
|
||||
|
||||
// Let microtasks resolve
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(server.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flushes logger after server closes", async () => {
|
||||
const logger = createMockLogger();
|
||||
const server = createMockServer();
|
||||
|
||||
registerGracefulShutdown({ logger, server });
|
||||
|
||||
const sigtermCall = processOnSpy.mock.calls.find(
|
||||
(call) => call[0] === "SIGTERM",
|
||||
);
|
||||
const handler = sigtermCall![1] as () => void;
|
||||
handler();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(logger.flush).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exits with code 0 after successful drain", async () => {
|
||||
const logger = createMockLogger();
|
||||
const server = createMockServer();
|
||||
|
||||
registerGracefulShutdown({ logger, server });
|
||||
|
||||
const sigtermCall = processOnSpy.mock.calls.find(
|
||||
(call) => call[0] === "SIGTERM",
|
||||
);
|
||||
const handler = sigtermCall![1] as () => void;
|
||||
handler();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(processExitSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("force-exits with code 1 if drain times out", async () => {
|
||||
const logger = createMockLogger();
|
||||
const server = {
|
||||
close: vi.fn(), // never calls callback — simulates stuck connections
|
||||
};
|
||||
|
||||
registerGracefulShutdown({ logger, server, drainTimeoutMs: 1000 });
|
||||
|
||||
const sigtermCall = processOnSpy.mock.calls.find(
|
||||
(call) => call[0] === "SIGTERM",
|
||||
);
|
||||
const handler = sigtermCall![1] as () => void;
|
||||
handler();
|
||||
|
||||
// Advance past the drain timeout
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("logs shutdown lifecycle events", async () => {
|
||||
const logger = createMockLogger();
|
||||
const server = createMockServer();
|
||||
|
||||
registerGracefulShutdown({ logger, server });
|
||||
|
||||
const sigtermCall = processOnSpy.mock.calls.find(
|
||||
(call) => call[0] === "SIGTERM",
|
||||
);
|
||||
const handler = sigtermCall![1] as () => void;
|
||||
handler();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
"SIGTERM received, starting graceful shutdown",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
"Server closed, flushing logs",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses default drainTimeoutMs of 30000", () => {
|
||||
const logger = createMockLogger();
|
||||
const server = {
|
||||
close: vi.fn(), // never calls callback
|
||||
};
|
||||
|
||||
registerGracefulShutdown({ logger, server });
|
||||
|
||||
const sigtermCall = processOnSpy.mock.calls.find(
|
||||
(call) => call[0] === "SIGTERM",
|
||||
);
|
||||
const handler = sigtermCall![1] as () => void;
|
||||
handler();
|
||||
|
||||
// Shouldn't have exited yet at 29s
|
||||
vi.advanceTimersByTime(29_000);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Should force-exit at 30s
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Logger } from "@/observability/logger/types.js";
|
||||
|
||||
export interface GracefulShutdownOptions {
|
||||
logger: Logger & { flush: () => Promise<void> };
|
||||
server: { close: (cb: () => void) => void };
|
||||
drainTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_DRAIN_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Registers a SIGTERM handler that gracefully shuts down the server.
|
||||
*
|
||||
* On SIGTERM:
|
||||
* 1. Stops accepting new requests (server.close)
|
||||
* 2. Waits for in-flight requests to drain (up to drainTimeoutMs)
|
||||
* 3. Flushes the log buffer
|
||||
* 4. Exits with code 0
|
||||
*
|
||||
* If drain times out, force-exits with code 1.
|
||||
*
|
||||
* This is a factory function — it does NOT auto-register as middleware.
|
||||
* Registration happens in a future integration step.
|
||||
*/
|
||||
export function registerGracefulShutdown(
|
||||
options: GracefulShutdownOptions,
|
||||
): void {
|
||||
const { logger, server, drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS } =
|
||||
options;
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
logger.info("SIGTERM received, starting graceful shutdown", {
|
||||
drainTimeoutMs,
|
||||
});
|
||||
|
||||
let drained = false;
|
||||
|
||||
// Force-exit timer
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
if (!drained) {
|
||||
logger.error("Drain timeout exceeded, forcing exit", {
|
||||
drainTimeoutMs,
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
}, drainTimeoutMs);
|
||||
|
||||
// Unref so the timer doesn't keep the process alive if drain succeeds
|
||||
if (typeof forceExitTimer === "object" && "unref" in forceExitTimer) {
|
||||
forceExitTimer.unref();
|
||||
}
|
||||
|
||||
server.close(async () => {
|
||||
drained = true;
|
||||
clearTimeout(forceExitTimer);
|
||||
|
||||
logger.info("Server closed, flushing logs", undefined);
|
||||
|
||||
try {
|
||||
await logger.flush();
|
||||
} catch {
|
||||
// Best-effort flush — don't block shutdown
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user