room.js: fix round 3 - adopt at the next quiet moment, ask per kind
- A connection built during the first /ice fetch is on the floor's empty list, and adoptServers will not restart it mid-negotiation. That left it on host candidates until the 150-minute renewal, which is most of a call. It now waits instead: createPC carries a signalingstatechange listener running the same adoptWhenQuiet decision, so the connection takes the list at its own next quiet moment. floor is cleared before the restart, so the restart's own state change cannot come back round. - createPC attaches its listeners before any media. addTrack fires negotiationneeded, that event is one-shot, and an entry registered in pcs with nothing listening for it is a connection that will never offer — which is exactly what a stable connection with a null localDescription and a connectionState of new looks like. The track loop is wrapped, and the recvonly fallback is per kind: a browser that came down startLocal's ladder to audio alone had a local stream, so the old all-or-nothing else asked to receive nothing, offered no video m-line at all, and the person on the other side had a tile that could never get a frame. An ended track counts as absent. - diagnose() prints pcState per side: signaling, connection, ice, gathering, local, remote, senders, transceivers as direction:kind and tracks as kind:readyState. No assertion changes. The construct trap that collects it moves to newContext so every scenario has it — a losing pair's evidence cannot be gathered after the fact — and is idempotent, so icerenewal's own install still works. - TestIceRenewAtIsFiveSixthsOfTheLife pins the ratio room.js schedules from, off the wire because the constants are unexported. Verified against a moved iceRenewAfter. - browser_test.go's note says three doors, and why the third is missing from the account above it: the hub sends `peer`, not a fresh roster, when someone joins. The slice 3 record's flake entry gains the reviewer's reading of the signature and the new diagnostic. 52 node tests. make browser three times: 26/26 at 318.20s, 318.44s and 318.30s, no scenario lost, so no pcState line from a failure to quote. Three clean runs in a row is a first for this branch against a flake that was running at one in three; it is evidence, not proof.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
6 files changed,
+363
−64
docs/superpowers/reviews/2026-09-10-slice-3-record.md+34 −9internal/meet/static/room.js+91 −38internal/meettest/browser_test.go+11 −4internal/meettest/ice_test.go+42 −0test/browser/call.mjs+43 −0test/browser/negotiation.test.mjs+142 −13
CI passed — run details
diff --git a/docs/superpowers/reviews/2026-09-10-slice-3-record.md b/docs/superpowers/reviews/2026-09-10-slice-3-record.md| index a0f7695..fd47e47 100644 |
| --- a/docs/superpowers/reviews/2026-09-10-slice-3-record.md |
| +++ b/docs/superpowers/reviews/2026-09-10-slice-3-record.md |
| @@ -130,9 +130,12 @@ full diff. |
| nothing on the Go side pins that `ice.go`'s `iceRenewAfter` constant |
| is still five sixths of `iceLife`, which is the assumption the |
| ratio's own "renewal at five sixths of the life" shape depends on. |
| - Named as owed in Task 4's own fix-round-2 report. **Destination: the |
| - next fix wave**, in `internal/meet/ice_test.go` — a one-line |
| - assertion, not a behaviour change. |
| + Named as owed in Task 4's own fix-round-2 report. **Done** in fix |
| + round 3: `TestIceRenewAtIsFiveSixthsOfTheLife` in |
| + `internal/meettest/ice_test.go` (not `internal/meet` — the constants |
| + are unexported, so the tripwire reads the ratio off the wire, which |
| + is the form the client actually depends on). Verified against a |
| + deliberately moved constant. |
| - **The residual gate flake, `connectionState: "new"`.** Roughly one |
| full `make browser` run in three loses one scenario — a different one |
| each time — always at the first pairing, always with the same |
| @@ -146,12 +149,34 @@ full diff. |
| `accept`-ordering fix (kept regardless — it closes a real hole), a |
| negotiation nudge for a lost `negotiationneeded` event (reverted — no |
| evidence it was the cause), and re-running affected scenarios in |
| - isolation, where all of them pass every time. **Destination: the next |
| - diagnostics pass**, in order of cost — run the gate on CI hardware and |
| - see whether the rate changes; add a check to `call.mjs` that reports |
| - `getStats`'s own candidate counts when a pair fails to connect, to |
| - settle "gathered nothing" against "gathered and could not pair" in |
| - one run; only then look at the client again. |
| + isolation, where all of them pass every time. |
| + |
| + The reviewer's reading of that signature, which the fix-round-3 |
| + diagnostic was written to settle: `stable` with `connectionState: |
| + "new"` does not mean "negotiated and could not gather" — it is also |
| + what a connection that *never negotiated at all* looks like, because |
| + a peer connection is born `stable` and `new`. The decisive value is |
| + `localDescription`: null says nothing was ever offered, so nothing |
| + was ever gathered, and the fault is upstream of ICE entirely. Fix |
| + round 3 prints it. `diagnose()` in `call.mjs` now carries a |
| + `pcState` array for every connection the page still holds — |
| + `signaling`, `connection`, `ice`, `gathering`, `local`, `remote`, |
| + `senders`, `transceivers` as `direction:kind`, and `tracks` as |
| + `kind:readyState` — collected through the construct trap that scenario |
| + already used for its own `getStats` walk, now installed on every |
| + context. It changes no assertion. |
| + |
| + Round 3 also closed the likeliest client-side cause the signature |
| + admits: `createPC` registered the connection before attaching its |
| + listeners (so a `negotiationneeded` fired by `addTrack` could be lost |
| + outright, leaving exactly a `stable`/`new` connection that never |
| + offers), and its recvonly fallback was all-or-nothing, so a browser |
| + that came down `startLocal`'s ladder to audio alone offered no video |
| + m-line and the peer's tile could never get a frame. Whether either was |
| + the flake is unproved; both were real. **Destination if it survives: |
| + the next diagnostics pass** — run the gate on CI hardware and see |
| + whether the rate changes, then read the `pcState` lines from a losing |
| + run before touching the client again. |
| --- |
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js| index 1b6a96c..22a8007 100644 |
| --- a/internal/meet/static/room.js |
| +++ b/internal/meet/static/room.js |
| @@ -929,20 +929,13 @@ |
| }; |
| pcs.set(id, entry); |
| - if (local) { |
| - for (const track of local.getTracks()) pc.addTrack(track, local); |
| - } else { |
| - // Without a track of our own nothing would fire negotiationneeded |
| - // and the pair would never connect, so a browser that joined with |
| - // no camera asks explicitly to receive. |
| - try { |
| - pc.addTransceiver('video', { direction: 'recvonly' }); |
| - pc.addTransceiver('audio', { direction: 'recvonly' }); |
| - } catch { |
| - // An old browser without addTransceiver simply waits to be |
| - // offered to, which the other side will do if it has media. |
| - } |
| - } |
| + // The listeners go on before any media does. `addTrack` fires |
| + // `negotiationneeded`, and a connection registered in `pcs` with |
| + // nothing listening for that is a connection that will never offer: |
| + // the event is one-shot, and by the time a listener arrives it has |
| + // been and gone. The same order also means a throw from the media |
| + // below leaves a connection that can still be reset and rebuilt, |
| + // rather than a live entry nobody is watching. |
| pc.addEventListener('negotiationneeded', async () => { |
| if (pcs.get(id) !== entry) return; |
| @@ -976,9 +969,57 @@ |
| renderTiles(); |
| }); |
| + // §5.2's deferred half. A connection built during the first /ice |
| + // fetch is on the floor's empty list, and `adoptServers` will not |
| + // restart it while a negotiation is in flight — doing that cost a |
| + // gate run its media. So it waits here for the connection's own |
| + // next quiet moment instead of waiting 150 minutes for the first |
| + // renewal. |
| + pc.addEventListener('signalingstatechange', () => { |
| + if (pcs.get(id) !== entry) return; |
| + adoptWhenQuiet(id, entry); |
| + }); |
| + |
| + // And now the media. Our own tracks, and a transceiver for every |
| + // kind we do not have one of — the two used to be all-or-nothing, |
| + // and that was a hole: a browser that came down `startLocal`'s |
| + // ladder to audio alone (a camera in use, or gone) has a local |
| + // stream, so nothing asked to receive video, so it offered no video |
| + // m-line at all and the person on the other side sat looking at a |
| + // tile that was never going to get a frame. Per kind, and a track |
| + // that has ended counts as absent, because it is. |
| + try { |
| + if (local) { |
| + for (const track of local.getTracks()) pc.addTrack(track, local); |
| + } |
| + } catch { |
| + // A track the browser will not add — ended between the ladder and |
| + // here, or a device that went away — is not worth the connection. |
| + // The recvonly transceivers below cover what it would have been. |
| + } |
| + for (const kind of ['video', 'audio']) { |
| + if (hasLiveTrack(kind)) continue; |
| + try { |
| + pc.addTransceiver(kind, { direction: 'recvonly' }); |
| + } catch { |
| + // An old browser without addTransceiver simply waits to be |
| + // offered to, which the other side will do if it has media. |
| + } |
| + } |
| + |
| return entry; |
| } |
| + // hasLiveTrack answers whether the local stream has a track of this |
| + // kind that could actually carry media. An ended track is not one: |
| + // the browser keeps it in the stream, and `addTrack` will take it, |
| + // but nothing ever comes out of it. |
| + function hasLiveTrack(kind) { |
| + if (!local) return false; |
| + const tracks = kind === 'video' ? local.getVideoTracks() : local.getAudioTracks(); |
| + return tracks.some((track) => track && track.readyState !== 'ended'); |
| + } |
| + |
| function describe(desc) { |
| return desc ? { type: desc.type, sdp: desc.sdp } : null; |
| } |
| @@ -1361,7 +1402,7 @@ |
| // servers and restarted rather than torn down and rebuilt. |
| function adoptServers() { |
| if (!ice.servers.length) return; |
| - for (const entry of pcs.values()) { |
| + for (const [id, entry] of pcs) { |
| if (!entry.floor) continue; |
| try { |
| entry.pc.setConfiguration({ iceServers: ice.servers }); |
| @@ -1370,32 +1411,44 @@ |
| // into, and the connection keeps what it was built with. |
| continue; |
| } |
| - // And a restart only for a connection that has not started |
| - // negotiating. Interrupting one that is mid-offer — which is |
| - // exactly what a connection built ten milliseconds before the |
| - // list lands is — cost the `ice` scenario its media in one gate |
| - // run out of four: both sides reached `connected`, the remote |
| - // track arrived, and not one frame ever came down it. A |
| - // connection left on the floor is not stuck, and not silent about |
| - // it: it gathers host candidates, which is §5.5 and connects on a |
| - // permissive network, and `entry.floor` staying true is what |
| - // makes its tile say "Could not connect." rather than claiming it |
| - // tried the relay. Its Retry restarts it — with these servers, |
| - // which are in the configuration either way — and so does the |
| - // first renewal, which is a restart this file actually proves. |
| - if (entry.desc || entry.makingOffer || entry.pc.signalingState !== 'stable') continue; |
| - entry.floor = false; |
| - entry.listAt = ice.fetchedAt; |
| - try { |
| - entry.pc.restartIce(); |
| - } catch { |
| - // No restartIce: the first offer this connection makes will |
| - // gather on the servers anyway, because they are already in the |
| - // configuration. |
| - } |
| + // And the restart, if this connection is quiet enough to take |
| + // one now. If it is not — which is the common case for a peer |
| + // built during the fetch, because it is mid-offer — it is |
| + // deferred, not dropped: `createPC`'s signalingstatechange |
| + // listener runs the same decision at every quiet moment, so the |
| + // connection takes the list as soon as its negotiation settles |
| + // rather than waiting 150 minutes for the first renewal. |
| + adoptWhenQuiet(id, entry); |
| + } |
| + } |
| + |
| + // adoptWhenQuiet is that decision and the act. It is safe to call as |
| + // often as anything likes: `floor` is what makes it happen at most |
| + // once per list, and clearing it first means the restart's own |
| + // signalingstatechange cannot come back round here. |
| + function adoptWhenQuiet(id, entry) { |
| + if (!readyToAdopt(entry, ice.servers.length)) return; |
| + entry.floor = false; |
| + entry.listAt = ice.fetchedAt; |
| + try { |
| + entry.pc.restartIce(); |
| + } catch { |
| + // No restartIce on this browser: a fresh generation is the only |
| + // way to put the servers under this pair, and the `pair` that |
| + // answers rebuilds the connection with them from nothing. |
| + requestReset(id); |
| } |
| } |
| + // readyToAdopt is §5.2's deferral rule, pure so it can be gated |
| + // without a browser: still on the floor, a list to move to, and a |
| + // connection quiet enough that a restart will not cut across a |
| + // negotiation already in flight. |
| + function readyToAdopt(entry, servers) { |
| + return !!(entry.floor && servers && !entry.makingOffer && |
| + entry.pc.signalingState === 'stable'); |
| + } |
| + |
| // scheduleRenewal is §5.3 step 4: the next renewal is the `renewAt` |
| // the server named, counted from the fetch that succeeded. Nothing is |
| // scheduled on the floor — there is no credential to renew — and a |
diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go| index 5f615bb..f4ad9ec 100644 |
| --- a/internal/meettest/browser_test.go |
| +++ b/internal/meettest/browser_test.go |
| @@ -154,10 +154,17 @@ var scenarios = []string{ |
| // it is renumbering. |
| // Empty, and the account above is kept because it is the clearest one |
| // this branch has of a defect that cost two slices a flake each. Task |
| -// 4's fix round 2 closed both halves: applyRoster reconciles the roster |
| -// held when the fetch returns, and a close from reconcile no longer |
| -// forgets §4.6's counters for a peer the hub may still name. The three |
| -// ICE scenarios and staletarget have run in the gate ever since. |
| +// 4's fix round 2 closed three doors onto it, not the two the account |
| +// above describes: applyRoster reconciles the roster held when the |
| +// fetch returns; a close from reconcile no longer forgets §4.6's |
| +// counters for a peer the hub may still name; and — the one that was |
| +// actually killing these scenarios — a roster cannot close a connection |
| +// built after the hub sent it. That third one is missing from the |
| +// account because the account is missing the fact under it: the hub |
| +// does not re-send a roster when someone joins, it sends `peer` to the |
| +// people already there, so for the commonest case there is no newer |
| +// roster for the first door to prefer. The three ICE scenarios and |
| +// staletarget have run in the gate ever since. |
| var skipped = map[string]string{} |
| func TestCallInARealBrowser(t *testing.T) { |
diff --git a/internal/meettest/ice_test.go b/internal/meettest/ice_test.go| index 2ca2559..7f46bd5 100644 |
| --- a/internal/meettest/ice_test.go |
| +++ b/internal/meettest/ice_test.go |
| @@ -128,6 +128,48 @@ func TestIceHandsAnAdmittedParticipationCredentialsThatVerify(t *testing.T) { |
| } |
| } |
| +// TestIceRenewAtIsFiveSixthsOfTheLife is a tripwire for the client, not |
| +// for this endpoint. room.js schedules its renewal from the *margin* |
| +// this answer leaves it — expiresAt minus renewAt, a subtraction of two |
| +// instants on one clock, and therefore the one reading immune to a |
| +// browser whose clock disagrees with ours — and it multiplies that |
| +// margin by five because the contract's shape says the margin is a |
| +// sixth of the life (§5.3 step 4). Move iceLife or iceRenewAfter without |
| +// the other and that arithmetic quietly starts scheduling renewals after |
| +// the credential it is renewing has already died. The client also reads |
| +// the clock directly and takes whichever answer is earlier, so this |
| +// would not be fatal — but it would silently cost the schedule its only |
| +// skew-proof half, and nothing else in the tree would notice. |
| +func TestIceRenewAtIsFiveSixthsOfTheLife(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + if err := x.ctl.SetRelay(relayContract(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("member join: %d %s", resp.StatusCode, b) |
| + } |
| + s.nextOf("roster", 2*time.Second) |
| + |
| + rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer) |
| + var ans iceAnswer |
| + if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil { |
| + t.Fatal(err) |
| + } |
| + now := x.now.Unix() |
| + life, renew, margin := ans.ExpiresAt-now, ans.RenewAt-now, ans.ExpiresAt-ans.RenewAt |
| + if renew*6 != life*5 { |
| + t.Fatalf("renewAt is not five sixths of the life: life %ds, renew %ds", life, renew) |
| + } |
| + if margin*6 != life { |
| + t.Fatalf("the margin the client multiplies by five is not a sixth of the life: margin %ds, life %ds", margin, life) |
| + } |
| +} |
| + |
| func TestIceIs404ForAnythingButAnAdmittedParticipationOfThisSession(t *testing.T) { |
| x, sqid, tok := memberWithMeeting(t) |
| if err := x.ctl.SetRelay(relayContract(t)); err != nil { |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index b467021..11e4487 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -187,6 +187,13 @@ async function newContext(label, { cookie = null, javaScriptEnabled = true } = { |
| consoleErrors.set(label, []); |
| consoleLines.set(label, []); |
| ctx.on('page', (p) => watch(p, label)); |
| + // Diagnostic only, and on every context rather than the one scenario |
| + // that walks getStats by hand: when a pair does not come up, what the |
| + // browser thinks of its own connections is the evidence, and it |
| + // cannot be collected after the fact. The trap only records what is |
| + // constructed — no page behaviour changes, and nothing reads it |
| + // except diagnose() and the renewal scenario's own ufrag walk. |
| + if (javaScriptEnabled) await ctx.addInitScript(pcTap); |
| return ctx; |
| } |
| @@ -266,10 +273,42 @@ async function diagnose(page, label) { |
| currentTime: v ? Number(v.currentTime.toFixed(2)) : null, |
| }; |
| }); |
| + // Every connection this page still holds, as the browser sees it |
| + // rather than as room.js reports it. `local` is the decisive one |
| + // when a pair does not come up: a null localDescription with a |
| + // signalingState of `stable` means this connection never |
| + // negotiated at all — nothing was offered, so nothing gathered — |
| + // which is a different fault from one that negotiated and could |
| + // not find a pair. The rest says whether there was anything to |
| + // offer: a transceiver per direction and kind, and the state of |
| + // the tracks behind them. |
| + const pcState = (window.__pcs || []).map((pc) => { |
| + try { |
| + if (pc.connectionState === 'closed') return null; |
| + return { |
| + signaling: pc.signalingState, |
| + connection: pc.connectionState, |
| + ice: pc.iceConnectionState, |
| + gathering: pc.iceGatheringState, |
| + local: pc.localDescription && pc.localDescription.type, |
| + remote: pc.remoteDescription && pc.remoteDescription.type, |
| + senders: pc.getSenders().length, |
| + transceivers: pc.getTransceivers().map((t) => t.direction + ':' + |
| + ((t.receiver && t.receiver.track && t.receiver.track.kind) || |
| + (t.sender && t.sender.track && t.sender.track.kind) || '?')), |
| + tracks: pc.getSenders().filter((sn) => sn.track) |
| + .map((sn) => sn.track.kind + ':' + sn.track.readyState), |
| + }; |
| + } catch { |
| + return null; |
| + } |
| + }).filter(Boolean); |
| + |
| return { |
| at: '/' + (location.pathname.split('/')[1] || ''), |
| status: (document.getElementById('status') || {}).textContent || '', |
| roomHidden: (document.getElementById('room') || {}).hidden, |
| + pcState, |
| state: s |
| ? { |
| peer: s.peer, |
| @@ -497,6 +536,10 @@ async function iceRoute(ctx, { ttl = null, failFrom = 0 } = {}) { |
| const pcTap = () => { |
| const Real = window.RTCPeerConnection; |
| if (!Real) return; |
| + // Idempotent: it is installed on every context for diagnose()'s sake, |
| + // and once more by hand where a scenario walks the connections itself. |
| + // Wrapping the proxy in another proxy would work and reads like a bug. |
| + if (window.__pcs) return; |
| window.__pcs = []; |
| window.RTCPeerConnection = new Proxy(Real, { |
| construct(target, args) { |
diff --git a/test/browser/negotiation.test.mjs b/test/browser/negotiation.test.mjs| index ba8c2a4..1ec4ca0 100644 |
| --- a/test/browser/negotiation.test.mjs |
| +++ b/test/browser/negotiation.test.mjs |
| @@ -704,8 +704,9 @@ test('a roster cannot close a connection built after the hub sent it', () => { |
| // reached `connected`, the remote track arrived, and no frame ever came |
| // down it. |
| const adopter = new Function( |
| - 'ice', 'pcs', |
| - lift(src, 'adoptServers') + '\nreturn { adoptServers };', |
| + 'ice', 'pcs', 'requestReset', |
| + [lift(src, 'adoptServers'), lift(src, 'adoptWhenQuiet'), lift(src, 'readyToAdopt')].join('\n') + |
| + '\nreturn { adoptServers };', |
| ); |
| function adoptable(state, desc) { |
| @@ -727,7 +728,7 @@ function adoptable(state, desc) { |
| test('a connection that has not started negotiating takes the servers and restarts', () => { |
| const entry = adoptable('stable', 0); |
| const ice = { servers: [1, 2], fetchedAt: 99 }; |
| - adopter(ice, new Map([['p', entry]])).adoptServers(); |
| + adopter(ice, new Map([['p', entry]]), () => {}).adoptServers(); |
| assert.deepEqual(entry.calls, [['setConfiguration', 2], ['restartIce']]); |
| assert.equal(entry.floor, false, 'this one really is on the list now'); |
| @@ -735,20 +736,23 @@ test('a connection that has not started negotiating takes the servers and restar |
| }); |
| test('a connection mid-negotiation takes the servers and is left alone', () => { |
| - for (const entry of [adoptable('have-local-offer', 0), adoptable('stable', 1)]) { |
| - const ice = { servers: [1, 2], fetchedAt: 99 }; |
| - adopter(ice, new Map([['p', entry]])).adoptServers(); |
| - |
| - assert.deepEqual(entry.calls, [['setConfiguration', 2]], |
| - 'an ICE restart across an offer in flight is what broke the media'); |
| - assert.equal(entry.floor, true, |
| - 'and the tile must not claim a relay this connection never gathered through'); |
| - } |
| + // Only mid-negotiation, now. A connection that is stable and *has* |
| + // negotiated is the deferred case landing, and restarting it there is |
| + // the whole point of the signalingstatechange listener — see "a quiet |
| + // connection on the floor takes the list" below. |
| + const entry = adoptable('have-local-offer', 0); |
| + const ice = { servers: [1, 2], fetchedAt: 99 }; |
| + adopter(ice, new Map([['p', entry]]), () => {}).adoptServers(); |
| + |
| + assert.deepEqual(entry.calls, [['setConfiguration', 2]], |
| + 'an ICE restart across an offer in flight is what broke the media'); |
| + assert.equal(entry.floor, true, |
| + 'it stays on the floor, and is asked again the moment it is quiet'); |
| }); |
| test('there is nothing to adopt on the floor', () => { |
| const entry = adoptable('stable', 0); |
| - adopter({ servers: [], fetchedAt: 99 }, new Map([['p', entry]])).adoptServers(); |
| + adopter({ servers: [], fetchedAt: 99 }, new Map([['p', entry]]), () => {}).adoptServers(); |
| assert.deepEqual(entry.calls, []); |
| }); |
| @@ -804,3 +808,128 @@ test('a message from a newer generation is not a replay, whatever the cursor say |
| assert.deepEqual(rebuilt, [['them', 2]], 'and it rebuilds the pair under the new generation'); |
| assert.equal(lastIn.get('them'), 1, 'the cursor follows the generation that is speaking now'); |
| }); |
| + |
| + |
| +// ── taking the list at the next quiet moment (§5.2) ─────────────────── |
| +// |
| +// A connection built during the first fetch is on the floor's empty |
| +// list. Restarting it there and then cuts across the negotiation it is |
| +// in the middle of, which cost a gate run its media; never restarting it |
| +// leaves it on host candidates until the 150-minute renewal, which is |
| +// most of a call. So the decision is deferred, and asked again at every |
| +// quiet moment: createPC's signalingstatechange listener and |
| +// adoptServers both come through here. |
| +const adopting = new Function( |
| + 'ice', 'requestReset', |
| + [lift(src, 'adoptWhenQuiet'), lift(src, 'readyToAdopt')].join('\n') + |
| + '\nreturn { adoptWhenQuiet, readyToAdopt };', |
| +); |
| + |
| +function floorEntry(signalingState, opts) { |
| + const o = opts || {}; |
| + const calls = []; |
| + return { |
| + floor: o.floor !== false, |
| + makingOffer: !!o.makingOffer, |
| + listAt: 0, |
| + calls, |
| + pc: { |
| + signalingState, |
| + restartIce: () => calls.push('restartIce'), |
| + }, |
| + }; |
| +} |
| + |
| +function adopt(entry, servers) { |
| + const reset = []; |
| + const ice = { servers: servers === undefined ? [1, 2] : servers, fetchedAt: 77 }; |
| + adopting(ice, (id) => reset.push(id)).adoptWhenQuiet('them', entry); |
| + return reset; |
| +} |
| + |
| +test('a quiet connection on the floor takes the list, once', () => { |
| + const entry = floorEntry('stable'); |
| + adopt(entry); |
| + assert.deepEqual(entry.calls, ['restartIce']); |
| + assert.equal(entry.floor, false, 'and is no longer on the floor'); |
| + assert.equal(entry.listAt, 77, 'and is stamped with the list it took'); |
| + |
| + // Every later quiet moment asks again; nothing happens twice. |
| + adopt(entry); |
| + assert.deepEqual(entry.calls, ['restartIce']); |
| +}); |
| + |
| +test('a connection mid-negotiation waits, and is still on the floor', () => { |
| + for (const entry of [floorEntry('have-local-offer'), floorEntry('have-remote-offer'), |
| + floorEntry('stable', { makingOffer: true })]) { |
| + adopt(entry); |
| + assert.deepEqual(entry.calls, [], 'a restart across a negotiation in flight is the bug'); |
| + assert.equal(entry.floor, true, 'so it stays on the floor and is asked again later'); |
| + } |
| +}); |
| + |
| +test('a connection that is not on the floor is never restarted by this', () => { |
| + const entry = floorEntry('stable', { floor: false }); |
| + adopt(entry); |
| + assert.deepEqual(entry.calls, []); |
| +}); |
| + |
| +test('there is no list to take on the floor', () => { |
| + const entry = floorEntry('stable'); |
| + adopt(entry, []); |
| + assert.deepEqual(entry.calls, []); |
| + assert.equal(entry.floor, true); |
| +}); |
| + |
| +test('a browser with no restartIce asks the hub for a fresh generation instead', () => { |
| + const entry = floorEntry('stable'); |
| + entry.pc.restartIce = () => { throw new Error('unsupported'); }; |
| + assert.deepEqual(adopt(entry), ['them']); |
| + assert.equal(entry.floor, false, 'the pair is being rebuilt; it is not left to try again'); |
| +}); |
| + |
| +// ── asking to receive what we cannot send (§5.1) ────────────────────── |
| +// |
| +// The recvonly fallback used to be all-or-nothing: a local stream of any |
| +// shape at all and nothing asked to receive. `startLocal`'s ladder ends |
| +// at audio alone, though — a camera in use, or gone — and that browser |
| +// then offered no video m-line, so the person on the other side had a |
| +// tile that could never get a frame. Per kind, and an ended track counts |
| +// as no track. |
| +const media = new Function( |
| + 'local', |
| + lift(src, 'hasLiveTrack') + '\nreturn { hasLiveTrack };', |
| +); |
| + |
| +function stream(tracks) { |
| + return { |
| + getVideoTracks: () => tracks.filter((t) => t.kind === 'video'), |
| + getAudioTracks: () => tracks.filter((t) => t.kind === 'audio'), |
| + }; |
| +} |
| + |
| +const live = (kind) => ({ kind, readyState: 'live' }); |
| +const ended = (kind) => ({ kind, readyState: 'ended' }); |
| + |
| +test('a stream with both kinds asks to receive neither', () => { |
| + const { hasLiveTrack } = media(stream([live('video'), live('audio')])); |
| + assert.equal(hasLiveTrack('video'), true); |
| + assert.equal(hasLiveTrack('audio'), true); |
| +}); |
| + |
| +test('the audio-only rung of the ladder still asks for video', () => { |
| + const { hasLiveTrack } = media(stream([live('audio')])); |
| + assert.equal(hasLiveTrack('video'), false, 'this is the m-line that was never offered'); |
| + assert.equal(hasLiveTrack('audio'), true); |
| +}); |
| + |
| +test('an ended track is not a track', () => { |
| + const { hasLiveTrack } = media(stream([ended('video'), live('audio')])); |
| + assert.equal(hasLiveTrack('video'), false); |
| +}); |
| + |
| +test('no local stream asks to receive both', () => { |
| + const { hasLiveTrack } = media(null); |
| + assert.equal(hasLiveTrack('video'), false); |
| + assert.equal(hasLiveTrack('audio'), false); |
| +}); |