Files
Haven/test/screenBitrate.test.js
T
Bernardo Costa c3ade67336 Screen sharing: drop the native GStreamer path, add a bitrate setting, fix black tiles on GPU encoders (#5672)
The native GStreamer screen share path is gone: in a peer-to-peer mesh every
hardware and OS combination behaved differently, and keeping them in step cost
more than it saved. Screen sharing uses Chromium's own capture everywhere.

- Screen share bitrate is a setting: a stepper from 300 to 10,000 Kbps or
  unlimited, with press-and-hold repeat and typed entry. Changes apply live,
  including to a share going through the voice relay (debounced, so a hold
  becomes one update). The default stays 1080p at 30 fps with an 8,000 Kbps cap.
- Black screen share tiles on GPU encoders: the sharer asks for a fresh
  keyframe after negotiation, and the watchdog only re-adopts tracks a tile is
  not already showing.
- Relay screen publishing is serialized and tied to the share it was started
  for, so a stop, a new share or a relay restart can never publish a stale
  producer, and a failed first publish retries on its own.
2026-10-02 12:12:31 -04:00

940 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* User-configurable screen share bitrate (300–10000 Kbps + unlimited).
*
* node --test test/screenBitrate.test.js
*/
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
const VOICE_SOURCE = fs.readFileSync(path.join(__dirname, '..', 'public/js/voice.js'), 'utf8');
function loadVoiceManager(storage = {}) {
const context = vm.createContext({
module: { exports: {} },
navigator: { userAgent: '', platform: '', maxTouchPoints: 0 },
localStorage: {
getItem(k) { return Object.prototype.hasOwnProperty.call(storage, k) ? storage[k] : null; },
setItem(k, v) { storage[k] = String(v); },
removeItem(k) { delete storage[k]; }
},
console: { log() {}, warn() {}, error() {} },
RTCSessionDescription: function RTCSessionDescription(d) { return d; },
setTimeout,
clearTimeout,
Date
});
vm.runInContext(`${VOICE_SOURCE}\nmodule.exports = VoiceManager;`, context, { filename: 'voice.js' });
return { VoiceManager: context.module.exports, storage };
}
function fakeSender(videoTrack) {
return {
track: videoTrack,
applied: null,
getParameters() { return { encodings: [{}] }; },
setParameters(p) { this.applied = p; return Promise.resolve(); }
};
}
function makeSharer(VoiceManager, { bitrate, resolution = 0 } = {}) {
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = resolution;
voice.screenFrameRate = 60;
if (bitrate !== undefined) voice.screenBitrate = bitrate;
voice._screenBitrates = { 0: 8_000_000, 720: 4_000_000, 1080: 8_000_000, 1440: 14_000_000 };
const sender = fakeSender(track);
const connection = { sender, getSenders: () => [sender] };
voice.peers.set(2, { connection, username: 'viewer' });
return { voice, connection, sender };
}
test('bitrate normalizes to 300–10000 Kbps, 0 for unlimited, 8000 default', () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
assert.equal(voice._normalizeScreenBitrate(null), 8000);
assert.equal(voice._normalizeScreenBitrate(''), 8000);
assert.equal(voice._normalizeScreenBitrate('abc'), 8000);
assert.equal(voice._normalizeScreenBitrate('0'), 0);
assert.equal(voice._normalizeScreenBitrate(0), 0);
assert.equal(voice._normalizeScreenBitrate(299), 300);
assert.equal(voice._normalizeScreenBitrate(300), 300);
assert.equal(voice._normalizeScreenBitrate(4700), 4700);
assert.equal(voice._normalizeScreenBitrate(10000), 10000);
assert.equal(voice._normalizeScreenBitrate(10001), 0);
assert.equal(voice._normalizeScreenBitrate(-1), 300, 'hold-repeat below range clamps to 300, never 8000');
assert.equal(voice._normalizeScreenBitrate(-100), 300);
});
test('user cap overrides the resolution table; unlimited returns null', () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
voice.screenBitrate = 8000;
assert.equal(voice._screenBitrateFor(1080), 8_000_000);
assert.equal(voice._screenBitrateFor(0), 8_000_000);
voice.screenBitrate = 0;
assert.equal(voice._screenBitrateFor(1080), null);
voice.screenBitrate = undefined;
assert.equal(voice._screenBitrateFor(1080), 8_000_000);
});
test('relay profile still wins on relayed paths', () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
voice.screenBitrate = 10000;
assert.equal(voice._screenBitrateFor(1080, true), 3_000_000);
});
test('setScreenBitrate persists and re-applies live', () => {
const { VoiceManager, storage } = loadVoiceManager();
const { voice, sender } = makeSharer(VoiceManager, { bitrate: 4000 });
voice.setScreenBitrate(2500);
assert.equal(voice.screenBitrate, 2500);
assert.equal(storage.haven_screen_bitrate, '2500');
assert.equal(sender.applied.encodings[0].maxBitrate, 2_500_000);
});
test('unlimited removes the sender cap instead of writing zero', () => {
const { VoiceManager } = loadVoiceManager();
const { voice, sender } = makeSharer(VoiceManager, { bitrate: 8000 });
voice._applyScreenBitrate(
voice.peers.get(2).connection,
voice._screenBitrateFor(1080),
2
);
assert.equal(sender.applied.encodings[0].maxBitrate, 8_000_000);
voice.setScreenBitrate(0);
assert.ok(!('maxBitrate' in sender.applied.encodings[0]), 'cap removed for unlimited');
assert.equal(sender.applied.encodings[0].maxFramerate, 60);
assert.equal(sender.applied.degradationPreference, 'maintain-framerate');
});
test('bitrate changes re-produce the relayed screen source live', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._relayBitrateDebounceMs = 0; // immediate for deterministic tests
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
const calls = [];
voice._relay = {
hasPublished: (source) => source === 'screen',
unpublish: async (source) => { calls.push(['unpublish', source]); },
publish: async (source, t, opts) => { calls.push(['publish', source, opts]); }
};
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000; // as recorded by the initial publish
voice.setScreenBitrate(8000); // same as current → direct peers only, no relay churn
await new Promise(resolve => setTimeout(resolve, 10));
assert.deepEqual(calls, [], 'unchanged cap must not re-produce the relay source');
voice.setScreenBitrate(6000);
await new Promise(resolve => setTimeout(resolve, 10));
// Cross-realm objects from the vm harness: compare structurally via JSON.
assert.equal(JSON.stringify(calls), JSON.stringify([
['unpublish', 'screen'],
['publish', 'screen', { simulcast: true, maxBitrate: 6_000_000 }]
]));
voice.setScreenBitrate(0); // unlimited carries an explicit high cap (never 2.5 Mbps)
await new Promise(resolve => setTimeout(resolve, 10));
assert.equal(JSON.stringify(calls.slice(2)), JSON.stringify([
['unpublish', 'screen'],
['publish', 'screen', { simulcast: true, maxBitrate: 14_000_000 }]
]));
});
test('relay republish is debounced: rapid stepper holds collapse to one re-produce', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._relayBitrateDebounceMs = 50;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
const calls = [];
voice._relay = {
hasPublished: () => true,
unpublish: async (source) => { calls.push(['unpublish', source, voice.screenBitrate]); },
publish: async (source, t, opts) => { calls.push(['publish', source, opts]); }
};
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000;
// Simulate holding the + stepper: a change every 80 ms used to tear down
// the relay producer every time. With the 50 ms debounce only the last
// value may re-produce.
voice.setScreenBitrate(8100);
await new Promise(resolve => setTimeout(resolve, 10));
voice.setScreenBitrate(8200);
await new Promise(resolve => setTimeout(resolve, 10));
voice.setScreenBitrate(8300);
await new Promise(resolve => setTimeout(resolve, 80));
assert.equal(calls.filter(c => c[0] === 'unpublish').length, 1, 'one teardown for the whole hold');
assert.equal(JSON.stringify(calls[calls.length - 1][2]), JSON.stringify({ simulcast: true, maxBitrate: 8_300_000 }));
});
test('a change landing mid-republish is not dropped (latest wins)', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._relayBitrateDebounceMs = 0;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
let unpublished = false;
const publishedCaps = [];
voice._relay = {
hasPublished: () => !unpublished,
unpublish: async () => {
unpublished = true;
// Change arrives while the first republish is mid-flight (after
// unpublish, before publish). The old code dropped it via the
// hasPublished early-return and left the relay on the middle value.
voice.screenBitrate = 9000;
voice._republishRelayScreenBitrate().catch(() => {});
await new Promise(resolve => setTimeout(resolve, 10));
},
publish: async (source, t, opts) => {
unpublished = false;
publishedCaps.push(opts.maxBitrate);
}
};
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000;
voice.screenBitrate = 8500;
await voice._republishRelayScreenBitrate();
// The republish re-reads the cap after unpublish, so the single publish
// already carries the 9 Mbps latest — never the 8.5 Mbps in-between value,
// and the overlapping change is not dropped.
assert.deepEqual(publishedCaps, [9_000_000]);
assert.equal(voice._lastRelayScreenBitrateKey, 9_000_000);
});
test('a failed publish recovers on retry instead of stranding the share', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 6000;
voice._relayBitrateDebounceMs = 0;
voice._relayPublishRetryBaseMs = 5;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000;
let live = true;
let publishes = 0;
const publishedCaps = [];
voice._relay = {
hasPublished: () => live,
unpublish: async () => { live = false; },
publish: async (source, t, opts) => {
publishes += 1;
if (publishes === 1) throw new Error('transport restarting');
live = true;
publishedCaps.push(opts.maxBitrate);
return { id: 'p2' };
}
};
await voice._republishRelayScreenBitrate();
assert.equal(publishes, 2, 'one failed attempt plus one retry');
assert.deepEqual(publishedCaps, [6_000_000]);
assert.equal(voice._lastRelayScreenBitrateKey, 6_000_000);
assert.equal(live, true);
});
test('a persistently failing publish gives up after 3 retries', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 6000;
voice._relayBitrateDebounceMs = 0;
voice._relayPublishRetryBaseMs = 5;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000;
let publishes = 0;
voice._relay = {
hasPublished: () => false,
unpublish: async () => {},
publish: async () => { publishes += 1; throw new Error('down'); }
};
await voice._republishRelayScreenBitrate();
assert.equal(publishes, 4, 'initial attempt plus 3 bounded retries');
assert.equal(voice._lastRelayScreenBitrateKey, 8_000_000, 'key untouched so a later change recovers');
});
test('a stop during retry backoff aborts instead of publishing into teardown', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track], getAudioTracks: () => [] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 6000;
voice._screenStartOperation = 0;
voice._relayBitrateDebounceMs = 0;
voice._relayPublishRetryBaseMs = 30;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000;
voice._relayScreenNeedsPublish = true; // recovery path: publish despite no live producer
let publishes = 0;
const actions = [];
voice._relay = {
hasPublished: () => false,
unpublish: async () => {},
publish: async (source, t, opts) => {
publishes += 1;
actions.push(['publish', source, opts.maxBitrate]);
if (publishes === 1) throw new Error('transport restarting');
return { id: `p${publishes}`, closed: false, close() {} };
}
};
try {
const republish = voice._republishRelayScreenBitrate();
// Let the first attempt fail so the retry is sleeping in backoff, then
// simulate stopScreenShare's synchronous prefix (op bumps while
// isScreenSharing is still true — stop awaits its unpublish first).
await new Promise(resolve => setTimeout(resolve, 10));
voice._screenStartOperation = 1;
await republish;
assert.equal(publishes, 1, 'retry woke up under the teardown generation and aborted');
assert.equal(voice._lastRelayScreenBitrateKey, 8_000_000, 'stale key not committed');
// The next share starts clean: its initial publish produces instead of
// landing on a producer the aborted retry created mid-teardown.
const track2 = { kind: 'video', readyState: 'live' };
voice.screenStream = { getVideoTracks: () => [track2], getTracks: () => [track2], getAudioTracks: () => [] };
voice.isScreenSharing = true;
voice._screenStartOperation = 2;
voice._relayScreenNeedsPublish = false;
voice._lastRelayScreenBitrateKey = undefined;
voice.screenBitrate = 8000;
await voice._publishInitialRelayScreen();
assert.equal(publishes, 2, 'new share publishes once');
assert.deepEqual(actions[1], ['publish', 'screen', 8_000_000]);
assert.equal(voice._lastRelayScreenBitrateKey, 8_000_000);
} finally {
try { clearTimeout(voice._relayBitrateTimer); } catch {}
}
});
test('a republish queued behind a slow start aborts if stop lands first', async () => {
// The identity token is bound at schedule time, not when the queued inner
// starts: a stop landing in the serialization queue must invalidate the
// wait, otherwise the inner adopts the teardown generation (isScreenSharing
// still true while stop awaits its unpublish) and re-produces mid-teardown.
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track], getAudioTracks: () => [] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._screenStartOperation = 0;
voice._relayBitrateDebounceMs = 0;
voice._relayPublishRetryBaseMs = 5;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
const liveProducer = { id: 'p1', closed: false, close() {} };
const producers = new Map([['screen', liveProducer]]);
const actions = [];
voice._relay = {
producers,
hasPublished: (s) => { const p = producers.get(s); return !!p && !p.closed; },
unpublish: async (s) => { producers.delete(s); actions.push(['unpublish', s]); },
unpublishIf: async (s, producer) => {
if (!producer || producers.get(s) !== producer) return false;
producers.delete(s);
actions.push(['unpublishIf', s, producer.id]);
return true;
},
publish: async (s, t, opts) => {
actions.push(['publish', s, opts.maxBitrate]);
const p = { id: `p${actions.length}`, closed: false, close() {} };
producers.set(s, p);
return p;
}
};
voice._lastRelayScreenBitrateKey = 8_000_000;
try {
// Block the serialization queue behind a slow op, then schedule the
// republish for the live share (token bound to generation 0)...
let releaseQueue;
const gate = new Promise(r => { releaseQueue = r; });
voice._relayScreenEnqueue(() => gate);
voice.screenBitrate = 7500; // genuine pending change: key mismatch is real
voice._scheduleRelayScreenRepublish(); // debounce 0 → enqueues behind the gate
// ...then stop starts while the republish is still queued (stuck before
// its unpublish: op bumped, everything else intact, producer live).
voice._screenStartOperation = 1;
releaseQueue();
await voice._relayScreenEnqueue(() => {});
await new Promise(resolve => setTimeout(resolve, 10));
assert.deepEqual(actions, [], 'queued republish adopted nothing, published nothing');
assert.equal(voice._lastRelayScreenBitrateKey, 8_000_000, 'stale key not committed');
assert.equal(producers.get('screen'), liveProducer, 'live producer untouched');
} finally {
try { clearTimeout(voice._relayBitrateTimer); } catch {}
}
});
test('a queued initial publish aborts if stop lands first', async () => {
// Same binding rule as the republish path, for the initial publish: the
// token is bound before the enqueue, so a stop landing while the publish
// waits behind a slow producer invalidates it instead of being adopted at
// execution time (isScreenSharing and screenStream are still present while
// stop awaits its unpublish).
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track], getAudioTracks: () => [] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._screenStartOperation = 0;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = undefined;
const producers = new Map();
const actions = [];
voice._relay = {
producers,
hasPublished: (s) => { const p = producers.get(s); return !!p && !p.closed; },
unpublish: async (s) => { producers.delete(s); actions.push(['unpublish', s]); },
unpublishIf: async (s, producer) => {
if (!producer || producers.get(s) !== producer) return false;
producers.delete(s);
actions.push(['unpublishIf', s, producer.id]);
return true;
},
publish: async (s, t, opts) => {
actions.push(['publish', s, opts.maxBitrate]);
const p = { id: `p${actions.length}`, closed: false, close() {} };
producers.set(s, p);
return p;
}
};
try {
// Block the queue, then enqueue the initial publish exactly like
// shareScreen does (token bound before the enqueue)...
let releaseQueue;
const gate = new Promise(r => { releaseQueue = r; });
voice._relayScreenEnqueue(() => gate);
const token = voice._relayScreenToken();
let innerRan = false;
const queued = voice._relayScreenEnqueue(() => {
innerRan = true;
return voice._publishInitialRelayScreen(token);
});
// ...then stop starts while the publish is still queued.
voice._screenStartOperation = 1;
releaseQueue();
await queued;
await voice._relayScreenEnqueue(() => {});
assert.equal(innerRan, true, 'queued publish ran and decided');
assert.deepEqual(actions, [], 'stopped share published nothing');
assert.equal(voice._lastRelayScreenBitrateKey, undefined, 'no key committed');
assert.equal(producers.size, 0, 'no late producer left live');
} finally {
try { clearTimeout(voice._relayBitrateTimer); } catch {}
}
});
test('stopping mid-publish removes the orphan instead of leaving it live', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8500;
voice._screenStartOperation = 0;
voice._relayBitrateDebounceMs = 0;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000;
const oldProducer = { id: 'old', closed: false, close() {} };
const producers = new Map([['screen', oldProducer]]);
const calls = [];
let resolveProduce;
voice._relay = {
producers,
hasPublished: (s) => { const p = producers.get(s); return !!p && !p.closed; },
unpublish: async (s) => { producers.delete(s); calls.push(['unpublish', s]); },
unpublishIf: async (s, producer) => {
if (!producer || producers.get(s) !== producer) return false;
producers.delete(s);
calls.push(['unpublishIf', s, producer.id]);
return true;
},
publish: (s, t, opts) => new Promise((resolve) => {
calls.push(['publish-start', s, opts.maxBitrate]);
resolveProduce = () => {
const p = { id: 'new', closed: false, close() {} };
producers.set(s, p);
calls.push(['publish-resolve', s]);
resolve(p);
};
})
};
const republish = voice._republishRelayScreenBitrate();
const deadline = Date.now() + 1000;
while (typeof resolveProduce !== 'function' && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 1));
}
assert.equal(typeof resolveProduce, 'function', 'republish reached the publish await');
// Simulate stopScreenShare's synchronous prefix while produce is pending.
voice._screenStartOperation = 1;
voice.isScreenSharing = false;
resolveProduce();
await republish;
assert.deepEqual([...producers.keys()], [], 'orphan producer removed');
assert.equal(voice._lastRelayScreenBitrateKey, 8_000_000, 'stale key not committed');
assert.ok(calls.some(c => c[0] === 'unpublishIf' && c[2] === 'new'), 'exactly our producer removed');
});
test('the initial relay publish reads the latest cap, not the pre-renegotiation one', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = {
getVideoTracks: () => [track],
getTracks: () => [track],
getAudioTracks: () => []
};
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 9000; // moved while renegotiations were in flight
voice._relayBitrateDebounceMs = 50;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = 8_000_000;
const calls = [];
voice._relay = {
hasPublished: (s) => calls.some(c => c[0] === 'publish' && c[1] === s),
unpublish: async (s) => { calls.push(['unpublish', s]); },
publish: async (s, t, opts) => { calls.push(['publish', s, opts.maxBitrate]); return { id: 'p' }; }
};
// A change during renegotiations arms the debounce timer...
voice._scheduleRelayScreenRepublish();
// ...but the initial publish (which runs after renegotiations settle)
// already carries latest, and drops the now-pointless timer.
await voice._publishInitialRelayScreen();
assert.deepEqual(calls, [['publish', 'screen', 9_000_000]], 'single fresh publish, no churn');
assert.equal(voice._lastRelayScreenBitrateKey, 9_000_000);
await new Promise(resolve => setTimeout(resolve, 80));
assert.deepEqual(calls, [['publish', 'screen', 9_000_000]], 'cancelled timer never re-produces');
});
test('relay unpublishIf only removes the exact producer', async () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'public/js/voice-relay.js'), 'utf8');
const sandbox = {
window: {},
document: { createElement: () => ({}), head: { appendChild() {} } },
console: { log() {}, warn() {}, error() {} },
setTimeout,
clearTimeout
};
vm.createContext(sandbox);
vm.runInContext(src, sandbox, { filename: 'voice-relay.js' });
const Session = sandbox.window.HavenRelaySession;
const emitted = [];
const socket = { on() {}, off() {}, emit(e, d, cb) { emitted.push(e); cb({}); } };
const session = new Session(socket, 'CODE', {});
const p1 = { id: 'p1', closed: false, close() {} };
session.producers.set('screen', p1);
assert.equal(await session.unpublishIf('screen', { id: 'p1' }), false, 'different identity is not removed');
assert.equal(session.producers.get('screen'), p1);
assert.deepEqual(emitted, []);
assert.equal(await session.unpublishIf('screen', p1), true);
assert.equal(session.producers.has('screen'), false);
assert.deepEqual(emitted, ['relay:close-producer']);
});
test('a failed initial publish retries automatically', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = {
getVideoTracks: () => [track],
getTracks: () => [track],
getAudioTracks: () => []
};
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._relayBitrateDebounceMs = 0;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = undefined;
let live = false;
let publishes = 0;
voice._relay = {
hasPublished: () => live,
unpublish: async () => { live = false; },
publish: async (source, t, opts) => {
publishes += 1;
if (publishes === 1) throw new Error('no transport yet');
live = true;
return { id: 'p2' };
}
};
try {
await voice._publishInitialRelayScreen();
// The failed start flags recovery AND schedules it: no next bitrate
// change is needed (#5672 review). Wait for the debounced republish to
// finish (publishes first, key commit lands just after).
const deadline = Date.now() + 1000;
while ((voice._lastRelayScreenBitrateKey !== 8_000_000 || voice._relayScreenNeedsPublish) && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 5));
}
assert.equal(publishes, 2, 'failed start retries without waiting for the next change');
assert.equal(voice._lastRelayScreenBitrateKey, 8_000_000);
assert.equal(voice._relayScreenNeedsPublish, false);
assert.equal(live, true);
// A later change still re-produces with the new cap.
voice.screenBitrate = 6000;
await voice._republishRelayScreenBitrate();
assert.equal(publishes, 3, 'a later change re-produces with the new cap');
assert.equal(voice._lastRelayScreenBitrateKey, 6_000_000);
assert.equal(voice._relayScreenNeedsPublish, false);
assert.equal(live, true);
} finally {
try { clearTimeout(voice._relayBitrateTimer); } catch {}
}
});
test('stopping during the initial publish leaves no orphan', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = {
getVideoTracks: () => [track],
getTracks: () => [track],
getAudioTracks: () => []
};
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._screenStartOperation = 0;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
const producers = new Map();
let resolveProduce;
voice._relay = {
producers,
hasPublished: (s) => { const p = producers.get(s); return !!p && !p.closed; },
unpublish: async (s) => { producers.delete(s); },
unpublishIf: async (s, producer) => {
if (!producer || producers.get(s) !== producer) return false;
producers.delete(s);
return true;
},
publish: (s, t, opts) => new Promise((resolve) => {
resolveProduce = () => {
const p = { id: 'p1', closed: false, close() {} };
producers.set(s, p);
resolve(p);
};
})
};
const initial = voice._publishInitialRelayScreen();
const deadline = Date.now() + 1000;
while (typeof resolveProduce !== 'function' && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 1));
}
assert.equal(typeof resolveProduce, 'function', 'initial publish reached produce');
// Simulate stopScreenShare's synchronous prefix while produce is pending.
voice._screenStartOperation = 1;
voice.isScreenSharing = false;
resolveProduce();
await initial;
assert.deepEqual([...producers.keys()], [], 'late producer cleaned up by identity');
assert.equal(voice._lastRelayScreenBitrateKey, undefined, 'stale key not committed');
});
test('a change during the initial produce is applied after, never lost', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = {
getVideoTracks: () => [track],
getTracks: () => [track],
getAudioTracks: () => []
};
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._screenStartOperation = 0;
voice._relayBitrateDebounceMs = 0;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = undefined;
const producers = new Map();
const publishedCaps = [];
let resolveProduce;
let n = 0;
voice._relay = {
producers,
hasPublished: (s) => { const p = producers.get(s); return !!p && !p.closed; },
unpublish: async (s) => { producers.delete(s); },
unpublishIf: async (s, producer) => {
if (!producer || producers.get(s) !== producer) return false;
producers.delete(s);
return true;
},
publish: (s, t, opts) => {
publishedCaps.push(opts.maxBitrate);
if (resolveProduce === undefined && s === 'screen' && publishedCaps.length === 1) {
return new Promise((resolve) => {
resolveProduce = () => {
n += 1;
const p = { id: `p${n}`, closed: false, close() {} };
producers.set(s, p);
resolve(p);
};
});
}
n += 1;
const p = { id: `p${n}`, closed: false, close() {} };
producers.set(s, p);
return Promise.resolve(p);
}
};
// Serialized: the initial publish runs first...
const initial = voice._relayScreenEnqueue(() => voice._publishInitialRelayScreen());
const deadline = Date.now() + 1000;
while (typeof resolveProduce !== 'function' && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 1));
}
assert.equal(typeof resolveProduce, 'function', 'initial publish reached produce');
// ...a change lands while produce is pending: its republish queues behind
// the initial instead of racing it and getting dropped.
voice.screenBitrate = 9000;
voice.reapplyScreenBitrate();
resolveProduce();
await initial;
const doneBy = Date.now() + 1000;
while (voice._lastRelayScreenBitrateKey !== 9_000_000 && Date.now() < doneBy) {
await new Promise(resolve => setTimeout(resolve, 5));
}
assert.deepEqual(publishedCaps, [8_000_000, 9_000_000], 'initial cap then the newer one, nothing lost');
assert.equal(voice._lastRelayScreenBitrateKey, 9_000_000);
assert.equal(producers.size, 1, 'exactly one live producer');
});
test('relay screen mutations never interleave', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const order = [];
let release;
const gate = new Promise(r => { release = r; });
const first = voice._relayScreenEnqueue(async () => { order.push('first-start'); await gate; order.push('first-end'); return 'a'; });
const second = voice._relayScreenEnqueue(async () => { order.push('second'); return 'b'; });
await new Promise(r => setTimeout(r, 10));
assert.deepEqual(order, ['first-start'], 'second waits for first');
release();
assert.deepEqual(await Promise.all([first, second]), ['a', 'b']);
assert.deepEqual(order, ['first-start', 'first-end', 'second']);
});
test('a failed relay-restart publish recovers on the next change', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const track = { kind: 'video', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = {
getVideoTracks: () => [track],
getTracks: () => [track],
getAudioTracks: () => []
};
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
voice._lastRelayScreenBitrateKey = undefined;
let live = false;
let publishes = 0;
voice._relay = {
hasPublished: () => live,
unpublish: async () => { live = false; },
publish: async (source, t, opts) => {
publishes += 1;
if (publishes === 1) throw new Error('restart racing');
live = true;
return { id: 'p2' };
}
};
try {
await voice._publishRelayTracks();
assert.equal(publishes, 1);
assert.equal(voice._lastRelayScreenBitrateKey, undefined, 'failed restart records no key');
assert.equal(voice._relayScreenNeedsPublish, true, 'failed restart flags recovery');
voice.screenBitrate = 6000;
await voice._republishRelayScreenBitrate();
assert.equal(publishes, 2, 'the next change recovers the missing producer');
assert.equal(voice._lastRelayScreenBitrateKey, 6_000_000);
assert.equal(voice._relayScreenNeedsPublish, false);
assert.equal(live, true);
} finally {
try { clearTimeout(voice._relayBitrateTimer); } catch {}
}
});
test('stopping during the initial audio publish leaves no audio orphan', async () => {
const { VoiceManager } = loadVoiceManager();
const voice = Object.create(VoiceManager.prototype);
const videoTrack = { kind: 'video', readyState: 'live' };
const audioTrack = { kind: 'audio', readyState: 'live' };
voice.peers = new Map();
voice.screenStream = {
getVideoTracks: () => [videoTrack],
getTracks: () => [videoTrack, audioTrack],
getAudioTracks: () => [audioTrack]
};
voice.isScreenSharing = true;
voice.screenResolution = 0;
voice.screenFrameRate = 60;
voice.screenBitrate = 8000;
voice._screenStartOperation = 0;
voice._screenRelayProfileEnabled = () => false;
voice._screenRelayAutoEnabled = () => false;
voice._relayPeers = new Set();
voice._prioritiseAudioSenders = () => {};
const producers = new Map();
let n = 0;
let resolveAudio;
voice._relay = {
producers,
hasPublished: (s) => { const p = producers.get(s); return !!p && !p.closed; },
unpublish: async (s) => { producers.delete(s); },
unpublishIf: async (s, producer) => {
if (!producer || producers.get(s) !== producer) return false;
producers.delete(s);
return true;
},
publish: (s, t, opts) => {
if (s === 'screen-audio' && typeof resolveAudio !== 'function') {
return new Promise((resolve) => {
resolveAudio = () => {
n += 1;
const p = { id: `pa${n}`, closed: false, close() {} };
producers.set(s, p);
resolve(p);
};
});
}
n += 1;
const p = { id: `p${n}`, closed: false, close() {} };
producers.set(s, p);
return Promise.resolve(p);
}
};
const initial = voice._publishInitialRelayScreen();
const deadline = Date.now() + 1000;
while (typeof resolveAudio !== 'function' && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 1));
}
assert.equal(typeof resolveAudio, 'function', 'initial publish reached audio produce');
// Simulate stopScreenShare's synchronous prefix while audio produce pends.
// (The video producer stays: removing it is stop's own unpublish job.)
voice._screenStartOperation = 1;
voice.isScreenSharing = false;
resolveAudio();
await initial;
assert.equal(producers.has('screen-audio'), false, 'late audio producer cleaned up');
assert.equal(voice._lastRelayScreenBitrateKey, 8_000_000, 'video key committed while valid');
});
test('keyframe requests only touch screen video senders', () => {
const { VoiceManager } = loadVoiceManager();
const { voice, connection } = makeSharer(VoiceManager, { bitrate: 4000 });
const screenTrack = voice.screenStream.getVideoTracks()[0];
const otherTrack = { kind: 'video', readyState: 'live' };
let keyframes = 0;
connection.getSenders = () => [
{ track: screenTrack, generateKeyFrame() { keyframes++; return Promise.resolve(); } },
{ track: otherTrack, generateKeyFrame() { keyframes += 100; return Promise.resolve(); } },
{ track: { kind: 'audio' } }
];
voice._requestScreenKeyframe();
assert.equal(keyframes, 1);
voice.isScreenSharing = false;
voice._requestScreenKeyframe();
assert.equal(keyframes, 1, 'no-op when not sharing');
});