@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.
@@ -19,7 +19,6 @@ import { Logger } from "akanjs/common";
19
19
  import type {
20
20
  BaseBuildArtifact,
21
21
  BuilderCsrReq,
22
- BuilderCsrRes,
23
22
  BuilderMessage,
24
23
  BuilderReq,
25
24
  BuilderRes,
@@ -28,6 +27,7 @@ import type {
28
27
  } from "akanjs/server";
29
28
  import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
30
29
  import { BuildBatchRunner } from "./buildBatchRunner";
30
+ import { BuilderReply } from "./builderReply";
31
31
  import { prepareDevWatchBatch } from "./devWatchBatch";
32
32
 
33
33
  interface IncrementalBuilderOptions {
@@ -53,6 +53,7 @@ class IncrementalBuilder {
53
53
  #changePlanner: DevChangePlanner;
54
54
  #generatedIndexSync: DevGeneratedIndexSync;
55
55
  #autoImportSync: AutoImportSync;
56
+ #watcher: HmrWatcher | null = null;
56
57
  #generation = 0;
57
58
  #csrActive = IncrementalBuilder.#csrArmedByEnv();
58
59
  #workQueue: Promise<void> = Promise.resolve();
@@ -82,8 +83,14 @@ class IncrementalBuilder {
82
83
  return `${this.#app.cwdPath}/.akan/artifact`;
83
84
  }
84
85
 
85
- async handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
86
- return this.#enqueueWork(`build-route:${msg.routeId}`, async () => this.#handleBuildRoute(msg));
86
+ /**
87
+ * Build a route and answer it. The reply is part of the work item on purpose: `shutdown` drains the
88
+ * work queue before exiting, so folding the flush in here is what makes "drained" mean "answered".
89
+ */
90
+ async handleBuildRoute(msg: BuilderReq): Promise<void> {
91
+ await this.#enqueueWork(`build-route:${msg.routeId}`, async () =>
92
+ BuilderReply.send(await this.#handleBuildRoute(msg)),
93
+ );
87
94
  }
88
95
 
89
96
  async #handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
@@ -260,7 +267,8 @@ class IncrementalBuilder {
260
267
  await this.#enqueueWork("hmr-batch", async () => this.#handleWatchBatch(appDir, artifactDir, batch));
261
268
  },
262
269
  });
263
- watcher.start();
270
+ await watcher.start();
271
+ this.#watcher = watcher;
264
272
  this.#logger.verbose(`watching ${roots.length} roots`);
265
273
  }
266
274
 
@@ -276,6 +284,10 @@ class IncrementalBuilder {
276
284
  if (autoImport.changedFiles.length > 0)
277
285
  this.#logger.verbose(`[auto-import] inserted imports into ${autoImport.changedFiles.length} file(s)`);
278
286
  const indexSync = await this.#generatedIndexSync.syncForBatch(batch.files);
287
+ //* Both passes above write source files, and this generation's build consumes what they wrote. Hand
288
+ //* them to the watcher so its verification scan does not read them back as a user edit and spend a
289
+ //* second generation rebuilding identical content.
290
+ await this.#watcher?.absorb([...autoImport.changedFiles, ...indexSync.changedFiles]);
279
291
  const { files, kinds, expandedBatch, event, hasSyncErrors } = prepareDevWatchBatch({
280
292
  generation,
281
293
  batch,
@@ -460,8 +472,8 @@ class IncrementalBuilder {
460
472
  * dev server only serves CSR through the opt-in `/__csr` and `?csr=true` routes — mobile local dev
461
473
  * points a device WebView at the latter — so nothing needs the artifact until one of them is hit.
462
474
  */
463
- async handleBuildCsr(msg: BuilderCsrReq): Promise<BuilderCsrRes> {
464
- return this.#enqueueWork("build-csr", async (): Promise<BuilderCsrRes> => {
475
+ async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
476
+ await this.#enqueueWork("build-csr", async (): Promise<void> => {
465
477
  const started = Date.now();
466
478
  // Messages are not relayed: an on-demand CSR build is a request/response, and the phase board
467
479
  // never carried a csr status for it before. The error travels in the response below.
@@ -471,11 +483,12 @@ class IncrementalBuilder {
471
483
  const error = result.errors.csr;
472
484
  if (error) {
473
485
  this.#logger.error(`csr-build failed: ${error}`);
474
- return { type: "build-csr-res", id: msg.id, ok: false, error };
486
+ await BuilderReply.send({ type: "build-csr-res", id: msg.id, ok: false, error });
487
+ return;
475
488
  }
476
489
  this.#csrActive = true;
477
490
  this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
478
- return { type: "build-csr-res", id: msg.id, ok: true };
491
+ await BuilderReply.send({ type: "build-csr-res", id: msg.id, ok: true });
479
492
  });
480
493
  }
481
494
 
@@ -558,7 +571,7 @@ class IncrementalBuilder {
558
571
  }
559
572
  },
560
573
  });
