From ae9721e336f041b02381264fe0c9404ba04c55df Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Tue, 2 Jun 2026 10:20:48 -0400 Subject: [PATCH] zebra-spaces: serialise SFU subscribe renegotiation + skip own mic echo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fox reported: late joiner sees host's screen + camera initially. The moment they share their own screen/camera, they lose the host's tiles. The host never sees their stuff either. Symptoms point at the renegotiation flow being raced. Root cause: SSE delivers offers via async onmessage handlers. JS is single-threaded but each `await` yields. When a speaker publishes mic + screen + camera in quick succession, the SFU's addPubToSub serialises and fires three SSE offers. The browser handler picks up offer 1 with setRemoteDescription (state → have-remote-offer), then awaits createAnswer. During that await another onmessage fires for offer 2 and tries setRemoteDescription — which throws because the PC is in have-remote-offer state. Offer 2 is dropped, the new tracks for that publish never register on the browser side. SFU still forwards RTP for those tracks but the browser has no receiver, so they vanish. Existing tiles can also stop receiving RTP when the SFU's track set diverges from the browser's transceiver set. Fix: - chain onmessage handlers through a single Promise queue (`renegQueue = renegQueue.then(...)`) so each renegotiation fully completes (SRD → answer → SLD → /answer POST) before the next starts. Browser PC always returns to stable between offers. - ontrack mic-path now also short-circuits when pubHex === own pubHex. Without this, a speaker who subscribes to the SFU would attach their OWN mic to a remote-audio sink and hear themselves. --- web/zebra-spaces.html | 45 +++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 84ef5c1..a3fb8b2 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1254,6 +1254,9 @@ async function sfuSubscribe(){ * delay which is well below noticeable. */ try { ev.receiver.playoutDelayHint = 0.4; } catch(_){} const pubHex = sid; + /* never play our own mic back to ourselves — the SFU echoes our + * publish to every sub including our own (we always subscribe now) */ + if (myKeys && pubHex === myKeys.pubHex) return; /* cache stream by publisher pubkey so it survives the member's session * uuid changing across leave/rejoin — see flushSfuStreams */ sfuStreamsByPubHex.set(pubHex, ev.streams[0]); @@ -1296,21 +1299,35 @@ async function sfuSubscribe(){ if (!ackRes.ok){ pc.close(); throw new Error('sfu subscribe-answer http '+ackRes.status); } sfuSubPC = pc; sfuSubPeerID = offer.peer_id; /* SSE: server pushes renegotiation offers when publisher set changes. - * We answer each via POST /answer. ping events are keepalive only. */ + * We answer each via POST /answer. ping events are keepalive only. + * + * Renegotiation must be SERIALISED. Each offer transitions the PC + * through have-remote-offer → stable, and if we kick off the next + * setRemoteDescription before the previous has applied its answer, + * the second one throws ('failed to set remote offer sdp: Called in + * wrong state'). When a speaker publishes mic + screen + camera in + * quick succession the SFU fires three offers in a row — without + * serialisation we drop the later ones, browser-side track set goes + * out of sync with the SFU, and existing screen/camera tiles can + * stop receiving RTP. Chain through a single promise queue. */ + let renegQueue = Promise.resolve(); sfuSubEvents = new EventSource(SFU_BASE + '/events?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID); - sfuSubEvents.onmessage = async (ev) => { + sfuSubEvents.onmessage = (ev) => { let m; try { m = JSON.parse(ev.data); } catch(_){ return; } if (m.type !== 'offer' || !sfuSubPC) return; - try { - await sfuSubPC.setRemoteDescription({ type:'offer', sdp: m.sdp }); - const ans = await sfuSubPC.createAnswer(); - await sfuSubPC.setLocalDescription(ans); - await waitForIceGathering(sfuSubPC); - await fetch(SFU_BASE + '/answer?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID, { - method:'POST', headers:{'Content-Type':'application/json'}, - body: JSON.stringify({ sdp: sfuSubPC.localDescription.sdp }) - }); - } catch(e){ logLine('err','sfu renegotiate: '+e.message); } + renegQueue = renegQueue.then(async () => { + if (!sfuSubPC) return; + try { + await sfuSubPC.setRemoteDescription({ type:'offer', sdp: m.sdp }); + const ans = await sfuSubPC.createAnswer(); + await sfuSubPC.setLocalDescription(ans); + await waitForIceGathering(sfuSubPC); + await fetch(SFU_BASE + '/answer?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID, { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ sdp: sfuSubPC.localDescription.sdp }) + }); + } catch(e){ logLine('err','sfu renegotiate: '+e.message); } + }); }; sfuSubEvents.onerror = () => { /* EventSource auto-reconnects */ }; logLine('', 'sfu: subscribed as '+shortHex(sfuSubPeerID)); @@ -2415,8 +2432,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');