mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
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:
parent
34ba2cdf38
commit
6688d01c8f
@ -71,7 +71,11 @@ export function useBrowserStream(
|
||||
);
|
||||
const [liveUrl, setLiveUrl] = useState<string | null>(null);
|
||||
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 pendingNavigateRef = useRef<Extract<
|
||||
BrowserInputEvent,
|
||||
@ -108,7 +112,8 @@ export function useBrowserStream(
|
||||
if (enabled) {
|
||||
return;
|
||||
}
|
||||
setConnectionAttempt(0);
|
||||
reconnectAttemptRef.current = 0;
|
||||
setReconnectGeneration(0);
|
||||
frameBuffer.dispose();
|
||||
setLiveUrl(null);
|
||||
setTabs([]);
|
||||
@ -143,15 +148,17 @@ export function useBrowserStream(
|
||||
}
|
||||
// Exponential backoff with a ceiling + attempt cap so a server that keeps
|
||||
// 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;
|
||||
}
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_DELAY_MS * 2 ** connectionAttempt,
|
||||
RECONNECT_BASE_DELAY_MS * 2 ** attempt,
|
||||
RECONNECT_MAX_DELAY_MS,
|
||||
);
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
setConnectionAttempt((attempt) => attempt + 1);
|
||||
reconnectAttemptRef.current += 1;
|
||||
setReconnectGeneration((generation) => generation + 1);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
@ -166,7 +173,7 @@ export function useBrowserStream(
|
||||
// mounted, so after RECONNECT_MAX_ATTEMPTS total reconnects — even across
|
||||
// many healthy connections — scheduleReconnect would bail forever and
|
||||
// Live would go permanently dead until the panel is toggled off/on.
|
||||
setConnectionAttempt(0);
|
||||
reconnectAttemptRef.current = 0;
|
||||
setStatus("open");
|
||||
};
|
||||
socket.onmessage = (message) => {
|
||||
@ -229,7 +236,7 @@ export function useBrowserStream(
|
||||
socket.close();
|
||||
frameBuffer.dispose();
|
||||
};
|
||||
}, [connectionAttempt, enabled, frameBuffer, threadId]);
|
||||
}, [reconnectGeneration, enabled, frameBuffer, threadId]);
|
||||
|
||||
// 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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user