@norskvideo/ctl-test-harness 0.1.40 → 0.1.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/demo/run.d.ts CHANGED
@@ -217,6 +217,28 @@ export interface SessionOptions {
217
217
  /** Containers teardown must also wait out — an overridden proxy's pair. */
218
218
  extraContainers?: string[];
219
219
  }
220
+ /** The host a demo's daemon-port URLs name.
221
+ *
222
+ * NORSK_TEST_HOST is a STUDIO-port setting: on a docker-outside-of-docker
223
+ * runner the test process sits in a container while the studio it launched
224
+ * published its port on the HOST, reachable only via the host-gateway alias.
225
+ * A PRIVATE daemon is the opposite case -- this process starts it, and its
226
+ * proxy, so its ports are on this process's own loopback. studio-state.ts
227
+ * states the invariant plainly ("daemon-port fetches are deliberately left on
228
+ * localhost"); letting the env reach them here aimed playout's visualiser gate
229
+ * at the host gateway, where nothing listens on the private proxy port, and
230
+ * `demo -- check` missed `video_compose advancing` on every poll for 180s
231
+ * while the compositor was advancing perfectly well (2026-09-03). It passed
232
+ * only where a runner's network happened to route back, which is what made it
233
+ * read as a flake rather than a wrong address.
234
+ *
235
+ * `reuse` keeps the alias: that daemon is a process this one did not start and
236
+ * may genuinely be the host sibling. An explicit --public-host always wins. */
237
+ export declare function demoUrlHost(opts: {
238
+ publicHost: string | undefined;
239
+ daemon: DemoDaemonPolicy;
240
+ env?: NodeJS.ProcessEnv;
241
+ }): string;
220
242
  /** The pieces of a run that `runDemo`, `runExportCheck` and the dev-loop
221
243
  * share: the daemon (private on its band, or the developer's), the dev
222
244
  * backend on the driver's port, the registration, and the template (built
package/demo/run.js CHANGED
@@ -37,7 +37,13 @@ import { pollUntil } from "../poll.js";
37
37
  import { nukePathAsRoot, reportForeignOwners } from "../root-nuke.js";
38
38
  import { startSrtSources } from "../source-pump.js";
39
39
  import { makeStoreDir, TEST_TMP_BASE } from "../temp-dir.js";
40
- const DEMO_PORT_BASE = 35000;
40
+ // Below 32768, for the reason smoke.ts's band carries in full: 35000 sat inside
41
+ // the kernel's ephemeral range (32768-60999), so a banded port could be handed
42
+ // to an outbound connection as its source port and the daemon would then fail
43
+ // to bind with EADDRINUSE while nothing was listening on it. Disjoint from the
44
+ // smoke band and from the products' own (15000-31800); guarded by
45
+ // tests/port-bands.test.ts.
46
+ const DEMO_PORT_BASE = 22000;
41
47
  const DEMO_BAND_WIDTH = 20;
42
48
  const DEMO_BANDS = 50;
43
49
  const DEFAULT_DAEMON_PORT = 8333;
@@ -311,6 +317,31 @@ export function defaultDemoDeps(cwd) {
311
317
  log: (line) => console.log(`[demo] ${line}`),
312
318
  };
313
319
  }
320
+ /** The host a demo's daemon-port URLs name.
321
+ *
322
+ * NORSK_TEST_HOST is a STUDIO-port setting: on a docker-outside-of-docker
323
+ * runner the test process sits in a container while the studio it launched
324
+ * published its port on the HOST, reachable only via the host-gateway alias.
325
+ * A PRIVATE daemon is the opposite case -- this process starts it, and its
326
+ * proxy, so its ports are on this process's own loopback. studio-state.ts
327
+ * states the invariant plainly ("daemon-port fetches are deliberately left on
328
+ * localhost"); letting the env reach them here aimed playout's visualiser gate
329
+ * at the host gateway, where nothing listens on the private proxy port, and
330
+ * `demo -- check` missed `video_compose advancing` on every poll for 180s
331
+ * while the compositor was advancing perfectly well (2026-09-03). It passed
332
+ * only where a runner's network happened to route back, which is what made it
333
+ * read as a flake rather than a wrong address.
334
+ *
335
+ * `reuse` keeps the alias: that daemon is a process this one did not start and
336
+ * may genuinely be the host sibling. An explicit --public-host always wins. */
337
+ export function demoUrlHost(opts) {
338
+ if (opts.publicHost !== undefined)
339
+ return opts.publicHost;
340
+ const envHost = (opts.env ?? process.env).NORSK_TEST_HOST;
341
+ if (opts.daemon === "reuse" && envHost)
342
+ return envHost;
343
+ return "localhost";
344
+ }
314
345
  /** The pieces of a run that `runDemo`, `runExportCheck` and the dev-loop
315
346
  * share: the daemon (private on its band, or the developer's), the dev
316
347
  * backend on the driver's port, the registration, and the template (built
@@ -346,7 +377,7 @@ export class DemoSession {
346
377
  this.mode = opts.mode;
347
378
  this.policy = opts.daemon;
348
379
  this.publicHost = opts.publicHost;
349
- const host = opts.publicHost ?? process.env.NORSK_TEST_HOST ?? "localhost";
380
+ const host = demoUrlHost({ publicHost: opts.publicHost, daemon: opts.daemon });
350
381
  this.host = host;
351
382
  if (opts.daemon === "reuse") {
352
383
  const daemonPort = opts.daemonPort ?? (Number(process.env.NORSK_CTL_PORT) || DEFAULT_DAEMON_PORT);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/root-nuke.d.ts CHANGED
@@ -1,8 +1,22 @@
1
- /** Minimal shape of `spawnSync` this needs, so tests can inject. */
1
+ /** Minimal shape of `spawnSync` this needs, so tests can inject. `stdout` is
2
+ * optional because only the container-scan calls read it. */
2
3
  export type NukeSpawn = (cmd: string, argv: string[], opts?: unknown) => {
3
4
  error?: unknown;
4
5
  status?: number | null;
6
+ stdout?: string | Buffer | null;
5
7
  };
