@akanjs/devkit 3.0.0-beta.25 → 3.0.0-beta.26

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.
@@ -8,7 +8,7 @@ import { bundleDefine } from "./bundleDefine";
8
8
  import type { ClientEntryDiscovery } from "./clientBuildTypes";
9
9
  import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
10
10
  import { CsrDevPaths } from "./csrDevPaths";
11
- import type { CsrDevContext, CsrDevGraph } from "./csrDevTypes";
11
+ import type { CsrDevContext } from "./csrDevTypes";
12
12
  import { DevRegistryBundler } from "./devRegistryBundler";
13
13
  import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
14
14
 
@@ -79,12 +79,6 @@ export class SsrDevBundler extends DevRegistryBundler {
79
79
  return this.#discovery;
80
80
  }
81
81
 
82
- override async wantedRoots(files: string[], graph: CsrDevGraph): Promise<Set<string>> {
83
- const entries = new Set(await this.clientEntries());
84
- const reached = await super.wantedRoots(files, graph);
85
- return new Set(files.filter((file) => reached.has(file) || entries.has(CsrDevPaths.realpath(file))));
86
- }
87
-
88
82
  protected async rootFiles(entryFiles: string[]): Promise<string[]> {
89
83
  return [...entryFiles, ...(await this.clientEntries())];
90
84
  }
@@ -1,4 +1,8 @@
1
1
  import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { CSR_DEV_PATCHING_MARKER, SSR_DEV_DIRNAME } from "akanjs/server/hmr/csrDevManifest";
2
6
  import { MemoryLimit } from "akanjs/server/memoryLimit";
3
7
  import { IncrementalBuilderHost } from "./incrementalBuilder.host";
4
8
 
@@ -172,6 +176,36 @@ describe("IncrementalBuilderHost", () => {
172
176
  host.stop();
173
177
  expect(host.recycle("after stop")).toBe(false);
174
178
  });
179
+
180
+ test("a patcher a crash turned off stays off in the host that replaces this one", async () => {
181
+ const cwdPath = await mkdtemp(path.join(os.tmpdir(), "akan-builder-patcher-"));
182
+ try {
183
+ const spawns = mockSpawns();
184
+ const app = { cwdPath } as never;
185
+ const host = new IncrementalBuilderHost({ app, entry: "/tmp/builder.ts", env: {}, onMessage: () => undefined });
186
+ host.start();
187
+ spawns[0]?.options.ipc?.({ type: "builder-ready" });
188
+ const marker = path.join(cwdPath, ".akan/artifact", SSR_DEV_DIRNAME, CSR_DEV_PATCHING_MARKER);
189
+ await Bun.write(marker, "");
190
+ spawns[0]?.options.onExit?.();
191
+ await wait(1_050);
192
+ expect(spawns[1]?.options.env?.AKAN_DEV_CSR_PATCHER).toBe("off");
193
+ expect(host.patcherOff).toBe(true);
194
+ host.stop();
195
+ const next = new IncrementalBuilderHost({
196
+ app,
197
+ entry: "/tmp/builder.ts",
198
+ env: {},
199
+ onMessage: () => undefined,
200
+ patcherOff: host.patcherOff,
201
+ });
202
+ next.start();
203
+ expect(spawns[2]?.options.env?.AKAN_DEV_CSR_PATCHER).toBe("off");
204
+ next.stop();
205
+ } finally {
206
+ await rm(cwdPath, { recursive: true, force: true });
207
+ }
208
+ });
175
209
  });
176
210
 
