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

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.
@@ -149,7 +149,6 @@ describe("dev stability integration harness", () => {
149
149
  return;
150
150
  }
151
151
  const mark = host.markLog();
152
- const hmrMark = hmr?.mark() ?? 0;
153
152
 
154
153
  await harness.replaceText("common/marker.ts", "initial-shared-marker", "updated-shared-marker");
155
154
 
@@ -159,13 +158,13 @@ describe("dev stability integration harness", () => {
159
158
  );
160
159
  const generation = plan[1];
161
160
  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}`));
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.
167
+ await host.waitForLogSince(mark, new RegExp(`\\[SSR\\] pages-updated.*generation=${generation}`));
169
168
  await harness.waitForHttpText("updated-shared-marker");
170
169
  hmr?.close();
171
170
  });
@@ -469,6 +468,9 @@ describe("dev resource budgets", () => {
469
468
 
470
469
  const idleTotal = await DevStabilityHarness.processTreeRssBytes(host.proc.pid);
471
470
  const idleWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, { excludeBuilder: true });
471
+ const idleBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
472
+ // Nothing should be building at idle, so the disposable worker must not be resident.
473
+ expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
472
474
  // Measured ~670MB for this fixture; the headroom covers machine variance, not a reintroduced
473
475
  // eager import (the cheapest of those is ~30MB, and the devkit barrel cycle was 236MB).
474
476
  expect(idleTotal).toBeLessThan(1_000 * MB);
@@ -482,23 +484,104 @@ describe("dev resource budgets", () => {
482
484
  const afterSave = host.logs.join("").slice(mark);
483
485
  expect(afterSave).toMatch(/csr-rebundle skipped/);
484
486
  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.
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).
489
492
  await host.waitForLogSince(mark, /css-rebuild checked/, WAIT_MS);
493
+ await host.waitForLogSince(mark, /\[hmr\] backend apply/, WAIT_MS);
494
+ await Bun.sleep(300);
490
495
  }
491
496
 
492
497
  // Each in-place reload re-imports the pages bundle under a fresh `?v=`, and Bun's ESM registry
493
498
  // never evicts — so without a recycle the worker grows for the life of the process.
494
499
  await host.waitForLogSince(start, /rolling recycle worker reason=pages-reload-accumulation/, WAIT_MS);
495
500
 
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.
501
+ // The dev host, gateway, replica and rsc worker must all stay flat across saves.
499
502
  const afterWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, {
500
503
  excludeBuilder: true,
501
504
  });
502
505
  expect(afterWithoutBuilder - idleWithoutBuilder).toBeLessThan(120 * MB);
506
+
507
+ // And so must the builder. It used to be excluded from this budget because `Bun.build` retains
508
+ // native arenas no GC reclaims, which made it grow ~120MB per save on this fixture; every build
509
+ // that scales per save now runs in a process that exits, so its memory goes back to the OS.
510
+ const afterBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
511
+ expect(afterBuilder?.pid).toBe(idleBuilder?.pid);
512
+ expect((afterBuilder?.rssBytes ?? 0) - (idleBuilder?.rssBytes ?? 0)).toBeLessThan(30 * MB);
513
+ // The worker is transient: three generations built, and none of them is still around.
514
+ expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
515
+ });
516
+
517
+ budgetTest("recycles the builder at an unmeetable ceiling and keeps developing through it", async () => {
518
+ const harness = await createHarness();
519
+ // Deliberately *below* this fixture's post-boot builder. Moving every per-save build into a
520
+ // disposable worker means the builder no longer grows into a ceiling, so a ceiling it is already
521
+ // over is the only way left to drive the recycle path end to end — and it is also the case the
522
+ // escape hatch exists for: an app whose boot floor simply does not fit under the limit.
523
+ const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
524
+ const start = host.markLog();
525
+ await harness.waitForHttpText("initial-client-marker", WAIT_MS);
526
+
527
+ // One save is enough: the builder reports its rss as soon as the batch drains, and the host arms
528
+ // the recycle from that report. The old pid comes from the log rather than from `ps`, so this does
529
+ // 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");
532
+ await host.waitForLogSince(firstSave, /pages-rebundle ok/, WAIT_MS);
533
+
534
+ // The host decides, the builder drains rather than being killed, and the replacement comes up.
535
+ const recycleLog = await host.waitForLogSince(
536
+ start,
537
+ /recycling builder pid=(\d+) \((rss=\d+MiB>=200MiB after \d+ build\(s\))\)/,
538
+ WAIT_MS,
539
+ );
540
+ await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
541
+ await host.waitForLogSince(start, /builder spawned pid=\d+ .*restart=1/, WAIT_MS);
542
+ await host.waitForLogSince(start, /builder ready after restart/, WAIT_MS);
543
+ // The backend read `base-artifact.json` once at boot, so the replacement has to re-announce what
544
+ // it booted with or the backend keeps serving the artifact of the builder that just exited.
545
+ await host.waitForLogSince(start, /announced boot state after recycle/, WAIT_MS);
546
+
547
+ const recycled = await DevStabilityHarness.builderProcess(host.proc.pid);
548
+ expect(recycled).not.toBeNull();
549
+ expect(String(recycled?.pid)).not.toBe(recycleLog[1]);
550
+
551
+ // And the dev server is still a dev server: the replacement watches, rebuilds and serves.
552
+ //
553
+ // Waiting for readiness above is load-bearing, not padding. The watcher is installed at the end of
554
+ // 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
+ }
571
+ 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(" ")}`,
573
+ );
574
+ expect(attempts.join(" ")).toMatch(/rebuilt/);
575
+ await harness.waitForHttpText("marker-after-recycle", WAIT_MS);
576
+
577
+ // A replacement that is still over the ceiling proves the ceiling cannot be met, and the host has
578
+ // to stop rather than recycle forever. Two reports inside the minimum interval is the threshold.
579
+ 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}`);
583
+ await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
584
+ }
585
+ await host.waitForLogSince(start, /ceiling cannot be met for this app/, WAIT_MS);
503
586
  });
504
587
  });
@@ -281,7 +281,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
281
281
  const decoder = new TextDecoder();
282
282
  const reader = stream.getReader();
283
283
  try {
284
- while (true) {
284
+ for (;;) {
285
285
  const { done, value } = await reader.read();
286
286
  if (done) break;
287
287
  logs.push(decoder.decode(value, { stream: true }));
@@ -460,6 +460,12 @@ export const dictionary = serviceDictionary(["en", "ko"])
460
460
  * process, whose growth across saves is `Bun.build` native arena retention rather than a leak the
461
461
  * other processes could be blamed for.
462
462
  */
463
+ // Matched precisely, not by directory: the disposable build worker lives at
464
+ // `incrementalBuilder/buildBatch.proc.ts`, so a substring match on `incrementalBuilder` would count
465
+ // the worker as the watcher and make every builder measurement depend on spawn timing.
466
+ static readonly #builderCmd = "incrementalBuilder.proc";
467
+ static readonly #buildWorkerCmd = "buildBatch.proc";
468
+
463
469
  static async processTreeRssBytes(
464
470
  rootPid: number,
465
471
  { excludeBuilder = false }: { excludeBuilder?: boolean } = {},
@@ -471,11 +477,31 @@ export const dictionary = serviceDictionary(["en", "ko"])
471
477
  .filter((row) => pids.has(row.pid))
472
478
  // `bun run akan …` is the npm-script shell wrapper, not a dev process.
473
479
  .filter((row) => !row.cmd.startsWith("bash -lc") && !row.cmd.includes("cli/build.ts"))
474
- .filter((row) => !excludeBuilder || !row.cmd.includes("incrementalBuilder"))
480
+ .filter((row) => !excludeBuilder || !row.cmd.includes(DevStabilityHarness.#builderCmd))
475
481
  .reduce((total, row) => total + row.rssKb * 1024, 0)
476
482
  );
477
483
  }
478
484
 
485
+ /**
486
+ * The long-lived builder process on its own: its RSS is what bundler-arena retention used to move,
487
+ * and its pid is what changes when the host recycles it, so a bounded-builder assertion needs both.
488
+ */
489
+ static async builderProcess(rootPid: number): Promise<{ pid: number; rssBytes: number } | null> {
490
+ return DevStabilityHarness.#findProcess(rootPid, DevStabilityHarness.#builderCmd);
491
+ }
492
+
493
+ /** The disposable per-generation build worker, which should only exist while a build is running. */
494
+ static async buildWorkerProcess(rootPid: number): Promise<{ pid: number; rssBytes: number } | null> {
495
+ return DevStabilityHarness.#findProcess(rootPid, DevStabilityHarness.#buildWorkerCmd);
496
+ }
497
+
498
+ static async #findProcess(rootPid: number, cmdIncludes: string) {
499
+ const rows = await DevStabilityHarness.#psRows();
500
+ const pids = DevStabilityHarness.#collectDescendants(rows, rootPid);
501
+ const found = rows.find((row) => pids.has(row.pid) && row.cmd.includes(cmdIncludes));
502
+ return found ? { pid: found.pid, rssBytes: found.rssKb * 1024 } : null;
503
+ }
504
+
479
505
  /** Pids of `rootPid` and everything under it, deepest first, so callers can signal children before parents. */
480
506
  static async descendantPids(rootPid: number | undefined): Promise<number[]> {
481
507
  if (!rootPid) return [];
@@ -0,0 +1 @@
1
+ process.send?.({ type: "done", value: 42 });
@@ -0,0 +1,11 @@
1
+ const started = Date.now();
2
+ let got: unknown = null;
3
+ const proc = Bun.spawn(["bun", `${import.meta.dir}/child.ts`], {
4
+ stdio: ["ignore", "inherit", "inherit"],
5
+ serialization: "advanced",
6
+ ipc: (m) => { got = m; },
7
+ });
8
+ const timeout = new Promise((r) => setTimeout(() => r("TIMEOUT"), 4000));
9
+ const outcome = await Promise.race([proc.exited, timeout]);
10
+ console.info(JSON.stringify({ outcome, got, ms: Date.now() - started }));
11
+ if (outcome === "TIMEOUT") proc.kill();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.4.1-rc.2",
3
+ "version": "2.4.1-rc.3",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -44,7 +44,7 @@
44
44
  "@langchain/openai": "^1.4.6",
45
45
  "@tailwindcss/node": "^4.3.0",
46
46
  "@trapezedev/project": "^7.1.4",
47
- "akanjs": "2.4.1-rc.2",
47
+ "akanjs": "2.4.1-rc.3",
48
48
  "chalk": "^5.6.2",
49
49
  "commander": "^14.0.3",
50
50
  "daisyui": "5.5.23",