@akanjs/devkit 2.4.1-rc.7 → 2.4.1

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.
@@ -0,0 +1,319 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ export type DevResourceRole = "host" | "builder" | "batch" | "gateway" | "replica" | "rsc" | "other";
5
+
6
+ export interface DevResourceProc {
7
+ pid: number;
8
+ ppid: number;
9
+ rssMb: number;
10
+ cpuSec: number;
11
+ role: DevResourceRole;
12
+ command: string;
13
+ }
14
+
15
+ export interface DevResourceProbeOptions {
16
+ appName: string;
17
+ /** Must be the workspace root: package resolution has to match the real dev processes. */
18
+ workspaceRoot: string;
19
+ /** A tenant runs `node_modules/@akanjs/cli`; this repo runs its own `dist` build. */
20
+ cliEntry: string;
21
+ port: number;
22
+ edits: number;
23
+ idleSeconds: number;
24
+ /** `0` leaves idle suspend at its default; any other value also arms the suspend phase. */
25
+ suspendSeconds: number;
26
+ /** App-relative file to append a comment to, once per edit. */
27
+ editPath: string;
28
+ logPath: string;
29
+ }
30
+
31
+ /**
32
+ * Process-tree RSS over boot, browse, edits and idle-suspend — probe #1 of
33
+ * `04-measurement-harness.md`, and the source of every idle/warm/suspended number in the
34
+ * `optimize-resource` docs.
35
+ *
36
+ * bun pkgs/@akanjs/devkit/integration/devResourceProbe.ts <app> [--idle=120] [--edits=3] [--suspend=60]
37
+ *
38
+ * Run it from the workspace root. Two things it exists to get right, both learned the hard way:
39
+ * it walks the process tree to a **fixpoint** (the tree is seven levels deep in places, so a
40
+ * fixed-depth walk silently misses the workers), and it **restores the edited file** in a `finally`
41
+ * so a killed probe cannot leave a comment in the tree.
42
+ *
43
+ * On macOS, a plateau that drops with no activity is the OS trimming idle pages, not convergence —
44
+ * report both numbers rather than the lower one.
45
+ */
46
+ export class DevResourceProbe {
47
+ static readonly #columns: DevResourceRole[] = ["host", "builder", "batch", "gateway", "replica", "rsc"];
48
+
49
+ static parseArgs(argv: string[]): DevResourceProbeOptions {
50
+ const args = new Map(
51
+ argv.slice(1).map((arg) => {
52
+ const [key, value] = arg.replace(/^--/, "").split("=");
53
+ return [key ?? "", value ?? "1"];
54
+ }),
55
+ );
56
+ const appName = argv[0] ?? "akan";
57
+ const num = (key: string, fallback: number) => Number(args.get(key) ?? fallback);
58
+ return {
59
+ appName,
60
+ workspaceRoot: args.get("root") ?? process.cwd(),
61
+ cliEntry: args.get("cli") ?? "dist/pkgs/@akanjs/cli/index.js",
62
+ port: num("port", 8482),
63
+ edits: num("edits", 3),
64
+ idleSeconds: num("idle", 120),
65
+ suspendSeconds: num("suspend", 0),
66
+ editPath: args.get("edit") ?? "page/(home)/_index.tsx",
67
+ logPath: args.get("log") ?? path.join(os.tmpdir(), `akan-dev-resource-${appName}.log`),
68
+ };
69
+ }
70
+
71
+ readonly #options: DevResourceProbeOptions;
72
+ #hostPid = 0;
73
+
74
+ constructor(options: DevResourceProbeOptions) {
75
+ this.#options = options;
76
+ }
77
+
78
+ async run(): Promise<void> {
79
+ const { appName, workspaceRoot, cliEntry, port, logPath, suspendSeconds } = this.#options;
80
+ await Bun.write(logPath, "");
81
+ const host = Bun.spawn(["bun", cliEntry, "start", appName], {
82
+ cwd: workspaceRoot,
83
+ env: {
84
+ ...process.env,
85
+ AKAN_PUBLIC_LOG_LEVEL: "verbose",
86
+ // Pinned: the derived port moves with the `apps/` listing, so a probe cannot predict it.
87
+ AKAN_DEV_PORT: String(port),
88
+ ...(suspendSeconds ? { AKAN_DEV_IDLE_SUSPEND_MS: String(suspendSeconds * 1_000) } : {}),
89
+ },
90
+ stdout: Bun.file(logPath),
91
+ stderr: Bun.file(logPath),
92
+ });
93
+ this.#hostPid = host.pid;
94
+ console.info(`[probe] app=${appName} hostPid=${host.pid} port=${port} log=${logPath}`);
95
+
96
+ // Captured before the try so a crashed probe cannot leave a probe comment in the user's tree.
97
+ const target = path.join(workspaceRoot, "apps", appName, this.#options.editPath);
98
+ const original = await Bun.file(target)
99
+ .text()
100
+ .catch(() => null);
101
+ try {
102
+ await this.#measure(target, original);
103
+ } finally {
104
+ if (
105
+ original !== null &&
106
+ (await Bun.file(target)
107
+ .text()
108
+ .catch(() => "")) !== original
109
+ )
110
+ await Bun.write(target, original);
111
+ await this.#cleanup(host);
112
+ }
113
+ }
114
+
115
+ async #measure(target: string, original: string | null): Promise<void> {
116
+ const { appName, edits, idleSeconds, suspendSeconds } = this.#options;
117
+ const booted = await this.#waitForLog(/backend ready pid=\d+|gateway is running on port/, 240_000);
118
+ console.info(`[probe] boot log seen=${booted}`);
119
+
120
+ const header = ["host", "bldr", "batch", "gway", "repl", "rsc"].map((h) => h.padStart(5)).join(" ");
121
+ console.info(`\n${"sample".padEnd(22)} ${header}`);
122
+ const started = Date.now();
123
+ let idleTotal = 0;
124
+ while ((Date.now() - started) / 1_000 < idleSeconds) {
125
+ await Bun.sleep(5_000);
126
+ idleTotal = this.#row(`idle+${Math.round((Date.now() - started) / 1_000)}s`, await this.#sampleTree());
127
+ }
128
+ console.info(`[probe] IDLE BASELINE ${idleTotal.toFixed(0)}MB`);
129
+ await this.#reportMetrics("idle");
130
+
131
+ // 4.2: every idle number understates, because route modules are evaluated on first request.
132
+ const routes = await this.#staticRoutes();
133
+ const first = await this.#browse(routes);
134
+ console.info(`[probe] browsed ${routes.length} static route(s): ok=${first.ok} failed=${first.failed}`);
135
+ this.#row("browse-all-once", await this.#sampleTree());
136
+ const hot = routes.slice(0, Math.max(1, Math.ceil(routes.length / 4)));
137
+ for (let pass = 1; pass <= 3; pass++) await this.#browse(hot);
138
+ const warm = this.#row("browse-hot-x3", await this.#sampleTree());
139
+ console.info(`[probe] WARM TOTAL ${warm.toFixed(0)}MB (idle was ${idleTotal.toFixed(0)}MB)`);
140
+ await this.#reportMetrics("warm");
141
+
142
+ if (original === null) console.info(`[probe] no edit target at ${target}; skipping edits`);
143
+ else {
144
+ for (let edit = 1; edit <= edits; edit++) {
145
+ await Bun.write(target, `${original}\n// probe-edit-${edit}\n`);
146
+ await Bun.sleep(25_000);
147
+ this.#row(`edit${edit}`, await this.#sampleTree());
148
+ }
149
+ await Bun.write(target, original);
150
+ await Bun.sleep(10_000);
151
+ this.#row("restored", await this.#sampleTree());
152
+ }
153
+ await this.#reportMetrics("after-edits");
154
+
155
+ if (!suspendSeconds) return;
156
+ const suspended = await this.#waitForLog(/\[idle-suspend\].*released the builder/, (suspendSeconds + 60) * 1_000);
157
+ console.info(`[probe] idle-suspend seen=${suspended} (app=${appName})`);
158
+ await Bun.sleep(5_000);
159
+ console.info(`[probe] IDLE-SUSPENDED ${this.#row("idle-suspended", await this.#sampleTree()).toFixed(0)}MB`);
160
+ }
161
+
162
+ #roleOf(command: string): DevResourceRole {
163
+ if (/cli\/index\.js\s+start\s/.test(command)) return "host";
164
+ if (/incrementalBuilder\.proc/.test(command)) return "builder";
165
+ if (/buildBatch\.proc/.test(command)) return "batch";
166
+ if (/rscWorker|react-server/.test(command)) return "rsc";
167
+ if (new RegExp(`apps/${this.#options.appName}/main\\.ts`).test(command)) return "gateway";
168
+ if (/server\.ts|import\(/.test(command)) return "replica";
169
+ return "other";
170
+ }
171
+
172
+ /** One `ps`, then walk from the host pid to a fixpoint — the tree is seven levels deep in places. */
173
+ async #sampleTree(): Promise<DevResourceProc[]> {
174
+ const proc = Bun.spawn(["ps", "-eo", "pid=,ppid=,rss=,time=,command="], { stdout: "pipe" });
175
+ const text = await new Response(proc.stdout).text();
176
+ await proc.exited;
177
+ const all = text
178
+ .split("\n")
179
+ .map((line) => line.trim())
180
+ .filter(Boolean)
181
+ .map((line) => {
182
+ const match = /^(\d+)\s+(\d+)\s+(\d+)\s+([\d:.]+)\s+(.*)$/.exec(line);
183
+ if (!match) return null;
184
+ const [, pid, ppid, rss, time, command] = match;
185
+ const parts = (time ?? "0:0").split(":");
186
+ const cpuSec =
187
+ parts.length === 3
188
+ ? Number(parts[0]) * 3600 + Number(parts[1]) * 60 + Number(parts[2])
189
+ : Number(parts[0] ?? 0) * 60 + Number(parts[1] ?? 0);
190
+ return {
191
+ pid: Number(pid),
192
+ ppid: Number(ppid),
193
+ rssMb: Number(rss) / 1024,
194
+ cpuSec,
195
+ role: this.#roleOf(command ?? ""),
196
+ command: command ?? "",
197
+ } satisfies DevResourceProc;
198
+ })
199
+ .filter((proc): proc is DevResourceProc => proc !== null);
200
+ const kept = new Map<number, DevResourceProc>();
201
+ const root = all.find((proc) => proc.pid === this.#hostPid);
202
+ if (root) kept.set(this.#hostPid, root);
203
+ for (let pass = 0; pass < 12; pass++) {
204
+ const before = kept.size;
205
+ for (const proc of all) if (kept.has(proc.ppid) && !kept.has(proc.pid)) kept.set(proc.pid, proc);
206
+ if (kept.size === before) break;
207
+ }
208
+ return [...kept.values()].filter((proc) => proc.role !== "other" || proc.pid === this.#hostPid);
209
+ }
210
+
211
+ #row(label: string, procs: DevResourceProc[]): number {
212
+ const byRole = new Map<DevResourceRole, number>();
213
+ for (const proc of procs) byRole.set(proc.role, (byRole.get(proc.role) ?? 0) + proc.rssMb);
214
+ const total = procs.reduce((sum, proc) => sum + proc.rssMb, 0);
215
+ const cpu = procs.reduce((sum, proc) => sum + proc.cpuSec, 0);
216
+ const fmt = (value: number) => value.toFixed(0).padStart(5);
217
+ const cells = DevResourceProbe.#columns.map((role) => (byRole.has(role) ? fmt(byRole.get(role) ?? 0) : " —"));
218
+ console.info(
219
+ `${label.padEnd(22)} ${cells.join(" ")} | total ${fmt(total)}MB cpu ${cpu.toFixed(0)}s n=${procs.length}`,
220
+ );
221
+ return total;
222
+ }
223
+
224
+ /**
225
+ * Static route urls from the page tree: `(group)` segments are stripped, and a route with a
226
+ * `[dynamic]` segment is skipped because it needs a real id to render.
227
+ */
228
+ async #staticRoutes(): Promise<string[]> {
229
+ const glob = new Bun.Glob("**/_index.tsx");
230
+ const cwd = path.join(this.#options.workspaceRoot, "apps", this.#options.appName, "page");
231
+ const urls = new Set<string>();
232
+ for await (const file of glob.scan({ cwd })) {
233
+ if (/\[[^\]]+\]/.test(file)) continue;
234
+ const segments = file
235
+ .replace(/\/?_index\.tsx$/, "")
236
+ .split("/")
237
+ .filter((segment) => segment && !/^\(.*\)$/.test(segment));
238
+ urls.add(`/${segments.join("/")}`);
239
+ }
240
+ return [...urls].sort();
241
+ }
242
+
243
+ async #browse(urls: string[]): Promise<{ ok: number; failed: number }> {
244
+ let ok = 0;
245
+ let failed = 0;
246
+ for (const url of urls) {
247
+ try {
248
+ const res = await fetch(`http://localhost:${this.#options.port}${url}`, {
249
+ signal: AbortSignal.timeout(60_000),
250
+ });
251
+ await res.text();
252
+ if (res.ok) ok++;
253
+ else failed++;
254
+ } catch {
255
+ failed++;
256
+ }
257
+ }
258
+ return { ok, failed };
259
+ }
260
+
261
+ /** Only the counters 4.2 asks for; the raw payload is hundreds of fields. */
262
+ async #reportMetrics(label: string): Promise<void> {
263
+ const child = await (async () => {
264
+ try {
265
+ const res = await fetch(`http://localhost:${this.#options.port}/_akan/app/metrics`, {
266
+ signal: AbortSignal.timeout(5_000),
267
+ });
268
+ if (!res.ok) return null;
269
+ const body = (await res.json()) as { children?: { metrics?: { [key: string]: number } }[] };
270
+ return body.children?.[0]?.metrics ?? null;
271
+ } catch {
272
+ return null;
273
+ }
274
+ })();
275
+ if (!child) {
276
+ console.info(`[metrics ${label}] unavailable`);
277
+ return;
278
+ }
279
+ const keys = [
280
+ "rscRenderCount",
281
+ "rscRouteModuleCount",
282
+ "rscLoadedRouteModuleCount",
283
+ "ssrChunkRegistrySize",
284
+ "ssrChunkLoadCount",
285
+ "rscWorkerRecycleCount",
286
+ "httpFullSsrCount",
287
+ ];
288
+ const rssMb = (Number(child.rssBytes ?? 0) / 1024 / 1024).toFixed(0);
289
+ const parts = keys.map((key) => `${key}=${child[key] ?? "?"}`);
290
+ console.info(`[metrics ${label}] rsc rss=${rssMb}MB ${parts.join(" ")}`);
291
+ }
292
+
293
+ async #waitForLog(pattern: RegExp, timeoutMs: number): Promise<boolean> {
294
+ const deadline = Date.now() + timeoutMs;
295
+ while (Date.now() < deadline) {
296
+ const text = await Bun.file(this.#options.logPath)
297
+ .text()
298
+ .catch(() => "");
299
+ if (pattern.test(text)) return true;
300
+ await Bun.sleep(500);
301
+ }
302
+ return false;
303
+ }
304
+
305
+ async #cleanup(host: { kill: (signal: NodeJS.Signals) => void }): Promise<void> {
306
+ for (const proc of (await this.#sampleTree()).reverse()) {
307
+ try {
308
+ process.kill(proc.pid, "SIGKILL");
309
+ } catch {}
310
+ }
311
+ try {
312
+ host.kill("SIGKILL");
313
+ } catch {}
314
+ await Bun.sleep(1_000);
315
+ console.info(`[probe] survivors after kill: ${(await this.#sampleTree()).length}`);
316
+ }
317
+ }
318
+
319
+ if (import.meta.main) await new DevResourceProbe(DevResourceProbe.parseArgs(process.argv.slice(2))).run();
@@ -513,6 +513,13 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
513
513
  describe("dev resource budgets", () => {
514
514
  const BOOT_MS = 150_000;
515
515
  const WAIT_MS = 90_000;
516
+ /**
517
+ * Measured on this fixture at **260-516ms** per save. Set ~6× above that, like every budget here: it is
518
+ * looking for a path that got slower by a factor — a boot build that stopped being avoidable, a worker
519
+ * spawn that stopped being warm — not for jitter on a busy laptop. `apps/akan`, ~25× this fixture's
520
+ * pages bundle, took ~1.5s per save when the plan started.
521
+ */
522
+ const SAVE_LATENCY_BUDGET_MS = 3_000;
516
523
  const budgetTest = (name: string, fn: () => Promise<void>): void => {
517
524
  if (integrationEnabled) test(name, fn, 300_000);
518
525
  else test.skip(name, fn);
@@ -669,15 +676,116 @@ describe("dev resource budgets", () => {
669
676
  );
670
677
  await harness.waitForHttpText("marker-after-recycle", WAIT_MS);
671
678
 
672
- // A replacement that is still over the ceiling proves the ceiling cannot be met, and the host has
673
- // to stop rather than recycle forever. Two reports inside the minimum interval is the threshold.
679
+ // This fixture's builder boots well under the ceiling and blows past it on its first build, which
680
+ // is the shape of every app the ceiling is derived for a 1.2GB sandbox gives the builder ~420MB
681
+ // and a single route build costs ~247MB. So the host says the ceiling is tight...
674
682
  for (let i = 1; i <= 3; i++) {
675
683
  const { mark } = await harness.editUntilSeen(host, (attempt) =>
676
684
  harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-settled-${i}-${attempt}`),
677
685
  );
678
686
  await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
679
687
  }
680
- await host.waitForLogSince(start, /ceiling cannot be met for this app/, WAIT_MS);
688
+ await host.waitForLogSince(start, /ceiling costs about one boot build per interval/, WAIT_MS);
689
+ // ...and goes on enforcing it. This used to disable the ceiling for the rest of the session on the
690
+ // same evidence, which on a small sandbox means nothing bounds the builder from the first page load
691
+ // onwards. Recycling is throttled to one per interval; that throttle is the answer to the cost, not
692
+ // dropping the bound.
693
+ expect(host.logs.join("").slice(start)).not.toMatch(/no longer enforcing it this session/);
694
+ });
695
+
696
+ // The guard the memory work did not have. Every budget above asserts RSS; none of them asserts that a
697
+ // page still renders while the machinery those budgets require is mid-swap — and that gap is where two
698
+ // shipped defects lived (`local/optimize-resource/19-shutdown-and-restart-races.md`).
699
+ budgetTest("serves a page requested while the builder is being replaced", async () => {
700
+ const harness = await createHarness();
701
+ // Same unmeetable ceiling as the guard above, for the same reason: a builder that no longer grows
702
+ // into a ceiling has to start under one for the recycle path to run end to end.
703
+ const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
704
+ const port = await harness.resolvePort();
705
+ await harness.waitForHttpText("initial-client-marker", WAIT_MS);
706
+
707
+ const start = host.markLog();
708
+ // One save does both halves of the setup: it drops the route's client entries, so the next request
709
+ // has to ask the builder again rather than being served from cache, and it makes the builder report
710
+ // an rss over the ceiling, which is what arms the recycle.
711
+ const { mark } = await harness.editUntilSeen(host, (attempt) =>
712
+ harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-through-recycle-${attempt}`),
713
+ );
714
+ await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS);
715
+
716
+ // A builder that has been asked to drain is still alive and refuses everything new, so this request
717
+ // has to be held for the replacement exactly like one that arrives after the process is gone. Timing
718
+ // decides which of the two windows it actually lands in — the drain of an idle builder is short, and
719
+ // the log is polled — so what is asserted is the outcome both windows must produce. The drain itself
720
+ // is pinned deterministically one layer down, in `incrementalBuilder.host.test.ts`.
721
+ await host.waitForLogSince(start, /recycling builder pid=\d+/, WAIT_MS);
722
+ // Not awaited here: a held request only returns once the replacement is up, and waiting for it would
723
+ // put every assertion below on the far side of the window they are about.
724
+ const drainRequest = fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(WAIT_MS) }).then(
725
+ async (response) => ({ status: response.status, html: await response.text() }),
726
+ );
727
+
728
+ // Wait until the replacement exists but is still doing its boot build. From here it cannot answer
729
+ // anything for seconds — which is exactly the window a developer's reload lands in, and the point of
730
+ // anchoring on the spawn rather than on the exit: the gap after it is wide and known, not a race.
731
+ await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
732
+ await host.waitForLogSince(start, /builder spawned pid=\d+ .*restart=1/, WAIT_MS);
733
+
734
+ const holdMark = host.markLog();
735
+ const startedAtMono = performance.now();
736
+ // The second route, never requested before, so this one cannot be answered from the route cache the
737
+ // request above just populated — it has to reach a builder that is not there yet.
738
+ const res = await fetch(`http://127.0.0.1:${port}/second`, { signal: AbortSignal.timeout(WAIT_MS) });
739
+ const html = await res.text();
740
+ const heldMs = Math.round(performance.now() - startedAtMono);
741
+
742
+ // What the developer sees, asserted first because it is the whole point: with the hold removed this
743
+ // same request is answered **500** by the dev error page, and nothing retries on its own — the tab
744
+ // stays broken until it is reloaded by hand. Measured both ways before this guard was committed.
745
+ expect(res.status).toBe(200);
746
+ expect(html).toContain("marker-through-recycle");
747
+ expect(html).not.toContain("reload after the builder is ready");
748
+ // And this is what stops the test passing having tested nothing: a request that arrives after the
749
+ // replacement is already ready never reaches the path under test, and everything above holds anyway.
750
+ expect(host.logs.join("").slice(holdMark)).toMatch(/holding build-route until the builder is ready/);
751
+
752
+ // The one fired at the drain, whichever side of the exit it landed on.
753
+ const drained = await drainRequest;
754
+ expect(drained.status).toBe(200);
755
+ expect(drained.html).toContain("marker-through-recycle");
756
+ console.info(`[restart-guard] page held ${heldMs}ms across the recycle, then rendered`);
757
+ });
758
+
759
+ // The other half of a resource budget, and the half this plan spent: every megabyte above was bought
760
+ // with latency — a worker process spawned per save, a boot build per recycle and per wake, a hold
761
+ // window in front of requests that land in one. Nothing measured what that cost, so a change that
762
+ // traded another second per save for another 50MB would have passed every guard in this block.
763
+ budgetTest("keeps a save's round trip inside its budget", async () => {
764
+ const harness = await createHarness();
765
+ const host = await harness.startHost({ timeoutMs: BOOT_MS });
766
+ await harness.waitForHttpText("initial-client-marker", WAIT_MS);
767
+
768
+ const samples: number[] = [];
769
+ for (let i = 1; i <= 3; i++) {
770
+ let savedAtMono = 0;
771
+ // Timed from inside the mutate callback so a retried save — Bun drops watcher events, which is why
772
+ // `editUntilSeen` exists — is measured from the attempt that actually landed, not from the first.
773
+ const { mark } = await harness.editUntilSeen(host, async (attempt) => {
774
+ await harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-latency-${i}-${attempt}`);
775
+ savedAtMono = performance.now();
776
+ });
777
+ // The moment the running dev server is serving the new code, which is everything the developer is
778
+ // waiting for. Deliberately not the browser's refresh message: that one is only published when a
779
+ // client is connected, so measuring it would measure a WebSocket probe's reconnects as well.
780
+ await host.waitForLogSince(mark, /\[hmr\] backend apply/, WAIT_MS);
781
+ samples.push(performance.now() - savedAtMono);
782
+ }
783
+
784
+ const rounded = samples.map((ms) => Math.round(ms));
785
+ console.info(`[latency-guard] save -> new code live ${rounded.join("ms, ")}ms`);
786
+ // Per save, not summed: the interesting regression is one save getting slower, and the median would
787
+ // hide a first save that pays for something the rest do not.
788
+ expect(Math.max(...rounded)).toBeLessThan(SAVE_LATENCY_BUDGET_MS);
681
789
  });
682
790
 
683
791
  budgetTest("suspends the builder when the dev server goes idle and wakes it on the next edit", async () => {
@@ -739,8 +847,49 @@ describe("dev resource budgets", () => {
739
847
  .catch(() => 0);
740
848
 
741
849
  await host.waitForLogSince(mark, /\[idle-suspend\] waking \(build-csr arrived while suspended\)/, WAIT_MS);
742
- await host.waitForLogSince(mark, /\[idle-suspend\] replaying 1 request\(s\) held during the wake/, WAIT_MS);
850
+ // `[builder]`, not `[idle-suspend]`: the same queue now also holds requests across a builder restart,
851
+ // so the replay says what it did rather than which of the two reasons put the request there. The wake
852
+ // line above is what distinguishes them, and it is asserted first for that reason.
853
+ await host.waitForLogSince(mark, /\[builder\] replaying 1 request\(s\) held while the builder was away/, WAIT_MS);
743
854
  expect(status).toBe(200);
744
855
  expect(await DevStabilityHarness.builderProcess(host.proc.pid)).not.toBeNull();
745
856
  });
857
+
858
+ // The gap on the other side of the same window: requests held across it are answered, but for a while
859
+ // nothing was *watching* across it. The suspend stops its watcher before the replacement builder has
860
+ // primed its index, and the replacement primes from the disk it finds — so a save in between is
861
+ // baseline to it, reported by nobody, and the backend goes on running the code it replaced. Phase 2
862
+ // made this window routine by recycling the builder on every rss ceiling crossing.
863
+ budgetTest("restarts the backend for a save that lands while the builder is away", async () => {
864
+ const harness = await createHarness();
865
+ const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_DEV_IDLE_SUSPEND_MS: "3000" } });
866
+ const start = host.markLog();
867
+ await harness.waitForHttpText("initial-client-marker", WAIT_MS);
868
+ await host.waitForLogSince(start, /\[idle-suspend\] .*released the builder/, WAIT_MS);
869
+
870
+ const mark = host.markLog();
871
+ const port = await harness.resolvePort();
872
+ // This request is the clock: it wakes the dev server and is answered only once the replacement
873
+ // builder is ready, so a write issued while it is in flight lands squarely inside the gap. Its own
874
+ // status is not asserted here — the write below restarts the backend, which drops the connection.
875
+ const request = fetch(`http://127.0.0.1:${port}/__csr`).catch(() => null);
876
+ // A service the backend imports, rather than this fixture's `srvkit/backendMarker.ts`: that marker is
877
+ // orphaned once `akan start` regenerates `server.ts`, so it is not in the backend graph at all and an
878
+ // edit to it restarts the backend by path role instead.
879
+ await harness.writeFile(
880
+ "lib/_fixture/fixture.service.ts",
881
+ `import { serve } from "akanjs/service";
882
+
883
+ export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
884
+ // touched while the builder was away
885
+ `,
886
+ );
887
+ await request;
888
+
889
+ await host.waitForLogSince(mark, /\[builder-gap\] 1 backend file\(s\) changed while the builder was away/, WAIT_MS);
890
+ await host.waitForLogSince(mark, /\[backend-reload\]/, WAIT_MS);
891
+ // And back to a working dev server, rather than one stuck restarting.
892
+ await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/, WAIT_MS);
893
+ await harness.waitForHttpText("initial-client-marker", WAIT_MS);
894
+ });
746
895
  });