561
- watcher.start();
574
+ await watcher.start();
562
575
  logger.warn(`[degraded] watching ${roots.length} roots for a fix`);
563
576
  })().catch(reject);
564
577
  });
@@ -592,7 +605,7 @@ class IncrementalBuilder {
592
605
  process.send?.({ type: "build-route-res", id: msg.id, ok: false, error });
593
606
  return;
594
607
  }
595
- void builder.handleBuildRoute(msg).then((res) => process.send?.(res));
608
+ void builder.handleBuildRoute(msg);
596
609
  return;
597
610
  }
598
611
  if (msg.type === "build-csr") {
@@ -601,7 +614,7 @@ class IncrementalBuilder {
601
614
  process.send?.({ type: "build-csr-res", id: msg.id, ok: false, error });
602
615
  return;
603
616
  }
604
- void builder.handleBuildCsr(msg).then((res) => process.send?.(res));
617
+ void builder.handleBuildCsr(msg);
605
618
  }
606
619
  });
607
620
  // The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
@@ -621,7 +634,7 @@ class IncrementalBuilder {
621
634
  }
622
635
  await builder.boot();
623
636
  if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
624
- else if (process.env.AKAN_BUILDER_RECYCLED === "1") await builder.announceBootState();
637
+ else if (process.env.AKAN_BUILDER_ANNOUNCE_BOOT === "1") await builder.announceBootState();
625
638
  await builder.rearmCsrFromEnv();
626
639
  }
627
640
  }
@@ -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,22 +220,20 @@ describe("dev stability integration harness", () => {
148
220
  hmr?.close();
149
221
  return;
150
222
  }
151
- const mark = host.markLog();
152
-
153
- await harness.replaceText("common/marker.ts", "initial-shared-marker", "updated-shared-marker");
154
-
155
- const plan = await host.waitForLogSince(
156
- mark,
157
- /\[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/ },
158
227
  );
159
- const generation = plan[1];
228
+ const generation = evidence[1];
160
229
  await host.waitForLogSince(mark, new RegExp(`\\[backend-reload\\].*generation=${generation}`));
161
- // Asserted backend-side, not through the probe. A shared edit restarts the backend, which closes
162
- // the socket the probe opened, and the probe is a raw WebSocket that never reconnects. A real
163
- // browser does: on reconnect it gets a `hello` and reloads when the buildId moved
164
- // (`akanjs/server/hmr/clientScript.ts`). Requiring a probe message here only held while the client
165
- // rebuild happened to finish before the restart killed the connection — a race this test lost the
166
- // moment builds moved into a worker process and took ~240ms longer to start.
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.
167
237
  await host.waitForLogSince(mark, new RegExp(`\\[SSR\\] pages-updated.*generation=${generation}`));
168
238
  await harness.waitForHttpText("updated-shared-marker");
169
239
  hmr?.close();
@@ -179,23 +249,27 @@ describe("dev stability integration harness", () => {
179
249
  hmr?.close();
180
250
  return;
181
251
  }
182
- const mark = host.markLog();
183
-
184
- await harness.writeFile(
185
- "lib/_fixture/fixture.dictionary.ts",
186
- `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";
187
258
 
188
259
  import type { FixtureEndpoint } from "./fixture.signal";
189
260
 
190
261
  export const dictionary = serviceDictionary(["en", "ko"])
191
262
  .endpoint<FixtureEndpoint>(() => ({}))
192
263
  .translate({
193
- hello: ["Updated Dictionary", "업데이트 사전"],
264
+ hello: ["Updated Dictionary ${attempt}", "업데이트 사전 ${attempt}"],
194
265
  });
195
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 },
196
271
  );
197
272
 
198
- await host.waitForLogSince(mark, /\[dev-plan\].*actions=.*restart-builder/);
199
273
  await host.waitForLogSince(mark, /\[dev-host\] recycling builder\/backend for runtime metadata/);
200
274
  await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
201
275
  await harness.waitForHttpText("initial-shared-marker");
@@ -211,18 +285,23 @@ export const dictionary = serviceDictionary(["en", "ko"])
211
285
  expect(host.proc.killed).toBe(false);
212
286
  return;
213
287
  }
214
- const mark = host.markLog();
215
-
216
- await harness.writeFile(
217
- "akan.config.ts",
218
- `import type { AppConfig } from "akanjs";
219
-
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}
220
296
  const config: AppConfig = { externalLibs: [] };
221
297
  export default config;
222
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 },
223
303
  );
