@akanjs/devkit 2.4.1-rc.3 → 2.4.1-rc.4

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.
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { isPortInUseError } from "akanjs/server/lifecycle/portInUse";
3
4
 
4
5
  export interface DevStabilityFixture {
5
6
  appName: string;
@@ -18,7 +19,9 @@ export interface DevStabilityHost {
18
19
  }
19
20
 
20
21
  export interface DevStabilityHmrProbe {
21
- ws: WebSocket;
22
+ /** The live socket — a reconnect replaces it, so read it rather than holding on to one. */
23
+ readonly ws: WebSocket;
24
+ readonly reconnects: number;
22
25
  messages: unknown[];
23
26
  mark(): number;
24
27
  waitForMessageSince(mark: number, predicate: (message: unknown) => boolean, timeoutMs?: number): Promise<unknown>;
@@ -31,16 +34,22 @@ const DEFAULT_TIMEOUT_MS = 60_000;
31
34
  const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
32
35
 
33
36
  export class DevStabilityHarness {
37
+ static readonly fixturePrefix = "zz-dev-stability-";
38
+ static readonly defaultWorkspaceRoot = path.resolve(import.meta.dir, "../../../..");
34
39
  readonly workspaceRoot: string;
35
40
  readonly appName: string;
36
41
  readonly appDir: string;
37
- readonly portOffset: number;
42
+ readonly #explicitPortOffset: number | null;
43
+ #portAllocation: Promise<{ port: number; offset: number }> | null = null;
44
+ #boundPort: number | null = null;
38
45
  #host: DevStabilityHost | null = null;
46
+ /** What the last HTTP poll saw, so a timeout can say which port answered and with what. */
47
+ #lastHttpProbe: { port: number; body: string | null } = { port: 0, body: null };
39
48
 
40
49
  constructor({
41
- workspaceRoot = path.resolve(import.meta.dir, "../../../.."),
42
- appName = `zz-dev-stability-${process.pid}-${Date.now()}`,
43
- portOffset = 3_000 + Math.floor(Math.random() * 1_000),
50
+ workspaceRoot = DevStabilityHarness.defaultWorkspaceRoot,
51
+ appName = `${DevStabilityHarness.fixturePrefix}${process.pid}-${Date.now()}`,
52
+ portOffset,
44
53
  }: {
45
54
  workspaceRoot?: string;
46
55
  appName?: string;
@@ -49,7 +58,7 @@ export class DevStabilityHarness {
49
58
  this.workspaceRoot = workspaceRoot;
50
59
  this.appName = appName;
51
60
  this.appDir = path.join(workspaceRoot, "apps", appName);
52
- this.portOffset = portOffset;
61
+ this.#explicitPortOffset = portOffset ?? null;
53
62
  }
54
63
 
55
64
  async createFixture(): Promise<DevStabilityFixture> {
@@ -247,9 +256,73 @@ export const dictionary = serviceDictionary(["en", "ko"])
247
256
  return { appName: this.appName, appDir: this.appDir, workspaceRoot: this.workspaceRoot, port };
248
257
  }
249
258
 
259
+ /**
260
+ * Stop the dev server and delete the fixture, within a budget that cannot outlive the hook awaiting it.
261
+ *
262
+ * Overrunning the `afterEach` budget costs more than one red test. Bun fails the test that had *already
263
+ * passed*, and the fixture is dropped from the cleanup list either way — so its dev host keeps running,
264
+ * holding ports and shifting the app index, and the tests after it fail too. That cascade is what turned
265
+ * one slow cleanup into three failures in a parallel shard.
266
+ */
250
267
  async cleanup(): Promise<void> {
251
- await this.stopHost();
252
- await rm(this.appDir, { recursive: true, force: true });
268
+ const startedAt = Date.now();
269
+ const finished = await Promise.race([
270
+ this.#stopAndDelete().then(() => true),
271
+ wait(DevStabilityHarness.#cleanupBudgetMs).then(() => false),
272
+ ]);
273
+ if (finished) {
274
+ if (Date.now() - startedAt > DevStabilityHarness.#slowCleanupMs)
275
+ console.warn(`[harness] ${this.appName} cleanup took ${Date.now() - startedAt}ms`);
276
+ return;
277
+ }
278
+ // Give up on the orderly path, but not on the process: killing the tracked child directly involves no
279
+ // `ps` and cannot itself hang, which is what the orderly path just demonstrated it can do.
280
+ this.#host?.proc.kill("SIGKILL");
281
+ this.#host = null;
282
+ console.warn(
283
+ `[harness] ${this.appName} cleanup exceeded ${DevStabilityHarness.#cleanupBudgetMs}ms; killed the host directly and moved on`,
284
+ );
285
+ }
286
+
287
+ async #stopAndDelete(): Promise<void> {
288
+ await DevStabilityHarness.#watched(`${this.appName} stop`, () => this.stopHost());
289
+ // Retries because the delete can still lose a race with a straggler writing into `.akan/artifact`.
290
+ await DevStabilityHarness.#watched(`${this.appName} delete`, () =>
291
+ rm(this.appDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
292
+ );
293
+ }
294
+
295
+ static readonly #slowCleanupMs = 10_000;
296
+ /** Comfortably inside the suite's 60s hook budget, so a hang is reported by the harness — which knows
297
+ * which phase stalled — rather than by Bun, which only knows that a hook did not return. */
298
+ static readonly #cleanupBudgetMs = 30_000;
299
+
300
+ /**
301
+ * Names a phase that is still running, rather than only reporting one that finished.
302
+ *
303
+ * A cleanup that never returns is reported by Bun as a bare `a beforeEach/afterEach hook timed out`
304
+ * against the test that had already passed, with nothing at all about where it hung — so any
305
+ * end-of-phase timing is exactly the information a hang destroys.
306
+ */
307
+ static async #watched<T>(label: string, work: () => Promise<T>): Promise<T> {
308
+ const timer = setTimeout(
309
+ () => console.warn(`[harness] ${label} still running after ${DevStabilityHarness.#slowCleanupMs}ms`),
310
+ DevStabilityHarness.#slowCleanupMs,
311
+ );
312
+ try {
313
+ return await work();
314
+ } finally {
315
+ clearTimeout(timer);
316
+ }
317
+ }
318
+
319
+ static async #waitForPidsGone(pids: number[], timeoutMs: number): Promise<boolean> {
320
+ const started = Date.now();
321
+ while (Date.now() - started < timeoutMs) {
322
+ if (!pids.some((pid) => DevStabilityHarness.#pidIsAlive(pid))) return true;
323
+ await wait(50);
324
+ }
325
+ return false;
253
326
  }
254
327
 
255
328
  async startHost({
@@ -260,6 +333,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
260
333
  env?: Record<string, string>;
261
334
  } = {}): Promise<DevStabilityHost> {
262
335
  const logs: string[] = [];
336
+ const { port, offset } = await this.#allocatePort();
263
337
  const proc = Bun.spawn(["bash", "-lc", `bun run akan start ${JSON.stringify(this.appName)}`], {
264
338
  cwd: this.workspaceRoot,
265
339
  env: {
@@ -269,7 +343,14 @@ export const dictionary = serviceDictionary(["en", "ko"])
269
343
  // stream only carries info/warn/error and those waits time out with empty tails.
270
344
  AKAN_PUBLIC_LOG_LEVEL: "verbose",
271
345
  NODE_NO_WARNINGS: "1",
272
- PORT_OFFSET: String(this.portOffset),
346
+ PORT_OFFSET: String(offset),
347
+ // Pinned, not predicted. `getDevPort()` derives the port from this fixture's index in the `apps/`
348
+ // listing, and a parallel run adds and removes fixtures constantly — so the index moved between the
349
+ // allocation here and the host reading it, and again on every restart. Tests that merely *wait* on a
350
+ // port recovered by adopting the logged one, but a test that has to reserve a port before boot (the
351
+ // occupied-ws-port test blocks `port + 10_000`) had no way to be right, and spent 60s waiting for a
352
+ // fallback that could not happen because the gateway had gone to a different port entirely.
353
+ AKAN_DEV_PORT: String(port),
273
354
  ...env,
274
355
  },
275
356
  stdout: "pipe",
@@ -296,21 +377,49 @@ export const dictionary = serviceDictionary(["en", "ko"])
296
377
  proc,
297
378
  logs,
298
379
  markLog: () => markLog(logs),
299
- waitForLog: (pattern, waitMs) => waitForLog(logs, pattern, waitMs),
300
- waitForLogSince: (mark, pattern, waitMs) => waitForLogSince(logs, mark, pattern, waitMs),
380
+ waitForLog: (pattern, waitMs) =>
381
+ DevStabilityHarness.#timed(`waitForLog ${pattern}`, () => waitForLog(logs, pattern, waitMs)),
382
+ waitForLogSince: (mark, pattern, waitMs) =>
383
+ DevStabilityHarness.#timed(`waitForLogSince ${pattern}`, () => waitForLogSince(logs, mark, pattern, waitMs)),
301
384
  stop: async () => {
385
+ await DevStabilityHarness.#dumpLogs(this.appName, logs);
302
386
  // The host runs under `bash -lc`, so killing `proc` only kills the shell: the dev host, its
303
387
  // builder and its backend outlive it as orphans that keep watching a deleted fixture app and
304
388
  // interfere with later tests. Collect the descendants first, then signal all of them.
305
- const pids = await DevStabilityHarness.descendantPids(proc.pid);
389
+ //
390
+ // The tracked child is always signalled directly as well, because `descendantPids` comes back empty
391
+ // when the `ps` behind it times out — and that path must still take the shell down.
392
+ const pids = await DevStabilityHarness.#watched("descendants", () =>
393
+ DevStabilityHarness.descendantPids(proc.pid),
394
+ );
306
395
  DevStabilityHarness.#signalPids(pids, "SIGTERM");
307
- await Promise.race([proc.exited.catch(() => undefined), wait(3_000)]);
308
- DevStabilityHarness.#signalPids(await DevStabilityHarness.descendantPids(proc.pid), "SIGKILL");
396
+ proc.kill("SIGTERM");
397
+ await DevStabilityHarness.#watched("await exit", () =>
398
+ Promise.race([proc.exited.catch(() => undefined), wait(3_000)]),
399
+ );
400
+ const survivors = await DevStabilityHarness.#watched("descendants after term", () =>
401
+ DevStabilityHarness.descendantPids(proc.pid),
402
+ );
403
+ DevStabilityHarness.#signalPids(survivors, "SIGKILL");
309
404
  DevStabilityHarness.#signalPids(pids, "SIGKILL");
405
+ proc.kill("SIGKILL");
406
+ // Signalling is not reaping. Returning while a build worker is still alive leaves it writing
407
+ // into `.akan/artifact` while the caller's `rm -rf` walks the same tree.
408
+ const signalled = [...new Set([...pids, ...survivors])];
409
+ const gone = await DevStabilityHarness.#watched("await gone", () =>
410
+ DevStabilityHarness.#waitForPidsGone(signalled, 15_000),
411
+ );
412
+ if (!gone)
413
+ console.warn(
414
+ `[harness] ${this.appName}: ${signalled.filter((pid) => DevStabilityHarness.#pidIsAlive(pid)).length} process(es) outlived SIGKILL`,
415
+ );
310
416
  },
311
417
  };
312
418
  this.#host = host;
313
- await host.waitForLog(/backend ready pid=(\d+)|AkanApp gateway is running on port/, timeoutMs);
419
+ await DevStabilityHarness.#timed("startHost:boot", () =>
420
+ host.waitForLog(/backend ready pid=(\d+)|AkanApp gateway is running on port/, timeoutMs),
421
+ );
422
+ await this.#adoptBoundPort(host);
314
423
  return host;
315
424
  }
316
425
 
@@ -319,12 +428,121 @@ export const dictionary = serviceDictionary(["en", "ko"])
319
428
  this.#host = null;
320
429
  }
321
430
 
431
+ /**
432
+ * Write a host's whole log to `AKAN_DEV_STABILITY_LOG_DIR` when it is set.
433
+ *
434
+ * A failing wait prints only a tail, and a *passing* run prints nothing — so a stall that stays inside
435
+ * the timeout budget is invisible, which is exactly the state a slow-but-green suite hides. Off unless
436
+ * the variable is set, since the suite otherwise produces megabytes per round.
437
+ */
438
+ static async #dumpLogs(appName: string, logs: string[]): Promise<void> {
439
+ const dir = process.env.AKAN_DEV_STABILITY_LOG_DIR;
440
+ if (!dir) return;
441
+ await mkdir(dir, { recursive: true }).catch(() => undefined);
442
+ await Bun.write(path.join(dir, `${appName}.log`), logs.join("")).catch(() => undefined);
443
+ }
444
+
322
445
  async writeFile(relativePath: string, contents: string): Promise<void> {
323
446
  const target = path.join(this.appDir, relativePath);
324
447
  await mkdir(path.dirname(target), { recursive: true });
325
448
  await writeFile(target, contents);
326
449
  }
