1- import { describe , expect , it , vi } from "vitest" ;
2- import { createGatewayCloseHandler } from "./server-close.js" ;
1+ import { beforeEach , describe , expect , it , vi } from "vitest" ;
2+
3+ const mocks = {
4+ logWarn : vi . fn ( ) ,
5+ } ;
6+ const WEBSOCKET_CLOSE_GRACE_MS = 1_000 ;
7+ const WEBSOCKET_CLOSE_FORCE_CONTINUE_MS = 250 ;
38
49vi . mock ( "../channels/plugins/index.js" , ( ) => ( {
510 listChannelPlugins : ( ) => [ ] ,
@@ -9,7 +14,20 @@ vi.mock("../hooks/gmail-watcher.js", () => ({
914 stopGmailWatcher : vi . fn ( async ( ) => undefined ) ,
1015} ) ) ;
1116
17+ vi . mock ( "../logging/subsystem.js" , ( ) => ( {
18+ createSubsystemLogger : vi . fn ( ( ) => ( {
19+ warn : mocks . logWarn ,
20+ } ) ) ,
21+ } ) ) ;
22+
23+ const { createGatewayCloseHandler } = await import ( "./server-close.js" ) ;
24+
1225describe ( "createGatewayCloseHandler" , ( ) => {
26+ beforeEach ( ( ) => {
27+ vi . useRealTimers ( ) ;
28+ mocks . logWarn . mockClear ( ) ;
29+ } ) ;
30+
1331 it ( "unsubscribes lifecycle listeners during shutdown" , async ( ) => {
1432 const lifecycleUnsub = vi . fn ( ) ;
1533 const stopTaskRegistryMaintenance = vi . fn ( ) ;
@@ -49,4 +67,107 @@ describe("createGatewayCloseHandler", () => {
4967 expect ( lifecycleUnsub ) . toHaveBeenCalledTimes ( 1 ) ;
5068 expect ( stopTaskRegistryMaintenance ) . toHaveBeenCalledTimes ( 1 ) ;
5169 } ) ;
70+
71+ it ( "terminates lingering websocket clients when websocket close exceeds the grace window" , async ( ) => {
72+ vi . useFakeTimers ( ) ;
73+
74+ let closeCallback : ( ( ) => void ) | null = null ;
75+ const terminate = vi . fn ( ( ) => {
76+ closeCallback ?.( ) ;
77+ } ) ;
78+ const close = createGatewayCloseHandler ( {
79+ bonjourStop : null ,
80+ tailscaleCleanup : null ,
81+ canvasHost : null ,
82+ canvasHostServer : null ,
83+ stopChannel : vi . fn ( async ( ) => undefined ) ,
84+ pluginServices : null ,
85+ cron : { stop : vi . fn ( ) } ,
86+ heartbeatRunner : { stop : vi . fn ( ) } as never ,
87+ updateCheckStop : null ,
88+ stopTaskRegistryMaintenance : null ,
89+ nodePresenceTimers : new Map ( ) ,
90+ broadcast : vi . fn ( ) ,
91+ tickInterval : setInterval ( ( ) => undefined , 60_000 ) ,
92+ healthInterval : setInterval ( ( ) => undefined , 60_000 ) ,
93+ dedupeCleanup : setInterval ( ( ) => undefined , 60_000 ) ,
94+ mediaCleanup : null ,
95+ agentUnsub : null ,
96+ heartbeatUnsub : null ,
97+ transcriptUnsub : null ,
98+ lifecycleUnsub : null ,
99+ chatRunState : { clear : vi . fn ( ) } ,
100+ clients : new Set ( ) ,
101+ configReloader : { stop : vi . fn ( async ( ) => undefined ) } ,
102+ wss : {
103+ clients : new Set ( [ { terminate } ] ) ,
104+ close : ( cb : ( ) => void ) => {
105+ closeCallback = cb ;
106+ } ,
107+ } as never ,
108+ httpServer : {
109+ close : ( cb : ( err ?: Error | null ) => void ) => cb ( null ) ,
110+ closeIdleConnections : vi . fn ( ) ,
111+ } as never ,
112+ } ) ;
113+
114+ const closePromise = close ( { reason : "test shutdown" } ) ;
115+ await vi . advanceTimersByTimeAsync ( WEBSOCKET_CLOSE_GRACE_MS ) ;
116+ await closePromise ;
117+
118+ expect ( terminate ) . toHaveBeenCalledTimes ( 1 ) ;
119+ expect (
120+ mocks . logWarn . mock . calls . some ( ( [ message ] ) =>
121+ String ( message ) . includes ( "websocket server close exceeded 1000ms" ) ,
122+ ) ,
123+ ) . toBe ( true ) ;
124+ } ) ;
125+
126+ it ( "continues shutdown when websocket close hangs without tracked clients" , async ( ) => {
127+ vi . useFakeTimers ( ) ;
128+
129+ const close = createGatewayCloseHandler ( {
130+ bonjourStop : null ,
131+ tailscaleCleanup : null ,
132+ canvasHost : null ,
133+ canvasHostServer : null ,
134+ stopChannel : vi . fn ( async ( ) => undefined ) ,
135+ pluginServices : null ,
136+ cron : { stop : vi . fn ( ) } ,
137+ heartbeatRunner : { stop : vi . fn ( ) } as never ,
138+ updateCheckStop : null ,
139+ stopTaskRegistryMaintenance : null ,
140+ nodePresenceTimers : new Map ( ) ,
141+ broadcast : vi . fn ( ) ,
142+ tickInterval : setInterval ( ( ) => undefined , 60_000 ) ,
143+ healthInterval : setInterval ( ( ) => undefined , 60_000 ) ,
144+ dedupeCleanup : setInterval ( ( ) => undefined , 60_000 ) ,
145+ mediaCleanup : null ,
146+ agentUnsub : null ,
147+ heartbeatUnsub : null ,
148+ transcriptUnsub : null ,
149+ lifecycleUnsub : null ,
150+ chatRunState : { clear : vi . fn ( ) } ,
151+ clients : new Set ( ) ,
152+ configReloader : { stop : vi . fn ( async ( ) => undefined ) } ,
153+ wss : {
154+ clients : new Set ( ) ,
155+ close : ( ) => undefined ,
156+ } as never ,
157+ httpServer : {
158+ close : ( cb : ( err ?: Error | null ) => void ) => cb ( null ) ,
159+ closeIdleConnections : vi . fn ( ) ,
160+ } as never ,
161+ } ) ;
162+
163+ const closePromise = close ( { reason : "test shutdown" } ) ;
164+ await vi . advanceTimersByTimeAsync ( WEBSOCKET_CLOSE_GRACE_MS + WEBSOCKET_CLOSE_FORCE_CONTINUE_MS ) ;
165+ await closePromise ;
166+
167+ expect (
168+ mocks . logWarn . mock . calls . some ( ( [ message ] ) =>
169+ String ( message ) . includes ( "websocket server close still pending after 250ms force window" ) ,
170+ ) ,
171+ ) . toBe ( true ) ;
172+ } ) ;
52173} ) ;
0 commit comments