@@ -20,6 +20,7 @@ import type { ServerConfig } from "../config.js";
2020import type { SessionManager } from "../session/manager.js" ;
2121import type { TerminalManager } from "../terminal/manager.js" ;
2222import type { WorkspaceManager } from "../workspace/manager.js" ;
23+ import { ActivationManager } from "../ws/activation.js" ;
2324import type { CommandContext } from "../ws/dispatch.js" ;
2425import type { FencingManager } from "../ws/fencing.js" ;
2526import { WsHub } from "../ws/hub.js" ;
@@ -59,7 +60,17 @@ const createMockRequest = (): FastifyRequest =>
5960 headers : { "user-agent" : "test-agent" } ,
6061 } ) as unknown as FastifyRequest ;
6162
62- const createCommandContext = ( eventBus : EventBus ) : CommandContext =>
63+ const createActivationManager = ( ) =>
64+ new ActivationManager ( {
65+ heartbeatMs : 10_000 ,
66+ leaseExpirationMs : 30_000 ,
67+ graceMs : 3_000 ,
68+ } ) ;
69+
70+ const createCommandContext = (
71+ eventBus : EventBus ,
72+ overrides : Partial < CommandContext > = { }
73+ ) : CommandContext =>
6374 ( {
6475 workspaceMgr : { } ,
6576 sessionMgr : { } ,
@@ -72,13 +83,8 @@ const createCommandContext = (eventBus: EventBus): CommandContext =>
7283 registerViewer : vi . fn ( ) ,
7384 unregisterViewer : vi . fn ( ) ,
7485 } ,
75- activationMgr : {
76- getLease : vi . fn ( ( ) => null ) ,
77- heartbeat : vi . fn ( ( ) => false ) ,
78- release : vi . fn ( ) ,
79- onSocketClosed : vi . fn ( ) ,
80- claim : vi . fn ( ) ,
81- } ,
86+ activationMgr : createActivationManager ( ) ,
87+ ...overrides ,
8288 } ) as unknown as CommandContext ;
8389
8490const createHub = ( eventBus : EventBus , commandContext : CommandContext ) : WsHub =>
@@ -132,6 +138,12 @@ const findResultMessage = (socket: MockSocket, id: string): ResultMessage | unde
132138 ( message ) : message is ResultMessage => message . kind === "result" && message . id === id
133139 ) ;
134140
141+ const getConnectedClientIds = ( sockets : MockSocket [ ] ) : string [ ] =>
142+ sockets . map ( ( socket ) => {
143+ const connected = parseSentEvents ( socket ) [ 0 ] ;
144+ return ( connected as Extract < ServerToClient , { kind : "event" } > ) . data . clientId as string ;
145+ } ) ;
146+
135147describe ( "WsHub" , ( ) => {
136148 let hub : WsHub ;
137149 let eventBus : EventBus ;
@@ -217,6 +229,31 @@ describe("WsHub", () => {
217229 expect ( socket . send ) . toHaveBeenCalledTimes ( 1 ) ;
218230 } ) ;
219231
232+ it ( "sends activation.revoked and closes the displaced client" , ( ) => {
233+ const activationMgr = createActivationManager ( ) ;
234+ mockCommandContext = createCommandContext ( eventBus , { activationMgr } ) ;
235+ hub . destroy ( ) ;
236+ hub = createHub ( eventBus , mockCommandContext ) ;
237+
238+ const socketA = createMockSocket ( ) ;
239+ const socketB = createMockSocket ( ) ;
240+ hub . handleConnection ( socketA as never , createMockRequest ( ) ) ;
241+ hub . handleConnection ( socketB as never , createMockRequest ( ) ) ;
242+
243+ const [ clientA , clientB ] = getConnectedClientIds ( [ socketA , socketB ] ) ;
244+ activationMgr . claim ( "client-a" , clientA , createMockRequest ( ) ) ;
245+
246+ const claim = activationMgr . claim ( "client-b" , clientB , createMockRequest ( ) ) ;
247+ expect ( claim . displacedWsClientId ) . toBe ( clientA ) ;
248+
249+ hub . revokeAndCloseClient ( clientA , claim . generation ) ;
250+
251+ expect ( socketA . send ) . toHaveBeenCalledWith (
252+ expect . stringContaining ( '"topic":"activation.revoked"' )
253+ ) ;
254+ expect ( socketA . close ) . toHaveBeenCalledWith ( 4001 , "single_active_displaced" ) ;
255+ } ) ;
256+
220257 it ( "should handle domain events" , ( ) => {
221258 const socket = createMockSocket ( ) ;
222259 hub . handleConnection ( socket as never , createMockRequest ( ) ) ;
0 commit comments