@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 +22 -0
- package/demo/run.js +33 -2
- package/package.json +1 -1
- package/root-nuke.d.ts +15 -1
- package/root-nuke.js +53 -0
- package/smoke.js +7 -1
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
|
-
|
|
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
|
|
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
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
|
-
|
|
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";
|