diff --git a/src/server/shutdown.test.ts b/src/server/shutdown.test.ts new file mode 100644 index 00000000..7d4fe843 --- /dev/null +++ b/src/server/shutdown.test.ts @@ -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 } { + 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 }; +} + +function createMockServer() { + return { + close: vi.fn((cb: () => void) => cb()), + }; +} + +describe("registerGracefulShutdown", () => { + let processOnSpy: ReturnType; + let processExitSpy: ReturnType; + + 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); + }); +}); diff --git a/src/server/shutdown.ts b/src/server/shutdown.ts new file mode 100644 index 00000000..255a1deb --- /dev/null +++ b/src/server/shutdown.ts @@ -0,0 +1,68 @@ +import type { Logger } from "@/observability/logger/types.js"; + +export interface GracefulShutdownOptions { + logger: Logger & { flush: () => Promise }; + 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); + }); + }); +}