Skip to content

Commit 5269c46

Browse files
committed
feat(server): add activation revoke close helper
1 parent c593683 commit 5269c46

2 files changed

Lines changed: 58 additions & 8 deletions

File tree

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

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { ServerConfig } from "../config.js";
2020
import type { SessionManager } from "../session/manager.js";
2121
import type { TerminalManager } from "../terminal/manager.js";
2222
import type { WorkspaceManager } from "../workspace/manager.js";
23+
import { ActivationManager } from "../ws/activation.js";
2324
import type { CommandContext } from "../ws/dispatch.js";
2425
import type { FencingManager } from "../ws/fencing.js";
2526
import { 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

8490
const 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+
135147
describe("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());

packages/server/src/ws/hub.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,19 @@ export class WsHub implements Broadcaster {
311311
return client.sendBinary(data);
312312
}
313313

314+
revokeAndCloseClient(clientId: ClientId, generation: number): void {
315+
const client = this.clients.get(clientId);
316+
if (!client) {
317+
return;
318+
}
319+
320+
client.sendEvent("activation.revoked", {
321+
reason: "displaced",
322+
generation,
323+
});
324+
client.close(4001, "single_active_displaced");
325+
}
326+
314327
getRequestMetadata(clientId: ClientId): FastifyRequest | undefined {
315328
return this.clientRequests.get(clientId);
316329
}

0 commit comments

Comments
 (0)