From 09a451697a43ffe5e901ec0864d21bc316707391 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 4 Jun 2026 13:27:59 -0400 Subject: [PATCH] zebra-spaces: rebuild sub PC on remote-driven 'closed' (not just 'failed') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the SFU closes the listener's sub PC server-side (e.g. via the wedge-recovery in renegotiateLocked), the browser's pc.connectionState transitions to 'closed', not 'failed'. The existing handler only triggered a rebuild on 'failed' and explicitly returned on 'closed' (under the assumption that 'closed' == self-teardown). That assumption holds for sfuUnsubscribe (which nulls sfuSubPC BEFORE pc.close()), but NOT for remote-driven closes — sfuSubPC === pc is still true, the guard sees 'unexpected close', and we should re-subscribe. Without this branch the host's sub stayed at sub=none indefinitely after any server-driven close — every subsequent addPubToSub for the host went into the void. Fox 2026-06-04 lost cohost camera + screen share via this exact path after the wedge-recovery falsely fired on his fresh subscribe (SFU fix 8c5fd65 prevents the false positive going forward, this fix ensures the page recovers on any legitimate server close). Both 'failed' and 'closed' now rebuild via sfuUnsubscribe + sfuSubscribe, gated on sfuSubPC === pc to catch only remote-driven state changes. --- web/zebra-spaces.html | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 6bd0736..b28d625 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -2911,15 +2911,23 @@ async function sfuSubscribe(){ * a hard refresh handles the WS side; this handles the SFU side. */ pc.onconnectionstatechange = () => { /* 'failed' is terminal ICE failure (we should rebuild). - * 'closed' is OUR OWN sfuUnsubscribe() — never rebuild on that - * (Chrome fires the state change synchronously before sfuSubPC is - * null'd, which used to trigger a subscribe→close→subscribe loop - * every time anyone deliberately tore down the sub). + * 'closed' coming from our OWN sfuUnsubscribe() must NOT rebuild + * (subscribe→close→subscribe loop). sfuUnsubscribe nulls + * sfuSubPC BEFORE pc.close(), so the `sfuSubPC === pc` guard + * catches self-teardown — the guard fails and we early-return. + * 'closed' coming from the SFU (server-side close — e.g. our + * own wedge-recovery in renegotiateLocked) DOES need a rebuild + * because sfuSubPC === pc is still true (we didn't tear down). + * Without this branch the host's sub stays at sub=none after + * any server-driven close, every subsequent track add goes + * nowhere. Fox 2026-06-04: "lost cohost camera / screen + * share" was downstream of this missing rebuild. * 'disconnected' is transient — let WebRTC try to recover before - * we yank the rug. */ - if (pc.connectionState !== 'failed') return; + * we yank the rug. */ + const s = pc.connectionState; + if (s !== 'failed' && s !== 'closed') return; if (sfuSubPC !== pc) return; - logLine('err', 'sfu sub PC failed — rebuilding'); + logLine('err', 'sfu sub PC '+s+' (remote-driven) — rebuilding'); sfuUnsubscribe().then(() => { if (wantConnected && roomID) sfuSubscribe().catch(e => logLine('err','sfu re-subscribe: '+e.message)); }); @@ -5568,8 +5576,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');