plan/react-rewrite #1

Merged
gnezim merged 138 commits from plan/react-rewrite into main 2026-04-15 12:21:16 +03:00
2 changed files with 239 additions and 0 deletions
Showing only changes of commit ca6ae0eea2 - Show all commits
+171
View File
@@ -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);
});
});
+68
View File
@@ -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);
});
});
}