fix(frontend): avoid recreating browser stream after reconnect (#4951)

* fix(frontend): avoid recreating browser stream after reconnect

* test(frontend): cover reconnect delay reset
This commit is contained in:
wutongyuonce 2026-08-24 10:29:57 +08:00 committed by GitHub
parent 34ba2cdf38
commit 6688d01c8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 107 additions and 7 deletions

View File

@ -71,7 +71,11 @@ export function useBrowserStream(
); );
const [liveUrl, setLiveUrl] = useState<string | null>(null); const [liveUrl, setLiveUrl] = useState<string | null>(null);
const [tabs, setTabs] = useState<BrowserTab[]>([]); const [tabs, setTabs] = useState<BrowserTab[]>([]);
const [connectionAttempt, setConnectionAttempt] = useState(0); // This state is only a lifecycle-generation signal. The actual consecutive
// reconnect count lives in a ref so resetting it after a successful open
// does not recreate the WebSocket effect.
const [reconnectGeneration, setReconnectGeneration] = useState(0);
const reconnectAttemptRef = useRef(0);
const socketRef = useRef<WebSocket | null>(null); const socketRef = useRef<WebSocket | null>(null);
const pendingNavigateRef = useRef<Extract< const pendingNavigateRef = useRef<Extract<
BrowserInputEvent, BrowserInputEvent,
@ -108,7 +112,8 @@ export function useBrowserStream(
if (enabled) { if (enabled) {
return; return;
} }
setConnectionAttempt(0); reconnectAttemptRef.current = 0;
setReconnectGeneration(0);
frameBuffer.dispose(); frameBuffer.dispose();
setLiveUrl(null); setLiveUrl(null);
setTabs([]); setTabs([]);
@ -143,15 +148,17 @@ export function useBrowserStream(
} }
// Exponential backoff with a ceiling + attempt cap so a server that keeps // Exponential backoff with a ceiling + attempt cap so a server that keeps
// rejecting the upgrade cannot pin the client in a tight reconnect loop. // rejecting the upgrade cannot pin the client in a tight reconnect loop.
if (connectionAttempt >= RECONNECT_MAX_ATTEMPTS) { const attempt = reconnectAttemptRef.current;
if (attempt >= RECONNECT_MAX_ATTEMPTS) {
return; return;
} }
const delay = Math.min( const delay = Math.min(
RECONNECT_BASE_DELAY_MS * 2 ** connectionAttempt, RECONNECT_BASE_DELAY_MS * 2 ** attempt,
RECONNECT_MAX_DELAY_MS, RECONNECT_MAX_DELAY_MS,
); );
reconnectTimer = window.setTimeout(() => { reconnectTimer = window.setTimeout(() => {
setConnectionAttempt((attempt) => attempt + 1); reconnectAttemptRef.current += 1;
setReconnectGeneration((generation) => generation + 1);
}, delay); }, delay);
}; };
@ -166,7 +173,7 @@ export function useBrowserStream(
// mounted, so after RECONNECT_MAX_ATTEMPTS total reconnects — even across // mounted, so after RECONNECT_MAX_ATTEMPTS total reconnects — even across
// many healthy connections — scheduleReconnect would bail forever and // many healthy connections — scheduleReconnect would bail forever and
// Live would go permanently dead until the panel is toggled off/on. // Live would go permanently dead until the panel is toggled off/on.
setConnectionAttempt(0); reconnectAttemptRef.current = 0;
setStatus("open"); setStatus("open");
}; };
socket.onmessage = (message) => { socket.onmessage = (message) => {
@ -229,7 +236,7 @@ export function useBrowserStream(
socket.close(); socket.close();
frameBuffer.dispose(); frameBuffer.dispose();
}; };
}, [connectionAttempt, enabled, frameBuffer, threadId]); }, [reconnectGeneration, enabled, frameBuffer, threadId]);
// Steer an already-open stream toward a changed seed in-band instead of // Steer an already-open stream toward a changed seed in-band instead of
// rebuilding the socket. Only navigates when the live page differs from the // rebuilding the socket. Only navigates when the live page differs from the

View File

@ -0,0 +1,93 @@
import { afterEach, describe, expect, rs, test } from "@rstest/core";
import { act, cleanup, renderHook } from "@testing-library/react";
rs.mock("@/components/workspace/browser-view/api", () => ({
browserStreamURL: (threadId: string) => `ws://example.test/${threadId}`,
}));
import { useBrowserStream } from "@/components/workspace/browser-view/use-browser-stream";
class FakeWebSocket {
static readonly OPEN = 1;
static readonly CLOSED = 3;
static instances: FakeWebSocket[] = [];
readonly url: string;
readyState = 0;
binaryType = "";
closeCalls = 0;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
onmessage: ((message: MessageEvent) => void) | null = null;
constructor(url: string) {
this.url = url;
FakeWebSocket.instances.push(this);
}
send() {
return undefined;
}
close() {
this.closeCalls += 1;
this.readyState = FakeWebSocket.CLOSED;
}
open() {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.();
}
disconnect() {
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.();
}
}
afterEach(() => {
cleanup();
rs.useRealTimers();
rs.restoreAllMocks();
rs.unstubAllGlobals();
FakeWebSocket.instances = [];
});
describe("useBrowserStream", () => {
test("keeps a successfully reconnected socket instead of recreating it", async () => {
rs.useFakeTimers();
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
renderHook(() => useBrowserStream("thread-1", true));
expect(FakeWebSocket.instances).toHaveLength(1);
act(() => {
FakeWebSocket.instances[0]?.open();
FakeWebSocket.instances[0]?.disconnect();
});
act(() => {
void rs.advanceTimersByTime(800);
});
expect(FakeWebSocket.instances).toHaveLength(2);
act(() => {
FakeWebSocket.instances[1]?.open();
});
expect(FakeWebSocket.instances).toHaveLength(2);
expect(FakeWebSocket.instances[1]?.closeCalls).toBe(0);
act(() => {
FakeWebSocket.instances[1]?.disconnect();
void rs.advanceTimersByTime(799);
});
expect(FakeWebSocket.instances).toHaveLength(2);
act(() => {
void rs.advanceTimersByTime(1);
});
expect(FakeWebSocket.instances).toHaveLength(3);
});
});