From 6a72e77518221e911f57182bc7e5a87110b40b07 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Tue, 2 Jun 2026 11:25:41 -0400 Subject: [PATCH] zebra-spaces: rebuild SFU sub PC on connectionstatechange + visibility resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lid-close / suspend recovery. When the OS suspends the browser, the SFU sub PC goes 'disconnected' → 'failed' (or just stops carrying RTP silently). On resume the old PC is dead but JS still holds a reference to it, so we get frozen remote screens until the host hard-reboots. Two recovery paths added: 1. pc.onconnectionstatechange — on 'failed' or 'closed' for the active sub PC, sfuUnsubscribe() then sfuSubscribe() to rebuild. The wantConnected guard prevents fighting a deliberate leave. 2. document visibilitychange → 'visible' — Chromium on Linux can silently keep the PC in 'connected' state through a suspend cycle without firing connectionstatechange. So we also probe on tab/lid wake: if the sub PC's connectionState is anything other than 'connected'/'new'/'connecting', force the rebuild. Both paths converge on the same teardown + re-subscribe so the SFU sends a fresh initial SDP with every current publisher's tracks. 83 FSM tests still green; this is runtime-only and doesn't affect the pure state machines yet. --- web/zebra-spaces.html | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index cc98b09..28430e6 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1996,6 +1996,23 @@ async function sfuSubscribe(){ }); if (!ackRes.ok){ pc.close(); throw new Error('sfu subscribe-answer http '+ackRes.status); } sfuSubPC = pc; sfuSubPeerID = offer.peer_id; + /* survive laptop-lid-close / network suspend. When the OS suspends the + * browser the SFU sub PC goes 'disconnected' first (transient) then + * 'failed'. On 'failed' the existing PC is dead — RTP can never + * recover even if we resume — so we tear it down and rebuild via + * 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)); + }); + } + } + }; /* SSE: server pushes renegotiation offers when publisher set changes. * We answer each via POST /answer. ping events are keepalive only. * @@ -3128,6 +3145,25 @@ if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){ * sees how many inputs exist. */ refreshMicList(); +/* laptop-lid-close / sleep / suspend recovery: when the tab comes back + * to visible, check whether our SFU sub PC is still in a healthy state. + * Some browsers (Chromium on Linux specifically) don't fire + * connectionstatechange on suspend → resume — the PC sits silently in + * 'connected' but no RTP flows. If it's in any non-live state, force + * the rebuild. */ +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)); + }); + } +}); + /* ================================================================== * leave / entry buttons * ================================================================== */ @@ -3226,8 +3262,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');