Skip to content

Commit bfe7eb6

Browse files
fix(client): reconnect a WebRTC device feed that stops producing frames
After the first frame nothing bounded a feed that stopped: the peer could sit disconnected with the last frame on the canvas while the status said streaming (review, CodeRabbit). A check every five seconds reconnects through the usual retry path once no frame has arrived for the first-frame deadline, so a transient disconnect that recovers is left alone. Co-Authored-By: Claude Fable 5.1 <[email protected]>
1 parent eac12e1 commit bfe7eb6

2 files changed

Lines changed: 56 additions & 1 deletion

File tree

‎packages/client-runtime/src/device/stream.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,35 @@ describe("WebRTC-only iOS devices", () => {
11001100
expect(closes.map(fixture.bodyOf)).toEqual([{ sessionId: offer.sessionId }]);
11011101
});
11021102

1103+
it("reconnects a WebRTC feed that stops producing frames", async () => {
1104+
const fixture = webRtcFixture({ webCodecs: true });
1105+
fixture.client.start();
1106+
await fixture.settle();
1107+
const peer = fixture.peers[0]!;
1108+
peer.ontrack?.({ streams: [{}], track: {} });
1109+
fixture.frameCallbacks.shift()?.();
1110+
expect(fixture.events.onStatus).toHaveBeenLastCalledWith("streaming", undefined);
1111+
// Frames keep coming: nothing happens.
1112+
await vi.advanceTimersByTimeAsync(10_000);
1113+
fixture.frameCallbacks.shift()?.();
1114+
await vi.advanceTimersByTimeAsync(10_000);
1115+
expect(fixture.peers).toHaveLength(1);
1116+
expect(peer.closed).toBe(false);
1117+
// Then none for the first-frame deadline (checked every 5 s): the feed
1118+
// reconnects through the usual retry path.
1119+
await vi.advanceTimersByTimeAsync(15_000);
1120+
expect(peer.closed).toBe(true);
1121+
expect(fixture.events.onStatus).toHaveBeenLastCalledWith(
1122+
"connecting",
1123+
"Device stream stalled. Reconnecting…",
1124+
);
1125+
await vi.advanceTimersByTimeAsync(1_000);
1126+
await fixture.settle();
1127+
expect(fixture.peers).toHaveLength(2);
1128+
expect(fixture.peers[1]!.remoteDescription).toEqual({ type: "answer", sdp: "v=0 answer" });
1129+
fixture.client.stop();
1130+
});
1131+
11031132
it("retries a WebRTC offer while another viewer is negotiating", async () => {
11041133
const fixture = webRtcFixture({ webCodecs: true, offerStatuses: [409] });
11051134
fixture.client.start();

‎packages/client-runtime/src/device/stream.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ export type DeviceHardwareButton = "home" | "back" | "recents" | "power" | "appS
126126

127127
const RETRY_DELAY_MS = 1_000;
128128
const FIRST_FRAME_TIMEOUT_MS = 15_000;
129+
// A WebRTC feed that shows no frame for this long is reconnected; checked
130+
// every WEBRTC_STALL_CHECK_MS, so a transient `disconnected` peer that
131+
// recovers is left alone.
132+
const WEBRTC_STALL_TIMEOUT_MS = FIRST_FRAME_TIMEOUT_MS;
133+
const WEBRTC_STALL_CHECK_MS = 5_000;
129134
const TRANSPORT_PROBE_TIMEOUT_MS = 5_000;
130135
// Offers carry every candidate: serve-sim does not accept trickled ICE.
131136
const ICE_GATHERING_TIMEOUT_MS = 2_000;
@@ -884,16 +889,37 @@ export function createDeviceStreamClient(
884889
generation === lifecycle &&
885890
videoGeneration === feedGeneration &&
886891
peer === current;
892+
// After the first frame nothing else bounds a feed that stops: the peer
893+
// can sit `disconnected` with the last frame on the canvas. A long gap
894+
// between frames reconnects through the same path as a failed connection.
895+
let checksWithoutFrame = 0;
896+
let watchdog: ReturnType<typeof setInterval> | null = null;
897+
const clearWatchdog = () => {
898+
if (watchdog !== null) clearInterval(watchdog);
899+
watchdog = null;
900+
};
887901
const retry = (detail: string) => {
902+
clearWatchdog();
888903
if (!isCurrent()) return;
889904
closePeer();
890905
connecting(detail);
891906
scheduleRetry("video", () => void readIosWebRtc(settings));
892907
};
893908
const presentFrame = () => {
894-
if (!isCurrent()) return;
909+
if (!isCurrent()) {
910+
clearWatchdog();
911+
return;
912+
}
895913
if (video.videoWidth > 0 && video.videoHeight > 0) {
896914
paint(video, video.videoWidth, video.videoHeight);
915+
checksWithoutFrame = 0;
916+
watchdog ??= setInterval(() => {
917+
if (!isCurrent()) return clearWatchdog();
918+
checksWithoutFrame++;
919+
if (checksWithoutFrame * WEBRTC_STALL_CHECK_MS >= WEBRTC_STALL_TIMEOUT_MS) {
920+
retry("Device stream stalled. Reconnecting…");
921+
}
922+
}, WEBRTC_STALL_CHECK_MS);
897923
}
898924
video.requestVideoFrameCallback(presentFrame);
899925
};

0 commit comments

Comments
 (0)