224
304
 
225
- await host.waitForLogSince(mark, /\[dev-plan\].*actions=.*restart-dev-host/);
226
305
  await host.waitForLogSince(mark, /\[dev-host\] config change detected; restarting dev host/);
227
306
  await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
228
307
  await harness.waitForHttpText("initial-shared-marker");
@@ -239,35 +318,40 @@ export default config;
239
318
  hmr?.close();
240
319
  return;
241
320
  }
242
- const failureMark = host.markLog();
243
321
  const failureHmrMark = hmr?.mark() ?? 0;
244
322
 
245
- await harness.writeFile(
246
- "ui/ClientMarker.tsx",
247
- `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}
248
328
  return <p>broken</p>
249
329
  `,
330
+ ),
250
331
  );
251
332
 
252
333
  await host.waitForLogSince(
253
334
  failureMark,
254
335
  /\[build-status\].*phase=pages.*ok=false|\[build-status\].*phase=csr.*ok=false/,
255
336
  );
256
- if (hmr) await hmr.waitForMessageSince(failureHmrMark, isBuildStatus("error"));
337
+ if (hmr) await expectHmrMessage(hmr, failureHmrMark, isBuildStatus("error"), "the build failure");
257
338
  await harness.waitForHttpText("initial-client-marker");
258
- const recoveryMark = host.markLog();
259
339
  const recoveryHmrMark = hmr?.mark() ?? 0;
260
340
 
261
- await harness.writeFile(
262
- "ui/ClientMarker.tsx",
263
- `export function ClientMarker() {
264
- 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>;
265
348
  }
266
349
  `,
350
+ ),
267
351
  );
268
352
 
269
353
  await host.waitForLogSince(recoveryMark, /\[build-status\].*ok=true/);
270
- if (hmr) await hmr.waitForMessageSince(recoveryHmrMark, isBuildStatus("ok"));
354
+ if (hmr) await expectHmrMessage(hmr, recoveryHmrMark, isBuildStatus("ok"), "the build recovery");
271
355
  await harness.waitForHttpText("recovered-client-marker");
272
356
  hmr?.close();
273
357
  });
@@ -314,18 +398,18 @@ export default config;
314
398
  integrationTest("backend boot failure stops the crash loop, surfaces build-status, and recovers on fix", async () => {
315
399
  const harness = await createHarness();
316
400
  const host = await harness.startHost();
317
- const failureMark = host.markLog();
318
-
319
401
  // The service file is part of the generated server graph (`akan start` regenerates server.ts
320
402
  // from lib/), so a module-level throw here breaks every replica boot.
321
- await harness.writeFile(
322
- "lib/_fixture/fixture.service.ts",
323
- `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";
324
407
 
325
408
  export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
326
409
 
327
- throw new Error("intentional-backend-boot-crash");
410
+ throw new Error("intentional-backend-boot-crash-${attempt}");
328
411
  `,
412
+ ),
329
413
  );
330
414
 
331
415
  // The gateway abandons the replica after three failed boots instead of retrying forever...
@@ -334,13 +418,15 @@ throw new Error("intentional-backend-boot-crash");
334
418
  await host.waitForLogSince(failureMark, /\[build-status\].*phase=backend.*ok=false/);
335
419
  expect(host.proc.killed).toBe(false);
336
420
 
337
- const recoveryMark = host.markLog();
338
- await harness.writeFile(
339
- "lib/_fixture/fixture.service.ts",
340
- `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";
341
425
 
426
+ // recovery ${attempt}
342
427
  export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
343
428
  `,
429
+ ),
344
430
  );
345
431
 
