@norskvideo/ctl-test-harness 0.1.40 → 0.1.41

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
@@ -311,6 +311,31 @@ export function defaultDemoDeps(cwd) {
311
311
  log: (line) => console.log(`[demo] ${line}`),
312
312
  };
313
313
  }
314
+ /** The host a demo's daemon-port URLs name.
315
+ *
316
+ * NORSK_TEST_HOST is a STUDIO-port setting: on a docker-outside-of-docker
317
+ * runner the test process sits in a container while the studio it launched
318
+ * published its port on the HOST, reachable only via the host-gateway alias.
319
+ * A PRIVATE daemon is the opposite case -- this process starts it, and its
320
+ * proxy, so its ports are on this process's own loopback. studio-state.ts
321
+ * states the invariant plainly ("daemon-port fetches are deliberately left on
322
+ * localhost"); letting the env reach them here aimed playout's visualiser gate
323
+ * at the host gateway, where nothing listens on the private proxy port, and
324
+ * `demo -- check` missed `video_compose advancing` on every poll for 180s
325
+ * while the compositor was advancing perfectly well (2026-09-03). It passed
326
+ * only where a runner's network happened to route back, which is what made it
327
+ * read as a flake rather than a wrong address.
328
+ *
329
+ * `reuse` keeps the alias: that daemon is a process this one did not start and
330
+ * may genuinely be the host sibling. An explicit --public-host always wins. */
331
+ export function demoUrlHost(opts) {
332
+ if (opts.publicHost !== undefined)
333
+ return opts.publicHost;
334
+ const envHost = (opts.env ?? process.env).NORSK_TEST_HOST;
335
+ if (opts.daemon === "reuse" && envHost)
336
+ return envHost;
337
+ return "localhost";
338
+ }
314
339
  /** The pieces of a run that `runDemo`, `runExportCheck` and the dev-loop
315
340
  * share: the daemon (private on its band, or the developer's), the dev
316
341
  * backend on the driver's port, the registration, and the template (built
@@ -346,7 +371,7 @@ export class DemoSession {
346
371
  this.mode = opts.mode;
347
372
  this.policy = opts.daemon;
348
373
  this.publicHost = opts.publicHost;
349
- const host = opts.publicHost ?? process.env.NORSK_TEST_HOST ?? "localhost";
374
+ const host = demoUrlHost({ publicHost: opts.publicHost, daemon: opts.daemon });
350
375
  this.host = host;
351
376
  if (opts.daemon === "reuse") {
352
377
  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.41",
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;