room.js: a yield that knows its own negotiation, and a camera watch
Codex S4-4 and S4-5 (both P2), and with them the Task 4 re-review's carried items 1 and 2 and the whole-branch review's W4-3 and W4-5. S4-4. `politeYield` asked only whether the connection was stable *now*, which cannot tell uninterrupted stability from a whole remote exchange that arrived, was answered and completed while the yield slept — and `negotiate` did not stop a second invocation starting while the first one slept. Three parts to the fix, all per connection: a negotiation generation, moved by this side's own offer and by every exchange it answers, which a yield captures and re-checks on waking; a `negotiating` flag, so one local offer attempt is in flight at a time; and `yields`, a small counter in place of the boolean, spent by giving way, zeroed by this side's own offer, and handed back one at a time by `answeredExchange` — a return to stable through somebody else's turn. That last is the carried reset defect (item 1) and W4-4: the allowance is no longer a per-connection budget one glare can spend for good. S4-5. The yield prevents the rollback that costs a camera its encoder, but it is a duration and not a guarantee — a round trip over 300 ms, a 429 retry, a slow offer — and nothing detected or repaired a camera that had already been rebuilt. `pollFrames` now runs in every browser rather than only where requestVideoFrameCallback is missing, and its getStats walk reads outbound-rtp as well: two polls in a row with no new framesEncoded on a sending m-line with a live track is a repair (replaceTrack(null), replaceTrack(track)); two more is requestReset; after that it stops asking, because a reset that did not take is not a reason to ask again every two seconds. W4-5: the comment claiming the yield "ends early because the remote offer has arrived" was false — the sleep is unconditional and the check is state-based — and now says so. W4-3: the node tests read every constant out of the source, and one test pins the values themselves, so POLITE_YIELD set to zero fails rather than passing green. Sixteen node tests, four of them mutation-verified against the shipped code: dropping the serializer fails the overlapping-invocation test, dropping the generation check fails both exchange tests, dropping the hand-back fails the protected-again test, and dropping the sending guard fails the camera test. node --check, 179 node tests, make ci.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
3 files changed,
+513
−56
internal/meet/static/room.js+232 −38test/browser/negotiation.test.mjs+11 −1test/browser/room-lane.test.mjs+270 −17
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js| index 38507b4..231bdf0 100644 |
| --- a/internal/meet/static/room.js |
| +++ b/internal/meet/static/room.js |
| @@ -147,10 +147,25 @@ |
| // was already on its way arrives while this side is still stable and |
| // is answered rather than collided with, and no rollback happens at |
| // all. The cost is this much delay before the polite side offers, and |
| - // only when the other side has nothing to offer — at every moment when |
| - // both sides do, which is the case this exists for, the yield ends |
| - // early because the remote offer has arrived. |
| + // only when the other side has nothing to offer. The wait itself is |
| + // unconditional — the check that reads the connection happens after |
| + // it, not during — so what a remote offer arriving buys is not a |
| + // shorter wait but a negotiation this side no longer has to make. |
| const POLITE_YIELD = 300; |
| + // How many times in a row the polite side may give way without an |
| + // offer of its own going out. A boolean could not answer both "give |
| + // the allowance back when the exchange that took our turn completes" |
| + // and "never let giving way become a habit"; a small counter answers |
| + // both (Codex S4-4, and the Task 4 re-review's item 1). |
| + const POLITE_YIELDS = 2; |
| + // Polls in a row that must report no new encoded frames before a |
| + // camera counts as stopped, and repairs on one connection before the |
| + // pair is reset instead. At one poll a second that is two seconds of |
| + // a live track on a sending m-line encoding nothing, then a |
| + // replaceTrack cycle, then two more seconds, then a reset (Codex |
| + // S4-5, and the Task 4 re-review's item 2). |
| + const CAMERA_STALL_POLLS = 2; |
| + const CAMERA_REPAIRS = 1; |
| // §5.4: the tile key a peer's screen gets, built by shareKey. |
| const SHARE_SUFFIX = '#share'; |
| const HAS_DISPLAY_MEDIA = typeof navigator !== 'undefined' && |
| @@ -985,9 +1000,29 @@ |
| // the outside — signalingState is still have-local-offer — and is |
| // not. |
| isSettingRemoteAnswerPending: false, |
| - // §5.1: whether this negotiation has already had its polite |
| - // yield. One per offer, so giving way can never become a habit. |
| - yielded: false, |
| + // §5.1's polite yield, in three parts (Codex S4-4). |
| + // |
| + // `yields` is how many times in a row this side has given way |
| + // without an offer of its own going out, capped at POLITE_YIELDS |
| + // and handed back one at a time by an exchange this side did not |
| + // offer. `negotiation` is the generation: it moves whenever this |
| + // connection's negotiation moves on, so a yield that captured it |
| + // and then slept can tell "nothing happened" from "an exchange |
| + // came and went while I was asleep". `negotiating` is the |
| + // serializer: one local offer attempt at a time, because two |
| + // `negotiationneeded` events while one of them sleeps used to |
| + // produce two offers, the second of them skipping the yield |
| + // entirely because the first had already spent it. |
| + yields: 0, |
| + negotiation: 0, |
| + negotiating: false, |
| + // §5.1's camera watch (Codex S4-5): the last framesEncoded read |
| + // off this connection's own camera m-line, how many polls in a |
| + // row have reported that same number, and how many times the |
| + // encoder has been rebuilt before giving up and resetting. |
| + cameraFrames: -1, |
| + cameraStalls: 0, |
| + cameraRepairs: 0, |
| pending: [], |
| descSeq: 0, |
| gen: g, |
| @@ -1154,15 +1189,39 @@ |
| return entry; |
| } |
| - // politeYield waits POLITE_YIELD and then answers whether this |
| - // connection is still ours and still stable — that is, whether no |
| - // remote offer arrived while we waited. False means one did: it is |
| - // being answered, and this negotiation belongs to the stable moment |
| - // after that rather than to a collision now. |
| - async function politeYield(id, entry) { |
| + // politeYield waits POLITE_YIELD and then answers whether the |
| + // negotiation it gave way for is still this side's to make. |
| + // |
| + // Three things can have happened while it slept. The connection can |
| + // have gone, or the call ended: nothing to offer to. A remote offer |
| + // can have arrived and still be being answered: signalingState says |
| + // so. Or — the case a state check alone cannot see, and Codex S4-4's |
| + // first half — a whole exchange can have arrived, been answered and |
| + // completed, leaving the connection back at `stable` looking exactly |
| + // as it did when this yield began. The generation is what tells those |
| + // two apart: it moves on every exchange, so a yield whose generation |
| + // has changed is a yield whose negotiation somebody else already |
| + // made. Offering anyway is the extra offer that produced. |
| + async function politeYield(id, entry, generation) { |
| await sleep(POLITE_YIELD); |
| - return pcs.get(id) === entry && !state.stopped && |
| - entry.pc.signalingState === 'stable'; |
| + if (pcs.get(id) !== entry || state.stopped) return false; |
| + if (entry.negotiation !== generation) return false; |
| + return entry.pc.signalingState === 'stable'; |
| + } |
| + |
| + // answeredExchange records one offer/answer exchange this side did |
| + // not offer: a return to `stable` through somebody else's turn. |
| + // |
| + // It moves the generation, which invalidates any yield sleeping |
| + // through it, and it hands back one spent yield — one, never more |
| + // than was spent, so a pair renegotiating in turn cannot talk this |
| + // side into giving way for ever. Without the hand-back, a yield lost |
| + // to an exchange that happened to satisfy this side's own change left |
| + // `yields` spent and the *next* independent negotiation unprotected, |
| + // which is the reset defect the Task 4 re-review carried (item 1). |
| + function answeredExchange(entry) { |
| + entry.negotiation += 1; |
| + if (entry.yields > 0) entry.yields -= 1; |
| } |
| // negotiate is §5.1's offering half: one offer per negotiation, with |
| @@ -1178,28 +1237,44 @@ |
| // it offers instead, and the offer that was already on its way is |
| // answered from a stable state with no rollback anywhere. |
| // |
| - // Once per negotiation, which is what `yielded` is for: a side that |
| - // gave way and came back to find another offer waiting would give way |
| - // again, and two peers renegotiating in turn would keep one of them |
| - // permanently a beat behind. The second time round it offers and takes |
| - // the ordinary collision path, which is what the pattern is for. The |
| - // flag is cleared by the offer that actually goes out. |
| + // Bounded, which is what `yields` is for: a side that gave way and |
| + // came back to find another offer waiting would give way again, and |
| + // two peers renegotiating in turn would keep one of them permanently a |
| + // beat behind. Past POLITE_YIELDS in a row it offers and takes the |
| + // ordinary collision path, which is what the pattern is for. The count |
| + // goes back to zero on this side's own offer, and back by one on an |
| + // exchange this side answered instead. |
| + // |
| + // One attempt at a time, too (Codex S4-4). `negotiationneeded` can |
| + // fire again while the first attempt is asleep in its yield — a |
| + // remote exchange completing is exactly the thing that fires it — and |
| + // the second attempt used to skip the yield, because the first had |
| + // already spent it, and then both of them offered. |
| async function negotiate(id, entry) { |
| - if (pcs.get(id) !== entry) return; |
| - if (entry.polite && !entry.yielded) { |
| - entry.yielded = true; |
| - if (!(await politeYield(id, entry))) return; |
| - } |
| + if (pcs.get(id) !== entry || entry.negotiating) return; |
| + entry.negotiating = true; |
| try { |
| - entry.makingOffer = true; |
| - await entry.pc.setLocalDescription(); |
| - entry.yielded = false; |
| - send(id, 'offer', { description: describe(entry.pc.localDescription) }); |
| - } catch { |
| - // A failed local description is not fatal: the pair recovers |
| - // through a reset if it never connects. |
| + if (entry.polite && entry.yields < POLITE_YIELDS) { |
| + entry.yields += 1; |
| + if (!(await politeYield(id, entry, entry.negotiation))) return; |
| + } |
| + try { |
| + entry.makingOffer = true; |
| + await entry.pc.setLocalDescription(); |
| + // This side's own offer: the negotiation is ours, the |
| + // generation moves with it, and the next one starts with the |
| + // whole allowance again. |
| + entry.negotiation += 1; |
| + entry.yields = 0; |
| + send(id, 'offer', { description: describe(entry.pc.localDescription) }); |
| + } catch { |
| + // A failed local description is not fatal: the pair recovers |
| + // through a reset if it never connects. |
| + } finally { |
| + entry.makingOffer = false; |
| + } |
| } finally { |
| - entry.makingOffer = false; |
| + entry.negotiating = false; |
| } |
| } |
| @@ -1321,8 +1396,11 @@ |
| entry.descSeq = msg.seq || entry.descSeq; |
| await drainCandidates(entry); |
| await entry.pc.setLocalDescription(); |
| - // One exchange completed, on the answering side (§5.3 step 2). |
| + // One exchange completed, on the answering side (§5.3 step 2) — |
| + // and an exchange this side did not offer, which is what moves |
| + // the negotiation generation and hands a spent yield back. |
| entry.desc += 1; |
| + answeredExchange(entry); |
| send(msg.from, 'answer', { description: describe(entry.pc.localDescription) }); |
| } catch { |
| // A description that will not apply leaves the pair to recover |
| @@ -3670,14 +3748,122 @@ |
| return ''; |
| } |
| + // localCameraTx is this connection's own camera transceiver: the one |
| + // whose sender is carrying the local stream's live video track. The |
| + // screen has a transceiver and a track of its own and is excluded by |
| + // identity, not by mid, so a tab that is sharing is still watched on |
| + // the right m-line. |
| + function localCameraTx(entry) { |
| + if (!entry.pc.getTransceivers || !local || !local.getVideoTracks) return null; |
| + const wanted = local.getVideoTracks()[0]; |
| + if (!wanted || wanted.readyState === 'ended') return null; |
| + for (const tx of entry.pc.getTransceivers()) { |
| + if (tx && tx.sender && tx.sender.track === wanted) return tx; |
| + } |
| + return null; |
| + } |
| + |
| + // cameraReport picks the outbound video report that belongs to the |
| + // camera. By mid when both sides of the comparison have one; with no |
| + // mid to compare, only a single unambiguous report counts — the same |
| + // refusal to guess between two video streams that frameKey makes. |
| + // Pure, and lifted by name in the node test. |
| + function cameraReport(reports, mid) { |
| + if (mid) { |
| + for (const r of reports) if (r.mid === mid) return r; |
| + return reports.length === 1 && !reports[0].mid ? reports[0] : null; |
| + } |
| + return reports.length === 1 ? reports[0] : null; |
| + } |
| + |
| + // judgeCamera watches one connection's own camera encoder and says |
| + // what to do about it: '' to leave it alone, 'repair' to rebuild the |
| + // encoder, 'reset' to rebuild the whole connection. |
| + // |
| + // This is Codex S4-5, and the Task 4 re-review's item 2. POLITE_YIELD |
| + // prevents the rollback that costs a camera its encoder, but it is a |
| + // duration and not a guarantee: a signalling round trip longer than |
| + // 300 ms, a 429 retry, or a slow offer can still land the remote offer |
| + // late, and then the polite side rolls back and Chromium rebuilds |
| + // every send stream on the connection. What is left looks entirely |
| + // healthy — `connected`, the m-line `sendrecv`, the track `live` — |
| + // and encodes nothing, ever. No connection-state handler fires, |
| + // because nothing failed. |
| + // |
| + // The one thing that does say so is framesEncoded standing still, so |
| + // that is what this reads, off the getStats walk that already runs. |
| + // Two polls in a row with no new frames on a sending m-line with a |
| + // live track is the signature; the repair is the cheapest thing that |
| + // makes the browser build a new encoder for the same sender, and if |
| + // two more polls are still frozen the pair is reset instead. |
| + // |
| + // The counters live on the entry, so a connection that is not sending |
| + // video — no camera, an ended track, a recvonly m-line — resets them |
| + // rather than accumulating a stall it could never repair. |
| + function judgeCamera(entry, tx, report) { |
| + const track = tx && tx.sender && tx.sender.track; |
| + const direction = (tx && (tx.currentDirection || tx.direction)) || ''; |
| + const sends = direction === 'sendrecv' || direction === 'sendonly'; |
| + if (!tx || !sends || !track || track.readyState === 'ended' || |
| + !report || typeof report.framesEncoded !== 'number') { |
| + entry.cameraFrames = -1; |
| + entry.cameraStalls = 0; |
| + return ''; |
| + } |
| + if (report.framesEncoded !== entry.cameraFrames) { |
| + // Encoding. Whatever was wrong is not wrong now, and a repair that |
| + // worked is forgotten rather than counted against the next stall. |
| + entry.cameraFrames = report.framesEncoded; |
| + entry.cameraStalls = 0; |
| + entry.cameraRepairs = 0; |
| + return ''; |
| + } |
| + entry.cameraStalls += 1; |
| + if (entry.cameraStalls < CAMERA_STALL_POLLS) return ''; |
| + entry.cameraStalls = 0; |
| + entry.cameraRepairs += 1; |
| + if (entry.cameraRepairs <= CAMERA_REPAIRS) return 'repair'; |
| + if (entry.cameraRepairs === CAMERA_REPAIRS + 1) return 'reset'; |
| + // A reset has already been asked for and the frames are still |
| + // frozen. The reset rebuilds the connection and these counters with |
| + // it, so being here at all means it did not take — and asking for |
| + // another one every two seconds would be a loop, not a recovery. |
| + return ''; |
| + } |
| + |
| + // repairCamera takes the track off its sender and puts it straight |
| + // back. Neither call renegotiates — the kind is unchanged — and the |
| + // second one is what makes the browser build an encoder for a sender |
| + // whose own was torn down and never rebuilt. |
| + async function repairCamera(tx) { |
| + const sender = tx && tx.sender; |
| + const track = sender && sender.track; |
| + if (!sender || !track || typeof sender.replaceTrack !== 'function') return; |
| + try { |
| + await sender.replaceTrack(null); |
| + await sender.replaceTrack(track); |
| + } catch { |
| + // A connection that is closing. The reset is behind this anyway. |
| + } |
| + } |
| + |
| + // pollFrames is one getStats walk per connection, once a second, and |
| + // it does two jobs. The inbound half is the frame counter's fallback |
| + // for a browser without requestVideoFrameCallback. The outbound half |
| + // is the camera watch, and it runs in every browser — rVFC counts |
| + // what arrives, and a camera that stopped encoding is a fault in what |
| + // leaves. |
| async function pollFrames() { |
| - // The fallback for a browser without requestVideoFrameCallback: |
| - // framesDecoded off the inbound video stats. |
| - if (HAS_RVFC) return; |
| for (const [id, entry] of pcs) { |
| try { |
| const stats = await entry.pc.getStats(); |
| + const outbound = []; |
| stats.forEach((report) => { |
| + if (report.type === 'outbound-rtp' && report.kind === 'video') { |
| + outbound.push(report); |
| + return; |
| + } |
| + if (HAS_RVFC) return; |
| if (report.type !== 'inbound-rtp' || report.kind !== 'video' || |
| typeof report.framesDecoded !== 'number') { |
| return; |
| @@ -3685,6 +3871,10 @@ |
| const key = frameKey(id, entry, report.mid || ''); |
| if (key) frames.set(key, report.framesDecoded); |
| }); |
| + const tx = localCameraTx(entry); |
| + const verdict = judgeCamera(entry, tx, cameraReport(outbound, (tx && tx.mid) || '')); |
| + if (verdict === 'repair') await repairCamera(tx); |
| + else if (verdict === 'reset') requestReset(id); |
| } catch { |
| // getStats on a closing connection: nothing to report. |
| } |
| @@ -3822,7 +4012,11 @@ |
| renderQueue(); |
| await startLocal(); |
| renderTiles(); |
| - if (!HAS_RVFC) statsTimer = setInterval(pollFrames, 1000); |
| + // Once a second, in every browser. rVFC counts arriving frames |
| + // better than getStats can, but nothing but getStats can see a |
| + // camera that stopped encoding (Codex S4-5), so the walk runs |
| + // either way and does whichever halves apply. |
| + statsTimer = setInterval(pollFrames, 1000); |
| connectStream(); |
| } |
diff --git a/test/browser/negotiation.test.mjs b/test/browser/negotiation.test.mjs| index 945b2dc..5097f5f 100644 |
| --- a/test/browser/negotiation.test.mjs |
| +++ b/test/browser/negotiation.test.mjs |
| @@ -88,10 +88,16 @@ test('a remote offer already applied is glare', () => { |
| // which the six cases above would pass without. |
| const handlers = new Function( |
| 'accept', 'send', 'describe', 'drainCandidates', |
| - [lift(src, 'offerCollision'), lift(src, 'onOffer'), lift(src, 'onAnswer')].join('\n') + |
| + [lift(src, 'offerCollision'), lift(src, 'onOffer'), lift(src, 'onAnswer'), |
| + lift(src, 'answeredExchange')].join('\n') + |
| '\nreturn { onOffer, onAnswer };', |
| ); |
| +// answeredExchange is lifted with them because onOffer calls it: an |
| +// exchange this side answered is what moves the negotiation generation |
| +// and hands the polite side its yield back (Codex S4-4). Its own |
| +// behaviour is gated in room-lane.test.mjs, next to `negotiate`. |
| + |
| // fakePC is the part of RTCPeerConnection these two handlers touch. The |
| // answer's setRemoteDescription is deliberately slow, and the state it |
| // leaves behind while it is in flight is the real one: signalingState |
| @@ -133,6 +139,8 @@ test('an offer arriving while an answer is being applied is answered, not discar |
| makingOffer: false, |
| ignoreOffer: false, |
| isSettingRemoteAnswerPending: false, |
| + yields: 0, |
| + negotiation: 0, |
| pending: [], |
| descSeq: 0, |
| }; |
| @@ -217,6 +225,8 @@ test('the answer handler clears its own flag', async () => { |
| makingOffer: false, |
| ignoreOffer: false, |
| isSettingRemoteAnswerPending: false, |
| + yields: 0, |
| + negotiation: 0, |
| pending: [], |
| descSeq: 0, |
| }; |
diff --git a/test/browser/room-lane.test.mjs b/test/browser/room-lane.test.mjs| index f2776fa..8e6319a 100644 |
| --- a/test/browser/room-lane.test.mjs |
| +++ b/test/browser/room-lane.test.mjs |
| @@ -472,16 +472,31 @@ test('chatEnabled is silent with no panel to enable', () => { |
| // `mutualshare`; what is here is the decision that keeps it from |
| // happening: the side that would roll back gives way before it offers. |
| +// Every number these tests assert against is read out of room.js |
| +// rather than retyped: a test carrying a constant of its own proves its |
| +// own harness (the Task 4 re-review's item 8). |
| +function pin(name) { |
| + const m = new RegExp('const ' + name + ' = (\\d+);').exec(src); |
| + assert.ok(m, name + ' is not a number in room.js any more — this test is stale, not passing'); |
| + return Number(m[1]); |
| +} |
| + |
| +const POLITE_YIELD = pin('POLITE_YIELD'); |
| +const POLITE_YIELDS = pin('POLITE_YIELDS'); |
| + |
| const negotiator = new Function( |
| - 'pcs', 'send', 'describe', 'sleep', 'state', 'POLITE_YIELD', 'console', |
| - [lift(src, 'negotiate'), lift(src, 'politeYield')].join('\n') + |
| - '\nreturn { negotiate };', |
| + 'pcs', 'send', 'describe', 'sleep', 'state', 'POLITE_YIELD', 'POLITE_YIELDS', 'console', |
| + [lift(src, 'negotiate'), lift(src, 'politeYield'), lift(src, 'answeredExchange')].join('\n') + |
| + '\nreturn { negotiate, answeredExchange };', |
| ); |
| // A connection as `negotiate` sees one, over a fake pc whose |
| // setLocalDescription can be watched and whose signalingState a test |
| // moves under it — which is what a remote offer arriving during the |
| -// yield actually looks like. |
| +// yield actually looks like. `duringYield` is handed the whole box, so |
| +// a test can also complete an exchange while the yield sleeps, which is |
| +// the thing a signalingState alone cannot express: the connection is |
| +// back at `stable` afterwards and looks untouched. |
| function negotiating(opts) { |
| const o = opts || {}; |
| const sent = []; |
| @@ -489,7 +504,9 @@ function negotiating(opts) { |
| const pcs = new Map(); |
| const entry = { |
| polite: !!o.polite, |
| - yielded: !!o.yielded, |
| + yields: o.yields || 0, |
| + negotiation: 0, |
| + negotiating: false, |
| makingOffer: false, |
| pc: { |
| signalingState: o.signalingState || 'stable', |
| @@ -500,19 +517,29 @@ function negotiating(opts) { |
| }, |
| }; |
| pcs.set('peer1', entry); |
| - const { negotiate } = negotiator( |
| + let box; |
| + const built = negotiator( |
| pcs, |
| (to, type, payload) => sent.push([to, type, !!(payload && payload.description)]), |
| (d) => d, |
| async (ms) => { |
| waits.push(ms); |
| - if (o.duringYield) o.duringYield(entry, pcs); |
| + if (o.duringYield) o.duringYield(entry, pcs, box); |
| }, |
| { stopped: !!o.stopped }, |
| - 250, |
| + POLITE_YIELD, |
| + POLITE_YIELDS, |
| quiet, |
| ); |
| - return { negotiate, entry, pcs, sent, waits }; |
| + box = { |
| + negotiate: built.negotiate, |
| + answeredExchange: built.answeredExchange, |
| + entry, |
| + pcs, |
| + sent, |
| + waits, |
| + }; |
| + return box; |
| } |
| test('the impolite side offers at once, and waits for nothing', async () => { |
| @@ -525,9 +552,10 @@ test('the impolite side offers at once, and waits for nothing', async () => { |
| test('the polite side gives way first, then offers', async () => { |
| const n = negotiating({ polite: true }); |
| await n.negotiate('peer1', n.entry); |
| - assert.deepEqual(n.waits, [250], 'the polite side did not wait, or waited the wrong amount'); |
| + assert.deepEqual(n.waits, [POLITE_YIELD], |
| + 'the polite side did not wait, or waited the wrong amount'); |
| assert.deepEqual(n.sent, [['peer1', 'offer', true]]); |
| - assert.equal(n.entry.yielded, false, 'the offer that went out must clear the flag'); |
| + assert.equal(n.entry.yields, 0, 'the offer that went out must give the allowance back'); |
| }); |
| test('a remote offer arriving during the yield takes the negotiation', async () => { |
| @@ -540,18 +568,87 @@ test('a remote offer arriving during the yield takes the negotiation', async () |
| }); |
| await n.negotiate('peer1', n.entry); |
| assert.deepEqual(n.sent, [], 'the polite side offered into a remote offer it could see'); |
| - assert.equal(n.entry.yielded, true, 'the yield it spent must be remembered'); |
| + assert.equal(n.entry.yields, 1, 'the yield it spent must be remembered'); |
| }); |
| -test('giving way happens once per negotiation, never twice', async () => { |
| +test('giving way is bounded, however many negotiations are lost', async () => { |
| // A side that gave way and came back to find another offer waiting |
| // would give way again, and two peers renegotiating in turn would keep |
| - // one of them permanently a beat behind. |
| - const n = negotiating({ polite: true, yielded: true }); |
| + // one of them permanently a beat behind. Nothing here completes an |
| + // exchange, so nothing hands the allowance back: after POLITE_YIELDS |
| + // in a row it offers and takes the ordinary collision path. |
| + const n = negotiating({ |
| + polite: true, |
| + duringYield: (entry) => { entry.pc.signalingState = 'have-remote-offer'; }, |
| + }); |
| + for (let i = 0; i < POLITE_YIELDS; i++) await n.negotiate('peer1', n.entry); |
| + assert.equal(n.waits.length, POLITE_YIELDS, 'it gave way more times than the bound allows'); |
| + assert.deepEqual(n.sent, []); |
| + |
| + n.entry.pc.signalingState = 'stable'; |
| + await n.negotiate('peer1', n.entry); |
| + assert.equal(n.waits.length, POLITE_YIELDS, 'it gave way past the bound'); |
| + assert.deepEqual(n.sent, [['peer1', 'offer', true]]); |
| +}); |
| + |
| +// --- the generation, the serializer, and the allowance (Codex S4-4) --- |
| + |
| +test('an exchange that completed during the yield is not offered over', async () => { |
| + // The case a signalingState check cannot see. A whole remote |
| + // exchange — offer in, answer out — arrives, is answered and |
| + // completes while the yield sleeps, leaving the connection back at |
| + // `stable` looking exactly as it did when the yield began. Offering |
| + // now is a second offer for a negotiation somebody else already made, |
| + // and an in-memory drive of the shipped functions produced exactly |
| + // that. |
| + const n = negotiating({ |
| + polite: true, |
| + duringYield: (entry, pcs, box) => box.answeredExchange(entry), |
| + }); |
| + await n.negotiate('peer1', n.entry); |
| + assert.deepEqual(n.sent, [], 'an offer went out for a negotiation that was already answered'); |
| + assert.equal(n.entry.yields, 0, 'the exchange that took the turn must hand the yield back'); |
| +}); |
| + |
| +test('two negotiations while one sleeps produce one offer', async () => { |
| + // `negotiationneeded` fires again while the first attempt is asleep — |
| + // a remote exchange completing is exactly what fires it — and the |
| + // second attempt used to skip the yield, because the first had |
| + // already spent it, and then both of them offered. |
| + const n = negotiating({ polite: true }); |
| + await Promise.all([ |
| + n.negotiate('peer1', n.entry), |
| + n.negotiate('peer1', n.entry), |
| + n.negotiate('peer1', n.entry), |
| + ]); |
| + assert.equal(n.waits.length, 1, 'more than one negotiation was in flight'); |
| + assert.deepEqual(n.sent, [['peer1', 'offer', true]]); |
| +}); |
| + |
| +test('a negotiation after a lost yield is protected again', async () => { |
| + // The reset defect the Task 4 re-review carried (item 1): a yield lost |
| + // to an exchange that happened to satisfy this side's own change left |
| + // the allowance spent, and the next independent negotiation went |
| + // straight out with no yield in front of it. |
| + // |
| + // Enough of them to exhaust the bound, so the bound cannot be what |
| + // makes this pass: without the hand-back, `yields` reaches |
| + // POLITE_YIELDS and the next negotiation goes straight out. |
| + let exchange = true; |
| + const n = negotiating({ |
| + polite: true, |
| + duringYield: (entry, pcs, box) => { |
| + if (exchange) box.answeredExchange(entry); |
| + }, |
| + }); |
| + for (let i = 0; i < POLITE_YIELDS; i++) await n.negotiate('peer1', n.entry); |
| + assert.deepEqual(n.sent, [], 'an offer went out for a negotiation somebody else made'); |
| + assert.equal(n.entry.yields, 0, 'every yield an exchange took must be handed back'); |
| + |
| + exchange = false; |
| await n.negotiate('peer1', n.entry); |
| - assert.deepEqual(n.waits, [], 'it gave way a second time'); |
| + assert.equal(n.waits.length, POLITE_YIELDS + 1, 'the later negotiation went out unprotected'); |
| assert.deepEqual(n.sent, [['peer1', 'offer', true]]); |
| - assert.equal(n.entry.yielded, false); |
| }); |
| test('a connection that went away during the yield is not offered to', async () => { |
| @@ -734,3 +831,159 @@ test('a screen announced but never arrived is still dropped', () => { |
| s.dropShare(entry); |
| assert.equal(entry.sharing, false); |
| }); |
| + |
| +test('the numbers these tests assert against are the ones room.js ships', () => { |
| + // Everything above reads its constants out of the source, which is |
| + // what keeps a test from proving its own harness — but a value read |
| + // from the source and then handed straight back in is still no gate on |
| + // the value itself: POLITE_YIELD set to zero would leave every test |
| + // above green (the whole-branch review's W4-3). Spec §15.16 says 300 |
| + // ms, so that is what this pins. |
| + assert.equal(POLITE_YIELD, 300, 'spec §15.16 names 300 ms'); |
| + assert.ok(POLITE_YIELDS >= 1, 'the polite side must be allowed to give way at least once'); |
| + assert.ok(CAMERA_STALL_POLLS >= 2, 'one poll is a sample, not a stall'); |
| + assert.ok(CAMERA_REPAIRS >= 1, 'a camera must be given at least one repair before a reset'); |
| +}); |
| + |
| +// --------------------------------------------------------------- |
| +// The camera watch (Codex S4-5) |
| +// --------------------------------------------------------------- |
| +// |
| +// POLITE_YIELD prevents the rollback that costs a camera its encoder, |
| +// but it is a duration and not a guarantee: a signalling round trip |
| +// longer than 300 ms, a 429 retry, or a slow offer can still land the |
| +// remote offer late. What is left afterwards looks entirely healthy — |
| +// `connected`, the m-line `sendrecv`, the track `live` — and encodes |
| +// nothing at all, so no connection-state handler ever fires. The one |
| +// thing that says so is framesEncoded standing still. |
| + |
| +const CAMERA_STALL_POLLS = pin('CAMERA_STALL_POLLS'); |
| +const CAMERA_REPAIRS = pin('CAMERA_REPAIRS'); |
| + |
| +const cameraBox = new Function( |
| + 'CAMERA_STALL_POLLS', 'CAMERA_REPAIRS', |
| + [lift(src, 'judgeCamera'), lift(src, 'cameraReport'), lift(src, 'repairCamera')].join('\n') + |
| + '\nreturn { judgeCamera, cameraReport, repairCamera };', |
| +); |
| +const cam = cameraBox(CAMERA_STALL_POLLS, CAMERA_REPAIRS); |
| + |
| +// A camera transceiver as the watch sees one, and the record of what |
| +// was done to its sender. |
| +function cameraTx(opts) { |
| + const o = opts || {}; |
| + const calls = []; |
| + const track = { kind: 'video', readyState: o.readyState || 'live' }; |
| + const tx = { |
| + mid: o.mid === undefined ? '1' : o.mid, |
| + currentDirection: o.direction === undefined ? 'sendrecv' : o.direction, |
| + sender: { |
| + track: o.noReplace ? track : track, |
| + replaceTrack: o.noReplace ? undefined : async (t) => { |
| + calls.push(t === null ? 'null' : 'track'); |
| + }, |
| + }, |
| + }; |
| + return { tx, calls, track }; |
| +} |
| + |
| +function watching() { |
| + return { cameraFrames: -1, cameraStalls: 0, cameraRepairs: 0 }; |
| +} |
| + |
| +// verdicts drives one entry through a sequence of framesEncoded counts |
| +// and hands back what the watch said each time. |
| +function verdicts(entry, tx, counts) { |
| + return counts.map((n) => |
| + cam.judgeCamera(entry, tx, n === null ? null : { framesEncoded: n, mid: tx.mid })); |
| +} |
| + |
| +test('a camera that keeps encoding is never touched', () => { |
| + const c = cameraTx(); |
| + const entry = watching(); |
| + assert.deepEqual(verdicts(entry, c.tx, [10, 20, 30, 40, 50, 60]), ['', '', '', '', '', '']); |
| + assert.equal(entry.cameraRepairs, 0); |
| +}); |
| + |
| +test('a camera that stops encoding is repaired, and then reset', () => { |
| + // The signature: the same framesEncoded poll after poll on a sending |
| + // m-line with a live track. Two polls of it is a repair; two more is |
| + // the pair being rebuilt outright. |
| + const c = cameraTx(); |
| + const entry = watching(); |
| + const said = verdicts(entry, c.tx, [100, 100, 100, 100, 100, 100, 100]); |
| + assert.equal(said.filter((v) => v === 'repair').length, 1, JSON.stringify(said)); |
| + assert.equal(said.filter((v) => v === 'reset').length, 1, JSON.stringify(said)); |
| + assert.ok(said.indexOf('repair') < said.indexOf('reset'), 'the reset came before the repair'); |
| + // And then it stops asking: a reset that did not take is not a reason |
| + // to ask for another one every two seconds. |
| + assert.deepEqual(verdicts(entry, c.tx, [100, 100, 100, 100]), ['', '', '', '']); |
| +}); |
| + |
| +test('a camera that comes back forgets the repair it needed', () => { |
| + // A repair that worked must not be counted against the next stall, |
| + // hours later: the next one gets a repair of its own before anything |
| + // is reset. |
| + const c = cameraTx(); |
| + const entry = watching(); |
| + let said = verdicts(entry, c.tx, [100, 100, 100]); |
| + assert.equal(said[said.length - 1], 'repair'); |
| + said = verdicts(entry, c.tx, [110, 120, 130]); |
| + assert.deepEqual(said, ['', '', ''], 'a camera that is encoding again was still being judged'); |
| + said = verdicts(entry, c.tx, [130, 130, 130]); |
| + assert.ok(said.includes('repair') && !said.includes('reset'), |
| + 'the second stall was reset rather than repaired: ' + JSON.stringify(said)); |
| +}); |
| + |
| +test('nothing is repaired on a connection that is not sending a camera', () => { |
| + for (const opts of [{ direction: 'recvonly' }, { direction: 'inactive' }, { readyState: 'ended' }]) { |
| + const c = cameraTx(opts); |
| + const entry = watching(); |
| + assert.deepEqual(verdicts(entry, c.tx, [100, 100, 100, 100, 100]), ['', '', '', '', ''], |
| + JSON.stringify(opts) + ' was treated as a stalled camera'); |
| + assert.equal(entry.cameraStalls, 0); |
| + } |
| +}); |
| + |
| +test('a stall is forgotten when the camera stops being watchable', () => { |
| + // Half a stall, then no report at all — a connection that stopped |
| + // sending video. The count starts again rather than tipping over into |
| + // a repair on the strength of a sample from before. |
| + const c = cameraTx(); |
| + const entry = watching(); |
| + verdicts(entry, c.tx, [100, 100]); |
| + assert.equal(entry.cameraStalls, 1); |
| + assert.equal(cam.judgeCamera(entry, c.tx, null), ''); |
| + assert.equal(entry.cameraStalls, 0); |
| + assert.equal(entry.cameraFrames, -1); |
| +}); |
| + |
| +test('the outbound report is chosen by mid, and never guessed at', () => { |
| + const camera = { mid: '1', framesEncoded: 10 }; |
| + const screen = { mid: '2', framesEncoded: 20 }; |
| + assert.equal(cam.cameraReport([camera, screen], '1'), camera); |
| + assert.equal(cam.cameraReport([screen, camera], '1'), camera); |
| + // A browser that reports no mid on outbound-rtp: one report is |
| + // unambiguous, two are the camera and the screen and neither can be |
| + // told from the other. |
| + const bare = { framesEncoded: 10 }; |
| + assert.equal(cam.cameraReport([bare], '1'), bare); |
| + assert.equal(cam.cameraReport([bare, { framesEncoded: 20 }], '1'), null); |
| + assert.equal(cam.cameraReport([], '1'), null); |
| + // No mid known at all: the same rule from the other side. |
| + assert.equal(cam.cameraReport([bare], ''), bare); |
| + assert.equal(cam.cameraReport([bare, { framesEncoded: 20 }], ''), null); |
| +}); |
| + |
| +test('the repair takes the track off its sender and puts it back', async () => { |
| + const c = cameraTx(); |
| + await cam.repairCamera(c.tx); |
| + assert.deepEqual(c.calls, ['null', 'track'], |
| + 'the encoder is rebuilt by removing the track and restoring it, in that order'); |
| +}); |
| + |
| +test('a sender that cannot replace its track is left alone', async () => { |
| + const c = cameraTx({ noReplace: true }); |
| + await assert.doesNotReject(() => cam.repairCamera(c.tx)); |
| + await assert.doesNotReject(() => cam.repairCamera(null)); |
| + await assert.doesNotReject(() => cam.repairCamera({ sender: null })); |
| +}); |