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
This commit is contained in:
Claude 2026-02-09 09:31:08 +00:00
parent 4c223b47ed
commit 6ca45de838
No known key found for this signature in database

View file

@ -430,7 +430,7 @@
peerId: null, peerId: null,
peerIp: null, // Our own IP as seen by the server peerIp: null, // Our own IP as seen by the server
peerName: localStorage.getItem('lanshare_name') || null, 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, localStream: null,
isSharing: false, isSharing: false,
includeAudio: true, includeAudio: true,
@ -601,9 +601,11 @@
peer.isSharing = false; peer.isSharing = false;
peer.hasAudio = false; peer.hasAudio = false;
peer.stream = null; peer.stream = null;
if (peer.peerConnection) { // Only close the incoming connection (their stream to us).
peer.peerConnection.close(); // Keep our outgoing connection if we're sharing to them.
peer.peerConnection = null; if (peer.incomingPC) {
peer.incomingPC.close();
peer.incomingPC = null;
} }
if (state.selectedPeerId === msg.peer_id) { if (state.selectedPeerId === msg.peer_id) {
state.selectedPeerId = null; state.selectedPeerId = null;
@ -648,18 +650,18 @@
isSharing, isSharing,
hasAudio, hasAudio,
stream: null, 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) { function removePeer(peerId) {
const peer = state.peers.get(peerId); const peer = state.peers.get(peerId);
if (peer) { if (peer) {
if (peer.peerConnection) { if (peer.outgoingPC) peer.outgoingPC.close();
peer.peerConnection.close(); if (peer.incomingPC) peer.incomingPC.close();
}
state.peers.delete(peerId); state.peers.delete(peerId);
if (state.selectedPeerId === peerId) { if (state.selectedPeerId === peerId) {
state.selectedPeerId = null; state.selectedPeerId = null;
mainVideo.srcObject = null; mainVideo.srcObject = null;
@ -668,45 +670,47 @@
} }
// ==================== WebRTC ==================== // ==================== WebRTC ====================
function createPeerConnection(peerId) { // direction: 'outgoing' (we send offer) or 'incoming' (we receive offer)
function createPeerConnection(peerId, direction) {
const pc = new RTCPeerConnection(rtcConfig); const pc = new RTCPeerConnection(rtcConfig);
// Debug: Log ICE gathering state changes // Debug: Log ICE gathering state changes
pc.onicegatheringstatechange = () => { 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') { if (pc.iceGatheringState === 'complete') {
const sdp = pc.localDescription?.sdp || ''; const sdp = pc.localDescription?.sdp || '';
const candidates = extractCandidatesFromSDP(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}`)); candidates.forEach(c => console.log(` ${c}`));
} }
}; };
// Debug: Log ICE connection state // Debug: Log ICE connection state
pc.oniceconnectionstatechange = () => { 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 // Trickle ICE - send candidates as they're discovered
pc.onicecandidate = (event) => { pc.onicecandidate = (event) => {
if (event.candidate) { if (event.candidate) {
const c = 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}`); console.log(` → type: ${c.type}, protocol: ${c.protocol}, address: ${c.address}, port: ${c.port}`);
if (USE_TRICKLE_ICE) { if (USE_TRICKLE_ICE) {
// Replace mDNS .local addresses with our real IP for direct LAN connectivity // Replace mDNS .local addresses with our real IP for direct LAN connectivity
const candidateObj = event.candidate.toJSON(); const candidateObj = event.candidate.toJSON();
candidateObj.candidate = replaceMdnsWithIp(candidateObj.candidate, state.peerIp); candidateObj.candidate = replaceMdnsWithIp(candidateObj.candidate, state.peerIp);
// Embed direction so the remote side routes to the right PC
send({ send({
type: 'ice_candidate', type: 'ice_candidate',
from: state.peerId, from: state.peerId,
to: peerId, to: peerId,
candidate: JSON.stringify(candidateObj), candidate: JSON.stringify({ ...candidateObj, _dir: direction }),
}); });
} }
} else { } 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 = () => { 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; return pc;
@ -781,16 +785,16 @@
console.warn('sendOfferTo: peer not found:', peerId); console.warn('sendOfferTo: peer not found:', peerId);
return; return;
} }
// Don't create a new connection if we already have one that's not closed // Don't create a new outgoing connection if we already have one
if (peer.peerConnection && peer.peerConnection.connectionState !== 'closed') { if (peer.outgoingPC && peer.outgoingPC.connectionState !== 'closed') {
console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have connection`); console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have outgoing connection`);
return; return;
} }
console.log(`[${peerId.slice(0,8)}] sendOfferTo: creating offer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`); console.log(`[${peerId.slice(0,8)}] sendOfferTo: creating offer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`);
const pc = createPeerConnection(peerId); const pc = createPeerConnection(peerId, 'outgoing');
peer.peerConnection = pc; peer.outgoingPC = pc;
// Add local stream tracks // Add local stream tracks
if (state.localStream) { if (state.localStream) {
@ -828,14 +832,14 @@
const peer = state.peers.get(fromPeerId); const peer = state.peers.get(fromPeerId);
// Close existing connection if any (new offer = renegotiation) // Close existing incoming connection if any (new offer = renegotiation)
if (peer.peerConnection) { if (peer.incomingPC) {
peer.peerConnection.close(); peer.incomingPC.close();
} }
console.log(`[${fromPeerId.slice(0,8)}] handleOffer: creating answer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`); console.log(`[${fromPeerId.slice(0,8)}] handleOffer: creating answer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`);
const pc = createPeerConnection(fromPeerId); const pc = createPeerConnection(fromPeerId, 'incoming');
peer.peerConnection = pc; peer.incomingPC = pc;
// Replace any mDNS .local addresses in the remote SDP with the peer's real IP // Replace any mDNS .local addresses in the remote SDP with the peer's real IP
const sdp = JSON.parse(sdpJson); const sdp = JSON.parse(sdpJson);
@ -869,14 +873,14 @@
async function handleAnswer(fromPeerId, sdpJson) { async function handleAnswer(fromPeerId, sdpJson) {
const peer = state.peers.get(fromPeerId); const peer = state.peers.get(fromPeerId);
if (!peer || !peer.peerConnection) { if (!peer || !peer.outgoingPC) {
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no peer connection`); console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no outgoing peer connection`);
return; return;
} }
// Only set remote description if we're in the right state // Only set remote description if we're in the right state
if (peer.peerConnection.signalingState !== 'have-local-offer') { if (peer.outgoingPC.signalingState !== 'have-local-offer') {
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.peerConnection.signalingState}`); console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.outgoingPC.signalingState}`);
return; return;
} }
@ -888,22 +892,32 @@
remoteCandidates.forEach(c => console.log(` ${c}`)); remoteCandidates.forEach(c => console.log(` ${c}`));
console.log(`[${fromPeerId.slice(0,8)}] handleAnswer: setting remote description`); 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) { function handleIceCandidate(fromPeerId, candidateJson) {
const peer = state.peers.get(fromPeerId); const peer = state.peers.get(fromPeerId);
if (!peer || !peer.peerConnection) { if (!peer) {
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`); 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; return;
} }
// Replace any mDNS .local addresses with the peer's real IP // Replace any mDNS .local addresses with the peer's real IP
const candidate = JSON.parse(candidateJson); data.candidate = replaceMdnsWithIp(data.candidate, peer.ip);
candidate.candidate = replaceMdnsWithIp(candidate.candidate, peer.ip); console.log(`[${fromPeerId.slice(0,8)}:${dir}] Remote candidate: ${data.candidate}`);
console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`);
peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) pc.addIceCandidate(new RTCIceCandidate(data))
.then(() => console.log(`[${fromPeerId.slice(0,8)}] Successfully added remote candidate`)) .then(() => console.log(`[${fromPeerId.slice(0,8)}] Successfully added remote candidate`))
.catch(err => console.error(`[${fromPeerId.slice(0,8)}] Error adding ICE candidate:`, err)); .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.getTracks().forEach(track => track.stop());
state.localStream = null; state.localStream = null;
} }
state.isSharing = false; 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) { for (const [peerId, peer] of state.peers) {
if (peer.peerConnection) { if (peer.outgoingPC) {
peer.peerConnection.close(); peer.outgoingPC.close();
peer.peerConnection = null; peer.outgoingPC = null;
} }
} }