Skip to content

Commit b4ccb5d

Browse files
committed
fix(web): corrige ciclo de vida do WebRTC e vazamentos de peer connections/streams (#59)
1 parent bd1cd13 commit b4ccb5d

7 files changed

Lines changed: 85 additions & 27 deletions

File tree

web/src/components/WebRTCStreamer.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ export default function WebRTCStreamer() {
2525
configRef.current = { webrtcUrl, providerType };
2626
}, [webrtcUrl, providerType]);
2727

28+
// Keep the latest gameData accessible without re-running the connection-lifecycle
29+
// effect below on every NUI push (its identity changes on each message).
30+
const gameDataRef = useRef(gameData);
31+
useEffect(() => {
32+
gameDataRef.current = gameData;
33+
}, [gameData]);
34+
2835
useEffect(() => {
2936
let rendererInstance: any = null;
3037

@@ -48,8 +55,10 @@ export default function WebRTCStreamer() {
4855
};
4956

5057
const initializeConnection = async (targetViewerId: string) => {
58+
let localPc: RTCPeerConnection | null = null;
59+
let streamStarted = false;
5160
try {
52-
let myId = (gameData as any).selfId;
61+
let myId = (gameDataRef.current as any).selfId;
5362
if (!myId) {
5463
try {
5564
const res = await sendNui('getSelfId', {}, 0);
@@ -70,6 +79,7 @@ export default function WebRTCStreamer() {
7079
const { default: renderer } = await import('@/utils/fivem-renderer');
7180
rendererInstance = renderer;
7281
stream = (renderer as any).startStream() as MediaStream;
82+
streamStarted = true;
7383
} catch (err) {
7484
console.error('[WebRTC Streamer] Failed to load renderer:', err);
7585
return;
@@ -78,6 +88,7 @@ export default function WebRTCStreamer() {
7888
if (configRef.current.providerType === 'cloudflare-sfu') {
7989
const { publishToCF } = await import('@/utils/cf-sfu');
8090
const { pc, sessionId, trackName } = await publishToCF(stream!);
91+
localPc = pc;
8192
const signalingUnsub = () => { };
8293
connectionsRef.current.set(targetViewerId, { pc, signalingUnsub });
8394
signaling.send({
@@ -91,6 +102,7 @@ export default function WebRTCStreamer() {
91102
}
92103

93104
const pc = new RTCPeerConnection(RTC_CONFIG);
105+
localPc = pc;
94106
stream!.getTracks().forEach(track => pc.addTrack(track, stream!));
95107

96108
pc.onconnectionstatechange = () => {
@@ -153,7 +165,16 @@ export default function WebRTCStreamer() {
153165
});
154166
} catch (err) {
155167
console.error('[WebRTC Streamer] Init failed:', err);
168+
// If the connection was already registered, cleanup() closes the pc
169+
// and stops the renderer stream via the map entry. If it failed
170+
// *before* connectionsRef.set(...), no map entry exists — tear down
171+
// the locally-created pc/renderer here so they don't leak.
172+
const hadEntry = connectionsRef.current.has(targetViewerId);
156173
cleanup(targetViewerId);
174+
if (!hadEntry) {
175+
if (localPc) localPc.close();
176+
if (streamStarted && rendererInstance) rendererInstance.stopStream();
177+
}
157178
} finally {
158179
startingViewersRef.current.delete(targetViewerId);
159180
}
@@ -172,6 +193,10 @@ export default function WebRTCStreamer() {
172193
old.pc.close();
173194
old.signalingUnsub();
174195
connectionsRef.current.delete(targetViewerId);
196+
// Release the renderer watcher held by the previous connection
197+
// before initializeConnection() calls startStream() again — otherwise
198+
// the watcher counter leaks and the render loop never stops.
199+
if (rendererInstance) rendererInstance.stopStream();
175200
}
176201
}
177202

@@ -192,7 +217,7 @@ export default function WebRTCStreamer() {
192217
off('StartWebRTC', startHandler);
193218
off('StopWebRTC', stopHandler);
194219
};
195-
}, [on, off, sendNui, gameData]);
220+
}, [on, off, sendNui]);
196221

197222
return null;
198223
}

web/src/components/players/ScreenModal.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,15 @@ export default function ScreenModal({ playerId, onClose, playerName }: ScreenMod
8181
useEffect(() => { selfIdRef.current = selfIdFromState }, [selfIdFromState]);
8282
useEffect(() => { targetIdRef.current = playerId }, [playerId]);
8383

84-
// Initialize signaling for the viewer role
84+
// Initialize signaling for the viewer role.
85+
// selfIdFromState is included so the effect re-runs (and finally initializes)
86+
// if selfId arrives after mount; the guard prevents init before it lands.
8587
useEffect(() => {
8688
if (!selfIdRef.current) return;
8789
const url = settings?.WebRTCUrl || gameData.webrtcUrl || null;
8890
const provider = (settings?.SignalingProvider || gameData.signalingProvider || 'websocket') as any;
8991
signaling.init(url, String(selfIdRef.current), provider);
90-
}, [gameData.webrtcUrl, gameData.signalingProvider, settings?.WebRTCUrl, settings?.SignalingProvider]);
92+
}, [selfIdFromState, gameData.webrtcUrl, gameData.signalingProvider, settings?.WebRTCUrl, settings?.SignalingProvider]);
9193

9294
const startingRef = useRef(false);
9395

web/src/pages/LiveScreensPage.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,15 @@ export default function LiveScreensPage() {
4141
const selfIdRef = useRef(selfIdValue);
4242
useEffect(() => { if (selfIdValue) selfIdRef.current = selfIdValue }, [selfIdValue]);
4343

44-
// Initialize signaling for the viewer role
44+
// Initialize signaling for the viewer role.
45+
// selfIdValue is included so the effect re-runs (and finally initializes)
46+
// if selfId arrives after mount; the guard prevents init before it lands.
4547
useEffect(() => {
4648
if (!selfIdRef.current) return;
4749
const url = settings?.WebRTCUrl || gameData.webrtcUrl || null;
4850
const provider = (settings?.SignalingProvider || gameData.signalingProvider || 'websocket') as any;
4951
signaling.init(url, String(selfIdRef.current), provider);
50-
}, [gameData.webrtcUrl, gameData.signalingProvider, settings?.WebRTCUrl, settings?.SignalingProvider]);
52+
}, [selfIdValue, gameData.webrtcUrl, gameData.signalingProvider, settings?.WebRTCUrl, settings?.SignalingProvider]);
5153

5254
const streamLabels = useMemo(() => ({
5355
connecting: t('stream_connecting'),

web/src/utils/cf-sfu.ts

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -68,26 +68,32 @@ export async function publishToCF(stream: MediaStream): Promise<{
6868

6969
const iceServers = session.iceServers ?? [{ urls: 'stun:stun.cloudflare.com:3478' }];
7070
const pc = new RTCPeerConnection({ iceServers });
71-
stream.getTracks().forEach(t => pc.addTrack(t, stream));
72-
73-
// 2. Offer + ICE gathering
74-
const offer = await pc.createOffer();
75-
await pc.setLocalDescription(offer);
76-
await _waitIce(pc);
77-
78-
// 3. Publish tracks via server → CF API
79-
const trackName = 'screen';
80-
const pub = await _nui<CFPublishResult>('CFPublishTracks', {
81-
sessionId: session.sessionId,
82-
offer: { type: pc.localDescription!.type, sdp: pc.localDescription!.sdp },
83-
});
84-
if (pub.error || !pub.sessionDescription) throw new Error('[CF SFU] CFPublishTracks: ' + pub.error);
85-
86-
// 4. Apply CF's answer
87-
await pc.setRemoteDescription(new RTCSessionDescription(pub.sessionDescription));
88-
console.log('[CF SFU] Published. sessionId:', session.sessionId, 'trackName:', trackName);
89-
90-
return { pc, sessionId: session.sessionId, trackName };
71+
try {
72+
stream.getTracks().forEach(t => pc.addTrack(t, stream));
73+
74+
// 2. Offer + ICE gathering
75+
const offer = await pc.createOffer();
76+
await pc.setLocalDescription(offer);
77+
await _waitIce(pc);
78+
79+
// 3. Publish tracks via server → CF API
80+
const trackName = 'screen';
81+
const pub = await _nui<CFPublishResult>('CFPublishTracks', {
82+
sessionId: session.sessionId,
83+
offer: { type: pc.localDescription!.type, sdp: pc.localDescription!.sdp },
84+
});
85+
if (pub.error || !pub.sessionDescription) throw new Error('[CF SFU] CFPublishTracks: ' + pub.error);
86+
87+
// 4. Apply CF's answer
88+
await pc.setRemoteDescription(new RTCSessionDescription(pub.sessionDescription));
89+
console.log('[CF SFU] Published. sessionId:', session.sessionId, 'trackName:', trackName);
90+
91+
return { pc, sessionId: session.sessionId, trackName };
92+
} catch (err) {
93+
// Close the peer connection we created so it doesn't leak on failure.
94+
pc.close();
95+
throw err;
96+
}
9197
}
9298

9399
// ─── Subscriber ───────────────────────────────────────────────────────────────

web/src/utils/signaling/fivem-native.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ export class FiveMNativeProvider implements SignalingProvider {
4949

5050
if (this._listener && !isNewPlayer && this._connected) return; // Already setup and registered
5151

52+
// Reconfiguring (e.g. new playerId): drop the previous listener before
53+
// installing a fresh one so we don't stack duplicate window listeners.
54+
if (this._listener) {
55+
window.removeEventListener('message', this._listener);
56+
this._listener = null;
57+
}
58+
5259
// Every message from Lua arrives as a window message with action='Signal'
5360
this._listener = (e: MessageEvent) => {
5461
try {

web/src/utils/signaling/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export const signaling = {
3333

3434
// Switch active provider if necessary
3535
if (this._active.type !== signalingType) {
36+
// Stop the previous provider (reconnect loops, listeners) before swapping.
37+
this._active.disconnect();
3638
this._active = _instances[signalingType] || _instances['websocket'];
3739
console.log('[Signaling] Switched to provider:', signalingType, '(streaming:', providerType, ')');
3840
}

web/src/utils/signaling/websocket.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,22 @@ export class WebSocketProvider implements SignalingProvider {
2121
connect(url: string | null, playerId: string) {
2222
// Force WSS — FiveM NUI pages are served over HTTPS
2323
const raw = url || 'wss://ws.gf2.in';
24-
this.url = raw.startsWith('ws://') ? raw.replace('ws://', 'wss://') : raw;
24+
const normalized = raw.startsWith('ws://') ? raw.replace('ws://', 'wss://') : raw;
25+
const urlChanged = this.url !== normalized;
26+
const playerChanged = this.playerId !== playerId;
27+
28+
this.url = normalized;
2529
this.playerId = playerId;
30+
31+
if (this.ws && urlChanged) {
32+
// Target endpoint changed — tear down the old socket and reopen.
33+
this.disconnect();
34+
} else if (this.ws && playerChanged && this.ws.readyState === WebSocket.OPEN) {
35+
// Same endpoint, new identity — re-register on the existing socket.
36+
this.send({ type: 'register', playerId: this.playerId! });
37+
return;
38+
}
39+
2640
this._open();
2741
}
2842

0 commit comments

Comments
 (0)