@norskvideo/ctl-test-harness 0.1.70 → 0.1.72

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.
Files changed (3) hide show
  1. package/daemon.d.ts +16 -0
  2. package/daemon.js +45 -2
  3. package/package.json +1 -1
package/daemon.d.ts CHANGED
@@ -110,11 +110,24 @@ export declare function daemonSpawnEnv(o: {
110
110
  overrides?: Record<string, string>;
111
111
  }): Record<string, string | undefined>;
112
112
  export declare function startDaemon(storeDir: string, options?: StartDaemonOptions): StartedDaemon;
113
+ /** How long one `instance delete` may take before teardown stops waiting for
114
+ * it and reaps the instance's containers itself. A healthy delete is seconds. */
115
+ export declare const INSTANCE_DELETE_TIMEOUT_MS = 20000;
113
116
  /**
114
117
  * Tear down a daemon-based test: delete instances, stop proxy, stop daemon, kill
115
118
  * process, rm store. The teardown actions are injected as pre-bound thunks
116
119
  * (`deleteInstance`/`stopProxy`/`stopDaemon`) — the caller binds them to
117
120
  * norsk-ctl's typed CLI, so no command type leaks into this package.
121
+ *
122
+ * Deleting through the daemon is not enough on its own. When bun times a test
123
+ * out it kills the test's dangling processes, the daemon among them, and the
124
+ * afterAll that then calls this has a dead daemon to ask: every delete fails
125
+ * (or hangs into the hook's own timeout) and the instance's containers run on
126
+ * with nothing left that knows they exist. commentary's publish-docs left a
127
+ * composition guide's engine running that way for 2.6 hours on 2026-10-03; it
128
+ * grew to 28 GB and the host OOM-killed the next morning's product CI. So each
129
+ * delete is bounded, and whatever an instance leaves behind is then removed by
130
+ * its `norsk-ctl.instance` label, which needs only Docker.
118
131
  */
119
132
  export declare function cleanupDaemon(opts: {
120
133
  deleteInstance: (id: string) => Promise<unknown>;
@@ -125,6 +138,9 @@ export declare function cleanupDaemon(opts: {
125
138
  daemon: DaemonProcess | null;
126
139
  storeDir: string;
127
140
  containers: string[];
141
+ /** Injected for tests; defaults to the Docker-label reap. */
142
+ reapInstance?: (id: string) => string[];
143
+ deleteTimeoutMs?: number;
128
144
  }): Promise<void>;
129
145
  /** Tear down a direct-API test: stop proxy, remove instances, rm temp dir. */
130
146
  export declare function cleanupDirect(opts: {
package/daemon.js CHANGED
@@ -15,6 +15,7 @@ import { join } from "node:path";
15
15
  import { cliCommand } from "./cli-command.js";
16
16
  import { rememberDaemonStore } from "./daemon-stores.js";
17
17
  import { pollUntil } from "./poll.js";
18
+ import { reapInstance } from "./reap.js";
18
19
  import { makeStoreDir, makeTempDir, TEST_TMP_BASE } from "./temp-dir.js";
19
20
  export const HEALTH_TIMEOUT_MS = 120_000;
20
21
  // The daemon's readiness probe (/api/ready) cannot answer until `serve` has
@@ -28,6 +29,22 @@ export const HEALTH_TIMEOUT_MS = 120_000;
28
29
  export const DAEMON_READY_TIMEOUT_MS = 60_000;
29
30
  export { makeStoreDir, makeTempDir, pollUntil, TEST_TMP_BASE };
30
31
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
32
+ /** "done" once `p` settles, or "timeout" after `ms` — whichever is first. A
33
+ * rejection inside the window rejects; one after it is dropped (the caller has
34
+ * moved on). */
35
+ async function settleWithin(p, ms) {
36
+ let timer;
37
+ const late = new Promise((resolve) => {
38
+ timer = setTimeout(() => resolve("timeout"), ms);
39
+ });
40
+ p.catch(() => { });
41
+ try {
42
+ return await Promise.race([p.then(() => "done"), late]);
43
+ }
44
+ finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
31
48
  function splitLines(text) {
32
49
  return text
33
50
  .split("\n")
@@ -301,22 +318,48 @@ async function awaitContainerShutdown(containers) {
301
318
  if (still.length)
302
319
  throw new Error(`Containers still running after cleanup: ${still.join(", ")}`);
303
320
  }
321
+ /** How long one `instance delete` may take before teardown stops waiting for
322
+ * it and reaps the instance's containers itself. A healthy delete is seconds. */
323
+ export const INSTANCE_DELETE_TIMEOUT_MS = 20_000;
304
324
  /**
305
325
  * Tear down a daemon-based test: delete instances, stop proxy, stop daemon, kill
306
326
  * process, rm store. The teardown actions are injected as pre-bound thunks
307
327
  * (`deleteInstance`/`stopProxy`/`stopDaemon`) — the caller binds them to
308
328
  * norsk-ctl's typed CLI, so no command type leaks into this package.
329
+ *
330
+ * Deleting through the daemon is not enough on its own. When bun times a test
331
+ * out it kills the test's dangling processes, the daemon among them, and the
332
+ * afterAll that then calls this has a dead daemon to ask: every delete fails
333
+ * (or hangs into the hook's own timeout) and the instance's containers run on
334
+ * with nothing left that knows they exist. commentary's publish-docs left a
335
+ * composition guide's engine running that way for 2.6 hours on 2026-10-03; it
336
+ * grew to 28 GB and the host OOM-killed the next morning's product CI. So each
337
+ * delete is bounded, and whatever an instance leaves behind is then removed by
338
+ * its `norsk-ctl.instance` label, which needs only Docker.
309
339
  */
310
340
  export async function cleanupDaemon(opts) {
341
+ const deleteTimeoutMs = opts.deleteTimeoutMs ?? INSTANCE_DELETE_TIMEOUT_MS;
311
342
  for (const id of opts.instances ?? []) {
312
343
  try {
313
- await opts.deleteInstance(id);
344
+ if ((await settleWithin(opts.deleteInstance(id), deleteTimeoutMs)) === "timeout")
345
+ console.error(`cleanup: delete ${id} gave no answer in ${deleteTimeoutMs}ms`);
314
346
  }
315
347
  catch (e) {
316
348
  console.error(`cleanup: delete ${id} failed: ${e}`);
317
349
  }
318
350
  }
319
- if (opts.proxy)
351
+ const reap = opts.reapInstance ?? reapInstance;
352
+ for (const id of opts.instances ?? []) {
353
+ const left = reap(id);
354
+ if (left.length > 0)
355
+ console.error(`cleanup: removed ${left.length} container(s) instance ${id} left running`);
356
+ }
357
+ // Stop the proxy unless the caller says it never ran one (`proxy: false`).
358
+ // It used to need `proxy: true`, and three callers passing a real stopProxy
359
+ // omitted it — so `shutdown --daemon-only` left their proxy pair running.
360
+ // On the shared ar-arm-vm runner a leftover pair then answered for the next
361
+ // job's proxy (2026-10-02, norsk-ctl Checks 37048720935).
362
+ if (opts.proxy ?? true)
320
363
  try {
321
364
  await opts.stopProxy();
322
365
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {