From b508b203e383ae39ecf5c71adfe052475ba1a6b5 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 4 Jun 2026 15:15:37 +0000 Subject: [PATCH] fix(node-agent): reclaim capture port by PORT env, not image-tag regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- services/node-agent/index.js | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/services/node-agent/index.js b/services/node-agent/index.js index e8c38f3..31fa9d9 100644 --- a/services/node-agent/index.js +++ b/services/node-agent/index.js @@ -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) {