@@ -188,6 +188,23 @@ export default function Page() {
188
188
  </main>
189
189
  );
190
190
  }
191
+ `,
192
+ ),
193
+ // A second route exists so a test can ask for a page the backend has *never* built, at a moment of
194
+ // its choosing. Route clients are built on demand and cached, so on a one-route fixture the first
195
+ // request of a test is the only one that reaches the builder at all.
196
+ this.writeFile(
197
+ "page/second/_index.tsx",
198
+ `import { ClientMarker } from "../../ui/ClientMarker";
199
+
200
+ export default function Page() {
201
+ return (
202
+ <main>
203
+ <h1>Second route</h1>
204
+ <ClientMarker />
205
+ </main>
206
+ );
207
+ }
191
208
  `,
192
209
  ),
193
210
  this.writeFile(
@@ -968,11 +985,19 @@ export const dictionary = serviceDictionary(["en", "ko"])
968
985
  static readonly #psTimeoutMs = 5_000;
969
986
 
970
987
  static async #psRows(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }> | null> {
971
- const proc = Bun.spawn(["ps", "-eo", "pid,ppid,rss,command"], {
972
- stdout: "pipe",
973
- stderr: "ignore",
974
- stdin: "ignore",
975
- });
988
+ let proc: Bun.Subprocess<"ignore", "pipe", "ignore">;
989
+ try {
990
+ proc = Bun.spawn(["ps", "-eo", "pid,ppid,rss,command"], {
991
+ stdout: "pipe",
992
+ stderr: "ignore",
993
+ stdin: "ignore",
994
+ });
995
+ } catch (error) {
996
+ // No `ps` at all — a slim container image such as `oven/bun` ships without procps. Same answer as
997
+ // a timeout, and for the same reason: this is "could not look", not "nothing is running".
998
+ console.warn(`[harness] could not run ps: ${error instanceof Error ? error.message : String(error)}`);
999
+ return null;
1000
+ }
976
1001
  const output = await Promise.race([
977
1002
  new Response(proc.stdout).text().catch(() => ""),
978
1003
  wait(DevStabilityHarness.#psTimeoutMs).then(() => null),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.4.1-rc.7",
3
+ "version": "2.4.1",
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.7",
47
+ "akanjs": "2.4.1",
48
48
  "chalk": "^5.6.2",
49
49
  "commander": "^14.0.3",
50
50
  "daisyui": "5.5.23",
@@ -64,18 +64,10 @@ export const createBarrelImportsPlugin = async (
64
64
  const hasMacroAttr = MACRO_ATTR_RE.test(source);
65
65
 
66
66
  if (!hasMacroAttr && barrels.length > 0) {
67
- // Fast pre-check to avoid tokenizing every file in the workspace.
68
- let maybe = false;
69
- for (const b of barrels) {
70
- if (source.includes(b)) {
71
- maybe = true;
72
- break;
73
- }
74
- }
75
- if (maybe) {
76
- const rewritten = await rewriteBarrelImports(source, barrels, analyzer);
77
- if (rewritten !== null) source = rewritten;
78
- }
67
+ // The pre-check that used to live here "does this file mention a barrel at all" — now
68
+ // lives inside `rewriteBarrelImports`, so every caller gets it rather than just this one.
69
+ const rewritten = await rewriteBarrelImports(source, barrels, analyzer);
70
+ if (rewritten !== null) source = rewritten;
79
71
  }
80
72
 
81
73
  if (pipeAfter) {
@@ -309,6 +301,12 @@ export const rewriteBarrelImports = async (
309
301
  barrels: string[],
310
302
  analyzer: BarrelAnalyzer,
311
303
  ): Promise<string | null> => {
304
+ // Establish there is something to rewrite before the TypeScript parser is involved. This runs on
305
+ // every source file of every dev rebuild, and parsing was by far the most expensive thing in one:
306
+ // measured across 1189 files here, 299ms and 161MB of RSS, of which **63% of files import no barrel
307
+ // at all**. A static import cannot name a specifier without that specifier appearing literally in the
308
+ // text, so a substring test is a sound filter and costs 4ms for the whole corpus.
309
+ if (!barrels.some((barrel) => source.includes(barrel))) return null;
312
310
  const statements = findImportStatements(source);
313
311
  if (statements.length === 0) return null;
314
312
 
@@ -340,7 +338,16 @@ interface ImportStatement {
340
338
 
341
339
  const findImportStatements = (source: string): ImportStatement[] => {
342
340
  const statements: ImportStatement[] = [];
343
- const sourceFile = ts.createSourceFile("barrel-imports.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
341
+ // `setParentNodes: false`: nothing below reads `node.parent`, and every position comes from
342
+ // `getStart(sourceFile)`, which takes the file explicitly. Building the parent links cost 132ms and
343
+ // 143MB of RSS across 1189 files for no reader.
344
+ const sourceFile = ts.createSourceFile(
345
+ "barrel-imports.tsx",
346
+ source,
347
+ ts.ScriptTarget.Latest,
348
+ false,
349
+ ts.ScriptKind.TSX,
350
+ );
344
351
  for (const statement of sourceFile.statements) {
345
352
  if (!ts.isImportDeclaration(statement)) continue;
346
353
  if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;