oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1// negotiation.test.mjs — the one piece of room.js that can be gated
2// without a browser.
3//
4// node --test test/browser/negotiation.test.mjs
5//
6// room.js is an IIFE that wants a document, so it cannot be imported.
7// `offerCollision` is a pure function of two arguments, though, and its
8// source is lifted out of the file by name and evaluated here. That
9// keeps the thing under test the shipped source — not a copy of it that
10// can drift — and it fails loudly if the function is renamed or
11// reshaped rather than passing on a stale duplicate.
12//
13// What it gates is spec §5.1's perfect-negotiation decision, exactly as
14// the W3C writes it:
15//
16// readyForOffer = !makingOffer &&
17// (signalingState === "stable" || isSettingRemoteAnswerPending)
18// offerCollision = !readyForOffer
19//
20// The `isSettingRemoteAnswerPending` half was missing (Codex S2-7): an
21// offer arriving while an answer was still being applied read as glare,
22// and the impolite side discarded a legitimate offer.
23import { test } from 'node:test';
24import assert from 'node:assert/strict';
25import { readFileSync } from 'node:fs';
26import { fileURLToPath } from 'node:url';
27
28const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url));
29
30// lift returns the source of one top-level-ish `function name(...) {...}`
31// by matching braces from its declaration.
32function lift(source, name) {
33 let start = source.indexOf('function ' + name + '(');
34 assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing');
35 // `async` is part of the declaration, and lifting a function that
36 // awaits without it is a syntax error rather than a test.
37 if (source.slice(start - 6, start) === 'async ') start -= 6;
38 let depth = 0;
39 for (let i = source.indexOf('{', start); i < source.length; i++) {
40 if (source[i] === '{') depth += 1;
41 else if (source[i] === '}') {
42 depth -= 1;
43 if (depth === 0) return source.slice(start, i + 1);
44 }
45 }
46 assert.fail('unbalanced braces reading ' + name + ' out of room.js');
47}
48
49const src = readFileSync(roomJS, 'utf8');
50// eslint-disable-next-line no-new-func
51const offerCollision = new Function(lift(src, 'offerCollision') + '; return offerCollision;')();
52
53const entry = (makingOffer, isSettingRemoteAnswerPending) => ({ makingOffer, isSettingRemoteAnswerPending });
54
55test('a stable connection is ready for an offer', () => {
56 assert.equal(offerCollision(entry(false, false), 'stable'), false);
57});
58
59test('an offer of our own in flight is glare', () => {
60 assert.equal(offerCollision(entry(true, false), 'stable'), true);
61 assert.equal(offerCollision(entry(true, false), 'have-local-offer'), true);
62});
63
64test('our own unanswered offer is glare', () => {
65 assert.equal(offerCollision(entry(false, false), 'have-local-offer'), true);
66});
67
68test('an answer being applied is not glare, whatever the signaling state says', () => {
69 // The regression itself: have-local-offer with an answer half applied
70 // is the state a legitimate follow-up offer arrives in.
71 assert.equal(offerCollision(entry(false, true), 'have-local-offer'), false);
72 assert.equal(offerCollision(entry(false, true), 'stable'), false);
73});
74
75test('an answer being applied while we are making an offer is still glare', () => {
76 assert.equal(offerCollision(entry(true, true), 'have-local-offer'), true);
77});
78
79test('a remote offer already applied is glare', () => {
80 assert.equal(offerCollision(entry(false, false), 'have-remote-offer'), true);
81});
82
83// The decision above is only half of it: the flag has to be *set*, by
84// the answer handler, around the await that makes the window. So the two
85// handlers are lifted out too and driven against a fake connection whose
86// setRemoteDescription can be held open — an answer still being applied
87// when the next offer arrives, which is the race Codex S2-7 named and
88// which the six cases above would pass without.
89const handlers = new Function(
90 'accept', 'send', 'describe', 'drainCandidates',
91 [lift(src, 'offerCollision'), lift(src, 'onOffer'), lift(src, 'onAnswer'),
92 lift(src, 'answeredExchange')].join('\n') +
93 '\nreturn { onOffer, onAnswer };',
94);
95
96// answeredExchange is lifted with them because onOffer calls it: an
97// exchange this side answered is what moves the negotiation generation
98// and hands the polite side its yield back (Codex S4-4). Its own
99// behaviour is gated in room-lane.test.mjs, next to `negotiate`.
100
101// fakePC is the part of RTCPeerConnection these two handlers touch. The
102// answer's setRemoteDescription is deliberately slow, and the state it
103// leaves behind while it is in flight is the real one: signalingState
104// stays at have-local-offer until the answer is applied.
105function fakePC() {
106 const applied = [];
107 let release = null;
108 const pc = {
109 signalingState: 'have-local-offer',
110 remoteDescription: null,
111 localDescription: { type: 'answer', sdp: 'local' },
112 applied,
113 hold: () => new Promise((resolve) => { release = resolve; }),
114 async setRemoteDescription(desc) {
115 applied.push(desc.type);
116 if (desc.type === 'answer') {
117 await new Promise((resolve) => { release = resolve; });
118 pc.signalingState = 'stable';
119 } else {
120 pc.signalingState = 'have-remote-offer';
121 }
122 pc.remoteDescription = desc;
123 },
124 async setLocalDescription() {
125 pc.signalingState = 'stable';
126 },
127 finishAnswer() {
128 release();
129 },
130 };
131 return pc;
132}
133
134test('an offer arriving while an answer is being applied is answered, not discarded', async () => {
135 const pc = fakePC();
136 const e = {
137 pc,
138 polite: false, // the impolite side: the one that discards on glare
139 makingOffer: false,
140 ignoreOffer: false,
141 isSettingRemoteAnswerPending: false,
142 yields: 0,
143 negotiation: 0,
144 pending: [],
145 descSeq: 0,
146 };
147 const sent = [];
148 const { onOffer, onAnswer } = handlers(
149 () => e,
150 (to, type) => sent.push(type),
151 (d) => d,
152 async () => {},
153 );
154
155 // The answer goes in and is left half-applied: its promise is still
156 // pending, and signalingState is still have-local-offer.
157 const answering = onAnswer({ from: 'other', seq: 1, payload: { description: { type: 'answer', sdp: 'a' } } });
158 assert.equal(e.isSettingRemoteAnswerPending, true, 'the answer handler must raise the flag before it awaits');
159 assert.equal(pc.signalingState, 'have-local-offer');
160
161 // And the next offer arrives in exactly that window.
162 await onOffer({ from: 'other', seq: 2, payload: { description: { type: 'offer', sdp: 'o' } } });
163
164 assert.equal(e.ignoreOffer, false, 'the impolite side treated a legitimate offer as glare');
165 assert.ok(pc.applied.includes('offer'), 'the offer was never applied: ' + JSON.stringify(pc.applied));
166 assert.deepEqual(sent, ['answer'], 'the offer was not answered: ' + JSON.stringify(sent));
167
168 pc.finishAnswer();
169 await answering;
170 assert.equal(e.isSettingRemoteAnswerPending, false, 'the flag must be cleared once the answer is applied');
171});
172
173// The recovery ladder, lifted the same way. This one is not about
174// negotiation at all — it lives here because this file is where room.js
175// can be run without a browser, and because a browser scenario cannot
176// see which internal path a client took, only what it did next.
177//
178// What it pins is §4.6's budget: a diagnosis that runs out of time must
179// release its target rather than returning quietly, because a quiet
180// return leaves the queue in place, the pump restarts it, its expired
181// head calls `unreachable`, and that opens a reset with a fresh thirty
182// seconds — recovery restarting the clock it was supposed to be bounded
183// by (Codex S2-17).
184const recovery = new Function(
185 'state', 'probe', 'refused', 'terminal', 'enterAfresh', 'staleTarget',
186 'sqid', 'SEND_DEADLINE', 'STREAM_BACKOFF_MAX', 'BACKOFF_MIN',
187 [lift(src, 'recoverFromNotFound'), lift(src, 'waitWithin'), lift(src, 'sleep'), lift(src, 'retryAfter')].join('\n') +
188 '\nreturn { recoverFromNotFound };',
189);
190
191test('a diagnosis that runs out of time releases its target and asks for nothing', async () => {
192 const released = [];
193 const enteredAfresh = [];
194 const terminals = [];
195 const { recoverFromNotFound } = recovery(
196 { stopped: false, peer: 'mypeer', boot: '' },
197 // A probe that never answers, exactly as the AbortController leaves
198 // it: a rejected promise.
199 async () => { throw new Error('aborted'); },
200 () => false,
201 (reason) => terminals.push(reason),
202 () => enteredAfresh.push(true),
203 (to, fromReset) => released.push([to, fromReset]),
204 'room', 30000, 10000, 500,
205 );
206
207 // A budget of fifty milliseconds, so the test costs fifty
208 // milliseconds. The path is the same one thirty seconds takes.
209 await recoverFromNotFound('', 'target', { deadline: Date.now() + 50 });
210
211 assert.deepEqual(released, [['target', true]],
212 'the target must be released exactly once, and never with a reset behind it');
213 assert.deepEqual(enteredAfresh, [], 'nothing here says this participation ended');
214 assert.deepEqual(terminals, [], 'nor that the call did');
215});
216
217// The case above cannot prove the answer handler clears the flag on its
218// own, because onOffer clears it too (round-3 note). So: an answer with
219// no offer behind it.
220test('the answer handler clears its own flag', async () => {
221 const pc = fakePC();
222 const e = {
223 pc,
224 polite: false,
225 makingOffer: false,
226 ignoreOffer: false,
227 isSettingRemoteAnswerPending: false,
228 yields: 0,
229 negotiation: 0,
230 pending: [],
231 descSeq: 0,
232 };
233 const { onAnswer } = handlers(() => e, () => {}, (d) => d, async () => {});
234 const answering = onAnswer({ from: 'other', seq: 1, payload: { description: { type: 'answer', sdp: 'a' } } });
235 assert.equal(e.isSettingRemoteAnswerPending, true);
236 pc.finishAnswer();
237 await answering;
238 assert.equal(e.isSettingRemoteAnswerPending, false, 'nothing else was there to clear it');
239});
240
241// ── §5.3's renewal proof ──────────────────────────────────────────────
242//
243// The other piece of room.js that can be decided without a browser: what
244// counts as proof that an ICE restart landed. §5.3 step 2 is exact about
245// it — the exchange completed, `signalingState` stable, and a selected,
246// succeeded candidate pair whose local candidate carries a *different*
247// ICE username fragment — because the obvious signal is a false one: a
248// `completed` transport moves to `connected` merely by starting a
249// restart, so iceConnectionState proves nothing at all.
250//
251// Both functions are lifted together: the decision calls the reader.
252const proof = new Function(
253 [lift(src, 'renewedUfrag'), lift(src, 'selectedPairUfrag')].join('\n') +
254 '\nreturn { renewedUfrag, selectedPairUfrag };',
255);
256const { renewedUfrag, selectedPairUfrag } = proof();
257
258// report builds a Chromium-shaped getStats answer: a transport naming
259// the selected pair, that pair, and its local candidate.
260function report(ufrag, opts) {
261 const o = opts || {};
262 return [
263 { type: 'transport', id: 'T', selectedCandidatePairId: o.unselected ? 'other' : 'P' },
264 {
265 type: 'candidate-pair',
266 id: 'P',
267 state: o.state || 'succeeded',
268 nominated: o.nominated !== false,
269 localCandidateId: 'L',
270 },
271 { type: 'local-candidate', id: 'L', usernameFragment: ufrag },
272 { type: 'inbound-rtp', id: 'I', kind: 'video' },
273 ];
274}
275
276test('a new username fragment on the selected pair is the proof', () => {
277 assert.equal(renewedUfrag(report('new1'), 'old0', 'stable', true), 'new1');
278});
279
280test('the same fragment is the same generation, not a renewal', () => {
281 assert.equal(renewedUfrag(report('old0'), 'old0', 'stable', true), null);
282});
283
284test('an exchange that has not completed is never proof', () => {
285 // The half that iceConnectionState cannot see: the restart's answer
286 // has not been applied, so whatever the candidate pairs say, the new
287 // generation is not up yet.
288 assert.equal(renewedUfrag(report('new1'), 'old0', 'stable', false), null);
289});
290
291test('a signaling state that is not stable is never proof', () => {
292 assert.equal(renewedUfrag(report('new1'), 'old0', 'have-local-offer', true), null);
293});
294
295test('a pair that is not succeeded, or not selected, is not proof', () => {
296 assert.equal(renewedUfrag(report('new1', { state: 'in-progress' }), 'old0', 'stable', true), null);
297 assert.equal(renewedUfrag(report('new1', { nominated: false, unselected: true }), 'old0', 'stable', true), null);
298});
299
300// Round-1 review, item 2. Chromium keeps the previous generation's pair
301// in the report for a while after a restart — still succeeded, still
302// nominated — so a scan for "a succeeded nominated pair" could be
303// answered by the very connection the restart replaced, and a renewal
304// would read as proven by its own predecessor. Where the browser names
305// the selected pair, that answer is the only one.
306test('the transport names the selected pair, and a stale nominated one does not overrule it', () => {
307 const reports = [
308 // Listed first, so a scan in report order would find this one.
309 { type: 'candidate-pair', id: 'stale', state: 'succeeded', nominated: true, localCandidateId: 'Lold' },
310 { type: 'transport', id: 'T', selectedCandidatePairId: 'fresh' },
311 { type: 'candidate-pair', id: 'fresh', state: 'succeeded', nominated: true, localCandidateId: 'Lnew' },
312 { type: 'local-candidate', id: 'Lold', usernameFragment: 'old0' },
313 { type: 'local-candidate', id: 'Lnew', usernameFragment: 'new1' },
314 ];
315 assert.equal(selectedPairUfrag(reports), 'new1', 'the stale pair answered for the fresh one');
316 assert.equal(renewedUfrag(reports, 'old0', 'stable', true), 'new1');
317});
318
319test('a transport-selected pair needs no nomination flag of its own', () => {
320 assert.equal(selectedPairUfrag([
321 { type: 'transport', id: 'T', selectedCandidatePairId: 'P' },
322 { type: 'candidate-pair', id: 'P', state: 'succeeded', localCandidateId: 'L' },
323 { type: 'local-candidate', id: 'L', usernameFragment: 'u1' },
324 ]), 'u1');
325});
326
327test('a transport that has selected nothing, or selected a pair that has not succeeded, is not a selection', () => {
328 const pair = (state) => ([
329 { type: 'transport', id: 'T', selectedCandidatePairId: state ? 'P' : '' },
330 { type: 'candidate-pair', id: 'P', state: state || 'succeeded', nominated: true, localCandidateId: 'L' },
331 { type: 'local-candidate', id: 'L', usernameFragment: 'u1' },
332 ]);
333 assert.equal(selectedPairUfrag(pair('')), null, 'a transport with no selection must not fall back to nominated');
334 assert.equal(selectedPairUfrag(pair('in-progress')), null);
335});
336
337test('without a transport report, nomination is the selection', () => {
338 // Firefox's shape: no transport, `selected` or `nominated` on the
339 // pair itself. The fallback has to stay, or the proof never lands
340 // there at all.
341 assert.equal(selectedPairUfrag([
342 { type: 'candidate-pair', id: 'P', state: 'succeeded', nominated: true, localCandidateId: 'L' },
343 { type: 'local-candidate', id: 'L', usernameFragment: 'ff1' },
344 ]), 'ff1');
345});
346
347test('the fallback prefers a nominated pair that names a fragment over one that does not', () => {
348 // Two nominated pairs and only the second names its fragment. Taking
349 // the first would answer '' — "a pair, but no generation" — which
350 // renewedUfrag reads as proof for a connection that had nothing
351 // recorded before the restart. The rule is: keep looking.
352 assert.equal(selectedPairUfrag([
353 { type: 'candidate-pair', id: 'P1', state: 'succeeded', nominated: true, localCandidateId: 'L1' },
354 { type: 'candidate-pair', id: 'P2', state: 'succeeded', nominated: true, localCandidateId: 'L2' },
355 { type: 'local-candidate', id: 'L1' },
356 { type: 'local-candidate', id: 'L2', usernameFragment: 'ff2' },
357 ]), 'ff2');
358});
359
360test('nothing recorded before the restart is not proof of anything', () => {
361 // This used to be the fallback: with no baseline, any succeeded
362 // selected pair counted. But an unchanged pair looks exactly like a
363 // new one when there is nothing to compare it with, so that reading
364 // could clear the warning and stamp a connection as renewed onto a
365 // credential it had never gathered on (Codex S3-3). A rejected
366 // getStats before the restart arrived here the same way. The poller
367 // takes its own baseline from the first reading after the restart
368 // instead — see "the proof takes a baseline after the restart".
369 assert.equal(renewedUfrag(report('new1'), null, 'stable', true), null);
370 assert.equal(renewedUfrag(report(''), null, 'stable', true), null);
371 assert.equal(renewedUfrag(report('new1'), '', 'stable', true), null);
372});
373
374test('a pair the browser will not name is not evidence', () => {
375 // '' says "there is a pair", not "there is a new one".
376 assert.equal(renewedUfrag(report(''), 'old0', 'stable', true), null);
377});
378
379test('the reader tells "no such pair" from "a pair with no fragment"', () => {
380 assert.equal(selectedPairUfrag([]), null, 'no pair at all must be null, not empty');
381 assert.equal(selectedPairUfrag(report('')), '', 'a pair with no fragment is proof of a pair');
382 assert.equal(selectedPairUfrag(report('u1')), 'u1');
383 // Firefox marks the pair with `selected` and names no transport.
384 assert.equal(selectedPairUfrag([
385 { type: 'candidate-pair', id: 'P', state: 'succeeded', selected: true, localCandidateId: 'L' },
386 { type: 'local-candidate', id: 'L', usernameFragment: 'ff1' },
387 ]), 'ff1');
388});
389
390// And the poller around that decision: it must keep asking until the
391// report changes, and stop at its own deadline rather than at the first
392// disappointing answer.
393// RENEW_POLL is passed in rather than lifted: the shipped half second
394// between polls is the right interval for a real ICE restart and the
395// wrong one for a test, and the interval is not what is under test here.
396const prover = new Function(
397 'state', 'pcs', 'RENEW_POLL',
398 [lift(src, 'proveRenewed'), lift(src, 'renewedUfrag'), lift(src, 'selectedPairUfrag'),
399 lift(src, 'statsList'), lift(src, 'sleep')].join('\n') +
400 '\nreturn { proveRenewed };',
401);
402
403// fakeStatsPC answers a Map-shaped report, as RTCStatsReport is, and
404// counts how many times it was asked.
405function fakeStatsPC(reports) {
406 let n = 0;
407 return {
408 signalingState: 'stable',
409 asked: () => n,
410 getStats() {
411 const r = reports[Math.min(n, reports.length - 1)];
412 n += 1;
413 return Promise.resolve(new Map(r.map((x) => [x.id, x])));
414 },
415 };
416}
417
418// bounded runs a poll against a hard cap, so that a proof which stops
419// at its own deadline is a pass and one that does not is a *failure*
420// rather than a test run that never ends. The cap's own timer is
421// unref'd, and `stop` is what ends the runaway poll behind it: an
422// unbounded loop with a sleep in it holds the event loop open for ever,
423// so without this the assertion would fail and the process would still
424// never exit (round-1 review, item 10).
425function bounded(promise, ms, stop) {
426 return Promise.race([promise, new Promise((resolve) => {
427 const t = setTimeout(() => resolve('NEVER STOPPED'), ms);
428 if (t.unref) t.unref();
429 })]).finally(stop);
430}
431
432test('the proof polls until the restart lands', async () => {
433 const pc = fakeStatsPC([report('old0'), report('old0'), report('new1')]);
434 const entry = { pc, desc: 1 };
435 const st = { stopped: false };
436 const { proveRenewed } = prover(st, new Map([['peer', entry]]), 10);
437 const got = await bounded(proveRenewed('peer', entry, 'old0', 0, Date.now() + 5000),
438 3000, () => { st.stopped = true; });
439 assert.equal(got, 'new1');
440 assert.ok(pc.asked() >= 3, 'it must have kept asking: ' + pc.asked());
441});
442
443test('the proof gives up at its deadline, and says so with null', async () => {
444 const pc = fakeStatsPC([report('old0')]);
445 const entry = { pc, desc: 1 };
446 const st = { stopped: false };
447 const { proveRenewed } = prover(st, new Map([['peer', entry]]), 10);
448 const got = await bounded(proveRenewed('peer', entry, 'old0', 0, Date.now() + 60),
449 3000, () => { st.stopped = true; });
450 assert.equal(got, null, 'a restart that never landed must not read as renewed');
451});
452
453test('the proof stops when the connection is replaced under it', async () => {
454 // A `pair`, a roster or an unreachable target rebuilds the
455 // connection: the entry in `pcs` is a different object, and thirty
456 // more seconds of polling the old one prove nothing about either.
457 const pc = fakeStatsPC([report('old0')]);
458 const entry = { pc, desc: 1 };
459 const pcs = new Map([['peer', { pc: fakeStatsPC([report('new1')]), desc: 9 }]]);
460 const st = { stopped: false };
461 const { proveRenewed } = prover(st, pcs, 10);
462 const got = await bounded(proveRenewed('peer', entry, 'old0', 0, Date.now() + 30000),
463 3000, () => { st.stopped = true; });
464 assert.equal(got, null);
465 assert.equal(pc.asked(), 0, 'it must not even have asked the connection that is gone');
466});
467
468// ── §5.3 step 3's watchdog ────────────────────────────────────────────
469//
470// Round-1 review, item 1. The must-succeed-by exists for one path: the
471// fetch keeps failing, the old credential's deadline passes, and the
472// connections still on it are owed the sentence. `ice.fetchedAt` only
473// advances on a *successful* fetch, so on exactly that path it is still
474// the expiring credential's own stamp — and a watchdog that exempted
475// "built at or after ice.fetchedAt" exempted every connection in the
476// call and marked nothing, ever. The stamp is captured before the fetch
477// and passed in instead.
478// Through the real marking functions, not a stub for them: the last
479// round's version passed a spy for `markRelayExpired` and so proved
480// nothing about what the shipped one does when it gets there — which
481// was to return without a word, because an earlier success had left
482// `record.renewed` true for the rest of the call (Codex S3-8). The
483// sentence a person would read is lifted too, and asserted.
484const watchdog = new Function(
485 'state', 'pcs', 'renewals', 'renderTiles', 'relayConfigured',
486 'ICE_EXPIRED', 'ICE_STUCK', 'FAILED_RELAY', 'FAILED_PLAIN',
487 [lift(src, 'expireUnrenewed'), lift(src, 'markRelayExpired'),
488 lift(src, 'markTrouble'), lift(src, 'renewalRecord'),
489 lift(src, 'tileNote')].join('\n') +
490 '\nreturn { expireUnrenewed, tileNote };',
491);
492
493const EXPIRED_SENTENCE = 'The relay credential expired for this connection.';
494
495function watching(pcs, renewals) {
496 return watchdog({ stopped: false }, pcs, renewals, () => {}, true,
497 EXPIRED_SENTENCE, 'stuck sentence', 'relay sentence', 'plain sentence');
498}
499
500test('the must-succeed-by marks the connections that credential was about, and only those', () => {
501 const expiring = 1000; // the fetch stamp of the credential that ran out
502 const pcs = new Map([
503 ['on-it', { listAt: expiring }],
504 ['older', { listAt: 400 }],
505 ['joined-since', { listAt: expiring + 1 }],
506 ['restarting', { listAt: expiring }],
507 ['proven-onto-the-new-one', { listAt: expiring + 1 }],
508 ]);
509 const renewals = new Map([
510 ['restarting', { running: true, renewed: false }],
511 ['proven-onto-the-new-one', { running: false, renewed: true }],
512 ]);
513 const { expireUnrenewed } = watching(pcs, renewals);
514
515 expireUnrenewed(expiring);
516
517 const marked = Array.from(renewals.entries())
518 .filter(([, r]) => r.trouble === 'expired').map(([id]) => id);
519 assert.deepEqual(marked.sort(), ['older', 'on-it'],
520 'a connection built on the expiring credential, with nothing running, is the whole point');
521});
522
523test('a renewal in an earlier cycle does not exempt a connection from a later one', () => {
524 // Codex S3-1. `renewed` is a boolean about the past: it goes true on
525 // the first successful renewal and nothing ever puts it back, so a
526 // watchdog that read it exempted that connection from every deadline
527 // for the rest of the call. What matters is which credential the
528 // connection is *on*, and `listAt` is that: it moves only on proof.
529 //
530 // Cycle 1 succeeds, so listAt moves to the first renewal's list.
531 // Cycle 2's fetch then fails, and the credential that expires is that
532 // same list — which this connection is still using.
533 const cycleOne = 2000;
534 const entry = { listAt: cycleOne, failed: false };
535 const pcs = new Map([['them', entry]]);
536 const renewals = new Map([['them', { running: false, renewed: true, attempts: 1, trouble: '' }]]);
537 const { expireUnrenewed, tileNote } = watching(pcs, renewals);
538
539 expireUnrenewed(cycleOne);
540
541 assert.equal(renewals.get('them').trouble, 'expired',
542 'the second credential expired under a connection that had renewed once, and nobody was told');
543 assert.equal(tileNote('them', entry), EXPIRED_SENTENCE,
544 'and the tile has to say so, which is the whole point of marking it');
545});
546
547test('a connection already proven onto a newer list is left alone by the deadline', () => {
548 // The other direction, so the fix above cannot be "mark everything":
549 // this one really is on a newer credential.
550 const entry = { listAt: 3000, failed: false };
551 const pcs = new Map([['them', entry]]);
552 const renewals = new Map([['them', { running: false, renewed: true, attempts: 1, trouble: '' }]]);
553 const { expireUnrenewed, tileNote } = watching(pcs, renewals);
554
555 expireUnrenewed(2000);
556
557 assert.equal(renewals.get('them').trouble, '');
558 assert.equal(tileNote('them', entry), '', 'and its tile says nothing');
559});
560
561
562// ── the roster the fetch came back to (§4.5, §4.6, §5.2) ──────────────
563//
564// §5.2 puts one await in front of the first connection this browser
565// builds, and that await is long enough for the room to change under it.
566// Two rules keep the call alive across it, and the gate's three ICE
567// scenarios died for want of both (Task 5's report):
568//
569// 1. what is reconciled is the roster held when the fetch *returns*,
570// not the one captured before it. A superseded roster does not name
571// the peer that arrived during the fetch, and reconciling it closes
572// a connection the hub has since named.
573// 2. a close from `reconcile` does not forget §4.6's counters. `seq`
574// belongs to the sender's participation, not to a connection, and a
575// connection closed and rebuilt that starts again at 1 is answered
576// by a peer whose cursor is already at 3 — which discards it as a
577// replay, for ever.
578const rosterPath = new Function(
579 'state', 'icePrimed', 'iceReady', 'reconcile', 'pendingRoster',
580 'pendingRosterAt', 'tick', 'console',
581 [lift(src, 'onRoster'), lift(src, 'applyRoster')].join('\n') +
582 '\nreturn { onRoster };',
583);
584
585test('the roster applied is the newest one, not the one the fetch started with', async () => {
586 const applied = [];
587 let release = null;
588 const fetching = new Promise((resolve) => { release = resolve; });
589 const { onRoster } = rosterPath(
590 { stopped: false },
591 false, // icePrimed: the first fetch is still out
592 () => fetching,
593 (roster) => applied.push(roster),
594 null, 0, 0,
595 console,
596 );
597
598 onRoster({ roster: { peers: [{ peer: 'a' }] } });
599 // A `peer` joins ten milliseconds later, and the hub sends the roster
600 // again. Both land while the fetch is out.
601 onRoster({ roster: { peers: [{ peer: 'a' }, { peer: 'b' }] } });
602
603 release();
604 await new Promise((resolve) => setTimeout(resolve, 0));
605
606 assert.equal(applied.length, 1, 'a superseded roster must not be applied at all');
607 assert.deepEqual(applied[0].peers.map((p) => p.peer), ['a', 'b'],
608 'the roster that names the peer who arrived during the fetch is the one that wins');
609});
610
611// reconcile and closePC, lifted together and driven over real Maps: the
612// forget rule is a property of the pair of them, and stubbing either
613// would be testing the stub.
614const reconciler = new Function(
615 'state', 'stale', 'names', 'pcs', 'gen', 'seqOut', 'lastIn', 'queue', 'frames',
616 'renewals', 'ensurePC', 'renderTiles', 'renderQueue', 'renderStatus',
617 'rosters', 'lastRoster', 'waiting', 'mountChat', 'chatEnabled', 'SHARE_SUFFIX',
618 // §15.19's roster half is its own function, gated in room-mute.test.mjs.
619 'rosterMedia',
620 // §15.20: closePC drops what was heard on the connection it closes.
621 'hearing',
622 // shareKey comes in as source rather than as a stub: closePC forgets a
623 // peer's share tile through it (§5.4), and a stub would be testing the
624 // stub.
625 [lift(src, 'reconcile'), lift(src, 'closePC'), lift(src, 'shareKey')].join('\n') +
626 '\nreturn { reconcile, closePC };',
627);
628
629function reconcileOver(pcs, seqOut, lastIn, frames) {
630 const built = [];
631 const chat = [];
632 const { reconcile, closePC } = reconciler(
633 { stopped: false, peer: 'me', kind: 'member' },
634 new Set(), new Map(), pcs, new Map(), seqOut, lastIn, new Map(),
635 frames || new Map(),
636 new Map(), (id, g) => built.push([id, g]), () => {}, () => {}, () => {},
637 0, null, [], () => chat.push('mount'), (on) => chat.push(on), '#share',
638 () => {}, new Map(),
639 );
640 return { reconcile, closePC, built, chat };
641}
642
643test('a roster is admission, so the chat panel is mounted and enabled', () => {
644 // §6: the panel appears when the hub says this tab is in the room, and
645 // not before. Mounting is idempotent, so a second roster is one more
646 // setEnabled(true) and nothing else.
647 const { reconcile, chat } = reconcileOver(new Map(), new Map(), new Map());
648 reconcile({ peers: [{ peer: 'me' }] }, 1);
649 reconcile({ peers: [{ peer: 'me' }] }, 2);
650 assert.deepEqual(chat, ['mount', true, 'mount', true]);
651});
652
653// A connection, as reconcile sees one: what tick it was built on, and a
654// close it can report.
655function connection(bornAt, closed, id) {
656 return { bornAt, pc: { close() { closed.push(id); } } };
657}
658
659test('a peer the roster stops naming loses its connection and keeps its counters', () => {
660 const closed = [];
661 const pcs = new Map([['peer1', connection(1, closed, 'peer1')]]);
662 const seqOut = new Map([['peer1', 7]]);
663 const lastIn = new Map([['peer1', 3]]);
664 const { reconcile } = reconcileOver(pcs, seqOut, lastIn);
665
666 reconcile({ peers: [{ peer: 'me' }] }, 2);
667
668 assert.deepEqual(closed, ['peer1'], 'the connection is still closed — that part was right');
669 assert.equal(seqOut.get('peer1'), 7,
670 'seq belongs to the sender participation (§4.6), not to the connection');
671 assert.equal(lastIn.get('peer1'), 3, 'and so does the replay cursor');
672});
673
674test('forgetting a peer forgets its screen\u2019s frame count too', () => {
675 // §5.4: a peer sharing a screen counts frames under two keys — its own
676 // and shareKey(its own) — and the participation that ends takes both
677 // with it. A share count left behind would be inherited by whatever
678 // next drew a tile under that key.
679 const closed = [];
680 const pcs = new Map([['peer1', connection(1, closed, 'peer1')]]);
681 const frames = new Map([['peer1', 30], ['peer1#share', 12], ['peer2', 5]]);
682 const { closePC } = reconcileOver(pcs, new Map(), new Map(), frames);
683
684 closePC('peer1', true);
685
686 assert.deepEqual(Array.from(frames.keys()), ['peer2'],
687 'both of the peer\u2019s tiles are forgotten, and nobody else\u2019s');
688});
689
690test('a close that is not a forgetting keeps both frame counts', () => {
691 // The other half of the same rule: a roster's silence closes a
692 // connection without ending a participation (§4.6), and the counts
693 // stand — including the screen's, which is how a gate can still see
694 // that the screen was arriving.
695 const closed = [];
696 const pcs = new Map([['peer1', connection(1, closed, 'peer1')]]);
697 const frames = new Map([['peer1', 30], ['peer1#share', 12]]);
698 const { closePC } = reconcileOver(pcs, new Map(), new Map(), frames);
699
700 closePC('peer1', false);
701
702 assert.equal(frames.get('peer1'), 30);
703 assert.equal(frames.get('peer1#share'), 12);
704});
705
706test('a peer the roster still names keeps its connection and its counters', () => {
707 const closed = [];
708 const pcs = new Map([['peer1', connection(1, closed, 'peer1')]]);
709 const seqOut = new Map([['peer1', 7]]);
710 const lastIn = new Map([['peer1', 3]]);
711 const { reconcile, built } = reconcileOver(pcs, seqOut, lastIn);
712
713 reconcile({ peers: [{ peer: 'me' }, { peer: 'peer1', gen: 1 }] }, 2);
714 assert.deepEqual(closed, [], 'a present peer must not be closed');
715
716 assert.ok(pcs.has('peer1'), 'the connection survives a roster that names it');
717 assert.deepEqual(built, [['peer1', 1]], 'and goes through the one door into the mesh');
718 assert.equal(seqOut.get('peer1'), 7);
719 assert.equal(lastIn.get('peer1'), 3);
720});
721
722// ── the renewal schedule (§5.3 step 4) ────────────────────────────────
723//
724// Two readings of the same schedule, and the earlier of them wins. The
725// ratio reading is immune to clock skew and wrong at short lives, where
726// the server's truncation to whole seconds eats the margin; the clock
727// reading is exact when the clocks agree and skewed when they do not,
728// but only ever in a direction that renews early. Neither is safe alone.
729const schedule = new Function(
730 'ice', 'MIN_RENEW_DELAY',
731 [lift(src, 'renewDelay'), lift(src, 'mustSucceedBy')].join('\n') +
732 '\nreturn { renewDelay, mustSucceedBy };',
733);
734
735// answered builds what /ice sends for a life in seconds — including the
736// server's own truncation, which is where the short lives go wrong —
737// read by a browser whose clock is `skew` ms ahead of the server's.
738const SERVER_NOW = 1700000000000; // an exact second, as Unix() gives
739function answered(life, skew) {
740 const secs = SERVER_NOW / 1000;
741 const expiresAt = (secs + life) * 1000;
742 const renewAt = (secs + Math.floor(life * 5 / 6)) * 1000;
743 return {
744 servers: [1],
745 expiresAt,
746 renewAt,
747 margin: Math.max(0, expiresAt - renewAt),
748 fetchedAt: SERVER_NOW + (skew || 0),
749 };
750}
751
752function scheduleFor(ice) {
753 const { renewDelay, mustSucceedBy } = schedule(ice, 1000);
754 return { delay: renewDelay(), life: mustSucceedBy() - ice.fetchedAt };
755}
756
757test('the three-hour life schedules where the contract says', () => {
758 // 10800 s: renew at 9000, expire at 10800, and the truncation is a
759 // no-op because both divide.
760 assert.deepEqual(scheduleFor(answered(10800)), { delay: 9000000, life: 10800000 });
761});
762
763test('a twelve-second life survives truncation because it divides too', () => {
764 // The gate's own icerenewal TTL: 10 and 12, margin 2, and 2 x 5 = 10.
765 assert.deepEqual(scheduleFor(answered(12)), { delay: 10000, life: 12000 });
766});
767
768test('a ten-second life is where the ratio reading alone would renew too late', () => {
769 // renewAt is 8 (8.333 truncated), so the margin reads 2 and the ratio
770 // says 10 — the exact moment the credential dies. The clock reading
771 // says 8, and the earlier of the two is what is used.
772 assert.deepEqual(scheduleFor(answered(10)), { delay: 8000, life: 10000 });
773});
774
775test('a two-second life renews at its floor and not before', () => {
776 // renewAt is 1 (1.667 truncated): the clock says 1000 ms, the ratio
777 // says 5000, and the floor is 1000 — so 1000, inside a life of 2000.
778 const got = scheduleFor(answered(2));
779 assert.deepEqual(got, { delay: 1000, life: 2000 });
780 assert.ok(got.delay < got.life, 'a renewal scheduled after the expiry is not a renewal');
781});
782
783test('a browser running behind the server is capped by the ratio, not stretched by the clock', () => {
784 // 60 s behind: the clock reading says 9060 s, which would renew a
785 // minute after the server expected. The ratio reading caps it.
786 assert.equal(scheduleFor(answered(10800, -60000)).delay, 9000000);
787});
788
789test('a browser running ahead of the server renews early, which costs nothing', () => {
790 const got = scheduleFor(answered(10800, 60000));
791 assert.equal(got.delay, 8940000, 'the clock reading is the earlier one here, and early is safe');
792 assert.equal(got.life, 10740000);
793});
794
795test('a server that leaves no margin at all cannot turn the schedule into a spin', () => {
796 const ice = { servers: [1], expiresAt: SERVER_NOW + 2000, renewAt: SERVER_NOW, margin: 0, fetchedAt: SERVER_NOW };
797 assert.equal(scheduleFor(ice).delay, 1000, 'the floor is what stops fetch-schedule-fetch');
798});
799
800test('a roster cannot close a connection built after the hub sent it', () => {
801 // The door the "newest roster" rule does not cover, and the one that
802 // actually cost the gate three scenarios: the hub does not re-send a
803 // roster when someone joins — it sends `peer` to the people already
804 // there. So a peer arriving while §5.2's fetch is out builds a
805 // connection, no newer roster ever comes, and the roster waiting on
806 // the fetch does not name it. Closing it there leaves a call with a
807 // participant it will never hear from again.
808 const closed = [];
809 const pcs = new Map([
810 ['before', connection(1, closed, 'before')],
811 ['during', connection(3, closed, 'during')],
812 ]);
813 const { reconcile } = reconcileOver(pcs, new Map(), new Map());
814
815 reconcile({ peers: [{ peer: 'me' }] }, 2);
816
817 assert.deepEqual(closed, ['before'],
818 'a connection older than the roster is the only one it can speak for');
819 assert.ok(pcs.has('during'), 'the connection built during the fetch survives');
820});
821
822
823// ── adopting the list a connection was built without (§5.2) ───────────
824//
825// The first fetch is one await long, and a `peer` can land inside it. It
826// builds a connection on the floor's empty list, and when the list
827// arrives that connection is given it. What it is NOT given is an ICE
828// restart on top of a negotiation already in flight: doing that cost the
829// `ice` scenario its media in one gate run out of four — both sides
830// reached `connected`, the remote track arrived, and no frame ever came
831// down it.
832const adopter = new Function(
833 'ice', 'pcs', 'requestReset',
834 [lift(src, 'adoptServers'), lift(src, 'adoptWhenQuiet'), lift(src, 'readyToAdopt')].join('\n') +
835 '\nreturn { adoptServers };',
836);
837
838function adoptable(state, desc) {
839 const calls = [];
840 return {
841 floor: true,
842 desc: desc || 0,
843 makingOffer: false,
844 listAt: 0,
845 calls,
846 pc: {
847 signalingState: state,
848 setConfiguration: (c) => calls.push(['setConfiguration', c.iceServers.length]),
849 restartIce: () => calls.push(['restartIce']),
850 },
851 };
852}
853
854test('a connection that has not started negotiating takes the servers and restarts', () => {
855 const entry = adoptable('stable', 0);
856 const ice = { servers: [1, 2], fetchedAt: 99 };
857 adopter(ice, new Map([['p', entry]]), () => {}).adoptServers();
858
859 assert.deepEqual(entry.calls, [['setConfiguration', 2], ['restartIce']]);
860 assert.equal(entry.floor, false, 'this one really is on the list now');
861 assert.equal(entry.listAt, 99);
862});
863
864test('a connection mid-negotiation takes the servers and is left alone', () => {
865 // Only mid-negotiation, now. A connection that is stable and *has*
866 // negotiated is the deferred case landing, and restarting it there is
867 // the whole point of the signalingstatechange listener — see "a quiet
868 // connection on the floor takes the list" below.
869 const entry = adoptable('have-local-offer', 0);
870 const ice = { servers: [1, 2], fetchedAt: 99 };
871 adopter(ice, new Map([['p', entry]]), () => {}).adoptServers();
872
873 assert.deepEqual(entry.calls, [['setConfiguration', 2]],
874 'an ICE restart across an offer in flight is what broke the media');
875 assert.equal(entry.floor, true,
876 'it stays on the floor, and is asked again the moment it is quiet');
877});
878
879test('there is nothing to adopt on the floor', () => {
880 const entry = adoptable('stable', 0);
881 adopter({ servers: [], fetchedAt: 99 }, new Map([['p', entry]]), () => {}).adoptServers();
882 assert.deepEqual(entry.calls, []);
883});
884
885
886// ── §4.6's inbound rules, in the order they have to be read ───────────
887//
888// The cursor and the generation both refuse messages, and which is
889// consulted first decides whether a pair that has just been reset can
890// ever speak again. A message from a newer generation is measured
891// against a cursor the *previous* connection built, so calling it a
892// replay is calling it something it cannot be.
893const inbound = new Function(
894 'gen', 'lastIn', 'pcs', 'rebuildPC',
895 lift(src, 'accept') + '\nreturn { accept };',
896);
897
898function inboundOver(g, cursor) {
899 const rebuilt = [];
900 const pcs = new Map([['them', { id: 'pc' }]]);
901 const gen = new Map([['them', g]]);
902 const lastIn = new Map([['them', cursor]]);
903 const { accept } = inbound(gen, lastIn, pcs, (from, to) => {
904 rebuilt.push([from, to]);
905 gen.set(from, to);
906 });
907 return { accept, rebuilt, lastIn };
908}
909
910test('a message at or below the cursor is a replay and is dropped', () => {
911 const { accept } = inboundOver(1, 3);
912 assert.equal(accept({ from: 'them', seq: 3, gen: 1 }), null);
913});
914
915test('a message above the cursor is taken, and moves it', () => {
916 const { accept, lastIn } = inboundOver(1, 3);
917 assert.ok(accept({ from: 'them', seq: 4, gen: 1 }));
918 assert.equal(lastIn.get('them'), 4);
919});
920
921test('a message from an older generation is dropped whatever its seq', () => {
922 const { accept } = inboundOver(2, 3);
923 assert.equal(accept({ from: 'them', seq: 99, gen: 1 }), null);
924});
925
926test('a message from a newer generation is not a replay, whatever the cursor says', () => {
927 // The line under the stale-roster defect: seq 1 from a pair that has
928 // just been reset, against a cursor of 3 from the connection it
929 // replaced. Read cursor-first, this is discarded — and so is
930 // everything after it, because the cursor never moves again.
931 const { accept, rebuilt, lastIn } = inboundOver(1, 3);
932 assert.ok(accept({ from: 'them', seq: 1, gen: 2 }),
933 'the first message of a rebuilt pair must get through');
934 assert.deepEqual(rebuilt, [['them', 2]], 'and it rebuilds the pair under the new generation');
935 assert.equal(lastIn.get('them'), 1, 'the cursor follows the generation that is speaking now');
936});
937
938
939// ── taking the list at the next quiet moment (§5.2) ───────────────────
940//
941// A connection built during the first fetch is on the floor's empty
942// list. Restarting it there and then cuts across the negotiation it is
943// in the middle of, which cost a gate run its media; never restarting it
944// leaves it on host candidates until the 150-minute renewal, which is
945// most of a call. So the decision is deferred, and asked again at every
946// quiet moment: createPC's signalingstatechange listener and
947// adoptServers both come through here.
948const adopting = new Function(
949 'ice', 'requestReset',
950 [lift(src, 'adoptWhenQuiet'), lift(src, 'readyToAdopt')].join('\n') +
951 '\nreturn { adoptWhenQuiet, readyToAdopt };',
952);
953
954function floorEntry(signalingState, opts) {
955 const o = opts || {};
956 const calls = [];
957 return {
958 floor: o.floor !== false,
959 makingOffer: !!o.makingOffer,
960 listAt: 0,
961 calls,
962 pc: {
963 signalingState,
964 restartIce: () => calls.push('restartIce'),
965 },
966 };
967}
968
969function adopt(entry, servers) {
970 const reset = [];
971 const ice = { servers: servers === undefined ? [1, 2] : servers, fetchedAt: 77 };
972 adopting(ice, (id) => reset.push(id)).adoptWhenQuiet('them', entry);
973 return reset;
974}
975
976test('a quiet connection on the floor takes the list, once', () => {
977 const entry = floorEntry('stable');
978 adopt(entry);
979 assert.deepEqual(entry.calls, ['restartIce']);
980 assert.equal(entry.floor, false, 'and is no longer on the floor');
981 assert.equal(entry.listAt, 77, 'and is stamped with the list it took');
982
983 // Every later quiet moment asks again; nothing happens twice.
984 adopt(entry);
985 assert.deepEqual(entry.calls, ['restartIce']);
986});
987
988test('a connection mid-negotiation waits, and is still on the floor', () => {
989 for (const entry of [floorEntry('have-local-offer'), floorEntry('have-remote-offer'),
990 floorEntry('stable', { makingOffer: true })]) {
991 adopt(entry);
992 assert.deepEqual(entry.calls, [], 'a restart across a negotiation in flight is the bug');
993 assert.equal(entry.floor, true, 'so it stays on the floor and is asked again later');
994 }
995});
996
997test('a connection that is not on the floor is never restarted by this', () => {
998 const entry = floorEntry('stable', { floor: false });
999 adopt(entry);
1000 assert.deepEqual(entry.calls, []);
1001});
1002
1003test('there is no list to take on the floor', () => {
1004 const entry = floorEntry('stable');
1005 adopt(entry, []);
1006 assert.deepEqual(entry.calls, []);
1007 assert.equal(entry.floor, true);
1008});
1009
1010test('a browser with no restartIce asks the hub for a fresh generation instead', () => {
1011 const entry = floorEntry('stable');
1012 entry.pc.restartIce = () => { throw new Error('unsupported'); };
1013 assert.deepEqual(adopt(entry), ['them']);
1014 assert.equal(entry.floor, false, 'the pair is being rebuilt; it is not left to try again');
1015});
1016
1017// ── asking to receive what we cannot send (§5.1) ──────────────────────
1018//
1019// The recvonly fallback used to be all-or-nothing: a local stream of any
1020// shape at all and nothing asked to receive. `startLocal`'s ladder ends
1021// at audio alone, though — a camera in use, or gone — and that browser
1022// then offered no video m-line, so the person on the other side had a
1023// tile that could never get a frame. Per kind, and an ended track counts
1024// as no track.
1025const media = new Function(
1026 'local',
1027 lift(src, 'hasLiveTrack') + '\nreturn { hasLiveTrack };',
1028);
1029
1030function stream(tracks) {
1031 return {
1032 getVideoTracks: () => tracks.filter((t) => t.kind === 'video'),
1033 getAudioTracks: () => tracks.filter((t) => t.kind === 'audio'),
1034 };
1035}
1036
1037const live = (kind) => ({ kind, readyState: 'live' });
1038const ended = (kind) => ({ kind, readyState: 'ended' });
1039
1040test('a stream with both kinds asks to receive neither', () => {
1041 const { hasLiveTrack } = media(stream([live('video'), live('audio')]));
1042 assert.equal(hasLiveTrack('video'), true);
1043 assert.equal(hasLiveTrack('audio'), true);
1044});
1045
1046test('the audio-only rung of the ladder still asks for video', () => {
1047 const { hasLiveTrack } = media(stream([live('audio')]));
1048 assert.equal(hasLiveTrack('video'), false, 'this is the m-line that was never offered');
1049 assert.equal(hasLiveTrack('audio'), true);
1050});
1051
1052test('an ended track is not a track', () => {
1053 const { hasLiveTrack } = media(stream([ended('video'), live('audio')]));
1054 assert.equal(hasLiveTrack('video'), false);
1055});
1056
1057test('no local stream asks to receive both', () => {
1058 const { hasLiveTrack } = media(null);
1059 assert.equal(hasLiveTrack('video'), false);
1060 assert.equal(hasLiveTrack('audio'), false);
1061});
1062
1063
1064// ── the renewal state machine, driven (§5.3 step 2, §15.14) ───────────
1065//
1066// The pure decisions above are read by a loop, and the loop is where
1067// three of Codex's round-1 findings lived: which connections a fetch is
1068// for, how many attempts a fresh credential buys, and which of the two
1069// sentences the failure earns. So the loop itself is lifted and driven
1070// over fake connections, with the windows shortened to milliseconds.
1071const machine = new Function(
1072 'state', 'pcs', 'renewals', 'ice', 'fetchIce', 'requestReset', 'renderTiles',
1073 'bareRestart', 'RENEW_ATTEMPTS', 'RENEW_WINDOW', 'RENEW_POLL', 'console',
1074 [lift(src, 'renewOne'), lift(src, 'renewOutdated'), lift(src, 'retryRenewal'),
1075 lift(src, 'followUp'),
1076 lift(src, 'renewalRecord'), lift(src, 'markTrouble'), lift(src, 'verdict'),
1077 lift(src, 'markRelayExpired'), lift(src, 'proveRenewed'), lift(src, 'renewedUfrag'),
1078 lift(src, 'selectedPairUfrag'), lift(src, 'selectedUfrag'), lift(src, 'statsList'),
1079 lift(src, 'sleep')].join('\n') +
1080 '\nreturn { renewOne, renewOutdated, retryRenewal, proveRenewed };',
1081);
1082
1083// A connection whose getStats walks a list of fragments, one reading at
1084// a time, and whose restartIce completes an exchange the way a real one
1085// does. Give it ['old0'] and it never moves: the restart does not land.
1086function renewable(ufrags, listAt) {
1087 const entry = { desc: 0, listAt: listAt || 0, floor: false, makingOffer: false };
1088 let i = 0;
1089 entry.calls = [];
1090 entry.pc = {
1091 signalingState: 'stable',
1092 setConfiguration: (c) => entry.calls.push('setConfiguration:' + ((c.iceServers || []).length)),
1093 restartIce: () => {
1094 entry.calls.push('restartIce');
1095 entry.desc += 1;
1096 },
1097 getStats: () => {
1098 const u = ufrags[Math.min(i, ufrags.length - 1)];
1099 i += 1;
1100 return Promise.resolve(new Map(report(u).map((r) => [r.id, r])));
1101 },
1102 };
1103 return entry;
1104}
1105
1106// The machine, wired to a mesh and a fetch that behaves as told.
1107function mesh(entries, fetchResult) {
1108 const pcs = new Map(Object.entries(entries));
1109 const renewals = new Map();
1110 const ice = { servers: [1, 2], fetchedAt: 1000, expiresAt: 0, renewAt: 0, margin: 0 };
1111 const resets = [];
1112 const fetchIce = async () => {
1113 if (!fetchResult) return false;
1114 ice.fetchedAt += 1000; // a new list, with a stamp of its own
1115 return true;
1116 };
1117 const api = machine(
1118 { stopped: false }, pcs, renewals, ice, fetchIce,
1119 (id) => resets.push(id), () => {}, (id) => resets.push('bare:' + id),
1120 3, 60, 10, console,
1121 );
1122 return { ...api, pcs, renewals, ice, resets };
1123}
1124
1125test('a fresh credential buys three restarts and the verdict is stuck, never expired', async () => {
1126 // Codex S3-4. A renewal that starts shortly before the old deadline —
1127 // after a tab suspension, say — used to spend one attempt of its
1128 // three, read the deadline, and report the credential expired. It has
1129 // a replacement credential in its hand while it says so. The
1130 // credential is fine; the path is not; that is a different sentence
1131 // and it is worth all three attempts.
1132 const them = renewable(['old0']);
1133 const m = mesh({ them });
1134
1135 // The second argument is deliberate and deliberately ignored: an old
1136 // deadline, passed the way the removed optional-deadline branch used
1137 // to take one. Restore that branch and this test fails on the first
1138 // attempt (Codex round 2's note on this test).
1139 const ok = await m.renewOne('them', Date.now() - 60000);
1140
1141 assert.equal(ok, false);
1142 const record = m.renewals.get('them');
1143 assert.equal(record.attempts, 3, 'the full budget, not one attempt cut short by a deadline');
1144 assert.equal(record.trouble, 'stuck');
1145 assert.equal(record.renewed, false);
1146 assert.equal(them.listAt, 0, 'and nothing was stamped: this connection is on no new credential');
1147});
1148
1149test('a restart that lands is proven, stamped and quiet', async () => {
1150 const them = renewable(['old0', 'old0', 'new1']);
1151 const m = mesh({ them });
1152
1153 assert.equal(await m.renewOne('them'), true);
1154 const record = m.renewals.get('them');
1155 assert.equal(record.renewed, true);
1156 assert.equal(record.ufrag, 'new1');
1157 assert.equal(record.trouble, '');
1158 assert.equal(record.attempts, 1, 'one restart was enough');
1159 assert.equal(them.listAt, m.ice.fetchedAt, 'and the connection is stamped onto the list it took');
1160});
1161
1162test('a Retry reaches a connection whose own restart was already running', async () => {
1163 // Codex S3-9's other half. B is mid-restart when the Retry fetches:
1164 // renewOutdated skips it, rightly — cutting across an attempt is what
1165 // broke the media in an earlier round — so the debt is paid when that
1166 // attempt settles, and B ends up on the new list rather than keeping
1167 // the old one until the next schedule.
1168 const a = renewable(['a0', 'a0', 'a1']);
1169 // B's fragments have to carry two restarts — its own, and the
1170 // follow-up onto the list the Retry fetches — and the first has to
1171 // take long enough that the Retry really does land inside it.
1172 const b = renewable(['b0', 'b0', 'b0', 'b0', 'b1', 'b1', 'b1', 'b2']);
1173 const m = mesh({ a, b }, true);
1174
1175 const running = m.renewOne('b'); // B is restarting already
1176 // Far enough in that B has installed the list it is renewing onto,
1177 // and not so far that its proof has already landed: a fetch before
1178 // the install is one B takes for itself, and one after the proof
1179 // finds B idle — both are different cases, and neither is this.
1180 await new Promise((resolve) => setTimeout(resolve, 15));
1181 await m.retryRenewal('a', 'expired'); // and A's tile is clicked
1182 await running;
1183 await new Promise((resolve) => setTimeout(resolve, 600));
1184
1185 assert.equal(m.renewals.get('a').renewed, true);
1186 assert.equal(m.renewals.get('b').renewed, true);
1187 assert.equal(b.listAt, m.ice.fetchedAt,
1188 'B is on the list the Retry fetched, not the one it was mid-restart onto');
1189 // And it got there by actually restarting onto it. Without the
1190 // follow-up, B's first proof simply stamped whatever the newest list
1191 // happened to be, which reads the same and is not the same thing.
1192 assert.ok(b.calls.filter((c) => c === 'restartIce').length >= 2,
1193 'B restarted again for the newer list rather than claiming it (' + JSON.stringify(b.calls) + ')');
1194});
1195
1196test('a Retry on one tile renews every connection the fetch re-credentialed', async () => {
1197 // Codex S3-2. The fetch a Retry makes replaces the credential the
1198 // whole call was using and resets the shared schedule with it, so
1199 // renewing only the tile that was clicked left every other connection
1200 // on a credential about to die with nothing coming to fix it.
1201 const a = renewable(['a0', 'a0', 'a1']);
1202 const b = renewable(['b0', 'b0', 'b1']);
1203 const m = mesh({ a, b }, true);
1204
1205 await m.retryRenewal('a', 'expired');
1206 // renewOutdated does not await its renewals — the mesh renews in
1207 // parallel, as it does in the page — so wait for both to settle.
1208 await new Promise((resolve) => setTimeout(resolve, 400));
1209
1210 assert.equal(m.renewals.get('a').renewed, true, 'the tile that was clicked');
1211 assert.equal(m.renewals.get('b').renewed, true, 'and the one that was not');
1212 assert.equal(b.listAt, m.ice.fetchedAt, 'both are stamped onto the new list');
1213});
1214
1215test('a connection already on the new list is not renewed again', async () => {
1216 const a = renewable(['a0', 'a0', 'a1'], 1000);
1217 const joined = renewable(['j0'], 9999); // built after the fetch
1218 const m = mesh({ a, joined });
1219
1220 m.renewOutdated(1000);
1221 await new Promise((resolve) => setTimeout(resolve, 400));
1222
1223 assert.equal(m.renewals.get('a').renewed, true);
1224 assert.equal(m.renewals.has('joined'), false, 'nothing was asked of it, so it has no record');
1225 assert.deepEqual(joined.calls, [], 'and its connection was not touched');
1226});
1227
1228test('the proof takes a baseline after the restart when it had none before', async () => {
1229 // Codex S3-3, the other half: with no baseline the poller does not
1230 // give up, it takes the first fragment it can read afterwards and
1231 // then requires a different one. A connection that moves is still
1232 // proven.
1233 const them = renewable(['', 'base', 'base', 'moved']);
1234 const m = mesh({ them });
1235 them.desc = 1; // the exchange has completed
1236
1237 const proven = await m.proveRenewed('them', them, null, 0, Date.now() + 2000);
1238 assert.equal(proven, 'moved');
1239});
1240
1241test('a connection that never moves is unproven, whatever it started with', async () => {
1242 const them = renewable(['', 'stuck0']);
1243 const m = mesh({ them });
1244 them.desc = 1;
1245
1246 const proven = await m.proveRenewed('them', them, null, 0, Date.now() + 200);
1247 assert.equal(proven, null, 'an unchanged pair with no baseline must not read as a new generation');
1248});
1249
1250// ── a margin too small to schedule on (W3-12) ─────────────────────────
1251//
1252// The delay has a floor of a second, so a list whose renew time and
1253// expiry are less than a second apart would be fetched about once a
1254// second for as long as the tab stayed open. The server clamps what it
1255// mints, so only the development TTL header can produce one — but a
1256// client that answers a nonsense schedule by hammering its own server
1257// is a client that can be made to do it, so it declines instead.
1258const scheduling = new Function(
1259 'ice', 'MIN_MARGIN',
1260 lift(src, 'schedulable') + '\nreturn { schedulable };',
1261);
1262
1263test('an ordinary list is schedulable', () => {
1264 assert.equal(scheduling({ expiresAt: 9, servers: [1], margin: 1800000 }, 1000).schedulable(), true);
1265 assert.equal(scheduling({ expiresAt: 9, servers: [1], margin: 1000 }, 1000).schedulable(), true);
1266});
1267
1268test('a margin under a second is not a schedule', () => {
1269 assert.equal(scheduling({ expiresAt: 9, servers: [1], margin: 999 }, 1000).schedulable(), false);
1270 assert.equal(scheduling({ expiresAt: 9, servers: [1], margin: 0 }, 1000).schedulable(), false);
1271});
1272
1273test('the floor has nothing to say about a call with no relay', () => {
1274 assert.equal(scheduling({ expiresAt: 0, servers: [], margin: 0 }, 1000).schedulable(), false);
1275});
1276
1277
1278test('a proof stamps the list it installed, not one that arrived while it was proving', async () => {
1279 // Codex S3-9. A restart installs list B and starts proving it. Another
1280 // tile's Retry fetches C in the meantime — renewOutdated skips this
1281 // connection, because a restart is running — and when B's proof landed
1282 // the connection was stamped with C: a credential it had never been
1283 // given, whose schedule then governed it.
1284 const them = renewable(['old0', 'old0', 'new1', 'new1', 'new1', 'new2']);
1285 const m = mesh({ them }, true);
1286 const B = m.ice.fetchedAt;
1287
1288 // C arrives after the list is installed and while the proof polls.
1289 const real = them.pc.getStats.bind(them.pc);
1290 let calls = 0;
1291 them.pc.getStats = () => {
1292 calls += 1;
1293 if (calls === 2) m.ice.fetchedAt += 1000;
1294 return real();
1295 };
1296
1297 assert.equal(await m.renewOne('them'), true);
1298 assert.equal(them.listAt, B,
1299 'the connection is on the list it installed, not on one that merely arrived');
1300
1301 // And the debt that leaves is paid: nothing else was coming for this
1302 // connection, because renewOutdated had already passed it by.
1303 await new Promise((resolve) => setTimeout(resolve, 600));
1304 assert.equal(them.listAt, m.ice.fetchedAt, 'and it is renewed onto the newer list afterwards');
1305});
1306
1307
1308test('a configuration the browser refuses is not a credential the connection has', async () => {
1309 // Codex S3-13. setConfiguration throws — an older browser, or one
1310 // that will not take the list mid-call — and the connection stays on
1311 // the servers it had. Restarting from there re-gathers on *those*
1312 // servers, and a new fragment would then have been read as proof and
1313 // stamped the connection onto a list it never took. Nothing is
1314 // stamped, nothing is restarted, and a rebuild is asked for instead:
1315 // a fresh connection takes the current list at construction.
1316 const them = renewable(['old0', 'old0', 'new1']);
1317 them.pc.setConfiguration = () => { throw new Error('refused'); };
1318 const m = mesh({ them });
1319
1320 assert.equal(await m.renewOne('them'), false);
1321 assert.deepEqual(them.calls, [], 'a refused configuration must not be followed by a restart');
1322 assert.equal(them.listAt, 0, 'and nothing may be stamped: this connection took no new list');
1323 const record = m.renewals.get('them');
1324 assert.equal(record.renewed, false);
1325 assert.equal(record.trouble, 'stuck');
1326 assert.deepEqual(m.resets, ['them'], 'a rebuild is the only way left to give it these servers');
1327});
1328
1329test('a run records the budget it spent, alongside the lifetime count', async () => {
1330 // Codex S3-14. `attempts` counts a whole connection's life, so a
1331 // follow-up run raises it while the first verdict is still on the
1332 // screen: it cannot say how many restarts *this* verdict followed.
1333 const them = renewable(['old0']);
1334 const m = mesh({ them });
1335
1336 await m.renewOne('them');
1337 const first = m.renewals.get('them');
1338 assert.deepEqual(first.lastRun, { attempts: 3, verdict: 'stuck' });
1339 assert.equal(first.attempts, 3);
1340
1341 // A second run: the lifetime count moves on, and the run's own does
1342 // not pretend to be it.
1343 await m.renewOne('them');
1344 const second = m.renewals.get('them');
1345 assert.deepEqual(second.lastRun, { attempts: 3, verdict: 'stuck' },
1346 'the run spent three, whatever the lifetime count now reads');
1347 assert.equal(second.attempts, 6, 'and the lifetime count is still there for whoever wants it');
1348});
1349
1350test('a proven run says so, and says what it cost', async () => {
1351 const them = renewable(['old0', 'old0', 'new1']);
1352 const m = mesh({ them });
1353 assert.equal(await m.renewOne('them'), true);
1354 assert.deepEqual(m.renewals.get('them').lastRun, { attempts: 1, verdict: 'renewed' });
1355});
1356