diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 813d2cf..688f280 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -2095,15 +2095,19 @@ async function sfuSubscribe(){ * sfuUnsubscribe + sfuSubscribe. The auto-rejoin code that follows * a hard refresh handles the WS side; this handles the SFU side. */ pc.onconnectionstatechange = () => { - const s = pc.connectionState; - if (s === 'failed' || s === 'closed'){ - logLine('err', 'sfu sub PC ' + s + ' — rebuilding'); - if (sfuSubPC === pc){ - sfuUnsubscribe().then(() => { - if (wantConnected && roomID) sfuSubscribe().catch(e => logLine('err','sfu re-subscribe: '+e.message)); - }); - } - } + /* '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). + * 'disconnected' is transient — let WebRTC try to recover before + * we yank the rug. */ + if (pc.connectionState !== 'failed') return; + if (sfuSubPC !== pc) return; + logLine('err', 'sfu sub PC failed — rebuilding'); + sfuUnsubscribe().then(() => { + if (wantConnected && roomID) sfuSubscribe().catch(e => logLine('err','sfu re-subscribe: '+e.message)); + }); }; /* SSE: server pushes renegotiation offers when publisher set changes. * We answer each via POST /answer. ping events are keepalive only. @@ -2142,7 +2146,15 @@ async function sfuSubscribe(){ async function sfuUnsubscribe(){ if (sfuSubEvents){ try { sfuSubEvents.close(); } catch(_){} sfuSubEvents = null; } - if (sfuSubPC){ try { sfuSubPC.close(); } catch(_){} sfuSubPC = null; sfuSubPeerID = null; } + /* null out sfuSubPC FIRST, then close — Chrome fires the + * connectionstatechange handler SYNCHRONOUSLY during pc.close(), and + * the handler's `if (sfuSubPC === pc)` guard depends on the global + * already being null. Otherwise the handler thinks the close was a + * 'failed' rebuild trigger and we get a subscribe/close loop. */ + const oldPC = sfuSubPC; + sfuSubPC = null; + sfuSubPeerID = null; + if (oldPC){ try { oldPC.close(); } catch(_){} } sfuStreamsByPubHex.clear(); } @@ -3246,14 +3258,14 @@ refreshMicList(); document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return; if (!wantConnected || !sfuSubPC) return; - const s = sfuSubPC.connectionState; - if (s === 'failed' || s === 'closed' || s === 'disconnected'){ - logLine('err', 'visibility back, sub PC ' + s + ' — rebuilding'); - const pc = sfuSubPC; - sfuUnsubscribe().then(() => { - if (wantConnected && roomID) sfuSubscribe().catch(e => logLine('err','sfu re-subscribe: '+e.message)); - }); - } + /* Only rebuild on terminal failure. 'disconnected' is transient + * (WebRTC tries to recover); 'closed' never happens here because + * sfuUnsubscribe() nulls sfuSubPC before pc.close(). */ + if (sfuSubPC.connectionState !== 'failed') return; + logLine('err', 'visibility back, sub PC failed — rebuilding'); + sfuUnsubscribe().then(() => { + if (wantConnected && roomID) sfuSubscribe().catch(e => logLine('err','sfu re-subscribe: '+e.message)); + }); }); /* ================================================================== @@ -3348,8 +3360,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');