From 0d5baab4c1a183f8823ddb905e14fc398585d787 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey <147930957+TheCryptoDonkey@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:30:24 +0000 Subject: [PATCH] Retire only the viewer-owned TURN relay allocation on renewal --- docs/media-showcase.md | 7 ++-- site/live-media.html | 2 +- site/media-lab.html | 2 +- site/media-lab.mjs | 4 +- site/media-public-swarm.mjs | 7 ++-- site/template.html | 2 +- test/media-public-swarm.test.mjs | 70 ++++++++++++++++++++++++++++++-- 7 files changed, 80 insertions(+), 14 deletions(-) diff --git a/docs/media-showcase.md b/docs/media-showcase.md index 65aba8a..aa27f27 100644 --- a/docs/media-showcase.md +++ b/docs/media-showcase.md @@ -823,9 +823,10 @@ new peer connections. The supported public service contract freshly mints one-ho credentials. The client keeps a conservative monotonic lifetime, charging request/body time and a rounding margin; a wall-clock correction can only shorten it. Renewal starts after two thirds of the remaining lifetime. -New peer connections use the latest credential. Relayed connections retire -shortly before their old credential expires so the SDK can reconnect while -the playing HLS instance and verified cache survive. Pause cancels renewal. +New peer connections use the latest credential. Connections using this viewer's +local relay allocation retire shortly before its old credential expires so the +SDK can reconnect while the playing HLS instance and verified cache survive. +Pause cancels renewal. Diagnostics expose availability and expiry duration, never the credential. An unconfigured public relay causes no request, timer or startup delay; diff --git a/site/live-media.html b/site/live-media.html index 2a6a612..20a8a30 100644 --- a/site/live-media.html +++ b/site/live-media.html @@ -123,6 +123,6 @@ - + diff --git a/site/media-lab.html b/site/media-lab.html index 8f77adc..8c9053f 100644 --- a/site/media-lab.html +++ b/site/media-lab.html @@ -123,6 +123,6 @@ - + diff --git a/site/media-lab.mjs b/site/media-lab.mjs index 89620d6..2c75ed5 100644 --- a/site/media-lab.mjs +++ b/site/media-lab.mjs @@ -424,7 +424,7 @@ async function initialise() { publicRoom?.stop(); publicRoom = null; const pending = (async () => { const [{ createListenerSession }, { PUBLIC_SWARM_SOURCE_SHA256, PUBLIC_SWARM_RELAYS }] = await Promise.all([ - import('./media-listener-session.mjs?v=20261027-bounded-startup-window'), import('./media-public-swarm.mjs?v=20261027-bounded-startup-window'), + import('./media-listener-session.mjs?v=20261027-bounded-startup-window'), import('./media-public-swarm.mjs?v=20261027-local-turn-lease'), ]); if (request !== publicRoomRequest || pageAbort.signal.aborted) return null; const clock = createListenerSession({ room: `forgesworn-dejavu-listening-v${quality === 'full' ? 2 : 1}-${quality}`, source: PUBLIC_SWARM_SOURCE_SHA256, @@ -1522,7 +1522,7 @@ async function initialise() { if (player) { let peer, setupEpoch; try { - const { createPublicSwarm, prepareRecordedSource, createRecordedLive } = await import('./media-public-swarm.mjs?v=20261027-bounded-startup-window'); + const { createPublicSwarm, prepareRecordedSource, createRecordedLive } = await import('./media-public-swarm.mjs?v=20261027-local-turn-lease'); if (!owns() || s.hls !== player) return; // These two exports share the exact helper module/recording brand. // A cold first room prepares locally before publishing its clock. diff --git a/site/media-public-swarm.mjs b/site/media-public-swarm.mjs index cb1a8b9..f4f7c3a 100644 --- a/site/media-public-swarm.mjs +++ b/site/media-public-swarm.mjs @@ -437,9 +437,10 @@ export function createPublicSwarm({ Hls, hls, video, playlistUrl, quality, signa const renew = () => { if (!enabled || stopped || this.connectionState === 'closed' || !record.identity) return; const current = turn.leaseIdentity(); - if (current && current !== record.identity && peerObservations.get(this)?.kind === 'relay') { - // Retire just this expiring transport. The SDK reconnects with - // the latest lease; the playing HLS instance and cache survive. + if (current && current !== record.identity && peerObservations.get(this)?.localCandidateType === 'relay') { + // Retire only this viewer's expiring local relay allocation. + // The SDK reconnects with the latest lease; the playing HLS + // instance and cache survive. for (const channel of record.channels) { try { channel.close(); } catch {} } return; } diff --git a/site/template.html b/site/template.html index cd2232a..00ab97c 100644 --- a/site/template.html +++ b/site/template.html @@ -1985,6 +1985,6 @@

