Skip to content

Commit a6359d2

Browse files
authored
fix(shutdown): add phase tracking and pool-close timeout test (#116) (#150)
- Add ShutdownPhase type and currentPhase tracking for precise timeout logging - Log current phase when drain timeout fires (server_close / pool_close) - Add test covering pool-close-hangs-within-drain-timeout scenario - Change timeout log from console.error to console.warn per acceptance criteria
1 parent 4c6c056 commit a6359d2

2 files changed

Lines changed: 51 additions & 1 deletion

File tree

src/shutdown.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,49 @@ describe("Graceful Shutdown", () => {
9090
expect(processExitSpy).toHaveBeenCalledWith(1);
9191
});
9292

93+
it("should force exit if pool close hangs within drain timeout", async () => {
94+
// Pool close hangs (never resolves)
95+
let poolCloseNeverResolve: () => void;
96+
const hangingPoolPromise = new Promise<void>((resolve) => {
97+
poolCloseNeverResolve = resolve;
98+
});
99+
mockClosePool.mockReturnValue(hangingPoolPromise);
100+
101+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
102+
103+
setupGracefulShutdown(mockServer as unknown as Server, mockClosePool, 10000);
104+
105+
const sigtermHandlerCall = processOnSpy.mock.calls.find((call: any) => call[0] === "SIGTERM");
106+
const handler = sigtermHandlerCall[1];
107+
108+
handler("SIGTERM");
109+
110+
expect(mockServer.close).toHaveBeenCalled();
111+
112+
// Call the server close callback without awaiting — it will execute
113+
// synchronously up to the pending `await closePool()` and yield.
114+
mockServer._closeCallback();
115+
116+
expect(mockClosePool).toHaveBeenCalled();
117+
118+
// Fast-forward past the drain timeout
119+
vi.advanceTimersByTime(10001);
120+
121+
// Should force exit with 1 because pool close hung and timeout fired
122+
expect(processExitSpy).toHaveBeenCalledWith(1);
123+
124+
// Verify the timeout warning mentions the pool_close phase
125+
const timeoutCall = warnSpy.mock.calls.find((call: any) =>
126+
call[0].includes("Drain timeout"),
127+
);
128+
expect(timeoutCall).toBeDefined();
129+
expect(timeoutCall[0]).toMatch(/pool_close/);
130+
131+
warnSpy.mockRestore();
132+
// Resolve the hanging promise to clean up
133+
poolCloseNeverResolve();
134+
});
135+
93136
it("should handle error during pool close", async () => {
94137
mockClosePool.mockRejectedValue(new Error("Pool close error"));
95138

src/shutdown.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ import { Server } from "http";
1010
* @param closePool - A function to close the database connection pool
1111
* @param drainTimeoutMs - The bounded timeout in milliseconds to wait for connections to drain
1212
*/
13+
type ShutdownPhase = "starting" | "server_close" | "pool_close";
14+
1315
export function setupGracefulShutdown(
1416
server: Server,
1517
closePool: () => Promise<void>,
1618
drainTimeoutMs: number,
1719
): void {
1820
let isShuttingDown = false;
21+
let currentPhase: ShutdownPhase = "starting";
1922

2023
const shutdownHandler = async (signal: string) => {
2124
if (isShuttingDown) {
@@ -27,11 +30,14 @@ export function setupGracefulShutdown(
2730

2831
// Create a bounded drain timeout
2932
const timeout = setTimeout(() => {
30-
console.error(`[shutdown] Drain timeout (${drainTimeoutMs}ms) exceeded, forcing exit`);
33+
console.warn(
34+
`[shutdown] Drain timeout (${drainTimeoutMs}ms) exceeded during ${currentPhase}, forcing exit`,
35+
);
3136
process.exit(1);
3237
}, drainTimeoutMs);
3338
timeout.unref();
3439

40+
currentPhase = "server_close";
3541
console.log("[shutdown] Stopping HTTP server from accepting new connections...");
3642
server.close(async (err) => {
3743
if (err) {
@@ -40,6 +46,7 @@ export function setupGracefulShutdown(
4046
console.log("[shutdown] HTTP server closed");
4147
}
4248

49+
currentPhase = "pool_close";
4350
try {
4451
await closePool();
4552
clearTimeout(timeout);

0 commit comments

Comments
 (0)