Skip to content

Commit 04b82b6

Browse files
committed
fix(web): keep offline recovery out of session gate
1 parent 3dd7fbf commit 04b82b6

9 files changed

Lines changed: 344 additions & 20 deletions

File tree

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

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ function createWsSendCommandMock(
6868
handler?: (op: string, args: unknown) => Promise<unknown> | unknown
6969
) {
7070
return vi.fn().mockImplementation(async (op: string, args: unknown) => {
71+
if (handler) {
72+
const handled = await handler(op, args);
73+
if (handled !== undefined) {
74+
return handled;
75+
}
76+
}
77+
7178
if (op === "activation.claim") {
7279
return {
7380
active: true,
@@ -80,10 +87,6 @@ function createWsSendCommandMock(
8087
return { ok: true };
8188
}
8289

83-
if (handler) {
84-
return await handler(op, args);
85-
}
86-
8790
return undefined;
8891
});
8992
}
@@ -562,6 +565,96 @@ describe("AppProviders lifecycle recovery", () => {
562565
).toHaveLength(0);
563566
});
564567

568+
it("does not gate activation when websocket reconnect fails", async () => {
569+
const store = createStore();
570+
wsState.client!.connect = vi.fn().mockRejectedValue(new Error("connect failed"));
571+
572+
renderProviders(store);
573+
574+
await vi.waitFor(() => {
575+
expect(wsState.client?.connect).toHaveBeenCalled();
576+
});
577+
578+
await vi.waitFor(() => {
579+
expect(store.get(activationStatusAtom)).not.toBe("gated");
580+
expect(store.get(activationReasonAtom)).toBeNull();
581+
});
582+
});
583+
584+
it("does not gate activation when activation.claim fails", async () => {
585+
const store = createStore();
586+
wsState.client!.sendCommand = createWsSendCommandMock(async (op: string) => {
587+
if (op === "activation.claim") {
588+
throw new Error("claim failed");
589+
}
590+
591+
return undefined;
592+
});
593+
594+
renderProviders(store);
595+
596+
await vi.waitFor(() => {
597+
expect(wsState.client?.connect).toHaveBeenCalled();
598+
});
599+
600+
act(() => {
601+
wsState.client?.statusHandler?.("connected");
602+
});
603+
604+
await vi.waitFor(() => {
605+
expect(store.get(activationStatusAtom)).not.toBe("gated");
606+
expect(store.get(activationReasonAtom)).toBeNull();
607+
});
608+
});
609+
610+
it("retries activation.claim after a transient failure while connected", async () => {
611+
const store = createStore();
612+
let claimAttempts = 0;
613+
vi.useFakeTimers();
614+
wsState.client!.sendCommand = createWsSendCommandMock(async (op: string) => {
615+
if (op === "activation.claim") {
616+
claimAttempts += 1;
617+
if (claimAttempts === 1) {
618+
throw new Error("claim failed");
619+
}
620+
621+
return {
622+
active: true,
623+
generation: 2,
624+
recoveryMode: "grace_recover",
625+
};
626+
}
627+
628+
return undefined;
629+
});
630+
631+
renderProviders(store);
632+
633+
await vi.waitFor(() => {
634+
expect(wsState.client?.connect).toHaveBeenCalled();
635+
});
636+
637+
act(() => {
638+
wsState.client?.statusHandler?.("connected");
639+
});
640+
641+
await vi.waitFor(() => {
642+
expect(claimAttempts).toBe(1);
643+
expect(store.get(activationStatusAtom)).toBe("idle");
644+
});
645+
646+
await act(async () => {
647+
await vi.advanceTimersByTimeAsync(1_000);
648+
});
649+
650+
await vi.waitFor(() => {
651+
expect(claimAttempts).toBe(2);
652+
expect(store.get(activationStatusAtom)).toBe("active");
653+
expect(store.get(activationGenerationAtom)).toBe(2);
654+
expect(store.get(activationReasonAtom)).toBeNull();
655+
});
656+
});
657+
565658
it("disconnects and gates when activation.revoked is received", async () => {
566659
const store = createStore();
567660
seedWorkspaces(store, ["ws-1"], "ws-1");

packages/web/src/app/providers.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,7 @@ export function AppProviders({ children }: AppProvidersProps) {
463463
// Track reconnect attempts
464464
if (status === "reconnecting") {
465465
setReconnectCount((count) => count + 1);
466-
setLastReconnect(Date.now());
466+
setLastReconnect((previous) => previous ?? Date.now());
467467
}
468468

469469
// Reset writer status on disconnect
@@ -472,6 +472,8 @@ export function AppProviders({ children }: AppProvidersProps) {
472472
}
473473

474474
if (status === "connected") {
475+
setReconnectCount(0);
476+
setLastReconnect(null);
475477
syncWorkspaceActivity(true);
476478
}
477479
};

packages/web/src/hooks/use-activation.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ interface ActivationClaimPayload {
1414
recoveryMode: "fresh" | "grace_recover" | "takeover";
1515
}
1616

17+
const CLAIM_RETRY_DELAY_MS = 1_000;
18+
1719
export function useActivation() {
1820
const wsClient = useAtomValue(wsClientAtom);
1921
const connectionStatus = useAtomValue(connectionStatusAtom);
@@ -22,6 +24,7 @@ export function useActivation() {
2224
const [generation, setGeneration] = useAtom(activationGenerationAtom);
2325
const setReason = useSetAtom(activationReasonAtom);
2426
const claimInFlightRef = useRef<Promise<boolean> | null>(null);
27+
const claimRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
2528

2629
const claim = useCallback(async (): Promise<boolean> => {
2730
if (!wsClient) {
@@ -32,8 +35,8 @@ export function useActivation() {
3235
try {
3336
await wsClient.connect();
3437
} catch {
35-
setStatus("gated");
36-
setReason("reconnect_failed");
38+
setReason(null);
39+
setStatus((current) => (current === "gated" ? current : "idle"));
3740
return false;
3841
}
3942
}
@@ -54,9 +57,9 @@ export function useActivation() {
5457
setStatus("active");
5558
return true;
5659
})
57-
.catch((error) => {
58-
setStatus("gated");
59-
setReason(error instanceof Error ? error.message : "claim_failed");
60+
.catch(() => {
61+
setReason(null);
62+
setStatus((current) => (current === "gated" ? current : "idle"));
6063
return false;
6164
})
6265
.finally(() => {
@@ -67,8 +70,38 @@ export function useActivation() {
6770
return pending;
6871
}, [clientInstanceId, connectionStatus, setGeneration, setReason, setStatus, wsClient]);
6972

73+
useEffect(() => {
74+
if (claimRetryTimerRef.current !== null) {
75+
clearTimeout(claimRetryTimerRef.current);
76+
claimRetryTimerRef.current = null;
77+
}
78+
79+
if (!wsClient || connectionStatus !== "connected" || status !== "idle") {
80+
return;
81+
}
82+
83+
claimRetryTimerRef.current = setTimeout(() => {
84+
claimRetryTimerRef.current = null;
85+
if (!claimInFlightRef.current) {
86+
void claim();
87+
}
88+
}, CLAIM_RETRY_DELAY_MS);
89+
90+
return () => {
91+
if (claimRetryTimerRef.current !== null) {
92+
clearTimeout(claimRetryTimerRef.current);
93+
claimRetryTimerRef.current = null;
94+
}
95+
};
96+
}, [claim, connectionStatus, status, wsClient]);
97+
7098
useEffect(() => {
7199
return () => {
100+
if (claimRetryTimerRef.current !== null) {
101+
clearTimeout(claimRetryTimerRef.current);
102+
claimRetryTimerRef.current = null;
103+
}
104+
72105
if (!wsClient || generation === null) {
73106
return;
74107
}

packages/web/src/shells/desktop-shell.test.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
22
import { createStore, Provider } from "jotai";
33
import { BrowserRouter } from "react-router-dom";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5-
import { activationStatusAtom } from "../atoms/activation";
5+
import { activationReasonAtom, activationStatusAtom } from "../atoms/activation";
66
import { authenticatedAtom, localeAtom } from "../atoms/app-ui";
77
import { authEnabledAtom, connectionStatusAtom, wsClientAtom } from "../atoms/connection";
88
import {
@@ -313,7 +313,21 @@ describe("DesktopShell auth gating", () => {
313313

314314
renderShell(store);
315315

316-
expect(screen.getByText("正在重新连接...")).toBeInTheDocument();
316+
expect(screen.getByText("连接已断开,正在重新连接...")).toBeInTheDocument();
317+
});
318+
319+
it("shows the displaced-session banner on desktop when activation is gated", () => {
320+
const store = createStore();
321+
store.set(connectionStatusAtom, "disconnected");
322+
store.set(authEnabledAtom, false);
323+
store.set(authenticatedAtom, true);
324+
store.set(activationStatusAtom, "gated");
325+
store.set(activationReasonAtom, "displaced");
326+
327+
renderShell(store);
328+
329+
expect(screen.getByText("另一个标签页已激活")).toBeInTheDocument();
330+
expect(screen.queryByText("连接已断开,正在重新连接...")).not.toBeInTheDocument();
317331
});
318332

319333
it("renders SessionGatePage on /session-gate", () => {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { act, render, screen } from "@testing-library/react";
2+
import { createStore, Provider } from "jotai";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
import { activationReasonAtom, activationStatusAtom } from "../../atoms/activation";
5+
import { connectionStatusAtom, lastReconnectAttemptAtom } from "../../atoms/connection";
6+
import { ConnectionStatusBanner } from "./connection-status-banner";
7+
8+
function renderBanner() {
9+
const store = createStore();
10+
11+
render(
12+
<Provider store={store}>
13+
<ConnectionStatusBanner />
14+
</Provider>
15+
);
16+
17+
return store;
18+
}
19+
20+
describe("ConnectionStatusBanner", () => {
21+
beforeEach(() => {
22+
vi.useFakeTimers();
23+
});
24+
25+
afterEach(() => {
26+
vi.useRealTimers();
27+
});
28+
29+
it("renders the unified reconnect message while reconnecting", () => {
30+
const store = renderBanner();
31+
32+
act(() => {
33+
store.set(connectionStatusAtom, "reconnecting");
34+
});
35+
36+
expect(screen.getByText("连接已断开,正在重新连接...")).toBeInTheDocument();
37+
});
38+
39+
it("shows the displaced-session message instead of reconnecting when activation is gated", () => {
40+
const store = renderBanner();
41+
42+
act(() => {
43+
store.set(activationStatusAtom, "gated");
44+
store.set(activationReasonAtom, "displaced");
45+
store.set(connectionStatusAtom, "disconnected");
46+
});
47+
48+
expect(screen.getByText("另一个标签页已激活")).toBeInTheDocument();
49+
expect(screen.queryByText("连接已断开,正在重新连接...")).not.toBeInTheDocument();
50+
});
51+
52+
it("shows the slow recovery hint after 25 seconds", () => {
53+
const startedAt = new Date("2026-05-14T00:00:00.000Z").getTime();
54+
vi.setSystemTime(startedAt + 25_000);
55+
const store = renderBanner();
56+
57+
act(() => {
58+
store.set(connectionStatusAtom, "reconnecting");
59+
store.set(lastReconnectAttemptAtom, startedAt);
60+
});
61+
62+
expect(
63+
screen.getByText("连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。")
64+
).toBeInTheDocument();
65+
});
66+
67+
it("reveals the slow recovery hint after time passes without another status update", () => {
68+
const startedAt = new Date("2026-05-14T00:00:00.000Z").getTime();
69+
vi.setSystemTime(startedAt);
70+
const store = renderBanner();
71+
72+
act(() => {
73+
store.set(connectionStatusAtom, "reconnecting");
74+
store.set(lastReconnectAttemptAtom, startedAt);
75+
});
76+
77+
expect(
78+
screen.queryByText("连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。")
79+
).not.toBeInTheDocument();
80+
81+
act(() => {
82+
vi.advanceTimersByTime(25_000);
83+
});
84+
85+
expect(
86+
screen.getByText("连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。")
87+
).toBeInTheDocument();
88+
});
89+
90+
it("does not show the slow recovery hint before the threshold", () => {
91+
const startedAt = new Date("2026-05-14T00:00:00.000Z").getTime();
92+
vi.setSystemTime(startedAt + 24_000);
93+
const store = renderBanner();
94+
95+
act(() => {
96+
store.set(connectionStatusAtom, "reconnecting");
97+
store.set(lastReconnectAttemptAtom, startedAt);
98+
});
99+
100+
expect(
101+
screen.queryByText("连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。")
102+
).not.toBeInTheDocument();
103+
});
104+
});

0 commit comments

Comments
 (0)