177
211
  describe("IncrementalBuilderHost.maxRssBytes", () => {
@@ -30,6 +30,8 @@ interface IncrementalBuilderHostOptions {
30
30
  onMessage: (message: BuilderMessage) => void;
31
31
  /** Required with `stdio: "pipe"` (an undrained pipe blocks the builder); receives decoded chunks, not lines. */
32
32
  onOutput?: (kind: "stdout" | "stderr", text: string) => void;
33
+ /** Carried over from the host this one replaces: a patcher a crash turned off stays off. */
34
+ patcherOff?: boolean;
33
35
  }
34
36
 
35
37
  /** `recycling`: the builder finishes accepted work but refuses new requests; hold them for the replacement. */
@@ -73,17 +75,29 @@ export class IncrementalBuilderHost {
73
75
  // its own exit, so an unanswered page request would spin forever.
74
76
  readonly #inFlight = new Map<number, "build-route" | "build-csr">();
75
77
  #startOptions: IncrementalBuilderStartOptions = {};
76
- constructor({ app, entry, env, stdio = "inherit", onMessage, onOutput }: IncrementalBuilderHostOptions) {
78
+ constructor({
79
+ app,
80
+ entry,
81
+ env,
82
+ stdio = "inherit",
83
+ onMessage,
84
+ onOutput,
85
+ patcherOff = false,
86
+ }: IncrementalBuilderHostOptions) {
77
87
  this.app = app;
78
88
  this.entry = entry;
79
89
  this.env = env;
80
90
  this.#stdio = stdio;
81
91
  this.#onMessage = onMessage;
82
92
  this.#onOutput = onOutput ?? null;
93
+ this.#patcherOff = patcherOff;
83
94
  }
84
95
  get status() {
85
96
  return this.#status;
86
97
  }
98
+ get patcherOff(): boolean {
99
+ return this.#patcherOff;
100
+ }
87
101
  /** For reading RSS between builds: the builder's own metrics sample the post-work peak, stale once arenas return. */
88
102
  get pid(): number | null {
89
103
  return this.#proc?.pid ?? null;
@@ -292,7 +306,8 @@ export class IncrementalBuilderHost {
292
306
  {
293
307
  stdio = "inherit",
294
308
  onOutput,
295
- }: { stdio?: DevStdioMode; onOutput?: (kind: "stdout" | "stderr", text: string) => void } = {},
309
+ patcherOff = false,
310
+ }: Pick<IncrementalBuilderHostOptions, "stdio" | "onOutput" | "patcherOff"> = {},
296
311
  ) {
297
312
  const candidates = [
298
313
  path.join(app.workspace.workspaceRoot, "pkgs/@akanjs/devkit/incrementalBuilder/incrementalBuilder.proc.ts"),
@@ -305,7 +320,7 @@ export class IncrementalBuilderHost {
305
320
  ];
306
321
  for (const c of candidates)
307
322
  if (await Bun.file(c).exists())
308
- return new IncrementalBuilderHost({ app, entry: c, env, stdio, onMessage, onOutput });
323
+ return new IncrementalBuilderHost({ app, entry: c, env, stdio, onMessage, onOutput, patcherOff });
309
324
  throw new Error(`[cli] frontend builder entry not found; looked in: ${candidates.join(", ")}`);
310
325
  }
311
326
  }
@@ -77,7 +77,7 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
77
77
  error: expect.stringContaining(reason),
78
78
  });
79
79
  expect(messages.find((message) => message.type === "build-status")).toMatchObject({
80
- data: { generation: 6, phase: "route", ok: false, message: expect.stringContaining(reason) },
80
+ data: { generation: 6, phase: "route", ok: false, message: expect.stringContaining(reason), scope: "page:/" },
81
81
  });
82
82
  expect(log).toContain(reason);
83
83
  }, 30_000);
@@ -148,6 +148,67 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
148
148
  }
149
149
  }, 60_000);
150
150
 
