@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.
- package/akanApp/akanApp.host.test.ts +84 -0
- package/akanApp/akanApp.host.ts +181 -5
- package/incrementalBuilder/buildBatch.proc.ts +194 -0
- package/incrementalBuilder/buildBatchProtocol.ts +53 -0
- package/incrementalBuilder/buildBatchRunner.ts +85 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +114 -9
- package/incrementalBuilder/incrementalBuilder.host.ts +72 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +238 -161
- package/integration/devStability.integration.test.ts +98 -15
- package/integration/devStabilityHarness.ts +28 -2
- package/local/optimize-resource/ipcprobe/child.ts +1 -0
- package/local/optimize-resource/ipcprobe/parent.ts +11 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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",
|