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 [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
|
||||||
|
|||||||
@ -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