| 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. |
| 23 | import { test } from 'node:test'; |
| 24 | import assert from 'node:assert/strict'; |
| 25 | import { readFileSync } from 'node:fs'; |
| 26 | import { fileURLToPath } from 'node:url'; |
| 27 | |
| 28 | const 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. |
| 32 | function 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 | |
| 49 | const src = readFileSync(roomJS, 'utf8'); |
| 50 | // eslint-disable-next-line no-new-func |
| 51 | const offerCollision = new Function(lift(src, 'offerCollision') + '; return offerCollision;')(); |
| 52 | |
| 53 | const entry = (makingOffer, isSettingRemoteAnswerPending) => ({ makingOffer, isSettingRemoteAnswerPending }); |
| 54 | |
| 55 | test('a stable connection is ready for an offer', () => { |
| 56 | assert.equal(offerCollision(entry(false, false), 'stable'), false); |
| 57 | }); |
| 58 | |
| 59 | test('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 | |
| 64 | test('our own unanswered offer is glare', () => { |
| 65 | assert.equal(offerCollision(entry(false, false), 'have-local-offer'), true); |
| 66 | }); |
| 67 | |
| 68 | test('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 | |
| 75 | test('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 | |
| 79 | test('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. |
| 89 | const 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. |
| 105 | function 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 | |
| 134 | test('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). |
| 184 | const 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 | |
| 191 | test('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. |
| 220 | test('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. |
| 252 | const proof = new Function( |
| 253 | [lift(src, 'renewedUfrag'), lift(src, 'selectedPairUfrag')].join('\n') + |
| 254 | '\nreturn { renewedUfrag, selectedPairUfrag };', |
| 255 | ); |
| 256 | const { 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. |
| 260 | function 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 | |
| 276 | test('a new username fragment on the selected pair is the proof', () => { |
| 277 | assert.equal(renewedUfrag(report('new1'), 'old0', 'stable', true), 'new1'); |
| 278 | }); |
| 279 | |
| 280 | test('the same fragment is the same generation, not a renewal', () => { |
| 281 | assert.equal(renewedUfrag(report('old0'), 'old0', 'stable', true), null); |
| 282 | }); |
| 283 | |
| 284 | test('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 | |
| 291 | test('a signaling state that is not stable is never proof', () => { |
| 292 | assert.equal(renewedUfrag(report('new1'), 'old0', 'have-local-offer', true), null); |
| 293 | }); |
| 294 | |
| 295 | test('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. |
| 306 | test('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 | |
| 319 | test('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 | |
| 327 | test('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 | |
| 337 | test('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 | |
| 347 | test('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 | |
| 360 | test('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 | |
| 374 | test('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 | |
| 379 | test('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. |
| 396 | const 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. |
| 405 | function 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). |
| 425 | function 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 | |
| 432 | test('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 | |
| 443 | test('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 | |
| 453 | test('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. |
| 484 | const 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 | |
| 493 | const EXPIRED_SENTENCE = 'The relay credential expired for this connection.'; |
| 494 | |
| 495 | function watching(pcs, renewals) { |
| 496 | return watchdog({ stopped: false }, pcs, renewals, () => {}, true, |
| 497 | EXPIRED_SENTENCE, 'stuck sentence', 'relay sentence', 'plain sentence'); |
| 498 | } |
| 499 | |
| 500 | test('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 | |
| 523 | test('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 | |
| 547 | test('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. |
| 578 | const 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 | |
| 585 | test('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. |
| 614 | const 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 | |
| 629 | function 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 | |
| 643 | test('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. |
| 655 | function connection(bornAt, closed, id) { |
| 656 | return { bornAt, pc: { close() { closed.push(id); } } }; |
| 657 | } |
| 658 | |
| 659 | test('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 | |
| 674 | test('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 | |
| 690 | test('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 | |
| 706 | test('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. |
| 729 | const 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. |
| 738 | const SERVER_NOW = 1700000000000; // an exact second, as Unix() gives |
| 739 | function 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 | |
| 752 | function scheduleFor(ice) { |
| 753 | const { renewDelay, mustSucceedBy } = schedule(ice, 1000); |
| 754 | return { delay: renewDelay(), life: mustSucceedBy() - ice.fetchedAt }; |
| 755 | } |
| 756 | |
| 757 | test('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 | |
| 763 | test('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 | |
| 768 | test('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 | |
| 775 | test('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 | |
| 783 | test('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 | |
| 789 | test('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 | |
| 795 | test('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 | |
| 800 | test('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. |
| 832 | const adopter = new Function( |
| 833 | 'ice', 'pcs', 'requestReset', |
| 834 | [lift(src, 'adoptServers'), lift(src, 'adoptWhenQuiet'), lift(src, 'readyToAdopt')].join('\n') + |
| 835 | '\nreturn { adoptServers };', |
| 836 | ); |
| 837 | |
| 838 | function 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 | |
| 854 | test('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 | |
| 864 | test('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 | |
| 879 | test('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. |
| 893 | const inbound = new Function( |
| 894 | 'gen', 'lastIn', 'pcs', 'rebuildPC', |
| 895 | lift(src, 'accept') + '\nreturn { accept };', |
| 896 | ); |
| 897 | |
| 898 | function 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 | |
| 910 | test('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 | |
| 915 | test('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 | |
| 921 | test('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 | |
| 926 | test('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. |
| 948 | const adopting = new Function( |
| 949 | 'ice', 'requestReset', |
| 950 | [lift(src, 'adoptWhenQuiet'), lift(src, 'readyToAdopt')].join('\n') + |
| 951 | '\nreturn { adoptWhenQuiet, readyToAdopt };', |
| 952 | ); |
| 953 | |
| 954 | function 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 | |
| 969 | function 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 | |
| 976 | test('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 | |
| 988 | test('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 | |
| 997 | test('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 | |
| 1003 | test('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 | |
| 1010 | test('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. |
| 1025 | const media = new Function( |
| 1026 | 'local', |
| 1027 | lift(src, 'hasLiveTrack') + '\nreturn { hasLiveTrack };', |
| 1028 | ); |
| 1029 | |
| 1030 | function stream(tracks) { |
| 1031 | return { |
| 1032 | getVideoTracks: () => tracks.filter((t) => t.kind === 'video'), |
| 1033 | getAudioTracks: () => tracks.filter((t) => t.kind === 'audio'), |
| 1034 | }; |
| 1035 | } |
| 1036 | |
| 1037 | const live = (kind) => ({ kind, readyState: 'live' }); |
| 1038 | const ended = (kind) => ({ kind, readyState: 'ended' }); |
| 1039 | |
| 1040 | test('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 | |
| 1046 | test('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 | |
| 1052 | test('an ended track is not a track', () => { |
| 1053 | const { hasLiveTrack } = media(stream([ended('video'), live('audio')])); |
| 1054 | assert.equal(hasLiveTrack('video'), false); |
| 1055 | }); |
| 1056 | |
| 1057 | test('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. |
| 1071 | const 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. |
| 1086 | function 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. |
| 1107 | function 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 | |
| 1125 | test('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 | |
| 1149 | test('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 | |
| 1162 | test('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 | |
| 1196 | test('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 | |
| 1215 | test('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 | |
| 1228 | test('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 | |
| 1241 | test('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. |
| 1258 | const scheduling = new Function( |
| 1259 | 'ice', 'MIN_MARGIN', |
| 1260 | lift(src, 'schedulable') + '\nreturn { schedulable };', |
| 1261 | ); |
| 1262 | |
| 1263 | test('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 | |
| 1268 | test('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 | |
| 1273 | test('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 | |
| 1278 | test('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 | |
| 1308 | test('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 | |
| 1329 | test('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 | |
| 1350 | test('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 | |