327
450
 
451
+ /**
452
+ * Apply an edit and return once the dev server has demonstrably *seen* it, re-applying it if it has not.
453
+ *
454
+ * Bun's recursive `fs.watch` reports about one path per coalescing window and discards the rest, so an
455
+ * edit landing in the same window as a build's write burst used to be dropped entirely — no rebuild, no
456
+ * HMR, no error. In a 3-way parallel run that failed the same test in all three shards, with 60 seconds
457
+ * of *completely empty* log output after the edit.
458
+ *
459
+ * `HmrWatcher` now resolves changes against a `SourceMtimeIndex` instead of trusting those payloads, so
460
+ * this no longer settles before editing: edits deliberately land right after the previous build's burst,
461
+ * which is exactly the case that used to be lost. `retried` staying 0 is therefore evidence the fix
462
+ * holds, where before it only meant the settle had dodged the window.
463
+ *
464
+ * The retry stays as the regression detector. What it waits for is evidence the watcher saw the edit,
465
+ * not the outcome the caller cares about: evidence arrives within a debounce, while an outcome can
466
+ * legitimately take far longer than any retry budget — so callers keep their own assertions, patterns
467
+ * and timeouts exactly as they were. An edit the dev server genuinely fails to act on exhausts every
468
+ * attempt and fails the test rather than being retried into a pass.
469
+ */
470
+ async editUntilSeen(
471
+ host: DevStabilityHost,
472
+ mutate: (attempt: number) => Promise<void>,
473
+ {
474
+ evidence = DevStabilityHarness.#editEvidence,
475
+ attempts = 3,
476
+ // Generous on purpose. A dropped event yields *nothing, ever*, so waiting longer never weakens the
477
+ // discrimination — while a budget tight enough to mistake a loaded machine for a dropped event makes
478
+ // the retry actively harmful, since re-applying an edit is not always cheap (a config rewrite costs a
479
+ // whole dev-host restart, and four of those overran a 120s test).
480
+ evidenceTimeoutMs = 20_000,
481
+ settleMs = 0,
482
+ retryDelayMs = 750,
483
+ }: {
484
+ evidence?: RegExp;
485
+ attempts?: number;
486
+ evidenceTimeoutMs?: number;
487
+ settleMs?: number;
488
+ retryDelayMs?: number;
489
+ } = {},
490
+ ): Promise<{ mark: number; attempts: number; evidence: RegExpMatchArray }> {
491
+ // Kept as an escape hatch for a caller that needs spacing for its own reasons; the watcher no longer
492
+ // needs it, so the default is 0 and edits land inside the burst window on purpose.
493
+ if (settleMs > 0) await wait(settleMs);
494
+ for (let attempt = 1; ; attempt++) {
495
+ const mark = host.markLog();
496
+ await mutate(attempt);
497
+ const seen = await host.waitForLogSince(mark, evidence, evidenceTimeoutMs).catch(() => null);
498
+ if (seen) {
499
+ DevStabilityHarness.#observedEdits++;
500
+ if (attempt > 1) DevStabilityHarness.#retriedEdits++;
501
+ return { mark, attempts: attempt, evidence: seen };
502
+ }
503
+ if (attempt >= attempts)
504
+ throw new Error(
505
+ `Dev server never reacted to ${attempts} edit(s) of ${this.appName}: waited ${evidenceTimeoutMs}ms each for ${evidence}`,
506
+ );
507
+ await wait(retryDelayMs);
508
+ }
509
+ }
510
+
511
+ /** Every classified change logs a dev plan before anything acts on it, and an idle-suspended host logs a
512
+ * wake first, so either line proves the watcher saw the edit. */
513
+ static readonly #editEvidence = /\[dev-plan\] generation=\d+|\[idle-suspend\] waking/;
514
+ static #observedEdits = 0;
515
+ static #retriedEdits = 0;
516
+
517
+ static editStats(): { edits: number; retried: number } {
518
+ return { edits: DevStabilityHarness.#observedEdits, retried: DevStabilityHarness.#retriedEdits };
519
+ }
520
+
521
+ static readonly #waitDurations: { label: string; ms: number }[] = [];
522
+
523
+ /**
524
+ * Time one wait so a run that dies of *cumulative* slowness can say where the time went.
525
+ *
526
+ * Each wait here is individually bounded, but their budgets sum past the per-test timeout — `startHost`
527
+ * (60s) plus one `waitForLogSince` (60s) plus one `waitForHttpText` (60s) already reaches 180s. So a
528
+ * loaded round can kill a test without any single wait failing, and Bun reports only "this test timed out",
529
+ * naming neither the step nor how close the others came. That produced failures that looked like they
530
+ * rotated between tests at random, when what rotates is which long test happened to be slowest.
531
+ */
532
+ static async #timed<T>(label: string, work: () => Promise<T>): Promise<T> {
533
+ const at = performance.now();
534
+ try {
535
+ return await work();
536
+ } finally {
537
+ DevStabilityHarness.#waitDurations.push({ label, ms: Math.round(performance.now() - at) });
538
+ }
539
+ }
540
+
541
+ /** The slowest waits observed, worst first, for an end-of-run report. */
542
+ static waitStats(limit = 5): { label: string; ms: number }[] {
543
+ return [...DevStabilityHarness.#waitDurations].sort((a, b) => b.ms - a.ms).slice(0, limit);
544
+ }
545
+
328
546
  async replaceText(relativePath: string, search: string | RegExp, replacement: string): Promise<void> {
329
547
  const file = Bun.file(path.join(this.appDir, relativePath));
330
548
  const contents = await file.text();
@@ -338,16 +556,39 @@ export const dictionary = serviceDictionary(["en", "ko"])
338
556
  async waitForHttpText(text: string | RegExp, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<string> {
339
557
  const body = await this.tryWaitForHttpText(text, timeoutMs);
340
558
  if (body) return body;
341
- throw new Error(`Timed out waiting for HTTP text ${String(text)}`);
559
+ const { port, body: last } = this.#lastHttpProbe;
560
+ // The host's own tail matters as much as the response: a 60s wait that ends in a served error page says
561
+ // what the browser saw, but only the dev server's log says why it got into that state.
562
+ const tail = (this.#host?.logs.join("") ?? "").slice(-2_000);
563
+ throw new Error(
564
+ `Timed out waiting for HTTP text ${String(text)} after ${timeoutMs}ms on port ${port}; ` +
565
+ (last === null
566
+ ? "nothing ever answered on that port"
567
+ : `last response was ${last.length} bytes: ${last.slice(0, 400)}`) +
568
+ `\nRecent host logs:\n${tail}`,
569
+ );
342
570
  }
343
571
 
344
572
  async tryWaitForHttpText(text: string | RegExp, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<string | null> {
345
- const port = await this.resolvePort();
573
+ return DevStabilityHarness.#timed(`waitForHttpText ${String(text)}`, () => this.#pollHttpText(text, timeoutMs));
574
+ }
575
+
576
+ async #pollHttpText(text: string | RegExp, timeoutMs: number): Promise<string | null> {
346
577
  const started = Date.now();
347
578
  while (Date.now() - started < timeoutMs) {
348
- const body = await fetch(`http://127.0.0.1:${port}/`)
579
+ // Re-resolved every poll rather than once up front. A dev-host restart re-runs `getDevPort()` and can
580
+ // bind a different port, and quietly polling a port nobody listens on for the rest of the budget is
581
+ // indistinguishable from a page that never updated — it cost 60s and a misleading failure.
582
+ const port = await this.resolvePort();
583
+ // Bounded per request, because the deadline above is only checked *between* iterations. A dev server
584
+ // mid-recovery accepts the connection and then never answers, so an unbounded `fetch` parks here for
585
+ // as long as the peer likes: measured 225561ms against this method's own 60000ms budget, which then
586
+ // ate the whole 180s test timeout and reported "this test timed out" with no other information.
587
+ const budget = Math.max(250, Math.min(5_000, timeoutMs - (Date.now() - started)));
588
+ const body = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(budget) })
349
589
  .then((res) => res.text())
350
590
  .catch(() => null);
591
+ this.#lastHttpProbe = { port, body };
351
592
  if (body && (typeof text === "string" ? body.includes(text) : text.test(body))) return body;
352
593
  await wait(100);
353
594
  }
@@ -380,27 +621,82 @@ export const dictionary = serviceDictionary(["en", "ko"])
380
621
  throw new Error("Timed out connecting HMR websocket");
381
622
  }
382
623
 
624
+ /**
625
+ * An HMR probe that reconnects, because the browser it stands in for does.
626
+ *
627
+ * A raw socket dies whenever the backend restarts, and a dead socket is indistinguishable from a quiet
628
+ * one: `waitForMessageSince` polls an array nothing can ever append to, then reports a plain timeout a
629
+ * minute later. In a 3-way parallel run that was a third of all failures — the diagnostic read
630
+ * `socket=closed since-mark=[] total=3` — and it says nothing about the product, since the real client
631
+ * reconnects and picks the stream back up (`akanjs/server/hmr/clientScript.ts`).
632
+ */
383
633
  async connectHmrProbe(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<DevStabilityHmrProbe> {
384
- const ws = await this.connectHmr(timeoutMs);
385
634
  const messages: unknown[] = [];
386
- ws.addEventListener("message", (event) => {
387
- const raw = typeof event.data === "string" ? event.data : "";
388
- try {
389
- messages.push(JSON.parse(raw));
390
- } catch {
391
- /* ignore non-json websocket payloads */
392
- }
393
- });
635
+ let socket = await this.connectHmr(timeoutMs);
636
+ let closedByCaller = false;
637
+ let reconnects = 0;
638
+ const listen = (ws: WebSocket): void => {
639
+ ws.addEventListener("message", (event) => {
640
+ const raw = typeof event.data === "string" ? event.data : "";
641
+ try {
642
+ messages.push(JSON.parse(raw));
643
+ } catch {
644
+ /* ignore non-json websocket payloads */
645
+ }
646
+ });
647
+ ws.addEventListener("close", () => {
648
+ // Bounded, and with a short budget: the last close of a probe's life is the dev server being torn
649
+ // down in `cleanup()`, and an unbounded retry there would keep reconnecting at a dead port for a
650
+ // minute after the test ended.
651
+ if (closedByCaller || reconnects >= DevStabilityHarness.#maxProbeReconnects) return;
652
+ void this.connectHmr(Math.min(timeoutMs, 5_000))
653
+ .then((next) => {
654
+ if (closedByCaller) {
655
+ next.close();
656
+ return;
657
+ }
658
+ reconnects++;
659
+ socket = next;
660
+ listen(next);
661
+ })
662
+ // A dev server that is gone for good is the caller's problem to notice through its own waits,
663
+ // not something to throw from an event listener.
664
+ .catch(() => undefined);
665
+ });
666
+ };
667
+ listen(socket);
394
668
  return {
395
- ws,
669
+ get ws() {
670
+ return socket;
671
+ },
672
+ get reconnects() {
673
+ return reconnects;
674
+ },
396
675
  messages,
397
676
  mark: () => messages.length,
398
- waitForMessageSince: (mark, predicate, waitMs) => waitForHmrMessageSince(messages, mark, predicate, waitMs),
677
+ waitForMessageSince: (mark, predicate, waitMs) =>
678
+ waitForHmrMessageSince(messages, mark, predicate, waitMs, () =>
679
+ DevStabilityHarness.#describeProbe(socket, messages, mark, reconnects),
680
+ ),
399
681
  waitForNoMessageSince: (mark, predicate, quietMs) => waitForNoHmrMessageSince(messages, mark, predicate, quietMs),
400
- close: () => ws.close(),
682
+ close: () => {
683
+ closedByCaller = true;
684
+ socket.close();
685
+ },
401
686
  };
402
687
  }
403
688
 
689
+ static readonly #maxProbeReconnects = 5;
690
+
691
+ static #describeProbe(ws: WebSocket, messages: unknown[], mark: number, reconnects: number): string {
692
+ const state = ["connecting", "open", "closing", "closed"][ws.readyState] ?? String(ws.readyState);
693
+ const types = messages
694
+ .slice(mark)
695
+ .map((message) => (message as { type?: unknown } | null)?.type ?? "?")
696
+ .join(",");
697
+ return `socket=${state} reconnects=${reconnects} since-mark=[${types}] total=${messages.length}`;
698
+ }
699
+
404
700
  async tryConnectHmrProbe(timeoutMs = 3_000): Promise<DevStabilityHmrProbe | null> {
405
701
  try {
406
702
  return await this.connectHmrProbe(timeoutMs);
@@ -436,9 +732,107 @@ export const dictionary = serviceDictionary(["en", "ko"])
436
732
  });
437
733
  }
438
734
 
735
+ /**
736
+ * The port this fixture's dev server is reachable on — the one the gateway actually bound once it is
737
+ * up, and a probed prediction before that.
738
+ *
739
+ * It used to recompute `8282 + appIndex + randomOffset` from the `apps/` listing on every call, which
740
+ * is wrong twice over. `appIndex` moves whenever any *other* fixture appears or disappears, and two
741
+ * suites running in parallel create and delete one every few seconds — so the answer changed
742
+ * mid-test and every HTTP wait then polled a port nobody was listening on. And the random offset
743
+ * collides, roughly 1-in-N per pair, while the gateway has no fallback for its http port
744
+ * (`AkanApp.start` logs "already in use" and exits), so a collision reads as a boot timeout with no
745
+ * hint of a port problem. Both failures are indistinguishable from a product regression, which is
746
+ * most of why a red run of this suite could not be told apart from noise.
747
+ */
439
748
  async resolvePort(): Promise<number> {
440
- // Mirror the CLI's `getDevPort()` exactly (apps = directories containing akan.config.ts,
441
- // locale-sorted): counting stray entries like .DS_Store would put us one port off the gateway.
749
+ return this.#gatewayPortFromLogs() ?? this.#boundPort ?? (await this.#allocatePort()).port;
750
+ }
751
+
752
+ /**
753
+ * The port the *most recent* gateway bound, or null before one has said.
754
+ *
755
+ * Read fresh rather than adopted once, because the port can move mid-test: a config edit restarts the dev
756
+ * host, and the replacement recomputes its own port from the `apps/` listing (`AppExecutor.getDevPort`) —
757
+ * which a parallel run has changed in the meantime. Holding the boot-time port meant the config test
758
+ * watched the restart succeed and then timed out fetching from the address the *old* gateway had used.
759
+ * (Worth knowing outside the tests too: adding or removing an app during a session moves a running dev
760
+ * server's port at its next restart.)
761
+ */
762
+ #gatewayPortFromLogs(): number | null {
763
+ const logs = this.#host?.logs;
764
+ if (!logs) return null;
765
+ let latest: number | null = null;
766
+ for (const match of logs.join("").matchAll(/AkanApp gateway is running on port http:\/\/localhost:(\d+)/g))
767
+ latest = Number(match[1]);
768
+ return latest;
769
+ }
770
+
771
+ /** Offsets are handed out in stride steps from a pid-seeded cursor and probed against the OS, never
772
+ * drawn at random. Forward-only means no two harnesses in one process share an offset even while the
773
+ * first host is still shutting down and holding its port; the probe covers everything outside this
774
+ * process; and the pid seed keeps two concurrent runs out of each other's band. */
775
+ static readonly portOffsetMin = 3_000;
776
+ static readonly portOffsetMax = 4_000;
777
+ /** The app's port is `8282 + appIndex + offset`, so offsets one apart alias each other as soon as a
778
+ * fixture shifts the index. A stride wider than any plausible drift keeps them distinct. */
779
+ static readonly portOffsetStride = 4;
780
+ static #portOffsetCursor: number | null = null;
781
+
782
+ static get #portOffsetSlots(): number {
783
+ return (
784
+ (DevStabilityHarness.portOffsetMax - DevStabilityHarness.portOffsetMin) / DevStabilityHarness.portOffsetStride
785
+ );
786
+ }
787
+
788
+ static #nextPortOffset(): number {
789
+ const slots = DevStabilityHarness.#portOffsetSlots;
790
+ DevStabilityHarness.#portOffsetCursor =
791
+ DevStabilityHarness.#portOffsetCursor === null
792
+ ? process.pid % slots
793
+ : (DevStabilityHarness.#portOffsetCursor + 1) % slots;
794
+ return (
795
+ DevStabilityHarness.portOffsetMin + DevStabilityHarness.#portOffsetCursor * DevStabilityHarness.portOffsetStride
796
+ );
797
+ }
798
+
799
+ /** Probed on the default interface, which is the strict test: a bind there fails if anything holds
800
+ * the port on any address the app might pick. */
801
+ static async isPortFree(port: number): Promise<boolean> {
802
+ try {
803
+ Bun.serve({ port, fetch: () => new Response("probe") }).stop(true);
804
+ return true;
805
+ } catch (error) {
806
+ if (isPortInUseError(error)) return false;
807
+ throw error;
808
+ }
809
+ }
810
+
811
+ async #allocatePort(): Promise<{ port: number; offset: number }> {
812
+ this.#portAllocation ??= this.#allocatePortOnce();
813
+ return await this.#portAllocation;
814
+ }
815
+
816
+ async #allocatePortOnce(): Promise<{ port: number; offset: number }> {
817
+ const basePort = 8282 + (await this.#appIndex());
818
+ if (this.#explicitPortOffset !== null)
819
+ return { port: basePort + this.#explicitPortOffset, offset: this.#explicitPortOffset };
820
+ for (let attempt = 0; attempt < DevStabilityHarness.#portOffsetSlots; attempt++) {
821
+ const offset = DevStabilityHarness.#nextPortOffset();
822
+ const port = basePort + offset;
823
+ // The gateway derives child 0's websocket port from its http port (`AkanApp` `#wsBasePort`), so
824
+ // both have to be free for a boot to be clean.
825
+ if ((await DevStabilityHarness.isPortFree(port)) && (await DevStabilityHarness.isPortFree(port + 10_000)))
826
+ return { port, offset };
827
+ }
828
+ throw new Error(
829
+ `No free dev port for ${this.appName} after probing ${DevStabilityHarness.#portOffsetSlots} offsets`,
830
+ );
831
+ }
832
+
833
+ /** Mirror the CLI's `getDevPort()` exactly (apps = directories containing akan.config.ts,
834
+ * locale-sorted): counting stray entries like .DS_Store would put us one port off the gateway. */
835
+ async #appIndex(): Promise<number> {
442
836
  const appsDir = path.join(this.workspaceRoot, "apps");
443
837
  const entries = await readdir(appsDir, { withFileTypes: true }).catch(() => []);
444
838
  const checked = await Promise.all(
@@ -451,7 +845,56 @@ export const dictionary = serviceDictionary(["en", "ko"])
451
845
  const apps = [...new Set([...checked.filter((name): name is string => name !== null), this.appName])].sort((a, b) =>
452
846
  a.localeCompare(b),
453
847
  );
454
- return 8282 + Math.max(apps.indexOf(this.appName), 0) + this.portOffset;
848
+ return Math.max(apps.indexOf(this.appName), 0);
849
+ }
850
+
851
+ /** Stop predicting once the gateway has said what it bound. */
852
+ async #adoptBoundPort(host: DevStabilityHost): Promise<void> {
853
+ const match = await host
854
+ .waitForLog(/AkanApp gateway is running on port http:\/\/localhost:(\d+)/, 30_000)
855
+ .catch(() => null);
856
+ const bound = Number(match?.[1]);
857
+ if (!Number.isFinite(bound) || bound <= 0) return;
858
+ const predicted = await this.resolvePort();
859
+ this.#boundPort = bound;
860
+ if (bound !== predicted)
861
+ console.warn(`[harness] ${this.appName} bound port ${bound}, not the predicted ${predicted}`);
862
+ }
863
+
864
+ /**
865
+ * Fixtures and dev hosts left behind by a test process that died before `cleanup()` could run.
866
+ *
867
+ * They keep watching a deleted app, hold their ports, and — because the fixture directory is still in
868
+ * `apps/` — shift the app index every other harness predicts its port from. Only fixtures whose
869
+ * owning test pid is gone are swept, so a concurrent run's live fixtures are never touched.
870
+ */
871
+ static async sweepAbandonedFixtures(workspaceRoot: string): Promise<string[]> {
872
+ const appsDir = path.join(workspaceRoot, "apps");
873
+ const entries = await readdir(appsDir, { withFileTypes: true }).catch(() => []);
874
+ const abandoned = entries
875
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith(DevStabilityHarness.fixturePrefix))
876
+ .filter((entry) => {
877
+ const ownerPid = Number(entry.name.slice(DevStabilityHarness.fixturePrefix.length).split("-")[0]);
878
+ return Number.isFinite(ownerPid) && ownerPid > 0 && !DevStabilityHarness.#pidIsAlive(ownerPid);
879
+ });
880
+ if (!abandoned.length) return [];
881
+ const rows = await DevStabilityHarness.#psRowsOrEmpty();
882
+ for (const entry of abandoned) {
883
+ for (const row of rows.filter((candidate) => candidate.cmd.includes(entry.name)))
884
+ DevStabilityHarness.#signalPids(await DevStabilityHarness.descendantPids(row.pid), "SIGKILL");
885
+ await rm(path.join(appsDir, entry.name), { recursive: true, force: true });
886
+ }
887
+ return abandoned.map((entry) => entry.name);
888
+ }
889
+
890
+ static #pidIsAlive(pid: number): boolean {
891
+ try {
892
+ process.kill(pid, 0);
893
+ return true;
894
+ } catch (error) {
895
+ // ESRCH is the only code that means gone — EPERM is a live process owned by someone else.
896
+ return (error as { code?: string }).code !== "ESRCH";
897
+ }
455
898
  }