151
+ test("a route build's check whose own worker is killed is tried again after the crash window", async () => {
152
+ const { root } = track(await createTempApp("demo", path.join(import.meta.dir, "..", "local")));
153
+ const appDir = path.join(root, "apps/demo");
154
+ const runs = path.join(root, "ssr-runs.log");
155
+ // The boot build succeeds, the route check's worker is killed, and the retry after the window succeeds.
156
+ await writeText(
157
+ path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
158
+ `import fs from "node:fs";
159
+ const { generation, needs } = JSON.parse(process.argv[2]);
160
+ if (needs.includes("ssr")) {
161
+ fs.appendFileSync(${JSON.stringify(runs)}, Date.now() + "\\n");
162
+ if (fs.readFileSync(${JSON.stringify(runs)}, "utf8").trim().split("\\n").length === 2) process.exit(1);
163
+ }
164
+ process.send({ type: "build-batch-result", data: { generation, errors: {}, artifact: {}, optimizedFonts: { css: "", files: [] } } });
165
+ `,
166
+ );
167
+ const page = path.join(appDir, "page/_index.tsx");
168
+ await writeText(page, 'import { Card } from "../ui/Card";\nexport default Card;\n');
169
+ await writeText(path.join(appDir, "ui/Card.tsx"), '"use client";\nexport const Card = () => null;\n');
170
+
171
+ const messages: BuilderMessage[] = [];
172
+ const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
173
+ cwd: appDir,
174
+ env: {
175
+ ...process.env,
176
+ AKAN_WORKSPACE_ROOT: root,
177
+ AKAN_PUBLIC_APP_NAME: "demo",
178
+ AKAN_PUBLIC_REPO_NAME: "repo",
179
+ AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
180
+ AKAN_PUBLIC_ENV: "local",
181
+ AKAN_WATCH: "0",
182
+ },
183
+ stdio: ["ignore", "ignore", "ignore"],
184
+ serialization: "advanced",
185
+ ipc: (message: BuilderMessage) => {
186
+ messages.push(message);
187
+ },
188
+ });
189
+ const ssrRuns = async () =>
190
+ (await Bun.file(runs).exists()) ? (await Bun.file(runs).text()).trim().split("\n").map(Number) : [];
191
+ try {
192
+ await until(proc, "boot-armed", () => messages.some((message) => message.type === "boot-armed"));
193
+ proc.send({
194
+ type: "build-route",
195
+ id: 1,
196
+ routeId: "page:/",
197
+ seeds: [page],
198
+ knownEntries: [],
199
+ generation: 3,
200
+ } satisfies BuilderReq);
201
+ await until(proc, "build-route-res", () => messages.some((message) => message.type === "build-route-res"));
202
+ const deadline = Date.now() + 20_000;
203
+ while ((await ssrRuns()).length < 3 && Date.now() < deadline) await wait(100);
204
+ const [, killed = 0, retried = 0] = await ssrRuns();
205
+ expect(retried - killed).toBeGreaterThanOrEqual(9_500);
206
+ } finally {
207
+ proc.kill();
208
+ await proc.exited;
209
+ }
210
+ }, 60_000);
211
+
151
212
  test("a route build's registry check that has nothing to add reports no ssr status, so it cannot hide a failure", async () => {
152
213
  const { root } = track(await createTempApp("demo", path.join(import.meta.dir, "..", "local")));
153
214
  const appDir = path.join(root, "apps/demo");
@@ -165,7 +165,7 @@ class IncrementalBuilder {
165
165
  this.#logger.verbose(`build-route ok routeId=${msg.routeId} newEntries=${delta.newEntries.length}`);
166
166
  //? The builder's generation, not the route cache's: the cache's does not move when a fixed file maps to no entry,
167
167
  //? so the rebuild's ok would carry the failure's own number.
168
- this.#sendBuildStatus("route", { generation: this.#generation, ok: true, files: msg.seeds });
168
+ this.#sendBuildStatus("route", { generation: this.#generation, ok: true, files: msg.seeds, scope: msg.routeId });
169
169
  return {
170
170
  type: "build-route-res",
171
171
  id: msg.id,
@@ -185,24 +185,30 @@ class IncrementalBuilder {
185
185
  } catch (err) {
186
186
  const errMsg = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
187
187
  this.#logger.error(`build-route failed routeId=${msg.routeId}: ${errMsg}`);
188
- this.#sendBuildStatus("route", { generation: this.#generation, ok: false, files: msg.seeds, message: errMsg });
188
+ this.#sendBuildStatus("route", {
189
+ generation: this.#generation,
190
+ ok: false,
191
+ files: msg.seeds,
192
+ message: errMsg,
193
+ scope: msg.routeId,
194
+ });
189
195
  return { type: "build-route-res", id: msg.id, ok: false, error: errMsg };
190
196
  }
191
197
  }
192
198
  #sendBuildStatus(
193
199
  phase: BuildPhase,
194
- { generation, ok, files, message }: { generation?: number; ok: boolean; files?: string[]; message?: string },
200
+ {
201
+ generation,
202
+ ok,
203
+ files,
204
+ message,
205
+ scope,
206
+ }: { generation?: number; ok: boolean; files?: string[]; message?: string; scope?: string },
195
207
  ): void {
196
208
  if (typeof generation !== "number") return;
197
209
  BuilderChannel.emit({
198
210
  type: "build-status",
199
- data: {
200
- generation,
201
- phase,
202
- ok,
203
- files: files ?? [],
204
- message,
205
- },
211
+ data: { generation, phase, ok, files: files ?? [], message, ...(scope !== undefined ? { scope } : {}) },
206
212
  });
207
213
  }
