Skip to content

Commit f3e4658

Browse files
committed
feat(web): add single-active session gate recovery
1 parent 3e0d19b commit f3e4658

20 files changed

Lines changed: 823 additions & 36 deletions

packages/web/src/app/providers.lifecycle.test.tsx

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@ import type { Workspace } from "@coder-studio/core";
22
import { act, render } from "@testing-library/react";
33
import { createStore, Provider } from "jotai";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import {
6+
activationGenerationAtom,
7+
activationReasonAtom,
8+
activationStatusAtom,
9+
} from "../atoms/activation";
510
import { authenticatedAtom } from "../atoms/app-ui";
611
import { authEnabledAtom, connectionStatusAtom } from "../atoms/connection";
12+
import { sessionsAtom } from "../atoms/sessions";
713
import {
814
activeWorkspaceIdAtom,
915
workspaceOrderAtom,
@@ -12,9 +18,11 @@ import {
1218
} from "../atoms/workspaces";
1319
import { terminalPreferencesAtom } from "../features/terminal-panel/preferences";
1420
import {
21+
fileTreeAtomFamily,
1522
fileTreeStaleAtomFamily,
1623
gitBranchListAtomFamily,
1724
gitStateAtomFamily,
25+
loadedDirsAtomFamily,
1826
worktreeListAtomFamily,
1927
} from "../features/workspace/atoms";
2028
import { AppProviders, resetAppProvidersSingletonsForTests } from "./providers";
@@ -56,6 +64,30 @@ function renderProviders(store = createStore()) {
5664
return { store, ...rendered };
5765
}
5866

67+
function createWsSendCommandMock(
68+
handler?: (op: string, args: unknown) => Promise<unknown> | unknown
69+
) {
70+
return vi.fn().mockImplementation(async (op: string, args: unknown) => {
71+
if (op === "activation.claim") {
72+
return {
73+
active: true,
74+
generation: 1,
75+
recoveryMode: "fresh",
76+
};
77+
}
78+
79+
if (op === "activation.heartbeat" || op === "activation.release") {
80+
return { ok: true };
81+
}
82+
83+
if (handler) {
84+
return await handler(op, args);
85+
}
86+
87+
return undefined;
88+
});
89+
}
90+
5991
function setVisibilityState(value: "visible" | "hidden") {
6092
Object.defineProperty(document, "visibilityState", {
6193
configurable: true,
@@ -128,7 +160,7 @@ describe("AppProviders lifecycle recovery", () => {
128160
}),
129161
getStatus: vi.fn(() => "disconnected"),
130162
recoverConnection: vi.fn(),
131-
sendCommand: vi.fn().mockResolvedValue(undefined),
163+
sendCommand: createWsSendCommandMock(),
132164
};
133165
});
134166

@@ -452,6 +484,151 @@ describe("AppProviders lifecycle recovery", () => {
452484
});
453485
});
454486

