@norskvideo/ctl-test-harness 0.1.83 → 0.1.85

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/daemon.d.ts CHANGED
@@ -102,7 +102,8 @@ export declare function seedDaemonConfig(storeDir: string): void;
102
102
  * (and each daemon start `rm -f`s the cpu-monitor sidecar by name). The port
103
103
  * alone is not unique there -- two repos' slugs can hash into the same band --
104
104
  * so the names carry the store dir too, which is per run and per job. A ctl
105
- * that predates an override ignores it. */
105
+ * that predates an override ignores it. The node MoQ relay has no per-daemon
106
+ * name, so a test daemon runs without it. */
106
107
  export declare function daemonSpawnEnv(o: {
107
108
  storeDir: string;
108
109
  port: number;
package/daemon.js CHANGED
@@ -231,7 +231,8 @@ export function seedDaemonConfig(storeDir) {
231
231
  * (and each daemon start `rm -f`s the cpu-monitor sidecar by name). The port
232
232
  * alone is not unique there -- two repos' slugs can hash into the same band --
233
233
  * so the names carry the store dir too, which is per run and per job. A ctl
234
- * that predates an override ignores it. */
234
+ * that predates an override ignores it. The node MoQ relay has no per-daemon
235
+ * name, so a test daemon runs without it. */
235
236
  export function daemonSpawnEnv(o) {
236
237
  // A spawned daemon must boot GUARDED: exercising the real ingress secret is
237
238
  // the point of the tiers that spawn one. The unit tier's marker would
@@ -242,6 +243,11 @@ export function daemonSpawnEnv(o) {
242
243
  return {
243
244
  NORSK_CTL_PROXY_CONTAINER: `norsk-proxy-${id}`,
244
245
  NORSK_CTL_CPU_MONITOR_CONTAINER: `norsk-ctl-cpu-monitor-${id}`,
246
+ // No node MoQ relay: it is a box-global singleton the daemon never removes,
247
+ // so a test daemon would leave one behind or adopt another job's (probe's
248
+ // smoke ran in relay mode on 2026-10-10). Instances keep their own MoQ
249
+ // listener. A caller that wants the relay sets it explicitly.
250
+ NORSK_CTL_MOQ_RELAY_ENABLE: "0",
245
251
  ...inherited,
246
252
  NORSK_CTL_STORE_DIR: o.storeDir,
247
253
  NORSK_CTL_PORT: String(o.port),
package/free-port.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ export declare const FREE_PORT_RANGE: {
2
+ readonly min: 32000;
3
+ readonly max: 32767;
4
+ };
5
+ /** Is `port` bindable on 0.0.0.0 right now, for both TCP and UDP? Advisory, as
6
+ * ctl's own probe is: docker's bind at container start is the real test. Both
7
+ * protocols because the caller does not know which one the template publishes. */
8
+ export declare function hostPortFree(port: number): Promise<boolean>;
9
+ export interface FreeHostPortOptions {
10
+ range?: {
11
+ min: number;
12
+ max: number;
13
+ };
14
+ /** Ports this run already holds, which a probe cannot see until docker binds them. */
15
+ taken?: ReadonlySet<number>;
16
+ /** Where the scan starts; random by default. */
17
+ start?: () => number;
18
+ probe?: (port: number) => Promise<boolean>;
19
+ }
20
+ /** A port in FREE_PORT_RANGE that is free on this host and not in `taken`. */
21
+ export declare function freeHostPort(opts?: FreeHostPortOptions): Promise<number>;
package/free-port.js ADDED
@@ -0,0 +1,60 @@
1
+ // A host port no concurrent run on the same host is also using.
2
+ //
3
+ // Every product's smoke shares one runner host, so a port a template fixes, or
4
+ // one ctl allocates from a shared preferred value ("auto", preferred 4443), is
5
+ // the same port in every repo's run. And the node MoQ relay every daemon brings
6
+ // up holds 4443 itself, so a product that fixes MOQ_PORT=4443 fails outright
7
+ // while the relay is up (relay's smoke, 2026-10-10).
8
+ //
9
+ // The range sits below the kernel's ephemeral range: outbound connections take
10
+ // their source ports from 32768 up, so a port picked there can be taken between
11
+ // the probe and docker's bind (port-bands.test.ts has that history). It is clear
12
+ // of the smoke and demo bands and of the products' own (15000-31800). The start
13
+ // is random so two runs that probe at once rarely meet, and the probe is what
14
+ // rules out the ports already published.
15
+ import { createSocket } from "node:dgram";
16
+ import { createServer } from "node:net";
17
+ export const FREE_PORT_RANGE = { min: 32000, max: 32767 };
18
+ function udpFree(port) {
19
+ return new Promise((resolve) => {
20
+ const sock = createSocket("udp4");
21
+ sock.once("error", () => {
22
+ try {
23
+ sock.close();
24
+ }
25
+ catch {
26
+ // never bound: nothing to release
27
+ }
28
+ resolve(false);
29
+ });
30
+ sock.bind(port, "0.0.0.0", () => sock.close(() => resolve(true)));
31
+ });
32
+ }
33
+ function tcpFree(port) {
34
+ return new Promise((resolve) => {
35
+ const srv = createServer();
36
+ srv.once("error", () => resolve(false));
37
+ srv.listen(port, "0.0.0.0", () => srv.close(() => resolve(true)));
38
+ });
39
+ }
40
+ /** Is `port` bindable on 0.0.0.0 right now, for both TCP and UDP? Advisory, as
41
+ * ctl's own probe is: docker's bind at container start is the real test. Both
42
+ * protocols because the caller does not know which one the template publishes. */
43
+ export async function hostPortFree(port) {
44
+ return (await udpFree(port)) && (await tcpFree(port));
45
+ }
46
+ /** A port in FREE_PORT_RANGE that is free on this host and not in `taken`. */
47
+ export async function freeHostPort(opts = {}) {
48
+ const { min, max } = opts.range ?? FREE_PORT_RANGE;
49
+ const size = max - min + 1;
50
+ const start = opts.start?.() ?? min + Math.floor(Math.random() * size);
51
+ const probe = opts.probe ?? hostPortFree;
52
+ for (let i = 0; i < size; i++) {
53
+ const port = min + ((start - min + i) % size);
54
+ if (opts.taken?.has(port))
55
+ continue;
56
+ if (await probe(port))
57
+ return port;
58
+ }
59
+ throw new Error(`no free host port in ${min}-${max}`);
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.83",
3
+ "version": "0.1.85",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -19,6 +19,10 @@
19
19
  "types": "./daemon-stores.d.ts",
20
20
  "default": "./daemon-stores.js"
21
21
  },
22
+ "./free-port": {
23
+ "types": "./free-port.d.ts",
24
+ "default": "./free-port.js"
25
+ },
22
26
  "./image-surface": {
23
27
  "types": "./image-surface.d.ts",
24
28
  "default": "./image-surface.js"
@@ -85,8 +89,8 @@
85
89
  "devDependencies": {
86
90
  "@types/bun": "latest",
87
91
  "@types/node": "^22.10.0",
88
- "@norskvideo/norsk-studio": "1.30.0-2026-10-08-8f4b6c11",
89
- "@norskvideo/norsk-studio-built-ins": "1.30.0-2026-10-08-8f4b6c11",
92
+ "@norskvideo/norsk-studio": "1.30.0-2026-10-09-0cd6f070",
93
+ "@norskvideo/norsk-studio-built-ins": "1.30.0-2026-10-09-0cd6f070",
90
94
  "@norskvideo/norsk-sdk": "1.3.1-2026-10-08-846f25e3"
91
95
  },
92
96
  "peerDependenciesMeta": {
package/smoke.d.ts CHANGED
@@ -115,6 +115,12 @@ export interface SmokeSpec {
115
115
  /** Extra `--param NAME=value`. INSTANCE_NAME is always passed; STUDIO_HOST_PORT
116
116
  * when the template declares it (else the port is pinned with --host-ports). */
117
117
  params?: string[];
118
+ /** Further template params that publish a host port the journey never dials
119
+ * (relay's RTMP_PORT, say), each launched on a free one. MOQ_PORT and any
120
+ * `*_PORT` defaulting to `auto` are freed without being named; a param
121
+ * `params` sets is left as set. Not for a port a source or the surface
122
+ * dials: the param moves the container's listener too. */
123
+ freePorts?: string[];
118
124
  hardware?: "nvidia" | "none";
119
125
  networkMode?: "docker";
120
126
  /** `port` is the container-internal listener port. May be empty. */
@@ -148,6 +154,10 @@ export interface SmokeDeps {
148
154
  ready: Promise<void>;
149
155
  running?: Promise<RunningDaemon>;
150
156
  };
157
+ /** A host port free right now and outside `taken` (free-port.ts). Optional so
158
+ * an existing custom-deps consumer keeps working; absent = the banded surface
159
+ * port and the template's own port values, as before. */
160
+ freePort?(taken: ReadonlySet<number>): Promise<number>;
151
161
  /** Does the resolved ctl run its daemon and proxy on system-assigned ports?
152
162
  * Optional so an existing custom-deps consumer keeps working; absent = no,
153
163
  * and the band decides both, as before. */
package/smoke.js CHANGED
@@ -22,6 +22,7 @@ import { join } from "node:path";
22
22
  import { dumpInstanceContainerLogs, preserveEngineLogs, preserveInstanceContainerLogs } from "./container-logs.js";
23
23
  import { authority, ensureRunnerOnNetwork, netReachMode, resolveEndpoint, STUDIO_INTERNAL_PORT, studioBaseFrom, } from "./container-net.js";
24
24
  import { cleanupDaemon, ctlSupportsEphemeralPorts, PROXY_PORT_FILE, requireLicenseFile, runCli, startDaemon, } from "./daemon.js";
25
+ import { freeHostPort } from "./free-port.js";
25
26
  import { hashSlot } from "./harness-config.js";
26
27
  import { ctlSupportsNoPublish, ctlSupportsPublishDebugPorts, runnerContainerUser } from "./launch.js";
27
28
  import { pollUntil } from "./poll.js";
@@ -40,6 +41,12 @@ const SMOKE_PORT_BASE = 21000;
40
41
  const SMOKE_BAND_WIDTH = 20;
41
42
  const SMOKE_BANDS = 50;
42
43
  const STUDIO_HOST_PORT_PARAM = "STUDIO_HOST_PORT";
44
+ /** The MoQ listener's param in every product that has one. Freed whatever its
45
+ * default: relay fixes it at 4443, the port the node MoQ relay also holds. */
46
+ const MOQ_PORT_PARAM = "MOQ_PORT";
47
+ /** ctl's allocated host port: a `*_PORT` param defaulting to `auto`, which it
48
+ * resolves to the first free port from a PREFERRED value every run shares. */
49
+ const isAllocatedPortParam = (name, dflt) => dflt === "auto" && /_PORT$/.test(name);
43
50
  export function smokePorts(slug, overrides = {}) {
44
51
  const daemonPort = SMOKE_PORT_BASE + hashSlot(`smoke:${slug}`, SMOKE_BANDS) * SMOKE_BAND_WIDTH;
45
52
  return {
@@ -57,6 +64,7 @@ export const defaultSmokeDeps = {
57
64
  writeFile: (path, contents) => writeFileSync(path, contents),
58
65
  startDaemon,
59
66
  supportsEphemeralPorts: ctlSupportsEphemeralPorts,
67
+ freePort: (taken) => freeHostPort({ taken }),
60
68
  cli: (storeDir, argv) => runCli(storeDir, ...argv),
61
69
  startSources: startSrtSources,
62
70
  stopSources: async (handles) => {
@@ -359,29 +367,59 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
359
367
  // with a host-port binding on the studio service. In direct mode with an
360
368
  // internal-only launch the target dials the service name instead.
361
369
  const shown = await cli(["template", "show", templateName], { output: "json" });
362
- const declared = new Set(shown.exitCode === 0
363
- ? (parseJson(shown, "template show").parameters ?? []).map((p) => p.name)
370
+ const declared = new Map(shown.exitCode === 0
371
+ ? (parseJson(shown, "template show").parameters ??
372
+ []).map((p) => [p.name, p.default])
364
373
  : []);
365
- const params = [`INSTANCE_NAME=${instanceId}`, ...(spec.params ?? [])];
366
- const launchArgv = ["instance", "launch-template", instanceId, "--template", templateName];
367
374
  const internalOnly = mode === "direct" && (await deps.supportsNoPublish());
375
+ const specParams = new Set((spec.params ?? []).map((p) => p.split("=")[0]));
376
+ for (const name of spec.freePorts ?? []) {
377
+ if (!declared.has(name)) {
378
+ throw new Error(`freePorts names ${name}, but template '${templateName}' does not declare it`);
379
+ }
380
+ }
381
+ // Ports a concurrent run on this host could also be using get a free one
382
+ // each (free-port.ts). An internal-only launch publishes none, so it keeps
383
+ // the template's values.
384
+ const freed = internalOnly
385
+ ? []
386
+ : [...declared]
387
+ .filter(([name, dflt]) => !specParams.has(name) &&
388
+ (name === MOQ_PORT_PARAM || isAllocatedPortParam(name, dflt) || (spec.freePorts ?? []).includes(name)))
389
+ .map(([name]) => name);
390
+ const taken = new Set();
391
+ const pick = async () => {
392
+ if (!deps.freePort)
393
+ return undefined;
394
+ const port = await deps.freePort(taken);
395
+ taken.add(port);
396
+ return port;
397
+ };
398
+ if (!internalOnly && spec.ports?.studioHostPort === undefined) {
399
+ const port = await pick();
400
+ if (port !== undefined) {
401
+ ports.studioHostPort = port;
402
+ studio.studioHostPort = port;
403
+ }
404
+ }
405
+ const baseArgv = ["instance", "launch-template", instanceId, "--template", templateName];
406
+ const params = [`INSTANCE_NAME=${instanceId}`, ...(spec.params ?? [])];
407
+ const flags = [];
368
408
  if (studioSurface && declared.has(STUDIO_HOST_PORT_PARAM)) {
369
409
  params.push(`${STUDIO_HOST_PORT_PARAM}=${ports.studioHostPort}`);
370
410
  }
371
411
  else if (!internalOnly && surface.hostPort === undefined) {
372
- launchArgv.push("--host-ports", `${ports.studioHostPort}:${surface.internalPort}@${surface.service}`);
412
+ baseArgv.push("--host-ports", `${ports.studioHostPort}:${surface.internalPort}@${surface.service}`);
373
413
  }
374
- for (const p of params)
375
- launchArgv.push("--param", p);
376
414
  if (spec.hardware)
377
- launchArgv.push("--hardware", spec.hardware);
415
+ flags.push("--hardware", spec.hardware);
378
416
  if (spec.networkMode)
379
- launchArgv.push("--network-mode", spec.networkMode);
417
+ flags.push("--network-mode", spec.networkMode);
380
418
  const user = deps.containerUser();
381
419
  if (user)
382
- launchArgv.push("--container-user", user);
420
+ flags.push("--container-user", user);
383
421
  if (internalOnly)
384
- launchArgv.push("--internal-only");
422
+ flags.push("--internal-only");
385
423
  // ctl binds a product's OPERATOR compose ports (STUDIO_HOST_PORT among
386
424
  // them) to 127.0.0.1. `fetchStudio` dials NORSK_TEST_HOST in publish mode;
387
425
  // when that is not loopback the test process is a container reaching a host
@@ -389,8 +427,21 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
389
427
  // fetchers already use rather than a second thing to configure.
390
428
  else if ((process.env.NORSK_TEST_HOST ?? "localhost") !== "localhost" &&
391
429
  (await (deps.supportsPublishDebugPorts ?? ctlSupportsPublishDebugPorts)())) {
392
- launchArgv.push("--publish-debug-ports");
430
+ flags.push("--publish-debug-ports");
393
431
  }
432
+ // Picked per attempt, so a launch that loses the bind race retries on
433
+ // fresh ports rather than the one it lost.
434
+ const launchArgv = async () => {
435
+ const freedParams = [];
436
+ for (const name of freed) {
437
+ const port = await pick();
438
+ if (port !== undefined)
439
+ freedParams.push(`${name}=${port}`);
440
+ }
441
+ if (freedParams.length > 0)
442
+ deps.log(`${slug}: free host ports ${freedParams.join(" ")}`);
443
+ return [...baseArgv, ...[...params, ...freedParams].flatMap((p) => ["--param", p]), ...flags];
444
+ };
394
445
  launched = true;
395
446
  // Same advisory-check race as the demo tier, same remedy: reap this id's
396
447
  // orphans from a run that was killed before teardown, then retry once if the
@@ -398,7 +449,7 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
398
449
  reapInstance(instanceId);
399
450
  await launchWithPortConflictRetry({
400
451
  instanceId,
401
- attempt: () => cliOk(launchArgv),
452
+ attempt: async () => cliOk(await launchArgv()),
402
453
  log: deps.log,
403
454
  });
404
455
  await pollUntil(async () => {