From 6ca45de838dfb10a9bcaf9f6f167ee331bcb6051 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 09:31:08 +0000 Subject: [PATCH] Split peerConnection into separate outgoing/incoming connections The root cause of both bugs was that each peer had a single peerConnection field used for both sending and receiving streams: 1. Starting a share while receiving another's stream failed because sendOfferTo() saw the existing incoming PC and bailed out, so the outgoing stream was never sent. 2. Stopping a share killed the other person's stream because stopSharing() closed ALL peer connections, including the incoming one used to receive their stream. Fix by splitting into outgoingPC (created via sendOfferTo, used for sending our stream) and incomingPC (created via handleOffer, used for receiving their stream). ICE candidates now embed a direction flag so the remote side routes them to the correct PC. https://claude.ai/code/session_01ALSwS4S8EHiP81i2KMsb9Y --- lanshare/static/index.html | 113 +++++++++++++++++++++---------------- 1 file changed, 64 insertions(+), 49 deletions(-) diff --git a/lanshare/static/index.html b/lanshare/static/index.html index cf21526..3221900 100644 --- a/lanshare/static/index.html +++ b/lanshare/static/index.html @@ -430,7 +430,7 @@ peerId: null, peerIp: null, // Our own IP as seen by the server peerName: localStorage.getItem('lanshare_name') || null, - peers: new Map(), // peer_id -> { name, ip, isSharing, hasAudio, stream, peerConnection } + peers: new Map(), // peer_id -> { name, ip, isSharing, hasAudio, stream, outgoingPC, incomingPC } localStream: null, isSharing: false, includeAudio: true, @@ -601,9 +601,11 @@ peer.isSharing = false; peer.hasAudio = false; peer.stream = null; - if (peer.peerConnection) { - peer.peerConnection.close(); - peer.peerConnection = null; + // Only close the incoming connection (their stream to us). + // Keep our outgoing connection if we're sharing to them. + if (peer.incomingPC) { + peer.incomingPC.close(); + peer.incomingPC = null; } if (state.selectedPeerId === msg.peer_id) { state.selectedPeerId = null; @@ -648,18 +650,18 @@ isSharing, hasAudio, stream: null, - peerConnection: null, + outgoingPC: null, // PC where we send our stream to this peer + incomingPC: null, // PC where we receive this peer's stream }); } function removePeer(peerId) { const peer = state.peers.get(peerId); if (peer) { - if (peer.peerConnection) { - peer.peerConnection.close(); - } + if (peer.outgoingPC) peer.outgoingPC.close(); + if (peer.incomingPC) peer.incomingPC.close(); state.peers.delete(peerId); - + if (state.selectedPeerId === peerId) { state.selectedPeerId = null; mainVideo.srcObject = null; @@ -668,45 +670,47 @@ } // ==================== WebRTC ==================== - function createPeerConnection(peerId) { + // direction: 'outgoing' (we send offer) or 'incoming' (we receive offer) + function createPeerConnection(peerId, direction) { const pc = new RTCPeerConnection(rtcConfig); - + // Debug: Log ICE gathering state changes pc.onicegatheringstatechange = () => { - console.log(`[${peerId.slice(0,8)}] ICE gathering state: ${pc.iceGatheringState}`); + console.log(`[${peerId.slice(0,8)}:${direction}] ICE gathering state: ${pc.iceGatheringState}`); if (pc.iceGatheringState === 'complete') { const sdp = pc.localDescription?.sdp || ''; const candidates = extractCandidatesFromSDP(sdp); - console.log(`[${peerId.slice(0,8)}] Final SDP has ${candidates.length} candidates:`); + console.log(`[${peerId.slice(0,8)}:${direction}] Final SDP has ${candidates.length} candidates:`); candidates.forEach(c => console.log(` ${c}`)); } }; - + // Debug: Log ICE connection state pc.oniceconnectionstatechange = () => { - console.log(`[${peerId.slice(0,8)}] ICE connection state: ${pc.iceConnectionState}`); + console.log(`[${peerId.slice(0,8)}:${direction}] ICE connection state: ${pc.iceConnectionState}`); }; - + // Trickle ICE - send candidates as they're discovered pc.onicecandidate = (event) => { if (event.candidate) { const c = event.candidate; - console.log(`[${peerId.slice(0,8)}] Local candidate: ${c.candidate}`); + console.log(`[${peerId.slice(0,8)}:${direction}] Local candidate: ${c.candidate}`); console.log(` → type: ${c.type}, protocol: ${c.protocol}, address: ${c.address}, port: ${c.port}`); if (USE_TRICKLE_ICE) { // Replace mDNS .local addresses with our real IP for direct LAN connectivity const candidateObj = event.candidate.toJSON(); candidateObj.candidate = replaceMdnsWithIp(candidateObj.candidate, state.peerIp); + // Embed direction so the remote side routes to the right PC send({ type: 'ice_candidate', from: state.peerId, to: peerId, - candidate: JSON.stringify(candidateObj), + candidate: JSON.stringify({ ...candidateObj, _dir: direction }), }); } } else { - console.log(`[${peerId.slice(0,8)}] ICE candidate gathering complete (null candidate)`); + console.log(`[${peerId.slice(0,8)}:${direction}] ICE candidate gathering complete (null candidate)`); } }; @@ -733,7 +737,7 @@ }; pc.onconnectionstatechange = () => { - console.log(`[${peerId.slice(0,8)}] Connection state: ${pc.connectionState}`); + console.log(`[${peerId.slice(0,8)}:${direction}] Connection state: ${pc.connectionState}`); }; return pc; @@ -781,16 +785,16 @@ console.warn('sendOfferTo: peer not found:', peerId); return; } - - // Don't create a new connection if we already have one that's not closed - if (peer.peerConnection && peer.peerConnection.connectionState !== 'closed') { - console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have connection`); + + // Don't create a new outgoing connection if we already have one + if (peer.outgoingPC && peer.outgoingPC.connectionState !== 'closed') { + console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have outgoing connection`); return; } - + console.log(`[${peerId.slice(0,8)}] sendOfferTo: creating offer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`); - const pc = createPeerConnection(peerId); - peer.peerConnection = pc; + const pc = createPeerConnection(peerId, 'outgoing'); + peer.outgoingPC = pc; // Add local stream tracks if (state.localStream) { @@ -828,14 +832,14 @@ const peer = state.peers.get(fromPeerId); - // Close existing connection if any (new offer = renegotiation) - if (peer.peerConnection) { - peer.peerConnection.close(); + // Close existing incoming connection if any (new offer = renegotiation) + if (peer.incomingPC) { + peer.incomingPC.close(); } console.log(`[${fromPeerId.slice(0,8)}] handleOffer: creating answer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`); - const pc = createPeerConnection(fromPeerId); - peer.peerConnection = pc; + const pc = createPeerConnection(fromPeerId, 'incoming'); + peer.incomingPC = pc; // Replace any mDNS .local addresses in the remote SDP with the peer's real IP const sdp = JSON.parse(sdpJson); @@ -869,14 +873,14 @@ async function handleAnswer(fromPeerId, sdpJson) { const peer = state.peers.get(fromPeerId); - if (!peer || !peer.peerConnection) { - console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no peer connection`); + if (!peer || !peer.outgoingPC) { + console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no outgoing peer connection`); return; } // Only set remote description if we're in the right state - if (peer.peerConnection.signalingState !== 'have-local-offer') { - console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.peerConnection.signalingState}`); + if (peer.outgoingPC.signalingState !== 'have-local-offer') { + console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.outgoingPC.signalingState}`); return; } @@ -888,22 +892,32 @@ remoteCandidates.forEach(c => console.log(` ${c}`)); console.log(`[${fromPeerId.slice(0,8)}] handleAnswer: setting remote description`); - await peer.peerConnection.setRemoteDescription(new RTCSessionDescription(sdp)); + await peer.outgoingPC.setRemoteDescription(new RTCSessionDescription(sdp)); } function handleIceCandidate(fromPeerId, candidateJson) { const peer = state.peers.get(fromPeerId); - if (!peer || !peer.peerConnection) { - console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`); + if (!peer) { + console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: peer not found`); + return; + } + + const data = JSON.parse(candidateJson); + const dir = data._dir; + delete data._dir; + + // Their outgoing PC corresponds to our incoming PC (and vice versa) + const pc = dir === 'outgoing' ? peer.incomingPC : peer.outgoingPC; + if (!pc) { + console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no matching PC for direction ${dir}`); return; } // Replace any mDNS .local addresses with the peer's real IP - const candidate = JSON.parse(candidateJson); - candidate.candidate = replaceMdnsWithIp(candidate.candidate, peer.ip); - console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`); + data.candidate = replaceMdnsWithIp(data.candidate, peer.ip); + console.log(`[${fromPeerId.slice(0,8)}:${dir}] Remote candidate: ${data.candidate}`); - peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) + pc.addIceCandidate(new RTCIceCandidate(data)) .then(() => console.log(`[${fromPeerId.slice(0,8)}] Successfully added remote candidate`)) .catch(err => console.error(`[${fromPeerId.slice(0,8)}] Error adding ICE candidate:`, err)); } @@ -950,14 +964,15 @@ state.localStream.getTracks().forEach(track => track.stop()); state.localStream = null; } - + state.isSharing = false; - - // Close all peer connections we initiated + + // Only close outgoing connections (where we were sending our stream). + // Keep incoming connections alive so we can still view others' streams. for (const [peerId, peer] of state.peers) { - if (peer.peerConnection) { - peer.peerConnection.close(); - peer.peerConnection = null; + if (peer.outgoingPC) { + peer.outgoingPC.close(); + peer.outgoingPC = null; } }