Skip to content

Commit b264979

Browse files
committed
Merge branch 'single-active-tab-session-gate' into develop
# Conflicts: # packages/server/src/__tests__/activation-commands.test.ts # packages/server/src/commands/activation.ts # packages/server/src/ws/activation.ts # packages/server/src/ws/dispatch.ts
2 parents 46256e7 + f3e4658 commit b264979

28 files changed

Lines changed: 1515 additions & 70 deletions

packages/server/src/__tests__/activation-commands.test.ts

Lines changed: 248 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,55 @@
1-
import { describe, expect, it } from "vitest";
1+
import type { FastifyRequest } from "fastify";
2+
import { describe, expect, it, vi } from "vitest";
23
import { ActivationManager } from "../ws/activation.js";
34
import { type CommandContext, dispatch } from "../ws/dispatch.js";
5+
import type { Broadcaster } from "../ws/hub.js";
46
import "../commands/activation.js";
7+
import "../commands/workspace.js";
8+
9+
function createMockRequest(): FastifyRequest {
10+
return {
11+
ip: "127.0.0.1",
12+
headers: { "user-agent": "test-agent" },
13+
} as unknown as FastifyRequest;
14+
}
15+
16+
function createBaseContext(overrides?: {
17+
broadcaster?: Broadcaster;
18+
activationMgr?: ActivationManager;
19+
}): CommandContext {
20+
return {
21+
workspaceMgr: {
22+
list: vi.fn(() => [{ id: "workspace-1" }]),
23+
} as unknown as CommandContext["workspaceMgr"],
24+
sessionMgr: {} as never,
25+
terminalMgr: {} as never,
26+
eventBus: {} as never,
27+
broadcaster:
28+
overrides?.broadcaster ??
29+
({
30+
broadcast: vi.fn(),
31+
sendToClient: vi.fn(() => true),
32+
sendBinaryToClient: vi.fn(() => true),
33+
} as unknown as Broadcaster),
34+
db: {} as never,
35+
providerRegistry: [],
36+
fencingMgr: {} as never,
37+
supervisorMgr: {} as never,
38+
autoFetch: {} as never,
39+
activationMgr: overrides?.activationMgr ?? new ActivationManager(),
40+
};
41+
}
542

643
describe("activation commands", () => {
744
it("returns generation data from activation.claim", async () => {
8-
const ctx = {
9-
activationMgr: new ActivationManager(),
10-
} as unknown as CommandContext;
45+
const request = createMockRequest();
46+
const broadcaster = {
47+
broadcast: vi.fn(),
48+
sendToClient: vi.fn(() => true),
49+
sendBinaryToClient: vi.fn(() => true),
50+
getRequestMetadata: vi.fn(() => request),
51+
} satisfies Broadcaster;
52+
const ctx = createBaseContext({ broadcaster });
1153

1254
const result = await dispatch(
1355
{
@@ -26,12 +68,23 @@ describe("activation commands", () => {
2668
generation: 1,
2769
recoveryMode: "fresh",
2870
});
71+
expect(broadcaster.getRequestMetadata).toHaveBeenCalledWith("ws-a");
72+
expect(ctx.activationMgr.getLease()).toMatchObject({
73+
clientInstanceId: "client-a",
74+
wsClientId: "ws-a",
75+
ip: "127.0.0.1",
76+
userAgent: "test-agent",
77+
});
2978
});
3079

31-
it("rejects non-activation commands when activation is missing", async () => {
32-
const ctx = {
33-
activationMgr: new ActivationManager(),
34-
} as unknown as CommandContext;
80+
it("rejects non-activation commands for websocket clients without an active lease", async () => {
81+
const broadcaster = {
82+
broadcast: vi.fn(),
83+
sendToClient: vi.fn(() => true),
84+
sendBinaryToClient: vi.fn(() => true),
85+
getRequestMetadata: vi.fn(() => createMockRequest()),
86+
} satisfies Broadcaster;
87+
const ctx = createBaseContext({ broadcaster });
3588

3689
const result = await dispatch(
3790
{
@@ -45,6 +98,192 @@ describe("activation commands", () => {
4598
);
4699

47100
expect(result.ok).toBe(false);
48-
expect(result.error?.code).toBe("activation_required");
101+
expect(result.error).toEqual({
102+
code: "activation_required",
103+
message: "This tab is no longer the active session",
104+
});
105+
});
106+
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+
235+
it("claiming from a second client revokes the previous websocket", async () => {
236+
const request = createMockRequest();
237+
const revokeAndCloseClient = vi.fn();
238+
const ctx = createBaseContext({
239+
broadcaster: {
240+
broadcast: vi.fn(),
241+
sendToClient: vi.fn(() => true),
242+
sendBinaryToClient: vi.fn(() => true),
243+
getRequestMetadata: vi.fn(() => request),
244+
revokeAndCloseClient,
245+
} as unknown as Broadcaster,
246+
});
247+
248+
await dispatch(
249+
{
250+
kind: "command",
251+
id: "00000000-0000-4000-8000-000000000003",
252+
op: "activation.claim",
253+
args: { clientInstanceId: "client-a" },
254+
},
255+
ctx,
256+
"ws-a"
257+
);
258+
259+
await dispatch(
260+
{
261+
kind: "command",
262+
id: "00000000-0000-4000-8000-000000000004",
263+
op: "activation.claim",
264+
args: { clientInstanceId: "client-b" },
265+
},
266+
ctx,
267+
"ws-b"
268+
);
269+
270+
expect(revokeAndCloseClient).toHaveBeenCalledWith("ws-a", 2);
271+
});
272+
273+
it("does not block direct internal dispatches without websocket request metadata", async () => {
274+
const ctx = createBaseContext();
275+
276+
const result = await dispatch(
277+
{
278+
kind: "command",
279+
id: "00000000-0000-4000-8000-000000000003",
280+
op: "workspace.list",
281+
args: {},
282+
},
283+
ctx
284+
);
285+
286+
expect(result.ok).toBe(true);
287+
expect(result.data).toEqual([{ id: "workspace-1" }]);
49288
});
50289
});

0 commit comments

Comments
 (0)