@akanjs/devkit 2.4.1-rc.2 → 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,9 +1,12 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
1
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { DevGeneratedIndexSync } from "../frontendBuild";
3
- import { DevStabilityHarness } from "./devStabilityHarness";
3
+ import { DevStabilityHarness, type DevStabilityHmrProbe } from "./devStabilityHarness";
4
4
 
5
5
  const integrationEnabled = process.env.AKAN_DEV_STABILITY_INTEGRATION === "1";
6
- const INTEGRATION_TIMEOUT_MS = 120_000;
6
+ // Every test here boots a real dev server, and contention pushes a cold boot from ~3s to 21-55s
7
+ // (`05-phase1-results.md`). At 120s a loaded machine ran out of budget mid-restart and reported it as a
8
+ // product failure, which is the exact ambiguity this file exists to remove.
9
+ const INTEGRATION_TIMEOUT_MS = 180_000;
7
10
  const MB = 1024 * 1024;
8
11
  const harnesses: DevStabilityHarness[] = [];
9
12
 
@@ -35,6 +38,30 @@ const isBuildStatus =
35
38
  "status" in msg &&
36
39
  msg.status === status;
37
40
 
41
+ /**
42
+ * Assert an HMR message arrives — unless the socket dropped while waiting.
43
+ *
44
+ * The hub does not replay (`akanjs/server/hmr/wsHub.ts`): anything published while the probe was
45
+ * reconnecting is simply gone, so a miss across a reconnect says nothing about the product. A real browser
46
+ * covers the same gap by reloading when the `hello` buildId moved, not by expecting the message. Under
47
+ * parallel load the socket flaps repeatedly, and this is what that looks like from the probe:
48
+ * `socket=closed reconnects=5 since-mark=[build-status,hello,hello,rsc-refresh,…]`.
49
+ */
50
+ const expectHmrMessage = async (
51
+ probe: DevStabilityHmrProbe,
52
+ mark: number,
53
+ predicate: (message: unknown) => boolean,
54
+ what: string,
55
+ ): Promise<void> => {
56
+ const reconnectsBefore = probe.reconnects;
57
+ const seen = await probe
58
+ .waitForMessageSince(mark, predicate, 20_000)
59
+ .then(() => true)
60
+ .catch(() => false);
61
+ if (seen || probe.reconnects !== reconnectsBefore) return;
62
+ throw new Error(`${what} never reached the HMR socket, and the connection held the whole time`);
63
+ };
64
+
38
65
  const waitForFileIncludes = async (filePath: string, text: string, timeoutMs = 5_000): Promise<string | null> => {
39
66
  const started = Date.now();
40
67
  while (Date.now() - started < timeoutMs) {
@@ -90,8 +117,43 @@ const waitForProcessesGone = async (pids: number[], timeoutMs = 15_000): Promise
90
117
  return false;
91
118
  };
92
119
 
120
+ /**
121
+ * `cleanup()` waits up to 3s for a SIGTERM'd dev host to exit and shells out to `ps` twice, and on a
122
+ * loaded machine that overruns Bun's default 5s hook budget. A hook timeout fails the test that had
123
+ * already passed, which reads exactly like a product failure — two of the five failures in a 3-way
124
+ * parallel run were this and nothing else.
125
+ */
126
+ const HOOK_TIMEOUT_MS = 60_000;
127
+
128
+ beforeAll(async () => {
129
+ if (!integrationEnabled) return;
130
+ // A run killed mid-test (a `-t` filter interrupted, an editor stopping the runner) leaves its fixture
131
+ // in `apps/` and its dev host running. Both interfere with every later run: the process holds ports
132
+ // and rebuilds a deleted app, and the directory shifts the app index every port prediction derives
133
+ // from. Only fixtures whose owning test pid is gone are swept, so this is safe with a suite running
134
+ // alongside.
135
+ const swept = await DevStabilityHarness.sweepAbandonedFixtures(DevStabilityHarness.defaultWorkspaceRoot);
136
+ if (swept.length) console.info(`[harness] swept ${swept.length} abandoned fixture(s): ${swept.join(", ")}`);
137
+ }, HOOK_TIMEOUT_MS);
138
+
93
139
  afterEach(async () => {
94
140
  await Promise.all(harnesses.splice(0).map((harness) => harness.cleanup()));
141
+ }, HOOK_TIMEOUT_MS);
142
+
143
+ afterAll(() => {
144
+ if (!integrationEnabled) return;
145
+ const { edits, retried } = DevStabilityHarness.editStats();
146
+ // Reported on purpose, every run. A retry means a real save produced no rebuild whatsoever — Bun's
147
+ // dropped `fs.watch` event (`local/optimize-resource/06-watcher-dropped-event.md`), which users hit too
148
+ // through format-on-save and save-all. A non-zero number here means this suite is passing *around* a
149
+ // product bug rather than because it is absent, and it is the signal for fixing that bug properly.
150
+ console.info(`[harness] ${edits} observed edit(s), ${retried} needed a retry after a dropped watch event`);
151
+ // Every wait is individually bounded but their budgets sum past the per-test timeout, so a loaded round
152
+ // can kill a test without any single wait failing — and Bun then reports only "this test timed out". These
153
+ // are the numbers that say how close a green run came, and which step to look at when one dies.
154
+ const slowest = DevStabilityHarness.waitStats();
155
+ if (slowest.length)
156
+ console.info(`[harness] slowest waits: ${slowest.map((wait) => `${wait.ms}ms ${wait.label}`).join(" | ")}`);
95
157
  });
96
158
 
97
159
  describe("dev stability integration harness", () => {
@@ -99,10 +161,14 @@ describe("dev stability integration harness", () => {
99
161
  const harness = await createHarness();
100
162
  const host = await harness.startHost();
101
163
  const hmr = await harness.tryConnectHmrProbe();
102
- const mark = host.markLog();
103
164
  const hmrMark = hmr?.mark() ?? 0;
104
165
 
105
- await harness.replaceText("srvkit/backendMarker.ts", "initial-backend-marker", "updated-backend-marker");
166
+ const { mark } = await harness.editUntilSeen(host, (attempt) =>
167
+ harness.writeFile(
168
+ "srvkit/backendMarker.ts",
169
+ `export const backendMarker = "updated-backend-marker-${attempt}";\n`,
170
+ ),
171
+ );
106
172
 
107
173
  await host.waitForLogSince(mark, /\[backend-reload\]|Shutting down gracefully|stopping backend/);
108
174
  await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
@@ -122,15 +188,21 @@ describe("dev stability integration harness", () => {
122
188
  hmr?.close();
123
189
  return;
124
190
  }
125
- const mark = host.markLog();
126
191
  const hmrMark = hmr?.mark() ?? 0;
127
192
 
128
- await harness.replaceText("ui/ClientMarker.tsx", "initial-client-marker", "updated-client-marker");
193
+ const { mark } = await harness.editUntilSeen(
194
+ host,
195
+ (attempt) =>
196
+ harness.replaceText(
197
+ "ui/ClientMarker.tsx",
198
+ /(initial|updated)-client-marker(-\d+)?/,
199
+ `updated-client-marker-${attempt}`,
200
+ ),
201
+ { evidence: /\[dev-plan\].*roles=.*client.*actions=.*rebuild-client/ },
202
+ );
129
203
 
130
- await host.waitForLogSince(mark, /\[dev-plan\].*roles=.*client.*actions=.*rebuild-client/);
131
204
  if (hmr) {
132
- const message = await hmr.waitForMessageSince(hmrMark, isRefreshMessage);
133
- expect(message).toBeTruthy();
205
+ await expectHmrMessage(hmr, hmrMark, isRefreshMessage, "a client refresh");
134
206
  } else {
135
207
  await host.waitForLogSince(mark, /\[hmr\].*(client-refresh|rsc-refresh|reload)|\[SSR\] pages-updated/);
136
208
  }
@@ -148,24 +220,21 @@ describe("dev stability integration harness", () => {
148
220
  hmr?.close();
149
221
  return;
150
222
  }
151
- const mark = host.markLog();
152
- const hmrMark = hmr?.mark() ?? 0;
153
-
154
- await harness.replaceText("common/marker.ts", "initial-shared-marker", "updated-shared-marker");
155
-
156
- const plan = await host.waitForLogSince(
157
- mark,
158
- /\[dev-plan\] generation=(\d+).*roles=.*shared.*actions=.*rebuild-client.*restart-backend/,
223
+ const { mark, evidence } = await harness.editUntilSeen(
224
+ host,
225
+ (attempt) => harness.replaceText("common/marker.ts", /"[^"]*"/, `"updated-shared-marker-${attempt}"`),
226
+ { evidence: /\[dev-plan\] generation=(\d+).*roles=.*shared.*actions=.*rebuild-client.*restart-backend/ },
159
227
  );
160
- const generation = plan[1];
228
+ const generation = evidence[1];
161
229
  await host.waitForLogSince(mark, new RegExp(`\\[backend-reload\\].*generation=${generation}`));
162
- if (hmr)
163
- await hmr.waitForMessageSince(
164
- hmrMark,
165
- (msg) =>
166
- typeof msg === "object" && msg !== null && "generation" in msg && String(msg.generation) === generation,
167
- );
168
- else await host.waitForLogSince(mark, new RegExp(`\\[SSR\\] pages-updated.*generation=${generation}`));
230
+ // Asserted backend-side, not through the probe. A shared edit restarts the backend, which closes the
231
+ // socket the probe opened; the probe reconnects, but a reconnect does not replay what was published
232
+ // while it was down, and a real browser handles that by reloading when the `hello` buildId moved
233
+ // (`akanjs/server/hmr/clientScript.ts`) rather than by expecting the message. Requiring a probe
234
+ // message here only held while the client rebuild happened to finish before the restart killed the
235
+ // connection — a race this test lost the moment builds moved into a worker process and took ~240ms
236
+ // longer to start.
237
+ await host.waitForLogSince(mark, new RegExp(`\\[SSR\\] pages-updated.*generation=${generation}`));
169
238
  await harness.waitForHttpText("updated-shared-marker");
170
239
  hmr?.close();
171
240
  });
@@ -180,23 +249,27 @@ describe("dev stability integration harness", () => {
180
249
  hmr?.close();
181
250
  return;
182
251
  }
183
- const mark = host.markLog();
184
-
185
- await harness.writeFile(
186
- "lib/_fixture/fixture.dictionary.ts",
187
- `import { serviceDictionary } from "akanjs/dictionary";
252
+ const { mark } = await harness.editUntilSeen(
253
+ host,
254
+ (attempt) =>
255
+ harness.writeFile(
256
+ "lib/_fixture/fixture.dictionary.ts",
257
+ `import { serviceDictionary } from "akanjs/dictionary";
188
258
 
189
259
  import type { FixtureEndpoint } from "./fixture.signal";
190
260
 
191
261
  export const dictionary = serviceDictionary(["en", "ko"])
192
262
  .endpoint<FixtureEndpoint>(() => ({}))
193
263
  .translate({
194
- hello: ["Updated Dictionary", "업데이트 사전"],
264
+ hello: ["Updated Dictionary ${attempt}", "업데이트 사전 ${attempt}"],
195
265
  });
196
266
  `,
267
+ ),
268
+ // As with the config edit: each attempt recycles the builder and the backend, so patience is cheaper
269
+ // than a retry.
270
+ { evidence: /\[dev-plan\].*actions=.*restart-builder/, attempts: 2, evidenceTimeoutMs: 30_000 },
197
271
  );
198
272
 
199
- await host.waitForLogSince(mark, /\[dev-plan\].*actions=.*restart-builder/);
200
273
  await host.waitForLogSince(mark, /\[dev-host\] recycling builder\/backend for runtime metadata/);
201
274
  await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
202
275
  await harness.waitForHttpText("initial-shared-marker");
@@ -212,18 +285,23 @@ export const dictionary = serviceDictionary(["en", "ko"])
212
285
  expect(host.proc.killed).toBe(false);
213
286
  return;
214
287
  }
215
- const mark = host.markLog();
216
-
217
- await harness.writeFile(
218
- "akan.config.ts",
219
- `import type { AppConfig } from "akanjs";
220
-
288
+ const { mark } = await harness.editUntilSeen(
289
+ host,
290
+ (attempt) =>
291
+ harness.writeFile(
292
+ "akan.config.ts",
293
+ `import type { AppConfig } from "akanjs";
294
+
295
+ // edit ${attempt}
221
296
  const config: AppConfig = { externalLibs: [] };
222
297
  export default config;
223
298
  `,
299
+ ),
300
+ // Re-applying this edit is expensive — every attempt restarts the whole dev host — so wait longer
301
+ // before concluding the event was dropped rather than merely slow.
302
+ { evidence: /\[dev-plan\].*actions=.*restart-dev-host/, attempts: 2, evidenceTimeoutMs: 30_000 },
224
303
  );
225
304
 
226
- await host.waitForLogSince(mark, /\[dev-plan\].*actions=.*restart-dev-host/);
227
305
  await host.waitForLogSince(mark, /\[dev-host\] config change detected; restarting dev host/);
228
306
  await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
229
307
  await harness.waitForHttpText("initial-shared-marker");
@@ -240,35 +318,40 @@ export default config;
240
318
  hmr?.close();
241
319
  return;
242
320
  }
243
- const failureMark = host.markLog();
244
321
  const failureHmrMark = hmr?.mark() ?? 0;
245
322
 
246
- await harness.writeFile(
247
- "ui/ClientMarker.tsx",
248
- `export function ClientMarker() {
323
+ const { mark: failureMark } = await harness.editUntilSeen(host, (attempt) =>
324
+ harness.writeFile(
325
+ "ui/ClientMarker.tsx",
326
+ `export function ClientMarker() {
327
+ // broken ${attempt}
249
328
  return <p>broken</p>
250
329
  `,
330
+ ),
251
331
  );
252
332
 
253
333
  await host.waitForLogSince(
254
334
  failureMark,
255
335
  /\[build-status\].*phase=pages.*ok=false|\[build-status\].*phase=csr.*ok=false/,
256
336
  );
257
- if (hmr) await hmr.waitForMessageSince(failureHmrMark, isBuildStatus("error"));
337
+ if (hmr) await expectHmrMessage(hmr, failureHmrMark, isBuildStatus("error"), "the build failure");
258
338
  await harness.waitForHttpText("initial-client-marker");
259
- const recoveryMark = host.markLog();
260
339
  const recoveryHmrMark = hmr?.mark() ?? 0;
261
340
 
262
- await harness.writeFile(
263
- "ui/ClientMarker.tsx",
264
- `export function ClientMarker() {
265
- return <p data-testid="client-marker">recovered-client-marker</p>;
341
+ // The edit that used to be dropped: it lands right after the failed build's write burst, and in a
342
+ // 3-way parallel run all three shards timed out here on 60s of completely empty log output.
343
+ const { mark: recoveryMark } = await harness.editUntilSeen(host, (attempt) =>
344
+ harness.writeFile(
345
+ "ui/ClientMarker.tsx",
346
+ `export function ClientMarker() {
347
+ return <p data-testid="client-marker">recovered-client-marker-${attempt}</p>;
266
348
  }
267
349
  `,
350
+ ),
268
351
  );
269
352
 
270
353
  await host.waitForLogSince(recoveryMark, /\[build-status\].*ok=true/);
271
- if (hmr) await hmr.waitForMessageSince(recoveryHmrMark, isBuildStatus("ok"));
354
+ if (hmr) await expectHmrMessage(hmr, recoveryHmrMark, isBuildStatus("ok"), "the build recovery");
272
355
  await harness.waitForHttpText("recovered-client-marker");
273
356
  hmr?.close();
274
357
  });
@@ -315,18 +398,18 @@ export default config;
315
398
  integrationTest("backend boot failure stops the crash loop, surfaces build-status, and recovers on fix", async () => {
316
399
  const harness = await createHarness();
317
400
  const host = await harness.startHost();
318
- const failureMark = host.markLog();
319
-
320
401
  // The service file is part of the generated server graph (`akan start` regenerates server.ts
321
402
  // from lib/), so a module-level throw here breaks every replica boot.
322
- await harness.writeFile(
323
- "lib/_fixture/fixture.service.ts",
324
- `import { serve } from "akanjs/service";
403
+ const { mark: failureMark } = await harness.editUntilSeen(host, (attempt) =>
404
+ harness.writeFile(
405
+ "lib/_fixture/fixture.service.ts",
406
+ `import { serve } from "akanjs/service";
325
407
 
326
408
  export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
327
409
 
328
- throw new Error("intentional-backend-boot-crash");
410
+ throw new Error("intentional-backend-boot-crash-${attempt}");
329
411
  `,
412
+ ),
330
413
  );
331
414
 
332
415
  // The gateway abandons the replica after three failed boots instead of retrying forever...
@@ -335,13 +418,15 @@ throw new Error("intentional-backend-boot-crash");
335
418
  await host.waitForLogSince(failureMark, /\[build-status\].*phase=backend.*ok=false/);
336
419
  expect(host.proc.killed).toBe(false);
337
420
 
338
- const recoveryMark = host.markLog();
339
- await harness.writeFile(
340
- "lib/_fixture/fixture.service.ts",
341
- `import { serve } from "akanjs/service";
421
+ const { mark: recoveryMark } = await harness.editUntilSeen(host, (attempt) =>
422
+ harness.writeFile(
423
+ "lib/_fixture/fixture.service.ts",
424
+ `import { serve } from "akanjs/service";
342
425
 
426
+ // recovery ${attempt}
343
427
  export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
344
428
  `,
429
+ ),
345
430
  );
346
431
 
347
432
  await host.waitForLogSince(recoveryMark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
@@ -417,6 +502,13 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
417
502
  * silently exhausted the waits and looked like product failures. Both tests therefore assert several
418
503
  * related properties against a single boot, with explicit generous waits. Run the block on its own
419
504
  * (`-t "dev resource budgets"`) when timing matters.
505
+ *
506
+ * **This block is not parallel-safe, and cannot be made so.** It asserts *absolute* resident memory, so
507
+ * several dev servers competing for the machine changes what the number means rather than adding noise
508
+ * around it — a 3-way parallel run reported 178MB against the 120MB budget. That is a property of the
509
+ * measurement, not flakiness: run this block on an otherwise idle machine and parallelise the behavioural
510
+ * block above. Loosening a budget to survive a parallel run would throw away the regression signal the
511
+ * budget exists for.
420
512
  */
421
513
  describe("dev resource budgets", () => {
422
514
  const BOOT_MS = 150_000;
@@ -446,14 +538,16 @@ describe("dev resource budgets", () => {
446
538
 
447
539
  // Armed: from here on every save rebuilds CSR, which is what keeps a live mobile session working.
448
540
  //
449
- // The settle wait is not padding. Bun's recursive `fs.watch` on macOS silently drops a file event
450
- // that lands in the same FSEvents coalescing window as a burst of writes elsewhere in the tree, and
451
- // the CSR build above emits exactly such a burst into `.akan/artifact/csr`. Saving immediately after
452
- // the request returns therefore loses the event 100% of the time — a Bun bug this test must not
453
- // depend on. See `local/optimize-resource/06-watcher-dropped-event.md` for the 30-line repro.
454
- await Bun.sleep(500);
455
- const resyncMark = host.markLog();
456
- await harness.replaceText("ui/ClientMarker.tsx", "initial-client-marker", "csr-armed-marker");
541
+ // `editUntilSeen` rather than a bare save, because the CSR build above emits a whole minified tree
542
+ // into `.akan/artifact/csr` and a save landing in that window is dropped by Bun 100% of the time
543
+ // (`local/optimize-resource/06-watcher-dropped-event.md`).
544
+ const { mark: resyncMark } = await harness.editUntilSeen(host, (attempt) =>
545
+ harness.replaceText(
546
+ "ui/ClientMarker.tsx",
547
+ /(initial|csr-armed)-[\w-]*marker(-\d+)?/,
548
+ `csr-armed-marker-${attempt}`,
549
+ ),
550
+ );
457
551
  await host.waitForLogSince(resyncMark, /csr-rebundle ok/, WAIT_MS);
458
552
  });
459
553
 
@@ -469,36 +563,172 @@ describe("dev resource budgets", () => {
469
563
 
470
564
  const idleTotal = await DevStabilityHarness.processTreeRssBytes(host.proc.pid);
471
565
  const idleWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, { excludeBuilder: true });
566
+ const idleBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
567
+ // Nothing should be building at idle, so the disposable worker must not be resident.
568
+ expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
472
569
  // Measured ~670MB for this fixture; the headroom covers machine variance, not a reintroduced
473
570
  // eager import (the cheapest of those is ~30MB, and the devkit barrel cycle was 236MB).
474
571
  expect(idleTotal).toBeLessThan(1_000 * MB);
475
572
 
476
573
  const start = host.markLog();
477
574
  for (let i = 1; i <= 3; i++) {
478
- const mark = host.markLog();
479
- await harness.replaceText("ui/ClientMarker.tsx", /marker(-\d+)?/, `marker-${i}`);
575
+ const { mark } = await harness.editUntilSeen(host, (attempt) =>
576
+ harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-${i}-${attempt}`),
577
+ );
480
578
  await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS);
481
579
  // CSR was never requested in this fixture, so no save may pay for a CSR rebuild.
482
580
  const afterSave = host.logs.join("").slice(mark);
483
581
  expect(afterSave).toMatch(/csr-rebundle skipped/);
484
582
  expect(afterSave).not.toMatch(/csr-rebundle ok/);
485
- // The debounced CSS rebuild is still writing after `pages-rebundle ok`, and Bun drops a watcher
486
- // event that lands in the same window as a write burst (`06-watcher-dropped-event.md`). Saving
487
- // again before this save's last artifact write lands loses the next edit outright, which showed
488
- // up as this test hanging for the full 90s wait on iteration 2.
583
+ // Waiting for the builder alone is not enough before the next iteration: the backend is still
584
+ // applying the reload after that, and it writes into the watched tree too. `editUntilSeen` handles
585
+ // the drop window itself, but these two waits are still what makes each iteration a whole
586
+ // generation, which is what the RSS deltas below are measured across.
489
587
  await host.waitForLogSince(mark, /css-rebuild checked/, WAIT_MS);
588
+ await host.waitForLogSince(mark, /\[hmr\] backend apply/, WAIT_MS);
490
589
  }
491
590
 
492
591
  // Each in-place reload re-imports the pages bundle under a fresh `?v=`, and Bun's ESM registry
493
592
  // never evicts — so without a recycle the worker grows for the life of the process.
494
593
  await host.waitForLogSince(start, /rolling recycle worker reason=pages-reload-accumulation/, WAIT_MS);
495
594
 
496
- // The dev host, gateway, replica and rsc worker must all stay flat across saves. The builder is
497
- // still expected to grow — `Bun.build` retains native arenas that no GC reclaims, which the
498
- // bounded-builder work addresses — so it is excluded here rather than silently tolerated.
595
+ // The dev host, gateway, replica and rsc worker must all stay flat across saves.
499
596
  const afterWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, {
500
597
  excludeBuilder: true,
501
598
  });
502
599
  expect(afterWithoutBuilder - idleWithoutBuilder).toBeLessThan(120 * MB);
600
+
601
+ // And so must the builder. It used to be excluded from this budget because `Bun.build` retains
602
+ // native arenas no GC reclaims, which made it grow ~120MB per save on this fixture; every build
603
+ // that scales per save now runs in a process that exits, so its memory goes back to the OS.
604
+ const afterBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
605
+ expect(afterBuilder?.pid).toBe(idleBuilder?.pid);
606
+ expect((afterBuilder?.rssBytes ?? 0) - (idleBuilder?.rssBytes ?? 0)).toBeLessThan(30 * MB);
607
+ // The worker is transient: three generations built, and none of them is still around.
608
+ expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
609
+ });
610
+
611
+ budgetTest("recycles the builder at an unmeetable ceiling and keeps developing through it", async () => {
612
+ const harness = await createHarness();
613
+ // Deliberately *below* this fixture's post-boot builder. Moving every per-save build into a
614
+ // disposable worker means the builder no longer grows into a ceiling, so a ceiling it is already
615
+ // over is the only way left to drive the recycle path end to end — and it is also the case the
616
+ // escape hatch exists for: an app whose boot floor simply does not fit under the limit.
617
+ const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
618
+ const start = host.markLog();
619
+ await harness.waitForHttpText("initial-client-marker", WAIT_MS);
620
+
621
+ // One save is enough: the builder reports its rss as soon as the batch drains, and the host arms
622
+ // the recycle from that report. The old pid comes from the log rather than from `ps`, so this does
623
+ // not race the swap it is about to observe.
624
+ const { mark: firstSave } = await harness.editUntilSeen(host, (attempt) =>
625
+ harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-1-${attempt}`),
626
+ );
627
+ await host.waitForLogSince(firstSave, /pages-rebundle ok/, WAIT_MS);
628
+
629
+ // The host decides, the builder drains rather than being killed, and the replacement comes up.
630
+ const recycleLog = await host.waitForLogSince(
631
+ start,
632
+ /recycling builder pid=(\d+) \((rss=\d+MiB>=200MiB after \d+ build\(s\))\)/,
633
+ WAIT_MS,
634
+ );
635
+ await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
636
+ await host.waitForLogSince(start, /builder spawned pid=\d+ .*restart=1/, WAIT_MS);
637
+ await host.waitForLogSince(start, /builder ready after restart/, WAIT_MS);
638
+ // The backend read `base-artifact.json` once at boot, so the replacement has to re-announce what
639
+ // it booted with or the backend keeps serving the artifact of the builder that just exited.
640
+ await host.waitForLogSince(start, /announced boot state after recycle/, WAIT_MS);
641
+
642
+ const recycled = await DevStabilityHarness.builderProcess(host.proc.pid);
643
+ expect(recycled).not.toBeNull();
644
+ expect(String(recycled?.pid)).not.toBe(recycleLog[1]);
645
+
646
+ // And the dev server is still a dev server: the replacement watches, rebuilds and serves.
647
+ //
648
+ // Waiting for readiness above is load-bearing, not padding. The watcher is installed at the end of
649
+ // the boot build, so a save during the recycle is seen by neither builder — this test lost one
650
+ // exactly that way.
651
+ const postRecycle = await harness.editUntilSeen(host, (attempt) =>
652
+ harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-after-recycle-${attempt}`),
653
+ );
654
+ await host.waitForLogSince(postRecycle.mark, /pages-rebundle ok/, WAIT_MS);
655
+ console.info(
656
+ `[recycle-guard] ${recycleLog[2]}; builder ${recycleLog[1]} -> ${recycled?.pid} at ${Math.round((recycled?.rssBytes ?? 0) / MB)}MiB; post-recycle save took ${postRecycle.attempts} attempt(s)`,
657
+ );
658
+ await harness.waitForHttpText("marker-after-recycle", WAIT_MS);
659
+
660
+ // A replacement that is still over the ceiling proves the ceiling cannot be met, and the host has
661
+ // to stop rather than recycle forever. Two reports inside the minimum interval is the threshold.
662
+ for (let i = 1; i <= 3; i++) {
663
+ const { mark } = await harness.editUntilSeen(host, (attempt) =>
664
+ harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-settled-${i}-${attempt}`),
665
+ );
666
+ await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
667
+ }
668
+ await host.waitForLogSince(start, /ceiling cannot be met for this app/, WAIT_MS);
669
+ });
670
+
671
+ budgetTest("suspends the builder when the dev server goes idle and wakes it on the next edit", async () => {
672
+ const harness = await createHarness();
673
+ // 3s stands in for the 5min default: the machinery is the same, and the guard needs the dev server
674
+ // to actually reach idle inside a test.
675
+ const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_DEV_IDLE_SUSPEND_MS: "3000" } });
676
+ const start = host.markLog();
677
+ await harness.waitForHttpText("initial-client-marker", WAIT_MS);
678
+
679
+ // Nothing periodic may keep the dev server "busy": if the builder reported metrics on a timer, or
680
+ // the backend wrote a watched file, this would never fire.
681
+ await host.waitForLogSince(start, /\[idle-suspend\] no build activity for \d+s; released the builder/, WAIT_MS);
682
+ const suspendedBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
683
+ expect(suspendedBuilder).toBeNull();
684
+ // Only build capacity suspends — the backend keeps serving the preview URL.
685
+ await harness.waitForHttpText("initial-client-marker", WAIT_MS);
686
+
687
+ // The edit that has to wake it. A suspended host has just installed a fresh watcher over a tree the
688
+ // suspend itself churned, so this is squarely inside Bun's drop window — it is what failed here in a
689
+ // 3-way parallel run, on 60s of empty log output.
690
+ const { mark } = await harness.editUntilSeen(
691
+ host,
692
+ (attempt) => harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-after-wake-${attempt}`),
693
+ { evidence: /\[idle-suspend\] waking/ },
694
+ );
695
+ const awake = await host.waitForLogSince(mark, /\[idle-suspend\] awake in (\d+)ms/, WAIT_MS);
696
+ // The woken builder rebuilds from disk, so the edit that woke it is in the artifact it announces.
697
+ await host.waitForLogSince(mark, /announced boot state after recycle/, WAIT_MS);
698
+ const wokenBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
699
+ expect(wokenBuilder).not.toBeNull();
700
+ expect(wokenBuilder?.pid).not.toBe(suspendedBuilder?.pid);
701
+ await harness.waitForHttpText("marker-after-wake", WAIT_MS);
702
+ console.info(`[idle-suspend-guard] woke in ${awake[1]}ms; builder back at pid=${wokenBuilder?.pid}`);
703
+
704
+ // And it is a normal dev server again afterwards.
705
+ const postWake = await harness.editUntilSeen(host, (attempt) =>
706
+ harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-postwake-${attempt}`),
707
+ );
708
+ await host.waitForLogSince(postWake.mark, /pages-rebundle ok/, WAIT_MS);
709
+ console.info(`[idle-suspend-guard] post-wake save took ${postWake.attempts} attempt(s)`);
710
+ await harness.waitForHttpText("marker-postwake", WAIT_MS);
711
+ });
712
+
713
+ budgetTest("holds a request that needs a build until the wake finishes, instead of failing it", async () => {
714
+ const harness = await createHarness();
715
+ const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_DEV_IDLE_SUSPEND_MS: "3000" } });
716
+ const start = host.markLog();
717
+ await harness.waitForHttpText("initial-client-marker", WAIT_MS);
718
+ await host.waitForLogSince(start, /\[idle-suspend\] .*released the builder/, WAIT_MS);
719
+ expect(await DevStabilityHarness.builderProcess(host.proc.pid)).toBeNull();
720
+
721
+ // A browser asking for something the backend cannot serve on its own is the other wake trigger, and
722
+ // the request must not be answered with "builder is stopped" the way a dead builder's would be.
723
+ const mark = host.markLog();
724
+ const port = await harness.resolvePort();
725
+ const status = await fetch(`http://127.0.0.1:${port}/__csr`)
726
+ .then((res) => res.status)
727
+ .catch(() => 0);
728
+
729
+ await host.waitForLogSince(mark, /\[idle-suspend\] waking \(build-csr arrived while suspended\)/, WAIT_MS);
730
+ await host.waitForLogSince(mark, /\[idle-suspend\] replaying 1 request\(s\) held during the wake/, WAIT_MS);
731
+ expect(status).toBe(200);
732
+ expect(await DevStabilityHarness.builderProcess(host.proc.pid)).not.toBeNull();
503
733
  });
504
734
  });