fix(streaming): resolve stale closure preventing remote WebRTC connections

The WebSocket onmessage handler captured isBroadcasting=false at creation
time. When ICE candidates arrived from remote viewers, the handler looked
up viewerPcRef instead of peerConnectionsRef, dropping all candidates.

Fix: use refs (isBroadcastingRef, viewingRef, handleWsMessageRef) so the
WS handler always reads current state. connectWs() now has [] deps and
delegates to handleWsMessageRef.current for every message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-07 00:51:09 +01:00
parent 29bcf67121
commit dacfde4328

View file

@ -61,6 +61,12 @@ export default function StreamingTab({ data }: { data: any }) {
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectDelayRef = useRef(1000); const reconnectDelayRef = useRef(1000);
// ── Refs that mirror state (to avoid stale closures in WS handler) ──
const isBroadcastingRef = useRef(false);
const viewingRef = useRef<ViewState | null>(null);
useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]);
useEffect(() => { viewingRef.current = viewing; }, [viewing]);
// ── Elapsed time ticker ── // ── Elapsed time ticker ──
useEffect(() => { useEffect(() => {
const hasActive = streams.length > 0 || isBroadcasting; const hasActive = streams.length > 0 || isBroadcasting;
@ -81,42 +87,25 @@ export default function StreamingTab({ data }: { data: any }) {
if (userName) localStorage.setItem('streaming_name', userName); if (userName) localStorage.setItem('streaming_name', userName);
}, [userName]); }, [userName]);
// ── WebSocket connect ── // ── Send via WS ──
const connectWs = useCallback(() => { const wsSend = useCallback((d: Record<string, any>) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return; if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(d));
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws/streaming`);
wsRef.current = ws;
ws.onopen = () => {
reconnectDelayRef.current = 1000;
};
ws.onmessage = (ev) => {
let msg: any;
try { msg = JSON.parse(ev.data); } catch { return; }
handleWsMessage(msg);
};
ws.onclose = () => {
wsRef.current = null;
// Auto-reconnect if broadcasting or viewing
if (isBroadcasting || viewing) {
reconnectTimerRef.current = setTimeout(() => {
reconnectDelayRef.current = Math.min(reconnectDelayRef.current * 2, 10000);
connectWs();
}, reconnectDelayRef.current);
} }
}; }, []);
ws.onerror = () => { // ── Viewer cleanup ──
ws.close(); const cleanupViewer = useCallback(() => {
}; if (viewerPcRef.current) {
}, [isBroadcasting, viewing]); viewerPcRef.current.close();
viewerPcRef.current = null;
}
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
}, []);
// ── WS message handler ── // ── WS message handler (uses refs, never stale) ──
const handleWsMessage = useCallback((msg: any) => { const handleWsMessageRef = useRef<(msg: any) => void>(() => {});
handleWsMessageRef.current = (msg: any) => {
switch (msg.type) { switch (msg.type) {
case 'welcome': case 'welcome':
clientIdRef.current = msg.clientId; clientIdRef.current = msg.clientId;
@ -126,15 +115,16 @@ export default function StreamingTab({ data }: { data: any }) {
case 'broadcast_started': case 'broadcast_started':
setMyStreamId(msg.streamId); setMyStreamId(msg.streamId);
setIsBroadcasting(true); setIsBroadcasting(true);
isBroadcastingRef.current = true; // immediate update for handler
setStarting(false); setStarting(false);
break; break;
case 'stream_available': case 'stream_available':
// SSE will update streams list; this is just a hint // SSE will update streams list
break; break;
case 'stream_ended': case 'stream_ended':
if (viewing?.streamId === msg.streamId) { if (viewingRef.current?.streamId === msg.streamId) {
cleanupViewer(); cleanupViewer();
setViewing(null); setViewing(null);
} }
@ -160,6 +150,16 @@ export default function StreamingTab({ data }: { data: any }) {
} }
}; };
pc.onnegotiationneeded = () => {
pc.createOffer()
.then(offer => pc.setLocalDescription(offer))
.then(() => {
wsSend({ type: 'offer', targetId: viewerId, sdp: pc.localDescription });
})
.catch(console.error);
};
// If negotiationneeded doesn't fire (tracks already added), create offer now
pc.createOffer() pc.createOffer()
.then(offer => pc.setLocalDescription(offer)) .then(offer => pc.setLocalDescription(offer))
.then(() => { .then(() => {
@ -222,9 +222,9 @@ export default function StreamingTab({ data }: { data: any }) {
break; break;
} }
// ── ICE candidate relay ── // ── ICE candidate relay (uses ref, not stale state!) ──
case 'ice_candidate': { case 'ice_candidate': {
const pc = isBroadcasting const pc = isBroadcastingRef.current
? peerConnectionsRef.current.get(msg.fromId) ? peerConnectionsRef.current.get(msg.fromId)
: viewerPcRef.current; : viewerPcRef.current;
if (pc && msg.candidate) { if (pc && msg.candidate) {
@ -238,20 +238,47 @@ export default function StreamingTab({ data }: { data: any }) {
setStarting(false); setStarting(false);
break; break;
} }
}, [isBroadcasting, viewing]); };
// ── Send via WS ── // ── WebSocket connect (stable, no state deps) ──
const wsSend = (data: Record<string, any>) => { const connectWs = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return;
wsRef.current.send(JSON.stringify(data));
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws/streaming`);
wsRef.current = ws;
ws.onopen = () => {
reconnectDelayRef.current = 1000;
};
// Delegate to ref so handler is always current
ws.onmessage = (ev) => {
let msg: any;
try { msg = JSON.parse(ev.data); } catch { return; }
handleWsMessageRef.current(msg);
};
ws.onclose = () => {
wsRef.current = null;
// Auto-reconnect if broadcasting or viewing (read from refs)
if (isBroadcastingRef.current || viewingRef.current) {
reconnectTimerRef.current = setTimeout(() => {
reconnectDelayRef.current = Math.min(reconnectDelayRef.current * 2, 10000);
connectWs();
}, reconnectDelayRef.current);
} }
}; };
ws.onerror = () => {
ws.close();
};
}, []);
// ── Start broadcasting ── // ── Start broadcasting ──
const startBroadcast = useCallback(async () => { const startBroadcast = useCallback(async () => {
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; } if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
// Check browser support
if (!navigator.mediaDevices?.getDisplayMedia) { if (!navigator.mediaDevices?.getDisplayMedia) {
setError('Dein Browser unterstützt keine Bildschirmfreigabe.'); setError('Dein Browser unterstützt keine Bildschirmfreigabe.');
return; return;
@ -280,7 +307,6 @@ export default function StreamingTab({ data }: { data: any }) {
// Connect WS and start broadcast // Connect WS and start broadcast
connectWs(); connectWs();
// Wait for WS to open, then send start_broadcast
const waitForWs = () => { const waitForWs = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) { if (wsRef.current?.readyState === WebSocket.OPEN) {
wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share' }); wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share' });
@ -297,24 +323,23 @@ export default function StreamingTab({ data }: { data: any }) {
setError(`Fehler: ${e.message}`); setError(`Fehler: ${e.message}`);
} }
} }
}, [userName, streamTitle, connectWs]); }, [userName, streamTitle, connectWs, wsSend]);
// ── Stop broadcasting ── // ── Stop broadcasting ──
const stopBroadcast = useCallback(() => { const stopBroadcast = useCallback(() => {
wsSend({ type: 'stop_broadcast' }); wsSend({ type: 'stop_broadcast' });
// Stop all tracks
localStreamRef.current?.getTracks().forEach(t => t.stop()); localStreamRef.current?.getTracks().forEach(t => t.stop());
localStreamRef.current = null; localStreamRef.current = null;
if (localVideoRef.current) localVideoRef.current.srcObject = null; if (localVideoRef.current) localVideoRef.current.srcObject = null;
// Close all peer connections
for (const pc of peerConnectionsRef.current.values()) pc.close(); for (const pc of peerConnectionsRef.current.values()) pc.close();
peerConnectionsRef.current.clear(); peerConnectionsRef.current.clear();
setIsBroadcasting(false); setIsBroadcasting(false);
isBroadcastingRef.current = false;
setMyStreamId(null); setMyStreamId(null);
}, []); }, [wsSend]);
// ── Join as viewer ── // ── Join as viewer ──
const joinStream = useCallback((streamId: string) => { const joinStream = useCallback((streamId: string) => {
@ -330,22 +355,14 @@ export default function StreamingTab({ data }: { data: any }) {
} }
}; };
waitForWs(); waitForWs();
}, [userName, connectWs]); }, [userName, connectWs, wsSend]);
// ── Leave viewer ── // ── Leave viewer ──
const cleanupViewer = useCallback(() => {
if (viewerPcRef.current) {
viewerPcRef.current.close();
viewerPcRef.current = null;
}
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
}, []);
const leaveViewing = useCallback(() => { const leaveViewing = useCallback(() => {
wsSend({ type: 'leave_viewer' }); wsSend({ type: 'leave_viewer' });
cleanupViewer(); cleanupViewer();
setViewing(null); setViewing(null);
}, [cleanupViewer]); }, [cleanupViewer, wsSend]);
// ── Cleanup on unmount ── // ── Cleanup on unmount ──
useEffect(() => { useEffect(() => {