487+
it("claims activation when the websocket becomes connected", async () => {
488+
const store = createStore();
489+
setVisibilityState("visible");
490+
491+
renderProviders(store);
492+
493+
await vi.waitFor(() => {
494+
expect(wsState.client?.connect).toHaveBeenCalled();
495+
});
496+
497+
act(() => {
498+
wsState.client?.statusHandler?.("connected");
499+
});
500+
501+
await vi.waitFor(() => {
502+
const claimCalls =
503+
wsState.client?.sendCommand?.mock.calls.filter(([op]) => op === "activation.claim") ?? [];
504+
expect(claimCalls.length).toBeGreaterThan(0);
505+
expect(claimCalls[0]?.[1]).toEqual(
506+
expect.objectContaining({
507+
clientInstanceId: expect.any(String),
508+
})
509+
);
510+
expect(store.get(activationStatusAtom)).toBe("active");
511+
expect(store.get(activationGenerationAtom)).toBe(1);
512+
expect(store.get(activationReasonAtom)).toBeNull();
513+
});
514+
});
515+
516+
it("disconnects and gates when activation.revoked is received", async () => {
517+
const store = createStore();
518+
seedWorkspaces(store, ["ws-1"], "ws-1");
519+
act(() => {
520+
store.set(activationStatusAtom, "active");
521+
store.set(activationGenerationAtom, 1);
522+
store.set(activationReasonAtom, null);
523+
store.set(gitStateAtomFamily("ws-1"), {
524+
branch: "feature/test",
525+
ahead: 1,
526+
behind: 0,
527+
modified: [],
528+
staged: [],
529+
untracked: [],
530+
deleted: [],
531+
});
532+
store.set(gitBranchListAtomFamily("ws-1"), {
533+
current: "feature/test",
534+
branches: [],
535+
loading: false,
536+
});
537+
store.set(fileTreeAtomFamily("ws-1"), new Map([[".", []]]));
538+
store.set(loadedDirsAtomFamily("ws-1"), new Set(["src"]));
539+
store.set(worktreeListAtomFamily("ws-1"), {
540+
items: [],
541+
loading: false,
542+
lastLoadedAt: Date.now(),
543+
});
544+
store.set(fileTreeStaleAtomFamily("ws-1"), true);
545+
store.set(sessionsAtom, {
546+
"session-1": {
547+
id: "session-1",
548+
workspaceId: "ws-1",
549+
terminalId: "terminal-1",
550+
providerId: "codex",
551+
state: "running",
552+
capability: "full",
553+
startedAt: Date.now(),
554+
lastActiveAt: Date.now(),
555+
},
556+
});
557+
});
558+
559+
renderProviders(store);
560+
561+
await vi.waitFor(() => {
562+
expect(wsState.client?.connect).toHaveBeenCalled();
563+
});
564+
565+
act(() => {
566+
wsState.client?.eventHandler?.(
567+
"activation.revoked",
568+
{ reason: "displaced", generation: 2 },
569+
1
570+
);
571+
});
572+
573+
await vi.waitFor(() => {
574+
expect(wsState.client?.disconnect).toHaveBeenCalledWith("single_active_displaced");
575+
expect(store.get(activationStatusAtom)).toBe("gated");
576+
expect(store.get(activationReasonAtom)).toBe("displaced");
577+
expect(store.get(activationGenerationAtom)).toBe(2);
578+
expect(store.get(workspacesLoadStateAtom)).toBe("idle");
579+
expect(store.get(workspaceOrderAtom)).toEqual([]);
580+
expect(store.get(workspacesAtom)).toEqual({});
581+
expect(store.get(activeWorkspaceIdAtom)).toBeNull();
582+
expect(store.get(fileTreeAtomFamily("ws-1"))).toBeNull();
583+
expect(Array.from(store.get(loadedDirsAtomFamily("ws-1")))).toEqual([]);
584+
expect(store.get(gitStateAtomFamily("ws-1"))).toBeNull();
585+
expect(store.get(gitBranchListAtomFamily("ws-1")).current).toBe("");
586+
expect(store.get(worktreeListAtomFamily("ws-1")).items).toEqual([]);
587+
expect(store.get(fileTreeStaleAtomFamily("ws-1"))).toBe(false);
588+
expect(store.get(sessionsAtom)).toEqual({});
589+
});
590+
});
591+
592+
it("does not auto-claim again while activation remains gated", async () => {
593+
const store = createStore();
594+
595+
renderProviders(store);
596+
597+
await vi.waitFor(() => {
598+
expect(wsState.client?.connect).toHaveBeenCalled();
599+
});
600+
601+
act(() => {
602+
store.set(activationStatusAtom, "gated");
603+
wsState.client?.statusHandler?.("connected");
604+
});
605+
606+
const claimCalls =
607+
wsState.client?.sendCommand?.mock.calls.filter(([op]) => op === "activation.claim") ?? [];
608+
609+
expect(claimCalls).toHaveLength(0);
610+
});
611+
612+
it("does not auto-recover the websocket from foreground signals while gated", async () => {
613+
const store = createStore();
614+
setVisibilityState("visible");
615+
616+
renderProviders(store);
617+
618+
await vi.waitFor(() => {
619+
expect(wsState.client?.connect).toHaveBeenCalled();
620+
});
621+
622+
act(() => {
623+
store.set(activationStatusAtom, "gated");
624+
window.dispatchEvent(new Event("focus"));
625+
window.dispatchEvent(new Event("online"));
626+
window.dispatchEvent(new Event("pageshow"));
627+
});
628+
629+
expect(wsState.client?.recoverConnection).not.toHaveBeenCalled();
630+
});
631+
455632
it("hydrates terminal copy-on-select preferences from settings.get once connected", async () => {
456633
const store = createStore();
457634
setVisibilityState("visible");

packages/web/src/app/providers.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ import {
3333
workspacesLoadStateAtom,
3434
wsClientAtom,
3535
} from "../atoms";
36+
import {
37+
activationGenerationAtom,
38+
activationReasonAtom,
39+
activationStatusAtom,
40+
} from "../atoms/activation";
3641
import { authenticatedAtom } from "../atoms/app-ui";
3742
import type { DispatchCommand } from "../atoms/connection";
3843
import { activeWorkspaceIdAtom } from "../atoms/workspaces";
@@ -45,11 +50,14 @@ import {
4550
} from "../features/terminal-panel/preferences";
4651
import {
4752
editorRefreshTokenAtomFamily,
53+
fileTreeAtomFamily,
4854
fileTreeStaleAtomFamily,
4955
gitBranchListAtomFamily,
5056
gitStateAtomFamily,
57+
loadedDirsAtomFamily,
5158
worktreeListAtomFamily,
5259
} from "../features/workspace/atoms";
60+
import { useActivation } from "../hooks/use-activation";
5361
import type { ConnectionStatus, EventListener } from "../ws";
5462
import { resolveWsUrl, WsClient } from "../ws";
5563

@@ -107,6 +115,43 @@ function mergeRefreshHints(
107115
};
108116
}
109117

118+
function resetServerProjectedState(store: Store): void {
119+
const workspaceIds = store.get(workspaceOrderAtom);
120+
const terminalIds = Object.values(store.get(sessionsAtom))
121+
.map((session) => session.terminalId)
122+
.filter((terminalId): terminalId is string => Boolean(terminalId));
123+
124+
store.set(workspacesAtom, {});
125+
store.set(workspaceOrderAtom, []);
126+
store.set(workspacesLoadStateAtom, "idle");
127+
store.set(workspacesLoadErrorAtom, null);
128+
store.set(sessionsAtom, {});
129+
store.set(activeWorkspaceIdAtom, null);
130+
store.set(supervisorsAtom, new Map());
131+
store.set(supervisorCyclesAtom, new Map());
132+
133+
for (const workspaceId of workspaceIds) {
134+
store.set(fileTreeAtomFamily(workspaceId), null);
135+
store.set(loadedDirsAtomFamily(workspaceId), new Set());
136+
store.set(gitStateAtomFamily(workspaceId), null);
137+
store.set(gitBranchListAtomFamily(workspaceId), {
138+
current: "",
139+
branches: [],
140+
loading: false,
141+
});
142+
store.set(worktreeListAtomFamily(workspaceId), {
143+
items: [],
144+
loading: false,
145+
});
146+
store.set(fileTreeStaleAtomFamily(workspaceId), false);
147+
store.set(editorRefreshTokenAtomFamily(workspaceId), 0);
148+
}
149+
150+
for (const terminalId of terminalIds) {
151+
store.set(terminalMetaAtomFamily(terminalId), null);
152+
}
153+
}
154+
110155
function parseWorkspaceRefreshHint(
111156
topic: string,
112157
payload: unknown
@@ -183,6 +228,7 @@ export function AppProviders({ children }: AppProvidersProps) {
183228
// Get Jotai store for writing to atomFamily atoms
184229
const store = useStore();
185230
const dispatch = useAtomValue(dispatchCommandAtom);
231+
const { claim } = useActivation();
186232

187233
useSessionNotifications();
188234

@@ -246,6 +292,18 @@ export function AppProviders({ children }: AppProvidersProps) {
246292
connectionStatusRef.current = connectionStatus;
247293
}, [connectionStatus]);
248294

295+
useEffect(() => {
296+
if (connectionStatus !== "connected") {
297+
return;
298+
}
299+
300+
if (store.get(activationStatusAtom) === "gated") {
301+
return;
302+
}
303+
304+
void claim();
305+
}, [claim, connectionStatus, store]);
306+
249307
// Initialize theme from localStorage
250308
useEffect(() => {
251309
const savedTheme = localStorage.getItem("ui.theme");
@@ -391,6 +449,10 @@ export function AppProviders({ children }: AppProvidersProps) {
391449
};
392450

393451
const triggerForegroundRecovery = () => {
452+
if (store.get(activationStatusAtom) === "gated") {
453+
return;
454+
}
455+
394456
syncWorkspaceActivity();
395457
if (document.visibilityState !== "visible") {
396458
lastForegroundRecoveryAtRef.current = null;
@@ -429,6 +491,10 @@ export function AppProviders({ children }: AppProvidersProps) {
429491
};
430492

431493
const handleOnline = () => {
494+
if (store.get(activationStatusAtom) === "gated") {
495+
return;
496+
}
497+
432498
wsClientRef.current?.recoverConnection("network_online");
433499
};
434500

@@ -529,6 +595,30 @@ export function AppProviders({ children }: AppProvidersProps) {
529595

530596
// Event handler: route WS events to atoms
531597
const handleEvent: EventListener = (topic: string, payload: unknown, _seq: number) => {
598+
if (topic === "activation.revoked") {
599+
const data = (payload ?? {}) as {
600+
reason?: string;
601+
generation?: number;
602+
};
603+
604+
store.set(activationStatusAtom, "gated");
605+
store.set(
606+
activationReasonAtom,
607+
typeof data.reason === "string" && data.reason.length > 0 ? data.reason : "displaced"
608+
);
609+
store.set(
610+
activationGenerationAtom,
611+
typeof data.generation === "number" ? data.generation : null
612+
);
613+
resetServerProjectedState(store);
614+
workspaceActivityRef.current = {
615+
mode: "inactive",
616+
workspaceId: null,
617+
};
618+
wsClientRef.current?.disconnect("single_active_displaced");
619+
return;
620+
}
621+
532622
const refreshInfo = parseWorkspaceRefreshHint(topic, payload);
533623
if (refreshInfo) {
534624
queueWorkspaceRefresh(refreshInfo.workspaceId, refreshInfo.hint);
@@ -544,6 +634,7 @@ export function AppProviders({ children }: AppProvidersProps) {
544634
// Subscribe to all topics we care about
545635
const topics = [
546636
"connection.*", // Connection-level events
637+
"activation.*",
547638
"workspace.*", // All workspace events (glob pattern)
548639
];
549640

0 commit comments

Comments
 (0)