456
899
 
457
900
  /**
@@ -470,7 +913,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
470
913
  rootPid: number,
471
914
  { excludeBuilder = false }: { excludeBuilder?: boolean } = {},
472
915
  ): Promise<number> {
473
- const rows = await DevStabilityHarness.#psRows();
916
+ const rows = await DevStabilityHarness.#psRowsOrThrow();
474
917
  const pids = DevStabilityHarness.#collectDescendants(rows, rootPid);
475
918
  return (
476
919
  rows
@@ -496,7 +939,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
496
939
  }
497
940
 
498
941
  static async #findProcess(rootPid: number, cmdIncludes: string) {
499
- const rows = await DevStabilityHarness.#psRows();
942
+ const rows = await DevStabilityHarness.#psRowsOrThrow();
500
943
  const pids = DevStabilityHarness.#collectDescendants(rows, rootPid);
501
944
  const found = rows.find((row) => pids.has(row.pid) && row.cmd.includes(cmdIncludes));
502
945
  return found ? { pid: found.pid, rssBytes: found.rssKb * 1024 } : null;
@@ -505,12 +948,40 @@ export const dictionary = serviceDictionary(["en", "ko"])
505
948
  /** Pids of `rootPid` and everything under it, deepest first, so callers can signal children before parents. */
506
949
  static async descendantPids(rootPid: number | undefined): Promise<number[]> {
507
950
  if (!rootPid) return [];
508
- const pids = DevStabilityHarness.#collectDescendants(await DevStabilityHarness.#psRows(), rootPid);
951
+ const pids = DevStabilityHarness.#collectDescendants(await DevStabilityHarness.#psRowsOrEmpty(), rootPid);
509
952
  return [...pids].reverse();
510
953
  }
511
954
 
512
- static async #psRows(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }>> {
513
- const output = await Bun.$`ps -eo pid,ppid,rss,command`.text().catch(() => "");
955
+ /**
956
+ * `ps` output as rows, or `null` when `ps` did not answer in time.
957
+ *
958
+ * Bounded because the unbounded version hung indefinitely under parallel load, and it was the single
959
+ * root cause of every remaining cleanup failure: a `ps` that never returns became an `afterEach` timeout,
960
+ * and once a `beforeAll` timeout that cost an entire shard — 0 of 16 tests ran. The watchdog named it
961
+ * outright (`descendants still running after 10000ms`). It also spawns `ps` directly rather than through
962
+ * `Bun.$`, whose hung shells were what the runner then reported as `killed 1 dangling process`.
963
+ *
964
+ * `null` rather than `[]` on purpose: an empty list is a legitimate answer to "is the builder running",
965
+ * so collapsing the two would let a measurement read a timeout as "no such process" and quietly assert
966
+ * the opposite of what it meant to.
967
+ */
968
+ static readonly #psTimeoutMs = 5_000;
969
+
970
+ static async #psRows(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }> | null> {
971
+ const proc = Bun.spawn(["ps", "-eo", "pid,ppid,rss,command"], {
972
+ stdout: "pipe",
973
+ stderr: "ignore",
974
+ stdin: "ignore",
975
+ });
976
+ const output = await Promise.race([
977
+ new Response(proc.stdout).text().catch(() => ""),
978
+ wait(DevStabilityHarness.#psTimeoutMs).then(() => null),
979
+ ]);
980
+ if (output === null) {
981
+ proc.kill("SIGKILL");
982
+ console.warn(`[harness] ps did not answer within ${DevStabilityHarness.#psTimeoutMs}ms`);
983
+ return null;
984
+ }
514
985
  return output
515
986
  .split("\n")
516
987
  .slice(1)
@@ -522,6 +993,19 @@ export const dictionary = serviceDictionary(["en", "ko"])
522
993
  });
