@@ -104,6 +104,134 @@ describe("activation commands", () => {
104104 } ) ;
105105 } ) ;
106106
107+ it ( "rejects non-activation websocket commands when metadata lookup returns undefined" , async ( ) => {
108+ const broadcaster = {
109+ broadcast : vi . fn ( ) ,
110+ sendToClient : vi . fn ( ( ) => true ) ,
111+ sendBinaryToClient : vi . fn ( ( ) => true ) ,
112+ getRequestMetadata : vi . fn ( ( ) => undefined ) ,
113+ } satisfies Broadcaster ;
114+ const ctx = createBaseContext ( { broadcaster } ) ;
115+
116+ const result = await dispatch (
117+ {
118+ kind : "command" ,
119+ id : "00000000-0000-4000-8000-000000000004" ,
120+ op : "workspace.list" ,
121+ args : { } ,
122+ } ,
123+ ctx ,
124+ "ws-a"
125+ ) ;
126+
127+ expect ( result . ok ) . toBe ( false ) ;
128+ expect ( result . error ) . toEqual ( {
129+ code : "activation_required" ,
130+ message : "This tab is no longer the active session" ,
131+ } ) ;
132+ } ) ;
133+
134+ it ( "does not allow a stale websocket to heartbeat after same-client rebind" , async ( ) => {
135+ const request = createMockRequest ( ) ;
136+ const broadcaster = {
137+ broadcast : vi . fn ( ) ,
138+ sendToClient : vi . fn ( ( ) => true ) ,
139+ sendBinaryToClient : vi . fn ( ( ) => true ) ,
140+ getRequestMetadata : vi . fn ( ( ) => request ) ,
141+ } satisfies Broadcaster ;
142+ const ctx = createBaseContext ( { broadcaster } ) ;
143+
144+ await dispatch (
145+ {
146+ kind : "command" ,
147+ id : "claim-1" ,
148+ op : "activation.claim" ,
149+ args : { clientInstanceId : "client-a" } ,
150+ } ,
151+ ctx ,
152+ "ws-a"
153+ ) ;
154+
155+ const rebound = await dispatch (
156+ {
157+ kind : "command" ,
158+ id : "claim-2" ,
159+ op : "activation.claim" ,
160+ args : { clientInstanceId : "client-a" } ,
161+ } ,
162+ ctx ,
163+ "ws-b"
164+ ) ;
165+
166+ expect ( rebound . ok ) . toBe ( true ) ;
167+
168+ const heartbeat = await dispatch (
169+ {
170+ kind : "command" ,
171+ id : "heartbeat-stale" ,
172+ op : "activation.heartbeat" ,
173+ args : { clientInstanceId : "client-a" , generation : 1 } ,
174+ } ,
175+ ctx ,
176+ "ws-a"
177+ ) ;
178+
179+ expect ( heartbeat . ok ) . toBe ( true ) ;
180+ expect ( heartbeat . data ) . toEqual ( { ok : false } ) ;
181+ } ) ;
182+
183+ it ( "does not allow a stale websocket to release after same-client rebind" , async ( ) => {
184+ const request = createMockRequest ( ) ;
185+ const broadcaster = {
186+ broadcast : vi . fn ( ) ,
187+ sendToClient : vi . fn ( ( ) => true ) ,
188+ sendBinaryToClient : vi . fn ( ( ) => true ) ,
189+ getRequestMetadata : vi . fn ( ( ) => request ) ,
190+ } satisfies Broadcaster ;
191+ const ctx = createBaseContext ( { broadcaster } ) ;
192+
193+ await dispatch (
194+ {
195+ kind : "command" ,
196+ id : "claim-3" ,
197+ op : "activation.claim" ,
198+ args : { clientInstanceId : "client-a" } ,
199+ } ,
200+ ctx ,
201+ "ws-a"
202+ ) ;
203+
204+ await dispatch (
205+ {
206+ kind : "command" ,
207+ id : "claim-4" ,
208+ op : "activation.claim" ,
209+ args : { clientInstanceId : "client-a" } ,
210+ } ,
211+ ctx ,
212+ "ws-b"
213+ ) ;
214+
215+ const release = await dispatch (
216+ {
217+ kind : "command" ,
218+ id : "release-stale" ,
219+ op : "activation.release" ,
220+ args : { clientInstanceId : "client-a" , generation : 1 } ,
221+ } ,
222+ ctx ,
223+ "ws-a"
224+ ) ;
225+
226+ expect ( release . ok ) . toBe ( true ) ;
227+ expect ( release . data ) . toEqual ( { ok : false } ) ;
228+ expect ( ctx . activationMgr . getLease ( ) ) . toMatchObject ( {
229+ clientInstanceId : "client-a" ,
230+ wsClientId : "ws-b" ,
231+ generation : 1 ,
232+ } ) ;
233+ } ) ;
234+
107235 it ( "does not block direct internal dispatches without websocket request metadata" , async ( ) => {
108236 const ctx = createBaseContext ( ) ;
109237
0 commit comments