fix(node-agent): serialize deltacast sidecar opens to prevent BufMngr wedge

Simultaneous VHD_OpenBoardHandle calls from 8 sidecars trips a kernel
array-index-out-of-bounds in BufMngr.c:781 (delta_x300 v6.34.1). Fix:
a process-wide promise-chain mutex gates deltacast sidecar starts so only
one board open is in flight at a time, with a configurable settle delay
(DELTACAST_START_STAGGER_MS, default 3500ms) before releasing the lock.
SDI, SRT, RTMP and all other source types are unaffected.
This commit is contained in:
Zac Gaetano 2026-06-01 18:41:35 -04:00
parent 9adcae0329
commit 8b8a19c465

View file

@ -29,6 +29,21 @@ const VERSION = '1.4.0';
// interpolated into a shell string.
const DRIVER_VENDORS = ['blackmagic', 'aja', 'deltacast', 'ndi'];
// Deltacast board-open stagger: serialize sidecar starts so only one
// VHD_OpenBoardHandle is in flight at a time. Simultaneous opens trip a
// kernel BufMngr OOB bug (delta_x300 v6.34.1, BufMngr.c:781). A promise
// chain acts as a FIFO mutex; the settle delay lets the driver stabilize.
const DELTACAST_STAGGER_MS = parseInt(process.env.DELTACAST_START_STAGGER_MS || '3500', 10);
let _dcMutex = Promise.resolve();
function acquireDeltacastSlot() {
let release;
const ticket = new Promise(r => { release = r; });
const prev = _dcMutex;
_dcMutex = prev.then(() => ticket);
return prev.then(() => release);
}
// Pick the host's LAN IP. Inside a bridge-mode container,
// os.networkInterfaces() returns the container's docker-bridge IP (172.x),
// not the host's LAN address. Two strategies:
@ -166,6 +181,41 @@ async function handleSidecarStart(body, res) {
HostConfig: hostConfig,
};
// Deltacast: serialize board opens to prevent simultaneous VHD_OpenBoardHandle
// calls from wedging the delta_x300 BufMngr (OOB bug in v6.34.1). Acquire the
// slot (waits for any prior deltacast start to clear its settle delay), then
// start the container, then hold for DELTACAST_STAGGER_MS before releasing.
// Fail-open: if launch throws, the slot is still released so the next start
// isn't blocked forever.
if (sourceType === 'deltacast') {
const release = await acquireDeltacastSlot();
try {
const createRes = await dockerApi('POST', '/containers/create', spec);
if (createRes.status !== 201) {
return jsonResponse(res, 502, { error: 'Failed to create container', details: createRes.data });
}
const containerId = createRes.data.Id;
const _u = (env.find(e => e.startsWith('MAM_API_URL=')) || '').slice(12);
const _tok = env.some(e => e.startsWith('MAM_API_TOKEN=') && e.length > 14);
console.log(`[sidecar-start] ${containerId} image=${image} src=${sourceType} MAM_API_URL=${_u} token=${_tok} stagger=${DELTACAST_STAGGER_MS}ms`);
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
if (startRes.status !== 204) {
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
return jsonResponse(res, 502, { error: 'Failed to start container', details: startRes.data });
}
jsonResponse(res, 201, { containerId, capturePort });
// Hold slot for settle delay AFTER responding — client can proceed,
// but the next deltacast start won't begin until after the delay.
await new Promise(r => setTimeout(r, DELTACAST_STAGGER_MS));
} finally {
release();
}
return;
}
// Non-deltacast path: no stagger needed.
const createRes = await dockerApi('POST', '/containers/create', spec);
if (createRes.status !== 201) {
return jsonResponse(res, 502, { error: 'Failed to create container', details: createRes.data });