import { beforeEach, describe, expect, it, vi } from "vitest" ;
const mocks = {
logWarn: vi.fn(),
disposeAgentHarnesses: vi.fn(async () => undefined),
};
const WEBSOCKET_CLOSE_GRACE_MS = 1 _000 ;
const WEBSOCKET_CLOSE_FORCE_CONTINUE_MS = 250 ;
const HTTP_CLOSE_GRACE_MS = 1 _000 ;
const HTTP_CLOSE_FORCE_WAIT_MS = 5 _000 ;
vi.mock("../channels/plugins/index.js" , async () => ({
...(await vi.importActual<typeof import ("../channels/plugins/index.js" )>(
"../channels/plugins/index.js" ,
)),
listChannelPlugins: () => [],
}));
vi.mock("../hooks/gmail-watcher.js" , () => ({
stopGmailWatcher: vi.fn(async () => undefined),
}));
vi.mock("../agents/harness/registry.js" , () => ({
disposeRegisteredAgentHarnesses: mocks.disposeAgentHarnesses,
}));
vi.mock("../logging/subsystem.js" , () => ({
createSubsystemLogger: vi.fn(() => ({
warn: mocks.logWarn,
})),
}));
const { createGatewayCloseHandler } = await import ("./server-close.js" );
type GatewayCloseHandlerParams = Parameters<typeof createGatewayCloseHandler>[0 ];
type GatewayCloseClient = GatewayCloseHandlerParams["clients" ] extends Set<infer T> ? T : never;
function createGatewayCloseTestDeps(
overrides: Partial<GatewayCloseHandlerParams> = {},
): GatewayCloseHandlerParams {
return {
bonjourStop: null ,
tailscaleCleanup: null ,
canvasHost: null ,
canvasHostServer: null ,
stopChannel: vi.fn(async () => undefined),
pluginServices: null ,
cron: { stop: vi.fn() },
heartbeatRunner: { stop: vi.fn() } as never,
updateCheckStop: null ,
stopTaskRegistryMaintenance: null ,
nodePresenceTimers: new Map(),
broadcast: vi.fn(),
tickInterval: setInterval(() => undefined, 60 _000 ),
healthInterval: setInterval(() => undefined, 60 _000 ),
dedupeCleanup: setInterval(() => undefined, 60 _000 ),
mediaCleanup: null ,
agentUnsub: null ,
heartbeatUnsub: null ,
transcriptUnsub: null ,
lifecycleUnsub: null ,
chatRunState: { clear: vi.fn() },
clients: new Set<GatewayCloseClient>(),
configReloader: { stop: vi.fn(async () => undefined) },
wss: {
clients: new Set(),
close: (cb: () => void ) => cb(),
} as never,
httpServer: {
close: (cb: (err?: Error | null ) => void ) => cb(null ),
closeIdleConnections: vi.fn(),
} as never,
...overrides,
};
}
describe("createGatewayCloseHandler" , () => {
beforeEach(() => {
vi.useRealTimers();
mocks.logWarn.mockClear();
mocks.disposeAgentHarnesses.mockClear();
});
it("unsubscribes lifecycle listeners during shutdown" , async () => {
const lifecycleUnsub = vi.fn();
const stopTaskRegistryMaintenance = vi.fn();
const close = createGatewayCloseHandler(
createGatewayCloseTestDeps({
stopTaskRegistryMaintenance,
lifecycleUnsub,
}),
);
await close({ reason: "test shutdown" });
expect(lifecycleUnsub).toHaveBeenCalledTimes(1 );
expect(stopTaskRegistryMaintenance).toHaveBeenCalledTimes(1 );
expect(mocks.disposeAgentHarnesses).toHaveBeenCalledTimes(1 );
});
it("terminates lingering websocket clients when websocket close exceeds the grace window" , async () => {
vi.useFakeTimers();
let closeCallback: (() => void ) | null = null ;
const terminate = vi.fn(() => {
closeCallback?.();
});
const close = createGatewayCloseHandler(
createGatewayCloseTestDeps({
wss: {
clients: new Set([{ terminate }]),
close: (cb: () => void ) => {
closeCallback = cb;
},
} as never,
}),
);
const closePromise = close({ reason: "test shutdown" });
await vi.advanceTimersByTimeAsync(WEBSOCKET_CLOSE_GRACE_MS);
await closePromise;
expect(terminate).toHaveBeenCalledTimes(1 );
expect(vi.getTimerCount()).toBe(0 );
expect(
mocks.logWarn.mock.calls.some(([message]) =>
String(message).includes("websocket server close exceeded 1000ms" ),
),
).toBe(true );
});
it("continues shutdown when websocket close hangs without tracked clients" , async () => {
vi.useFakeTimers();
const close = createGatewayCloseHandler(
createGatewayCloseTestDeps({
wss: {
clients: new Set(),
close: () => undefined,
} as never,
}),
);
const closePromise = close({ reason: "test shutdown" });
await vi.advanceTimersByTimeAsync(WEBSOCKET_CLOSE_GRACE_MS + WEBSOCKET_CLOSE_FORCE_CONTINUE_MS);
await closePromise;
expect(vi.getTimerCount()).toBe(0 );
expect(
mocks.logWarn.mock.calls.some(([message]) =>
String(message).includes("websocket server close still pending after 250ms force window" ),
),
).toBe(true );
});
it("forces lingering HTTP connections closed when server close exceeds the grace window" , async () => {
vi.useFakeTimers();
let closeCallback: ((err?: Error | null ) => void ) | null = null ;
const closeAllConnections = vi.fn(() => {
closeCallback?.(null );
});
const close = createGatewayCloseHandler(
createGatewayCloseTestDeps({
httpServer: {
close: (cb: (err?: Error | null ) => void ) => {
closeCallback = cb;
},
closeAllConnections,
closeIdleConnections: vi.fn(),
} as never,
}),
);
const closePromise = close({ reason: "test shutdown" });
await vi.advanceTimersByTimeAsync(HTTP_CLOSE_GRACE_MS);
await closePromise;
expect(closeAllConnections).toHaveBeenCalledTimes(1 );
expect(vi.getTimerCount()).toBe(0 );
expect(
mocks.logWarn.mock.calls.some(([message]) =>
String(message).includes("http server close exceeded 1000ms" ),
),
).toBe(true );
});
it("fails shutdown when http server close still hangs after force close" , async () => {
vi.useFakeTimers();
const close = createGatewayCloseHandler(
createGatewayCloseTestDeps({
httpServer: {
close: () => undefined,
closeAllConnections: vi.fn(),
closeIdleConnections: vi.fn(),
} as never,
}),
);
const closePromise = close({ reason: "test shutdown" });
const closeExpectation = expect(closePromise).rejects.toThrow(
"http server close still pending after forced connection shutdown (5000ms)" ,
);
await vi.advanceTimersByTimeAsync(HTTP_CLOSE_GRACE_MS + HTTP_CLOSE_FORCE_WAIT_MS);
await closeExpectation;
expect(vi.getTimerCount()).toBe(0 );
});
it("ignores unbound http servers during shutdown" , async () => {
const close = createGatewayCloseHandler({
bonjourStop: null ,
tailscaleCleanup: null ,
canvasHost: null ,
canvasHostServer: null ,
stopChannel: vi.fn(async () => undefined),
pluginServices: null ,
cron: { stop: vi.fn() },
heartbeatRunner: { stop: vi.fn() } as never,
updateCheckStop: null ,
nodePresenceTimers: new Map(),
broadcast: vi.fn(),
tickInterval: setInterval(() => undefined, 60 _000 ),
healthInterval: setInterval(() => undefined, 60 _000 ),
dedupeCleanup: setInterval(() => undefined, 60 _000 ),
mediaCleanup: null ,
agentUnsub: null ,
heartbeatUnsub: null ,
transcriptUnsub: null ,
lifecycleUnsub: null ,
chatRunState: { clear: vi.fn() },
clients: new Set(),
configReloader: { stop: vi.fn(async () => undefined) },
wss: { close: (cb: () => void ) => cb() } as never,
httpServer: {
close: (cb: (err?: NodeJS.ErrnoException | null ) => void ) =>
cb(
Object.assign(new Error("Server is not running." ), { code: "ERR_SERVER_NOT_RUNNING" }),
),
closeIdleConnections: vi.fn(),
} as never,
});
await expect(close({ reason: "startup failed before bind" })).resolves.toBeUndefined();
});
});
Messung V0.5 in Prozent C=100 H=96 G=97
¤ Dauer der Verarbeitung: 0.14 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland