@akanjs/devkit 2.4.1-rc.3 → 2.4.1-rc.5
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/akanApp/akanApp.host.test.ts +199 -2
- package/akanApp/akanApp.host.ts +399 -9
- package/executors.test.ts +60 -0
- package/executors.ts +11 -0
- package/frontendBuild/fontOptimizer.test.ts +111 -0
- package/frontendBuild/fontOptimizer.ts +102 -17
- package/frontendBuild/hmrWatcher.test.ts +191 -0
- package/frontendBuild/hmrWatcher.ts +176 -5
- package/frontendBuild/index.ts +1 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +280 -0
- package/frontendBuild/sourceMtimeIndex.ts +326 -0
- package/incrementalBuilder/buildBatch.proc.ts +34 -1
- package/incrementalBuilder/buildBatchProtocol.ts +13 -2
- package/incrementalBuilder/builderChannel.test.ts +144 -0
- package/incrementalBuilder/builderChannel.ts +72 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +88 -3
- package/incrementalBuilder/incrementalBuilder.host.ts +50 -3
- package/incrementalBuilder/incrementalBuilder.proc.ts +93 -34
- package/integration/devStability.integration.test.ts +260 -101
- package/integration/devStabilityHarness.test.ts +111 -0
- package/integration/devStabilityHarness.ts +528 -39
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
42
|
-
appName =
|
|
43
|
-
portOffset
|
|
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
|
|
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
|
-
|
|
252
|
-
|
|
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(
|
|
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) =>
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
DevStabilityHarness.#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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) =>
|
|
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: () =>
|
|
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
|
-
|
|
441
|
-
|
|
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
|
|
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.#
|
|
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.#
|
|
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.#
|
|
951
|
+
const pids = DevStabilityHarness.#collectDescendants(await DevStabilityHarness.#psRowsOrEmpty(), rootPid);
|
|
509
952
|
return [...pids].reverse();
|
|
510
953
|
}
|
|
511
954
|
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
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(
|