Pick a block and start

}); }); - + diff --git a/test/media-public-swarm.test.mjs b/test/media-public-swarm.test.mjs index 99016b6..1f423a3 100644 --- a/test/media-public-swarm.test.mjs +++ b/test/media-public-swarm.test.mjs @@ -147,9 +147,9 @@ function lifecycleFixture(t, { turnCredentials, turnConfiguration, turnFactory, close() { if (this.readyState === 3) return; this.readyState = 3; this.dispatchEvent(new Event('close')); } } class Connection extends EventTarget { - constructor(config) { super(); this.configuration = structuredClone(config); this.connectionState = 'connected'; this.iceConnectionState = 'connected'; connections.push(this); } - getStats() { return Promise.resolve(new Map()); } - createDataChannel() { return { send() {} }; } + constructor(config) { super(); this.configuration = structuredClone(config); this.connectionState = 'connected'; this.iceConnectionState = 'connected'; this.stats = new Map(); connections.push(this); } + getStats() { return Promise.resolve(this.stats); } + createDataChannel() { return { closeCalls: 0, send() {}, close() { this.closeCalls++; } }; } close() { if (this.connectionState === 'closed') return; this.connectionState = 'closed'; this.dispatchEvent(new Event('connectionstatechange')); } } class Loader { load(context, config, callbacks) { callbacks.onSuccess({ data: new ArrayBuffer(16) }, {}, context, null); } } @@ -290,6 +290,70 @@ test('native peer construction uses the current automatic lease and pause cancel assert.equal(fixture.pendingTimers(), 0); }); +for (const { name, local, remote, kind, retired } of [ + { name: 'selected local relay', local: 'relay', remote: 'host', kind: 'relay', retired: true }, + { name: 'selected remote relay only', local: 'host', remote: 'relay', kind: 'relay', retired: false }, + { name: 'selected direct route', local: 'host', remote: 'srflx', kind: 'direct', retired: false }, + { name: 'unknown selected route', local: null, remote: null, kind: null, retired: false }, +]) test(`automatic lease rotation retires only its local allocation: ${name}`, async t => { + let identity = 'initial-viewer'; + const f = lifecycleFixture(t, { turnCredentials: { + async start() {}, stop() {}, + servers: () => [{ urls: ['turns:relay.invalid:5349?transport=tcp'], username: identity, credential: 'fixture-only' }], + leaseIdentity: () => identity, + snapshot: () => ({ phase: 'ready', available: true, remainingMs: 90000, failures: 0 }), + } }); + await f.adapter.setEnabled(true); + const pc = f.connections[0], channel = pc.createDataChannel('fixture'); + if (local && remote) pc.stats = new Map([ + ['transport', { id: 'transport', type: 'transport', selectedCandidatePairId: 'selected' }], + ['selected', { id: 'selected', type: 'candidate-pair', localCandidateId: 'local', remoteCandidateId: 'remote', bytesSent: 0, bytesReceived: 0 }], + ['local', { id: 'local', type: 'local-candidate', candidateType: local }], + ['remote', { id: 'remote', type: 'remote-candidate', candidateType: remote }], + ]); + // Use the adapter's ordinary stats sampling and original renewal timer. + f.advance(750); await new Promise(resolve => setImmediate(resolve)); + assert.equal(f.adapter.metrics().topology.connections[0].kind, kind, 'aggregate route reporting remains unchanged'); + assert.equal(f.adapter.metrics().topology.connections[0].localCandidateType, local); + const loader = f.hls.config.fLoader, media = { ...f.video }; + identity = 'renewed-viewer'; + f.advance(30249); + assert.equal(channel.closeCalls, 0, 'the old lease is not retired before its scheduled boundary'); + f.advance(1); + assert.equal(channel.closeCalls, Number(retired), 'only the selected local allocation belongs to the rotating lease'); + assert.equal(f.cycles.length, 1); assert.equal(f.cycles[0].stopped, false); + assert.equal(f.hls.config.fLoader, loader); assert.deepEqual(f.video, media); + assert.equal(f.adapter.metrics().resources.heldBytes, 1024, 'transport retirement preserves the active media cache'); + const next = new f.cycles[0].options.RTCPeerConnectionImpl({}); + assert.equal(next.configuration.iceServers[1].username, 'renewed-viewer', 'a subsequent dial takes the current lease'); + next.close(); + await f.adapter.setEnabled(false); + assert.equal(f.pendingTimers(), 0, 'pause cancels all remaining sampling and lease timers'); +}); + +test('pause cancels scheduled local relay retirement before a later lease rotation', async t => { + let identity = 'initial-viewer', stops = 0; + const f = lifecycleFixture(t, { turnCredentials: { + async start() {}, stop() { stops++; }, + servers: () => [{ urls: ['turns:relay.invalid:5349?transport=tcp'], username: identity, credential: 'fixture-only' }], + leaseIdentity: () => identity, + snapshot: () => ({ phase: 'ready', available: true, remainingMs: 90000, failures: 0 }), + } }); + await f.adapter.setEnabled(true); + const pc = f.connections[0], channel = pc.createDataChannel('fixture'); + pc.stats = new Map([ + ['selected', { id: 'selected', type: 'candidate-pair', state: 'succeeded', nominated: true, localCandidateId: 'local', remoteCandidateId: 'remote' }], + ['local', { id: 'local', type: 'local-candidate', candidateType: 'relay' }], + ['remote', { id: 'remote', type: 'remote-candidate', candidateType: 'host' }], + ]); + f.advance(750); await new Promise(resolve => setImmediate(resolve)); + assert.equal(f.adapter.metrics().topology.connections[0].localCandidateType, 'relay'); + await f.adapter.setEnabled(false); + identity = 'renewed-viewer'; f.advance(90000); + assert.equal(channel.closeCalls, 0, 'a retired adapter cannot run its previous lease timer'); + assert.equal(stops, 1); assert.equal(pc.connectionState, 'closed'); assert.equal(f.pendingTimers(), 0); +}); + test('a completed empty ICE gather reports no browser route without crediting peer delivery', async t => { const f = lifecycleFixture(t); await f.adapter.setEnabled(true);