fix(node-agent): reclaim capture port by PORT env, not image-tag regex

Burn test: only 3 of 8 Deltacast ports reached 'receiving'; the rest stuck
'connecting' forever. Root cause was NOT the board (all 8 SDI ports lock +
feed framecache at 60fps — verified 8 live shm cursors). It was orphaned
standby sidecars squatting host ports 7441-7445: new sidecars hit
EADDRINUSE, got zero frames, and getStatus() reported 'connecting' forever.

freeCapturePort() pre-filtered the container list by .Image regex, but after
a wild-dragon-capture:latest rebuild the Docker list API degrades older
containers' .Image to a bare image ID — so the tag regex silently SKIPPED
the exact orphans holding the ports. Now we match by PORT env (survives
rebuilds) and guard with the inspected Config.Image (which keeps the tag),
so a port is always reclaimed before a new sidecar binds. This makes
enable/disable 'just work' across image rebuilds.
This commit is contained in:
Zac Gaetano 2026-06-04 15:15:37 +00:00
parent e45de85512
commit b508b203e3

View file

@ -733,18 +733,29 @@ async function freeCapturePort(capturePort) {
const listRes = await dockerApi('GET', '/containers/json?all=1');
if (listRes.status !== 200 || !Array.isArray(listRes.data)) return;
for (const c of listRes.data) {
const img = c.Image || '';
if (!/wild-dragon-capture/.test(img)) continue;
// Inspect to read the PORT env (list payload doesn't include env).
// NOTE: do NOT pre-filter on c.Image here. After `wild-dragon-capture:latest`
// is rebuilt, the Docker list API reports older containers' .Image as the
// bare image ID (e.g. "226f9c953799") instead of the tag, so a regex on the
// tag silently SKIPS those orphans — they keep holding the host port and the
// replacement sidecar dies with EADDRINUSE ("connecting forever"). Identify
// capture sidecars by their PORT env (+ inspected Config.Image) instead,
// which survives a tag rebuild.
try {
const insp = await dockerApi('GET', `/containers/${c.Id}/json`);
const cenv = (insp.status === 200 && insp.data?.Config?.Env) || [];
if (insp.status !== 200) continue;
const cfg = insp.data?.Config || {};
const cenv = cfg.Env || [];
const portEnv = cenv.find(e => e.startsWith('PORT='));
const p = portEnv ? parseInt(portEnv.split('=')[1], 10) : NaN;
if (p === capturePort) {
console.log(`[sidecar] force-freeing capture port ${capturePort}: removing stale container ${c.Id.slice(0, 12)}`);
await dockerApi('DELETE', `/containers/${c.Id}?force=true`).catch(() => {});
}
if (p !== capturePort) continue;
// Config.Image (from inspect) preserves the original "wild-dragon-capture:..."
// string even after a tag rebuild — use it as a sanity guard so we only ever
// remove our own capture sidecars, never an unrelated host-net container that
// happens to expose the same PORT env.
const cfgImg = cfg.Image || '';
if (!/wild-dragon-capture/.test(cfgImg)) continue;
console.log(`[sidecar] force-freeing capture port ${capturePort}: removing stale container ${c.Id.slice(0, 12)} (image=${cfgImg})`);
await dockerApi('DELETE', `/containers/${c.Id}?force=true`).catch(() => {});
} catch (_) { /* container vanished mid-scan — fine */ }
}
} catch (e) {