208
214
  async #enqueueFast<T>(label: string, fn: () => Promise<T>): Promise<T> {
@@ -445,11 +451,8 @@ class IncrementalBuilder {
445
451
  if (ssrToWorker) needs.unshift("ssr");
446
452
  const batch: BatchJob = { generation, needs, changedFiles: files, trace, ...(discovery ? { discovery } : {}) };
447
453
  // A worker's registry build rewrites the directory its patcher reads, so the next save waits for it.
448
- if (needs.includes("csr") || needs.includes("ssr")) {
449
- await this.#workQueue.enqueueBatch(batch);
450
- if (needs.includes("csr")) this.#patcher?.forget();
451
- if (needs.includes("ssr")) this.#ssrPatcher?.forget();
452
- } else void this.#workQueue.enqueueBatch(batch).catch(this.#slowLaneFailed("batch"));
454
+ if (needs.includes("csr") || needs.includes("ssr")) await this.#workQueue.enqueueBatch(batch);
455
+ else void this.#workQueue.enqueueBatch(batch).catch(this.#slowLaneFailed("batch"));
453
456
  return;
454
457
  }
455
458
  if (discovery)
@@ -525,6 +528,9 @@ class IncrementalBuilder {
525
528
  const hold = await ServerGraphFile.touches(await ServerGraphFile.read(this.#artifactDir), files, (file) =>
526
529
  ServerGraphFile.clientExportsOf(file),
527
530
  );
531
+ //? Ahead of this save's own discovery job in the slow lane: which client entries a page still names decides which
532
+ //? pending roots this patch keeps.
533
+ this.#discovery.invalidate?.(files);
528
534
  return await this.#withSsrLock(
529
535
  async () => await this.#runSsrPatcher(patcher, files, { trace, hold, batchGeneration: generation }),
530
536
  );
@@ -532,14 +538,16 @@ class IncrementalBuilder {
532
538
 
533
539
  //* Adds the entries a route build names to the registry, since the tab requires them by id. A registry that cannot
534
540
  //* take them fails no route build: the page answers, and its tab waits for the registry while the overlay says why.
535
- async #ensureSsrEntries(entries: string[]): Promise<void> {
541
+ async #ensureSsrEntries(entries: string[], { crashRetry = false }: { crashRetry?: boolean } = {}): Promise<void> {
536
542
  if (entries.length === 0) return;
537
543
  //? The boot build takes every entry the routes reach, so the first page answers now and its tab waits for the
538
544
  //? registry (boot.json) instead; whatever that build missed is added right after it lands.
539
545
  const arming = this.#ssrArming;
540
546
  if (arming) {
541
547
  void this.#runBeside(
542
- arming.then(async () => await this.#ensureSsrEntries(entries)).catch(this.#slowLaneFailed("ssr-ensure")),
548
+ arming
549
+ .then(async () => await this.#ensureSsrEntries(entries, { crashRetry }))
550
+ .catch(this.#slowLaneFailed("ssr-ensure")),
543
551
  );
544
552
  return;
545
553
  }
@@ -558,6 +566,8 @@ class IncrementalBuilder {
558
566
  //? the SSR lock that save's patch waits on.
559
567
  if (this.#ssrBroken) return;
560
568
  if (this.#ssrCrashedAt !== null && Date.now() - this.#ssrCrashedAt < IncrementalBuilder.#crashRetryMs) {
569
+ //? Handed over just now with nothing run for them: the retry must find them to hand over again.
570
+ patcher?.releaseHandedOver();
561
571
  this.#retryAfterCrash(this.#ssrCrashedAt, entries);
562
572
  return;
563
573
  }
@@ -566,6 +576,9 @@ class IncrementalBuilder {
566
576
  `ssr-registry could not take a route's entries; the save that fixes it rebuilds it: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
567
577
  );
568
578
  });
579
+ //? A worker that died took these entries with it and left them pending nowhere: they are tried once more after the
580
+ //? crash window, but a retry that crashes again waits for the next route build or save.
581
+ if (this.#ssrCrashedAt !== null && !crashRetry) this.#retryAfterCrash(this.#ssrCrashedAt, entries);
569
582
  });
570
583
  }
571
584
 
@@ -580,7 +593,9 @@ class IncrementalBuilder {
580
593
  const retried = [...this.#crashRetryEntries];
581
594
  this.#crashRetryEntries.clear();
582
595
  if (this.shuttingDown || retried.length === 0) return;
583
- void this.#runBeside(this.#ensureSsrEntries(retried)).catch(this.#slowLaneFailed("ssr-crash-retry"));
596
+ void this.#runBeside(this.#ensureSsrEntries(retried, { crashRetry: true })).catch(
597
+ this.#slowLaneFailed("ssr-crash-retry"),
598
+ );
584
599
  },
585
600
  Math.max(0, crashedAt + IncrementalBuilder.#crashRetryMs - Date.now()),
586
601
  );
@@ -654,8 +669,12 @@ class IncrementalBuilder {
654
669
 
655
670
  // Runs the worker directly, under the SSR lock its callers hold: queueing it could wait on a batch that waits on them.
656
671
  async #runSsrWorker(generation: number): Promise<void> {
657
- const result = await this.#runBatch({ generation, needs: ["ssr"], changedFiles: [] });
658
- this.#ssrPatcher?.forget();
672
+ let result: BuildBatchResult;
673
+ try {
674
+ result = await this.#runBatch({ generation, needs: ["ssr"], changedFiles: [] });
675
+ } finally {
676
+ this.#ssrPatcher?.forget();
677
+ }
659
678
  this.#noteSsrWorker(result);
660
679
  if (result.errors.ssr) throw new Error(result.errors.ssr);
661
680
  }
@@ -741,9 +760,20 @@ class IncrementalBuilder {
741
760
  //? The slow lane runs in order, so every batch up to this one has had its discovery job.
742
761
  this.#discoveredThrough = Math.max(this.#discoveredThrough, work.generation);
743
762
  // A worker writing ssr-dev must not overlap the boot build, which no longer runs in this lane.
744
- if (work.needs.includes("ssr"))
745
- await this.#withSsrLock(async () => this.#noteSsrWorker(await this.#runBatch(work)));
746
- else await this.#runBatch(work);
763
+ //? Forgotten inside the lock: a route build's check waiting on it would patch the old state over the worker's.
764
+ try {
765
+ if (work.needs.includes("ssr"))
766
+ await this.#withSsrLock(async () => {
767
+ try {
768
+ this.#noteSsrWorker(await this.#runBatch(work));
769
+ } finally {
770
+ this.#ssrPatcher?.forget();
771
+ }
772
+ });
773
+ else await this.#runBatch(work);
774
+ } finally {
775
+ if (work.needs.includes("csr")) this.#patcher?.forget();
776
+ }
747
777
  }
748
778
 
749
779
  async #refreshDiscovery({ files, refresh, generation }: DiscoveryJob): Promise<void> {
@@ -762,8 +792,12 @@ class IncrementalBuilder {
762
792
  if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
763
793
  // A need the worker died before reporting goes red here instead of looking silently successful.
764
794
  if (result.crashed) {
795
+ const crashedNeeds = result.crashedNeeds ?? needs;
796
+ //? A worker that died wrote no failure for the roots handed to it: they must not wait on one that never comes.
797
+ if (crashedNeeds.includes("csr")) this.#patcher?.releaseHandedOver();
798
+ if (crashedNeeds.includes("ssr")) this.#ssrPatcher?.releaseHandedOver();
765
799
  // `base` is not a `BuildPhase` and never comes through here: `#buildBootDeps` throws into the degraded boot.
766
- for (const need of result.crashedNeeds ?? needs)
800
+ for (const need of crashedNeeds)
767
801
  if (need !== "base")
768
802
  this.#sendBuildStatus(need, {
769
803
  generation,
@@ -589,6 +589,7 @@ describe("dev resource budgets", () => {
589
589
  // A ceiling the replacement is back over within the interval is reported, and still enforced (recycles are
590
590
  // throttled), never disabled for the session. Whether it is back over depends on the machine's allocator, so the
591
591
  // warning is asserted only when it is; the decision itself is unit-tested (devHostPolicy).
592
+ const settledFrom = host.markLog();
592
593
  for (let i = 1; i <= 3; i++) {
593
594
  const { mark } = await harness.editUntilSeen(host, (attempt) =>
594
595
  harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-settled-${i}-${attempt}`),
@@ -596,8 +597,14 @@ describe("dev resource budgets", () => {
596
597
  await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
597
598
  }
598
599
  const settled = await DevStabilityHarness.builderProcess(host.proc.pid);
599
- if ((settled?.rssBytes ?? 0) >= 200 * MB)
600
- await host.waitForLogSince(start, /ceiling costs about one boot build per interval/, WAIT_MS);
600
+ const warned = /ceiling costs about one boot build per interval/.test(host.logs.join("").slice(start));
601
+ //? Past the interval (a slow machine) the next report recycles instead of warning: either answer settles it.
602
+ if ((settled?.rssBytes ?? 0) >= 200 * MB && !warned)
603
+ await host.waitForLogSince(
604
+ settledFrom,
605
+ /ceiling costs about one boot build per interval|recycling builder pid=\d+|skipped: the builder fell to/,
606
+ WAIT_MS,
607
+ );
601
608
  expect(host.logs.join("").slice(start)).not.toMatch(/no longer enforcing it this session/);
602
609
  });
603
610
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-beta.25",
3
+ "version": "3.0.0-beta.26",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -47,7 +47,7 @@
47
47
  "@earendil-works/pi-tui": "0.86.1",
48
48
  "@inquirer/prompts": "^8.4.3",
49
49
  "@tailwindcss/node": "^4.3.0",
50
- "akanjs": "3.0.0-beta.25",
50
+ "akanjs": "3.0.0-beta.26",
51
51
  "chalk": "^5.6.2",
52
52
  "commander": "^14.0.3",
53
53
  "dayjs": "^1.11.20",