Files
2026-09-16 01:47:28 -07:00

95 lines
3.2 KiB
TypeScript

import Fastify from 'fastify';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { drainWithTimeout, registerGracefulShutdown } from '../src/shutdown.js';
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
describe('drainWithTimeout', () => {
afterEach(() => {
vi.useRealTimers();
});
it('resolves "drained" when the work settles first and clears its timer', async () => {
vi.useFakeTimers();
const work = deferred();
const outcome = drainWithTimeout(work.promise, 30_000);
work.resolve();
await expect(outcome).resolves.toBe('drained');
expect(vi.getTimerCount()).toBe(0);
});
it('resolves "timeout" when the deadline wins', async () => {
vi.useFakeTimers();
const never = new Promise<void>(() => undefined);
const outcome = drainWithTimeout(never, 30_000);
await vi.advanceTimersByTimeAsync(30_000);
await expect(outcome).resolves.toBe('timeout');
});
});
describe('registerGracefulShutdown', () => {
it('on signal: stops the poller, drains the runner inside app.close(), and exits 0', async () => {
const app = Fastify({ logger: false });
const drain = deferred();
const stop = deferred();
const runner = { drain: vi.fn(() => drain.promise) };
const poller = { stop: vi.fn(() => stop.promise) };
const exited = deferred<number>();
const shutdown = registerGracefulShutdown({ app, poller, runner, timeoutMs: 5000, exit: exited.resolve });
await app.ready();
shutdown.onSignal('SIGTERM');
// Wait for the onClose hook to actually start the drain.
await vi.waitFor(() => expect(runner.drain).toHaveBeenCalledTimes(1));
expect(poller.stop).toHaveBeenCalledTimes(1);
// Exit must wait for BOTH the runner drain and the in-flight poll tick.
let exitCode: number | undefined;
void exited.promise.then((code) => {
exitCode = code;
});
drain.resolve();
await new Promise((r) => setImmediate(r));
expect(exitCode).toBeUndefined();
stop.resolve();
expect(await exited.promise).toBe(0);
expect(shutdown.timedOut).toBe(false);
});
it('exits 1 when in-flight work does not settle within the timeout', async () => {
const app = Fastify({ logger: false });
const runner = { drain: () => new Promise<void>(() => undefined) }; // never settles
const poller = { stop: () => Promise.resolve() };
const exited = deferred<number>();
const shutdown = registerGracefulShutdown({ app, poller, runner, timeoutMs: 50, exit: exited.resolve });
await app.ready();
shutdown.onSignal('SIGINT');
expect(await exited.promise).toBe(1);
expect(shutdown.timedOut).toBe(true);
});
it('exits 1 when close() itself rejects', async () => {
const app = Fastify({ logger: false });
const exited = deferred<number>();
const shutdown = registerGracefulShutdown({
app,
poller: { stop: () => Promise.resolve() },
runner: { drain: () => Promise.reject(new Error('drain exploded')) },
timeoutMs: 5000,
exit: exited.resolve,
});
await app.ready();
shutdown.onSignal('SIGTERM');
expect(await exited.promise).toBe(1);
});
});