8
+ /** Container ids whose bind-mount sources sit at or under `dir`.
9
+ *
10
+ * Matching on the mount SOURCE rather than a label is the point. The
11
+ * containers that strand a temp dir are exactly the ones no teardown ever
12
+ * tracked -- a run killed mid-flight (CI cancel, timeout, SIGKILL) never
13
+ * labels or records them -- so a label filter finds nothing. The mount is the
14
+ * one thing that is always true of a container holding the path open. */
15
+ export declare function containersHoldingMountsUnder(dir: string, spawn?: NukeSpawn): string[];
16
+ /** Remove the containers pinning `dir` so a nuke can actually unlink it.
17
+ * Returns the ids it tried to reap. Best-effort: a docker that cannot list is
18
+ * a docker that cannot nuke either, and the caller reports that separately. */
19
+ export declare function reapContainersUnder(dir: string, spawn?: NukeSpawn, log?: (line: string) => void): string[];
6
20
  /** Refuse a target that would turn the nuke into something catastrophic.
7
21
  *
8
22
  * `allowedBase` is the containment: the target must sit strictly INSIDE it.
package/root-nuke.js CHANGED
@@ -10,6 +10,52 @@ import { lstatSync, readdirSync } from "node:fs";
10
10
  import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
11
11
  const NUKE_IMAGE = "alpine:3";
12
12
  const NUKE_TIMEOUT_MS = 60_000;
13
+ const REAP_TIMEOUT_MS = 30_000;
14
+ function spawnOut(spawn, argv) {
15
+ const r = spawn("docker", argv, { timeout: REAP_TIMEOUT_MS, encoding: "utf8" });
16
+ if (r?.error || (r?.status ?? 0) !== 0)
17
+ return undefined;
18
+ return String(r.stdout ?? "");
19
+ }
20
+ function lines(out) {
21
+ return out
22
+ .split("\n")
23
+ .map((l) => l.trim())
24
+ .filter((l) => l.length > 0);
25
+ }
26
+ /** Container ids whose bind-mount sources sit at or under `dir`.
27
+ *
28
+ * Matching on the mount SOURCE rather than a label is the point. The
29
+ * containers that strand a temp dir are exactly the ones no teardown ever
30
+ * tracked -- a run killed mid-flight (CI cancel, timeout, SIGKILL) never
31
+ * labels or records them -- so a label filter finds nothing. The mount is the
32
+ * one thing that is always true of a container holding the path open. */
33
+ export function containersHoldingMountsUnder(dir, spawn = spawnSync) {
34
+ const prefix = dir.endsWith(sep) ? dir : dir + sep;
35
+ const listed = spawnOut(spawn, ["ps", "-aq"]);
36
+ if (listed === undefined)
37
+ return [];
38
+ const held = [];
39
+ for (const id of lines(listed)) {
40
+ const mounts = spawnOut(spawn, ["inspect", "-f", "{{range .Mounts}}{{println .Source}}{{end}}", id]);
41
+ if (mounts === undefined)
42
+ continue;
43
+ if (lines(mounts).some((src) => src === dir || src.startsWith(prefix)))
44
+ held.push(id);
45
+ }
46
+ return held;
47
+ }
48
+ /** Remove the containers pinning `dir` so a nuke can actually unlink it.
49
+ * Returns the ids it tried to reap. Best-effort: a docker that cannot list is
50
+ * a docker that cannot nuke either, and the caller reports that separately. */
51
+ export function reapContainersUnder(dir, spawn = spawnSync, log) {
52
+ const held = containersHoldingMountsUnder(dir, spawn);
53
+ if (held.length === 0)
54
+ return held;
55
+ log?.(`reaping ${held.length} container(s) pinning ${dir}: ${held.join(" ")}`);
56
+ spawn("docker", ["rm", "-f", ...held], { timeout: REAP_TIMEOUT_MS });
57
+ return held;
58
+ }
13
59
  /** Refuse a target that would turn the nuke into something catastrophic.
14
60
  *
15
61
  * `allowedBase` is the containment: the target must sit strictly INSIDE it.
@@ -83,6 +129,13 @@ export function nukeArgv(target) {
83
129
  * Throws only for an unsafe target -- see {@link assertNukeTarget}. */