346
432
  await host.waitForLogSince(recoveryMark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
@@ -416,6 +502,13 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
416
502
  * silently exhausted the waits and looked like product failures. Both tests therefore assert several
417
503
  * related properties against a single boot, with explicit generous waits. Run the block on its own
418
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.
419
512
  */
420
513
  describe("dev resource budgets", () => {
421
514
  const BOOT_MS = 150_000;
@@ -445,14 +538,16 @@ describe("dev resource budgets", () => {
445
538
 
446
539
  // Armed: from here on every save rebuilds CSR, which is what keeps a live mobile session working.
447
540
  //
448
- // The settle wait is not padding. Bun's recursive `fs.watch` on macOS silently drops a file event
449
- // that lands in the same FSEvents coalescing window as a burst of writes elsewhere in the tree, and
450
- // the CSR build above emits exactly such a burst into `.akan/artifact/csr`. Saving immediately after
451
- // the request returns therefore loses the event 100% of the time — a Bun bug this test must not
452
- // depend on. See `local/optimize-resource/06-watcher-dropped-event.md` for the 30-line repro.
453
- await Bun.sleep(500);
454
- const resyncMark = host.markLog();
455
- 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
+ );
456
551
  await host.waitForLogSince(resyncMark, /csr-rebundle ok/, WAIT_MS);
457
552
  });
458
553
 
@@ -477,21 +572,20 @@ describe("dev resource budgets", () => {
477
572
 
478
573
  const start = host.markLog();
479
574
  for (let i = 1; i <= 3; i++) {
480
- const mark = host.markLog();
481
- 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
+ );
482
578
  await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS);
483
579
  // CSR was never requested in this fixture, so no save may pay for a CSR rebuild.
484
580
  const afterSave = host.logs.join("").slice(mark);
485
581
  expect(afterSave).toMatch(/csr-rebundle skipped/);
486
582
  expect(afterSave).not.toMatch(/csr-rebundle ok/);
487
- // Bun drops a watcher event that lands in the same window as a write burst
488
- // (`06-watcher-dropped-event.md`), so saving again before this generation has fully settled loses
489
- // the next edit outright this test hanging for the full 90s wait on iteration 2. Waiting for
490
- // the builder alone is not enough: the backend is still applying the reload after that, and it
491
- // writes too. Wait for the backend to finish, then leave the drop window (measured under 200ms).
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.
492
587
  await host.waitForLogSince(mark, /css-rebuild checked/, WAIT_MS);
493
588
  await host.waitForLogSince(mark, /\[hmr\] backend apply/, WAIT_MS);
494
- await Bun.sleep(300);
495
589
  }
496
590
 
497
591
  // Each in-place reload re-imports the pages bundle under a fresh `?v=`, and Bun's ESM registry
@@ -527,8 +621,9 @@ describe("dev resource budgets", () => {
527
621
  // One save is enough: the builder reports its rss as soon as the batch drains, and the host arms
528
622
  // the recycle from that report. The old pid comes from the log rather than from `ps`, so this does
529
623
  // not race the swap it is about to observe.
530
- const firstSave = host.markLog();
531
- await harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, "marker-1");
624
+ const { mark: firstSave } = await harness.editUntilSeen(host, (attempt) =>
625
+ harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-1-${attempt}`),
626
+ );
532
627
  await host.waitForLogSince(firstSave, /pages-rebundle ok/, WAIT_MS);
533
628
 
534
629
  // The host decides, the builder drains rather than being killed, and the replacement comes up.
@@ -552,36 +647,88 @@ describe("dev resource budgets", () => {
552
647
  //
553
648
  // Waiting for readiness above is load-bearing, not padding. The watcher is installed at the end of
554
649
  // the boot build, so a save during the recycle is seen by neither builder — this test lost one
555
- // exactly that way. The settle wait on top is for Bun's dropped-event bug: the recycle boot writes
556
- // a burst of artifacts, and a save inside that window is never reported at all
557
- // (`06-watcher-dropped-event.md`).
558
- await Bun.sleep(500);
559
- const attempts: string[] = [];
560
- for (let attempt = 1; attempt <= 4; attempt++) {
561
- const mark = host.markLog();
562
- await harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-after-recycle-${attempt}`);
563
- const seen = await host
564
- .waitForLogSince(mark, /pages-rebundle ok/, 15_000)
565
- .then(() => true)
566
- .catch(() => false);
567
- attempts.push(`${attempt}=${seen ? "rebuilt" : "silent"}`);
568
- if (seen) break;
569
- await Bun.sleep(750);
570
- }
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);
571
655
  console.info(
572
- `[recycle-guard] ${recycleLog[2]}; builder ${recycleLog[1]} -> ${recycled?.pid} at ${Math.round((recycled?.rssBytes ?? 0) / MB)}MiB; post-recycle saves: ${attempts.join(" ")}`,
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)`,
573
657
  );
574
- expect(attempts.join(" ")).toMatch(/rebuilt/);
575
658
  await harness.waitForHttpText("marker-after-recycle", WAIT_MS);
576
659
 
577
660
  // A replacement that is still over the ceiling proves the ceiling cannot be met, and the host has
578
661
  // to stop rather than recycle forever. Two reports inside the minimum interval is the threshold.
579
662
  for (let i = 1; i <= 3; i++) {
580
- await Bun.sleep(500);
581
- const mark = host.markLog();
582
- await harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-settled-${i}`);
663
+ const { mark } = await harness.editUntilSeen(host, (attempt) =>
664
+ harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-settled-${i}-${attempt}`),
665
+ );
583
666
  await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
584
667
  }
585
668
  await host.waitForLogSince(start, /ceiling cannot be met for this app/, WAIT_MS);
586
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();
733
+ });
587
734
  });