523
994
  }
524
995
 
996
+ /** For callers that kill: an incomplete list is survivable, because they also signal the tracked child
997
+ * directly, and hanging instead would cost the whole test. */
998
+ static async #psRowsOrEmpty(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }>> {
999
+ return (await DevStabilityHarness.#psRows()) ?? [];
1000
+ }
1001
+
1002
+ /** For callers that measure: a missing process list must fail loudly, never read as "nothing running". */
1003
+ static async #psRowsOrThrow(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }>> {
1004
+ const rows = (await DevStabilityHarness.#psRows()) ?? (await DevStabilityHarness.#psRows());
1005
+ if (!rows) throw new Error("ps did not answer twice in a row; cannot measure the process tree");
1006
+ return rows;
1007
+ }
1008
+
525
1009
  static #collectDescendants(rows: Array<{ pid: number; ppid: number }>, rootPid: number): Set<number> {
526
1010
  // Iterate to a fixpoint rather than a fixed depth: the tree is `bash -lc` -> `bun run` -> the `&&`
527
1011
  // shell -> dev host -> gateway -> replica -> rsc worker, and a pass only guarantees one new
@@ -555,6 +1039,7 @@ export async function waitForHmrMessageSince(
555
1039
  mark: number,
556
1040
  predicate: (message: unknown) => boolean,
557
1041
  timeoutMs = DEFAULT_TIMEOUT_MS,
1042
+ describeProbe?: () => string,
558
1043
  ): Promise<unknown> {
559
1044
  const started = Date.now();
560
1045
  while (Date.now() - started < timeoutMs) {
@@ -562,7 +1047,11 @@ export async function waitForHmrMessageSince(
562
1047
  if (found) return found;
563
1048
  await wait(50);
564
1049
  }
565
- throw new Error(`Timed out waiting for HMR message since mark ${mark}`);
1050
+ // "Timed out" alone cannot distinguish the two cases that matter: the message was never published, or
1051
+ // the socket died and nothing could have arrived. Both look identical from the message array.
1052
+ throw new Error(
1053
+ `Timed out waiting for HMR message since mark ${mark}${describeProbe ? ` (${describeProbe()})` : ""}`,
1054
+ );
566
1055
  }
567
1056
 
568
1057
  export async function waitForNoHmrMessageSince(