84
130
  export function nukePathAsRoot(target, allowedBase, spawn = spawnSync) {
85
131
  assertNukeTarget(target, allowedBase);
132
+ // A container still bind-mounting something under `target` makes that path
133
+ // unlinkable even for root -- the kernel returns EBUSY, `rm -rf` reports it
134
+ // as a plain non-zero exit, and the nuke silently no-ops. The litter then
135
+ // outlives every later run and eventually wedges an innocent job's checkout
136
+ // (2026-09-03: a leaked guide-composition studio, five days up, took
137
+ // commentary's checks AND integration red). Clear the holders first.
138
+ reapContainersUnder(target, spawn);
86
139
  const r = spawn("docker", nukeArgv(target), { timeout: NUKE_TIMEOUT_MS });
87
140
  if (r?.error)
88
141
  return false;
package/smoke.js CHANGED
@@ -29,7 +29,13 @@ import { nukePathAsRoot, reportForeignOwners } from "./root-nuke.js";
29
29
  import { startSrtSources } from "./source-pump.js";
30
30
  import { applyTestHost, fetchComponentState, fetchComponents, fetchStreamMappings, isSrtListenerState, } from "./studio-state.js";
31
31
  import { makeStoreDir, TEST_TMP_BASE } from "./temp-dir.js";
32
- const SMOKE_PORT_BASE = 33000;
32
+ // Below 32768: the band is a reservation, and Linux hands 32768-60999 out to
33
+ // OUTBOUND connections. At 33000 this band sat inside that range, so any of the
34
+ // many connections a run makes could be assigned a banded port as its source
35
+ // port and the daemon would fail to bind with EADDRINUSE while nothing was
36
+ // listening. Kept clear of the products' own bands (15000-31800) and guarded by
37
+ // tests/port-bands.test.ts.
38
+ const SMOKE_PORT_BASE = 21000;
33
39
  const SMOKE_BAND_WIDTH = 20;
34
40
  const SMOKE_BANDS = 50;
35
41
  const STUDIO_HOST_PORT_PARAM = "STUDIO_HOST_PORT";