zebra-spaces: auto-rebuild publish PCs on ICE failure (mic/screen/camera)

Repro from fox: a listener joined a room while host's publish PCs were
silently in 'failed' state — saw no audio, no camera, no screen for
~60s until host left and rejoined, which created fresh publish PCs.

SFU log confirmed: host's mic/screen/camera publish PCs all hit ICE
failure within the first minute. SFU reaped them on
OnConnectionStateChange(failed). When the listener subscribed there
were zero publishers in the room — they got an SDP with only the
inactive placeholder m-line and never recovered until host re-published.

Page had a 'failed' rebuild path only for sfuSubPC (the subscribe side).
The three publish PCs were unwatched — once Pion-on-server killed them
they were dead but the page UI kept saying 'sfu: publishing as ...'
with nothing actually broadcasting.

New watchPublishPC() helper installs the same rebuild pattern on
sfuPubPC, sfuScreenPC, sfuCameraPC: on 'failed' the dead PC is closed,
the slot is nulled, and the publish entrypoint runs again. The
'wasOurs' guard prevents the rebuild from firing if the user explicitly
unpublished (which also fires onconnectionstatechange).

sfuGamePC gets a softer treatment — its source is a user-picked iframe
via Region Capture, so auto-restart isn't safe. The page just logs the
failure clearly + tears down so the share button comes back.
This commit is contained in:
Russell Ballestrini 2026-06-02 17:37:14 -04:00
parent 3a6b80e3f4
commit 8631c106d2
No known key found for this signature in database

View file

@ -1959,9 +1959,35 @@ async function sfuPublish(){
const ans = await res.json();
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
sfuPubPC = pc; sfuPubPeerID = ans.peer_id;
watchPublishPC(pc, 'mic', sfuPublish, () => sfuPubPC === pc, () => { sfuPubPC = null; sfuPubPeerID = null; });
logLine('', 'sfu: publishing as '+sfuPubPeerID);
}
/* Publish-side ICE failure recovery. The SFU reaps a failed publisher
* (OnConnectionStateChange in the Go side calls h.removePublisher), so
* any listener that arrives during the failure window gets an SDP with
* zero m-lines and never sees audio/video until the host re-publishes.
* Bug observed: fxhp's mic/screen/camera all failed ICE; a listener
* who joined while they were down got nothing until fxhp left + rejoined.
*
* The page now auto-rebuilds: on 'failed' on any of the publish PCs we
* tear down the dead PC and call the publish entrypoint again. wasOurs
* guards against the racey case where the user explicitly stopped
* sharing between the 'failed' fire and our reaction (e.g. closing a
* screen-share). cleanup runs synchronously before the rebuild so the
* idempotent guard at the top of sfuPublishX (`if (sfuXPC) return`)
* doesn't bail us out of the recovery. */
function watchPublishPC(pc, label, rebuildFn, wasOurs, cleanup){
pc.onconnectionstatechange = () => {
if (pc.connectionState !== 'failed') return;
if (!wasOurs()) return;
logLine('err', 'sfu '+label+' publish PC failed — rebuilding');
try { pc.close(); } catch(_){}
cleanup();
rebuildFn().catch(e => logLine('err','sfu '+label+' re-publish: '+e.message));
};
}
/* ----- screen share ----- */
async function sfuPublishScreen(){
if (sfuScreenPC || !myKeys || !roomID) return;
@ -2027,6 +2053,9 @@ async function sfuPublishScreen(){
if (s.track.kind === 'video') setSenderMaxBitrate(s, 6000000);
if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000);
}
watchPublishPC(pc, 'screen', sfuPublishScreen,
() => sfuScreenPC === pc,
() => { sfuScreenPC = null; sfuScreenPeerID = null; sfuScreenStream = null; });
logLine('', 'sfu: sharing screen as '+sfuScreenPeerID);
/* render a muted local preview so the publisher sees what they're
* sharing — SFU does not echo the publisher's own stream back */
@ -2106,6 +2135,15 @@ async function sfuPublishGame(iframe){
if (s.track.kind === 'video') setSenderMaxBitrate(s, 4000000);
if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000);
}
/* game-share doesn't auto-rebuild on 'failed' — its capture source is
* a user-selected iframe via Region Capture; the user would have to
* pick it again anyway. Just surface the failure clearly and tear
* down the dead PC so the share button comes back. */
pc.onconnectionstatechange = () => {
if (pc.connectionState !== 'failed' || sfuGamePC !== pc) return;
logLine('err', 'sfu game-share PC failed — stop and re-share to recover');
sfuUnpublishGame().catch(()=>{});
};
logLine('', 'sfu: sharing gameplay as '+sfuGamePeerID);
}
async function sfuUnpublishGame(){
@ -2156,6 +2194,9 @@ async function sfuPublishCamera(){
for (const s of pc.getSenders()){
if (s.track && s.track.kind === 'video') setSenderMaxBitrate(s, 1500000);
}
watchPublishPC(pc, 'camera', sfuPublishCamera,
() => sfuCameraPC === pc,
() => { sfuCameraPC = null; sfuCameraPeerID = null; sfuCameraStream = null; });
logLine('', 'sfu: camera on as '+sfuCameraPeerID);
renderCameraTile(myKeys.pubHex, stream, { local: true });
$('btn-camera-share').classList.add('hidden');
@ -3617,8 +3658,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-02</span><br>
md5 <span class="stamp-md5">88a1041dc605ad408b4bfd87fa8d441e</span><br>
sha256 <span class="stamp-sha">24867165e436c9d417bdb3709bac7ddf1f374a0ffbf95b84742d00423e645dc7</span><br>
md5 <span class="stamp-md5">b3db2fd79fc51b75927a92257dee6295</span><br>
sha256 <span class="stamp-sha">08421e83202e6042104b1b06b7f5075eab58c844f5b8109d9a00f5d295cd1cde</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>