Bound OBS source inventory bursts

This commit is contained in:
Franz Rolfsvaag 2026-07-22 21:36:50 +02:00
parent d87fe397ce
commit 27ae8dcb81
4 changed files with 20 additions and 7 deletions

View File

@ -55,8 +55,9 @@ payload deterministically, adds an executable-level package diagnostic, and show
OBS maintenance failures directly instead of leaving them only in diagnostics. OBS maintenance failures directly instead of leaving them only in diagnostics.
Experimental.9 coalesces OBS callbacks into fixed 20 ms network frames and gives Experimental.9 coalesces OBS callbacks into fixed 20 ms network frames and gives
audio its own soft gateway budget, so normal or pathological capture cadence can audio and initial OBS source inventory their own soft gateway budgets, so normal
never rate-limit and disconnect the Companion control connection. or pathological capture cadence cannot rate-limit and disconnect the Companion
control connection.
Release-blocking work remains: Release-blocking work remains:

View File

@ -4,6 +4,7 @@ const { insecureDeviceAllowed } = require("./device_store");
const MAX_CONTROL_MESSAGES_PER_SECOND = 120; const MAX_CONTROL_MESSAGES_PER_SECOND = 120;
const MAX_AUDIO_MESSAGES_PER_SECOND = 200; const MAX_AUDIO_MESSAGES_PER_SECOND = 200;
const MAX_SOURCE_MESSAGES_PER_SECOND = 1000;
class CompanionGateway { class CompanionGateway {
constructor(options) { constructor(options) {
@ -32,7 +33,9 @@ class CompanionGateway {
let windowStarted = Date.now(); let windowStarted = Date.now();
let controlMessagesInWindow = 0; let controlMessagesInWindow = 0;
let audioMessagesInWindow = 0; let audioMessagesInWindow = 0;
let sourceMessagesInWindow = 0;
let audioMessagesDropped = 0; let audioMessagesDropped = 0;
let sourceMessagesDropped = 0;
let messageChain = Promise.resolve(); let messageChain = Promise.resolve();
const send = (type, payload, sessionId = session?.id || null) => { const send = (type, payload, sessionId = session?.id || null) => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(envelope(type, payload, sessionId))); if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(envelope(type, payload, sessionId)));
@ -49,7 +52,8 @@ class CompanionGateway {
try { try {
if (Date.now() - windowStarted >= 1000) { if (Date.now() - windowStarted >= 1000) {
if (audioMessagesDropped) this.log.append({ kind: "audio_rate_limited", device_id: device.id, session_id: session?.id, dropped_frames: audioMessagesDropped }); if (audioMessagesDropped) this.log.append({ kind: "audio_rate_limited", device_id: device.id, session_id: session?.id, dropped_frames: audioMessagesDropped });
windowStarted = Date.now(); controlMessagesInWindow = 0; audioMessagesInWindow = 0; audioMessagesDropped = 0; if (sourceMessagesDropped) this.log.append({ kind: "source_rate_limited", device_id: device.id, session_id: session?.id, dropped_updates: sourceMessagesDropped });
windowStarted = Date.now(); controlMessagesInWindow = 0; audioMessagesInWindow = 0; sourceMessagesInWindow = 0; audioMessagesDropped = 0; sourceMessagesDropped = 0;
} }
if (isBinary) { if (isBinary) {
if (!helloComplete || !session) throw coded("HELLO_REQUIRED", "Complete the handshake before sending audio."); if (!helloComplete || !session) throw coded("HELLO_REQUIRED", "Complete the handshake before sending audio.");
@ -59,9 +63,14 @@ class CompanionGateway {
if (result.gap) send("metric", { kind: "sequence_gap", missing_frames: result.gap }); if (result.gap) send("metric", { kind: "sequence_gap", missing_frames: result.gap });
return; return;
} }
controlMessagesInWindow += 1;
if (controlMessagesInWindow > MAX_CONTROL_MESSAGES_PER_SECOND) throw coded("RATE_LIMIT", "Companion control message rate exceeded its limit.");
const message = parseEnvelope(body); const message = parseEnvelope(body);
if (helloComplete && message.type === "source_update") {
sourceMessagesInWindow += 1;
if (sourceMessagesInWindow > MAX_SOURCE_MESSAGES_PER_SECOND) { sourceMessagesDropped += 1; return; }
} else {
controlMessagesInWindow += 1;
if (controlMessagesInWindow > MAX_CONTROL_MESSAGES_PER_SECOND) throw coded("RATE_LIMIT", "Companion control message rate exceeded its limit.");
}
if (!helloComplete) { if (!helloComplete) {
const hello = validateHello(message); const hello = validateHello(message);
this.devices.updateRuntime(device.id, { companion_version: hello.companion_version, companion_plugin_version: hello.plugin_version }); this.devices.updateRuntime(device.id, { companion_version: hello.companion_version, companion_plugin_version: hello.plugin_version });
@ -131,4 +140,4 @@ function sameHostOrigin(origin, host) { try { return new URL(origin).host === ho
function coded(code, message) { return Object.assign(new Error(message), { code }); } function coded(code, message) { return Object.assign(new Error(message), { code }); }
function cleanReason(value) { return ["requested", "test_complete", "benchmark_complete", "silence_timeout", "disconnect"].includes(String(value)) ? String(value) : "requested"; } function cleanReason(value) { return ["requested", "test_complete", "benchmark_complete", "silence_timeout", "disconnect"].includes(String(value)) ? String(value) : "requested"; }
module.exports = { CompanionGateway, sameHostOrigin, MAX_CONTROL_MESSAGES_PER_SECOND, MAX_AUDIO_MESSAGES_PER_SECOND }; module.exports = { CompanionGateway, sameHostOrigin, MAX_CONTROL_MESSAGES_PER_SECOND, MAX_AUDIO_MESSAGES_PER_SECOND, MAX_SOURCE_MESSAGES_PER_SECOND };

View File

@ -2,7 +2,7 @@
"schema_version": 1, "schema_version": 1,
"version": "0.1.0-experimental.9", "version": "0.1.0-experimental.9",
"signed": false, "signed": false,
"release_notes": "Prevents OBS audio callback bursts from rate-limiting the Companion connection by sending steady 20 ms audio frames and keeping excess audio traffic separate from control messages.", "release_notes": "Prevents OBS audio and initial source-inventory bursts from rate-limiting the Companion connection by sending steady 20 ms audio frames and bounding high-volume capture traffic separately from control messages.",
"installer": { "installer": {
"id": "windows-x64-installer", "id": "windows-x64-installer",
"platform": "win32", "platform": "win32",

View File

@ -387,6 +387,9 @@ async function verifyAuthenticatedGateway() {
await new Promise((resolve) => setTimeout(resolve, 250)); await new Promise((resolve) => setTimeout(resolve, 250));
assert.equal(client.readyState, WebSocket.OPEN, "Audio bursts must be bounded without closing the authenticated control connection"); assert.equal(client.readyState, WebSocket.OPEN, "Audio bursts must be bounded without closing the authenticated control connection");
assert.ok(audioMessages > 0 && audioMessages < 350, "Excess audio frames were not softly rate-limited"); assert.ok(audioMessages > 0 && audioMessages < 350, "Excess audio frames were not softly rate-limited");
for (let index = 0; index < 1100; index += 1) client.send(JSON.stringify(protocol.envelope("source_update", { source_uuid: crypto.randomUUID(), display_name: `OBS source ${index}` }, sessionId)));
await new Promise((resolve) => setTimeout(resolve, 250));
assert.equal(client.readyState, WebSocket.OPEN, "OBS source inventory bursts must not close the authenticated control connection");
await new Promise((resolve) => { client.once("close", resolve); client.close(); }); await new Promise((resolve) => { client.once("close", resolve); client.close(); });
for (let attempt = 0; attempt < 20 && !disconnected; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 5)); for (let attempt = 0; attempt < 20 && !disconnected; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(disconnected, true); assert.equal(disconnected, true);