Skip to content

Commit 611fa3f

Browse files
committed
fix(server): sequence activation revoke before close
1 parent 5269c46 commit 611fa3f

3 files changed

Lines changed: 125 additions & 9 deletions

File tree

packages/server/src/__tests__/ws-hub.test.ts

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ const TEST_CONFIG: Pick<ServerConfig, "auth" | "appVersion"> = {
4747

4848
const createMockSocket = (): MockSocket => ({
4949
readyState: WebSocket.OPEN,
50-
send: vi.fn(),
50+
send: vi.fn((...args: unknown[]) => {
51+
const callback = args.find((arg) => typeof arg === "function") as
52+
| ((error?: Error) => void)
53+
| undefined;
54+
callback?.();
55+
}),
5156
ping: vi.fn(),
5257
close: vi.fn(),
5358
on: vi.fn(),
@@ -246,14 +251,88 @@ describe("WsHub", () => {
246251
const claim = activationMgr.claim("client-b", clientB, createMockRequest());
247252
expect(claim.displacedWsClientId).toBe(clientA);
248253

254+
socketA.send.mockClear();
249255
hub.revokeAndCloseClient(clientA, claim.generation);
250256

251-
expect(socketA.send).toHaveBeenCalledWith(
252-
expect.stringContaining('"topic":"activation.revoked"')
253-
);
257+
expect(parseSentEvents(socketA)).toEqual([
258+
expect.objectContaining({
259+
kind: "event",
260+
topic: "activation.revoked",
261+
data: {
262+
reason: "displaced",
263+
generation: claim.generation,
264+
},
265+
}),
266+
]);
254267
expect(socketA.close).toHaveBeenCalledWith(4001, "single_active_displaced");
255268
});
256269

270+
it("no-ops when revoking a client that is already disconnected", () => {
271+
const socket = createMockSocket();
272+
hub.handleConnection(socket as never, createMockRequest());
273+
socket.send.mockClear();
274+
275+
hub.revokeAndCloseClient("missing-client", 7);
276+
277+
expect(socket.send).not.toHaveBeenCalled();
278+
expect(socket.close).not.toHaveBeenCalled();
279+
});
280+
281+
it("cleans up activation and request metadata after a revoked client closes", () => {
282+
const activationMgr = createActivationManager();
283+
const onSocketClosedSpy = vi.spyOn(activationMgr, "onSocketClosed");
284+
mockCommandContext = createCommandContext(eventBus, { activationMgr });
285+
hub.destroy();
286+
hub = createHub(eventBus, mockCommandContext);
287+
288+
const socketA = createMockSocket();
289+
const socketB = createMockSocket();
290+
hub.handleConnection(socketA as never, createMockRequest());
291+
hub.handleConnection(socketB as never, createMockRequest());
292+
293+
const [clientA, clientB] = getConnectedClientIds([socketA, socketB]);
294+
activationMgr.claim("client-a", clientA, createMockRequest());
295+
const claim = activationMgr.claim("client-b", clientB, createMockRequest());
296+
const closeHandler = getCloseHandler(socketA);
297+
298+
expect(hub.getRequestMetadata(clientA)).toBeDefined();
299+
300+
hub.revokeAndCloseClient(clientA, claim.generation);
301+
closeHandler?.();
302+
303+
expect(onSocketClosedSpy).toHaveBeenCalledWith(clientA);
304+
expect(hub.getRequestMetadata(clientA)).toBeUndefined();
305+
});
306+
307+
it("sends activation.revoked before starting the close handshake", () => {
308+
let sendCallback: ((error?: Error) => void) | undefined;
309+
const socket = createMockSocket();
310+
socket.send = vi.fn((...args: unknown[]) => {
311+
sendCallback = args.find((arg) => typeof arg === "function") as
312+
| ((error?: Error) => void)
313+
| undefined;
314+
});
315+
hub.handleConnection(socket as never, createMockRequest());
316+
317+
const [clientId] = getConnectedClientIds([socket]);
318+
socket.send.mockClear();
319+
320+
hub.revokeAndCloseClient(clientId, 9);
321+
322+
expect(parseSentEvents(socket)).toEqual([
323+
expect.objectContaining({
324+
kind: "event",
325+
topic: "activation.revoked",
326+
data: { reason: "displaced", generation: 9 },
327+
}),
328+
]);
329+
expect(socket.close).not.toHaveBeenCalled();
330+
331+
sendCallback?.();
332+
333+
expect(socket.close).toHaveBeenCalledWith(4001, "single_active_displaced");
334+
});
335+
257336
it("should handle domain events", () => {
258337
const socket = createMockSocket();
259338
hub.handleConnection(socket as never, createMockRequest());

packages/server/src/ws/client.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,22 @@ export class WsClient {
128128
}
129129
}
130130

131+
sendControlAndClose(msg: ServerToClient, code: number, reason: string): boolean {
132+
if (this.socket.readyState !== WebSocket.OPEN) {
133+
return false;
134+
}
135+
136+
try {
137+
this.socket.send(JSON.stringify(msg), () => {
138+
this.socket.close(code, reason);
139+
});
140+
return true;
141+
} catch (error) {
142+
console.error(`Failed to send message to client ${this.id}:`, error);
143+
return false;
144+
}
145+
}
146+
131147
sendBinary(data: Buffer): boolean {
132148
if (this.socket.readyState !== WebSocket.OPEN) {
133149
return false;
@@ -313,6 +329,23 @@ export class WsClient {
313329
return this.send(event);
314330
}
315331

332+
sendEventAndClose(
333+
topic: string,
334+
data: unknown,
335+
code: number,
336+
reason: string,
337+
seq: number = 0
338+
): boolean {
339+
const event: Event = {
340+
kind: "event",
341+
topic,
342+
seq,
343+
timestamp: Date.now(),
344+
data,
345+
};
346+
return this.sendControlAndClose(event, code, reason);
347+
}
348+
316349
/**
317350
* Check if client subscribes to a topic (supports glob patterns)
318351
*/

packages/server/src/ws/hub.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -317,11 +317,15 @@ export class WsHub implements Broadcaster {
317317
return;
318318
}
319319

320-
client.sendEvent("activation.revoked", {
321-
reason: "displaced",
322-
generation,
323-
});
324-
client.close(4001, "single_active_displaced");
320+
client.sendEventAndClose(
321+
"activation.revoked",
322+
{
323+
reason: "displaced",
324+
generation,
325+
},
326+
4001,
327+
"single_active_displaced"
328+
);
325329
}
326330

327331
getRequestMetadata(clientId: ClientId): FastifyRequest | undefined {

0 commit comments

Comments
 (0)