From 54f97866a24b353f365703ea1a73fab656233459 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Tue, 2 Jun 2026 14:45:15 -0400 Subject: [PATCH] zebra-spaces: keep mesh on role change, drop all publishes on demote, no looked-away noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects fox hit in one demotion. Fixes: 1) 'looked away' triggered on every demoted person. Cause: dropping the user's screen/camera tile in sfuUnpublishX during demotion fired pickNextSpotlight → broadcastSpotlight with empty key → every peer logged 'X looked away'. False signal — they didn't look away, the room tore their tile. Fix: inRoleTransition flag set inside onRoleChanged(); broadcast- Spotlight() short-circuits when true. 2) Speaker links not severed — they could keep talking to listeners. Cause: onRoleChanged tore down mic+sfuPubPC but NOT screen / camera / game publishes. Listeners kept seeing the demoted user's mic + screen via the now-orphan publishers. Fix: demotion now awaits sfuUnpublish + sfuUnpublishScreen + sfuUnpublishCamera + sfuUnpublishGame. 3) Mesh peers were torn automatically on demotion (per fox: 'never drop people out of the mesh automatically'). Cause: both onRoleChanged AND the other-side handler in case 'role-change' called tearPeer when a member became listener. Fix: removed both tearPeer calls. Mesh connections ride as a back-channel until one side actually leaves the room. Promote path still connects (idempotent, only when both can speak and the peer doesn't exist). The 'demoted user can't see screen-shares' part of fox's report is likely a sub-PC renegotiation race during the multi-unpublish — diagnostic log lines from earlier commit (bc7bbbf) should help us narrow it down once fox reproduces and pastes the log. --- web/zebra-spaces.html | 68 ++++++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 70df133..bbfa87b 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1631,7 +1631,12 @@ function reorderTiles(){ for (const el of els) c.appendChild(el); } function broadcastSpotlight(){ - /* fire-and-forget to the signal server; no-op if not connected yet */ + /* fire-and-forget to the signal server; no-op if not connected yet. + * Suppressed during role transitions — otherwise removeScreenTile + + * removeCameraTile triggered by sfuUnpublishScreen/Camera during a + * demotion fire pickNextSpotlight → broadcasts an empty spotlight, + * which every other peer logs as 'X looked away'. False signal. */ + if (typeof inRoleTransition !== 'undefined' && inRoleTransition) return; try { send({ type: 'spotlight', key: spotlightKey() }); } catch(_){} } function logSpotlightChange(uuid, key){ @@ -2797,12 +2802,13 @@ async function handleSignal(raw){ onRoleChanged(prev, m.role); } else { logLine('', mm.handle+' is now '+m.role); - /* mesh adjustments */ - if (canSpeak(myRole)){ - if (canSpeak(m.role) && !peers.has(m.uuid)) connectToPeer(m.uuid, myUUID < m.uuid); - if (!canSpeak(m.role) && peers.has(m.uuid)) tearPeer(m.uuid); - } else if (peers.has(m.uuid)){ - tearPeer(m.uuid); + /* mesh adjustments — fox: 'never drop people out of the mesh + * automatically'. Promote: connect if we can speak + they + * can speak + we don't already have them. Demote: do NOT + * tear existing mesh peer connections; let them ride as a + * back-channel until one side actually leaves the room. */ + if (canSpeak(myRole) && canSpeak(m.role) && !peers.has(m.uuid)){ + connectToPeer(m.uuid, myUUID < m.uuid); } } renderRoom(); @@ -2894,23 +2900,39 @@ async function onRoleEntered(){ sfuPublish().catch(e => logLine('err','sfu publish: '+e.message)); } } +let inRoleTransition = false; async function onRoleChanged(prev, next){ - if (!canSpeak(prev) && canSpeak(next)){ - await ensureMicAndUI(); - for (const [uuid, mm] of members){ - if (uuid === myUUID) continue; - if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid); + /* suppress spotlight broadcasts triggered by tile cleanup during the + * role transition — otherwise removeScreenTile / removeCameraTile + * fires pickNextSpotlight which broadcasts an empty spotlight key, + * which every other peer logs as 'X looked away'. */ + inRoleTransition = true; + try { + if (!canSpeak(prev) && canSpeak(next)){ + await ensureMicAndUI(); + for (const [uuid, mm] of members){ + if (uuid === myUUID) continue; + if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid); + } + /* keep sfuSub alive — screen + camera tracks still ride it. The + * ontrack handler suppresses SFU mic when mesh is also up. */ + sfuPublish().catch(e => logLine('err','sfu publish: '+e.message)); + } else if (canSpeak(prev) && !canSpeak(next)){ + /* demote to listener: drop everything we PUBLISH (mic + screen + + * camera + game) but DON'T tear mesh peers. fox: 'never drop + * people out of the mesh automatically'. The mesh connections + * stay up as a bonus low-latency audio path; they get GC'd + * naturally when the other end leaves or also demotes. */ + dropMic(); muted = false; + await sfuUnpublish(); + await sfuUnpublishScreen(); + await sfuUnpublishCamera(); + await sfuUnpublishGame(); } - /* keep sfuSub alive — screen + camera tracks still ride it. The - * ontrack handler suppresses SFU mic when mesh is also up. */ - sfuPublish().catch(e => logLine('err','sfu publish: '+e.message)); - } else if (canSpeak(prev) && !canSpeak(next)){ - for (const u of [...peers.keys()]) tearPeer(u); - dropMic(); muted = false; - await sfuUnpublish(); - /* still subscribed — listener role needs the same incoming streams */ + updateRoleUI(); + } finally { + inRoleTransition = false; } - updateRoleUI(); } async function ensureMicAndUI(){ /* mic input + music-mode rows are always visible; here we just grant the @@ -3525,8 +3547,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');