@akanjs/devkit 2.4.1-rc.6 → 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.
@@ -17,6 +17,12 @@ const UNREADABLE = Number.NaN;
17
17
  export interface SourceMtimeIndexOptions {
18
18
  roots: string[];
19
19
  classifier?: HmrChangeClassifier;
20
+ /**
21
+ * How close to "now" a directory's mtime may be before this scan's reading of it is treated as
22
+ * possibly stale. Raise it for a filesystem whose timestamps are coarser than the 1ms Linux stamps
23
+ * directories with — some network mounts stamp whole seconds.
24
+ */
25
+ dirSettleMs?: number;
20
26
  }
21
27
 
22
28
  /**
@@ -32,20 +38,41 @@ export interface SourceMtimeIndexOptions {
32
38
  * Re-stating the tracked set costs ~15ms against ~1300 source files here, where a fresh walk costs
33
39
  * ~70ms — the walk has to visit ~40k entries (`ios/`, `android/`, `public/`) to find those 1300. So
34
40
  * changes are found by re-stating known files plus re-reading only the directories whose own mtime
35
- * moved, which is what adding or removing an entry bumps.
41
+ * moved, which is what adding or removing an entry bumps — with one exception for the window in which
42
+ * that mtime cannot be trusted, see `#defaultDirSettleMs`.
36
43
  */
37
44
  export class SourceMtimeIndex {
45
+ /**
46
+ * How close to "now" a directory's mtime may be before this scan's reading of it is treated as
47
+ * possibly stale, and the directory re-read next time regardless of what its mtime says.
48
+ *
49
+ * Linux stamps directory times from a coarse clock. Measured on Bun 1.3.14 under Docker, 400
50
+ * back-to-back `mkdir`s left the parent's mtime unmoved **319 times on overlayfs and 324 times on
51
+ * ext4**, smallest observable step 1ms; macOS APFS (0.042ms) and a virtiofs bind mount (0.29ms) missed
52
+ * none. So a directory mutated in the same millisecond as the value this index recorded — but after the
53
+ * walk that recorded it — leaves no trace at all, and because its files were never tracked, later edits
54
+ * to them go unreported too. That is permanent for the life of the process, which is what makes it worth
55
+ * a second look rather than a note.
56
+ *
57
+ * 20ms covers a 1ms tick with room for a `HZ=100` kernel's 10ms, and costs one extra `readdir` per
58
+ * directory touched in the last 20ms — during a save, a handful.
59
+ */
60
+ static readonly #defaultDirSettleMs = 20;
61
+ readonly #dirSettleMs: number;
38
62
  readonly #roots: string[];
39
63
  readonly #classifier: HmrChangeClassifier;
40
64
  readonly #files = new Map<string, TrackedFile>();
41
65
  readonly #dirs = new Map<string, number>();
42
66
  readonly #unreadable = new Map<string, string>();
67
+ /** Directories whose recorded mtime was too fresh to trust, re-read on the next scan. */
68
+ readonly #unsettled = new Set<string>();
43
69
  #primed = false;
44
70
  #queue: Promise<unknown> = Promise.resolve();
45
71
 
46
- constructor({ roots, classifier }: SourceMtimeIndexOptions) {
72
+ constructor({ roots, classifier, dirSettleMs }: SourceMtimeIndexOptions) {
47
73
  this.#roots = SourceMtimeIndex.#pruneNestedRoots(roots);
48
74
  this.#classifier = classifier ?? new HmrChangeClassifier();
75
+ this.#dirSettleMs = dirSettleMs ?? SourceMtimeIndex.#defaultDirSettleMs;
49
76
  }
50
77
 
51
78
  get primed(): boolean {
@@ -67,12 +94,22 @@ export class SourceMtimeIndex {
67
94
  return [...this.#unreadable].map(([file, code]) => ({ path: file, code }));
68
95
  }
69
96
 
97
+ /**
98
+ * Whether the last scan left a directory whose mtime was too fresh to trust. The caller should scan
99
+ * again once the timestamp has settled, since nothing else will look at that directory until something
100
+ * moves its mtime — and on a coarse clock the change that should have moved it already happened.
101
+ */
102
+ get hasUnsettledDirs(): boolean {
103
+ return this.#unsettled.size > 0;
104
+ }
105
+
70
106
  /** Record the current state as the baseline. Reports nothing; call before the first `collectChanges`. */
71
107
  async prime(): Promise<void> {
72
108
  await this.#serialize(async () => {
73
109
  this.#files.clear();
74
110
  this.#dirs.clear();
75
111
  this.#unreadable.clear();
112
+ this.#unsettled.clear();
76
113
  await Promise.all(this.#roots.map((root) => this.#walk(root, null)));
77
114
  this.#primed = true;
78
115
  });
@@ -168,7 +205,7 @@ export class SourceMtimeIndex {
168
205
  * content changes, so this finds creations and deletions the file pass cannot see.
169
206
  */
170
207
  async #collectDirChanges(changed: Set<string>): Promise<void> {
171
- const moved: string[] = [];
208
+ const moved = new Set<string>();
172
209
  const gone: string[] = [];
173
210
  await Promise.all(
174
211
  [...this.#dirs.entries()].map(async ([dir, mtimeMs]) => {
@@ -183,9 +220,13 @@ export class SourceMtimeIndex {
183
220
  }
184
221
  // `UNREADABLE` is NaN, so a directory retained from a failed read always mismatches and is
185
222
  // rewalked here — that retry is what lets a transient failure recover on its own.
186
- if (stats.mtimeMs !== mtimeMs) moved.push(dir);
223
+ if (stats.mtimeMs !== mtimeMs) moved.add(dir);
187
224
  }),
188
225
  );
226
+ // An unsettled directory is re-read whether or not its mtime moved: on a coarse clock a mutation
227
+ // that landed in the same tick as the recorded value leaves it identical, so the mtime is exactly
228
+ // the signal that cannot be trusted here.
229
+ for (const dir of this.#unsettled) if (this.#dirs.has(dir)) moved.add(dir);
189
230
  for (const dir of gone) this.#forget(dir);
190
231
  // Sequential: a moved directory can reveal a new subtree, and walking those in order keeps the
191
232
  // number of concurrent `readdir` calls proportional to the change rather than to the tree.
@@ -216,6 +257,8 @@ export class SourceMtimeIndex {
216
257
  const known = this.#dirs.has(dir);
217
258
  this.#dirs.set(dir, dirStats.mtimeMs);
218
259
  this.#unreadable.delete(dir);
260
+ if (this.#isUnsettled(dirStats.mtimeMs)) this.#unsettled.add(dir);
261
+ else this.#unsettled.delete(dir);
219
262
  const descend: string[] = [];
220
263
  const present = new Set<string>();
221
264
  let blind = false;
@@ -255,7 +298,11 @@ export class SourceMtimeIndex {
255
298
  changed?.add(abs);
256
299
  }
257
300
  }
258
- if (blind) this.#dirs.set(dir, UNREADABLE);
301
+ // `UNREADABLE` already forces a re-walk every scan, so the freshness retry would only duplicate it.
302
+ if (blind) {
303
+ this.#dirs.set(dir, UNREADABLE);
304
+ this.#unsettled.delete(dir);
305
+ }
259
306
  // Known subdirectories carry their own mtime check, so only unseen ones need walking. Concurrent
260
307
  // because `prime` reaches every directory through here.
261
308
  await Promise.all(descend.filter((sub) => !this.#dirs.has(sub)).map((sub) => this.#walk(sub, changed)));
@@ -269,6 +316,7 @@ export class SourceMtimeIndex {
269
316
  #markUnreadable(dir: string, err: NodeJS.ErrnoException): void {
270
317
  this.#dirs.set(dir, UNREADABLE);
271
318
  this.#unreadable.set(dir, err.code ?? "EUNKNOWN");
319
+ this.#unsettled.delete(dir);
272
320
  }
273
321
 
274
322
  /** Drop a directory and everything the index holds beneath it. */
@@ -276,9 +324,22 @@ export class SourceMtimeIndex {
276
324
  const prefix = `${dir}${path.sep}`;
277
325
  this.#dirs.delete(dir);
278
326
  this.#unreadable.delete(dir);
327
+ this.#unsettled.delete(dir);
279
328
  for (const known of [...this.#dirs.keys()]) if (known.startsWith(prefix)) this.#dirs.delete(known);
280
329
  // Otherwise a gap under a deleted directory is reported forever, since nothing revisits it to clear.
281
330
  for (const known of [...this.#unreadable.keys()]) if (known.startsWith(prefix)) this.#unreadable.delete(known);
331
+ // Left behind, this would re-walk a path that no longer exists on every scan.
332
+ for (const known of [...this.#unsettled]) if (known.startsWith(prefix)) this.#unsettled.delete(known);
333
+ }
334
+
335
+ /**
336
+ * Whether a directory's mtime is recent enough that another mutation could share its timestamp tick.
337
+ *
338
+ * Symmetric so a filesystem clock running *ahead* of this process — a network mount, a container with a
339
+ * skewed host — does not read as permanently fresh and re-walk the whole tree on every scan.
340
+ */
341
+ #isUnsettled(mtimeMs: number): boolean {
342
+ return Math.abs(Date.now() - mtimeMs) < this.#dirSettleMs;
282
343
  }
283
344
 
284
345
  /** `null` stats with the errno kept, so callers can tell "not there" from "could not look". */
@@ -74,7 +74,16 @@ export class BuildBatchRunner {
74
74
  );
75
75
  return result;
76
76
  }
77
- const message = `build worker exited with code ${exitCode} before reporting a result`;
77
+ // A worker the kernel OOM-killed exits with code `null` and `SIGKILL`, which without the signal
78
+ // reads exactly like an ordinary crash — and the two have opposite fixes: one is a build error to
79
+ // find, the other is a memory limit to raise. The peak here is the largest transient in the tree
80
+ // (a boot build measured 548MB on a tenant app, 1.1GB on apps/akan), so on a small sandbox this is
81
+ // the process the kernel reaches for first.
82
+ const message = proc.signalCode
83
+ ? `build worker was killed by ${proc.signalCode} before reporting a result${
84
+ proc.signalCode === "SIGKILL" ? " — most often the kernel OOM killer; check the sandbox's memory limit" : ""
85
+ }`
86
+ : `build worker exited with code ${exitCode} before reporting a result`;
78
87
  this.#logger.error(`[build-batch] generation=${request.generation} ${message}`);
79
88
  return {
80
89
  generation: request.generation,
@@ -76,13 +76,22 @@ describe("BuilderChannel", () => {
76
76
  expect(await sendThenExit(200_000, { mode: "drain", payload: "css" })).toMatchObject([{ type: "css-updated" }]);
77
77
  });
78
78
 
79
- test("without the flush wait the same messages are lost, which is why this class exists", async () => {
80
- // Controls, not requirements: if a future bun flushes ipc writes on exit, these fail and say so.
81
- expect(await sendThenExit(1_000_000, { mode: "bare", payload: "manifest" })).toEqual([]);
82
- // 20KB of css, far below the 64KB the manifest shape survives one long string dies much earlier,
83
- // so no size threshold would have been safe to special-case.
84
- expect(await sendThenExit(20_000, { mode: "bare", payload: "css" })).toEqual([]);
85
- });
79
+ /**
80
+ * darwin only, measured: on Linux a 1MB message sent immediately before `process.exit` **arrives in
81
+ * full**, so the loss this control demonstrates does not reproduce there and the assertion would fail
82
+ * for the right reason on the wrong platform. `BuilderChannel` itself stays correct and costs nothing
83
+ * on Linux the bug it prevents is one macOS developers hit and a Linux fleet does not.
84
+ */
85
+ test.skipIf(process.platform !== "darwin")(
86
+ "without the flush wait the same messages are lost, which is why this class exists",
87
+ async () => {
88
+ // Controls, not requirements: if a future bun flushes ipc writes on exit, these fail and say so.
89
+ expect(await sendThenExit(1_000_000, { mode: "bare", payload: "manifest" })).toEqual([]);
90
+ // 20KB of css, far below the 64KB the manifest shape survives — one long string dies much earlier,
91
+ // so no size threshold would have been safe to special-case.
92
+ expect(await sendThenExit(20_000, { mode: "bare", payload: "css" })).toEqual([]);
93
+ },
94
+ );
86
95
 
87
96
  test("drain resolves only once every tracked send has flushed", async () => {
88
97
  const send = process.send;
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BuilderReq, BuilderRes } from "akanjs/server";
3
+ import { BuilderRequestRouter } from "./builderRequestRouter";
4
+
5
+ const routeReq = (id: number, routeId: string): BuilderReq => ({
6
+ type: "build-route",
7
+ id,
8
+ routeId,
9
+ seeds: [],
10
+ knownEntries: [],
11
+ });
12
+
13
+ /** `routeId` is what tells one generation's answer from the other's; the rest is an empty delta. */
14
+ const routeRes = (id: number, routeId: string): Extract<BuilderRes, { ok: true }> => ({
15
+ type: "build-route-res",
16
+ id,
17
+ ok: true,
18
+ data: { manifestDelta: {}, ssrManifestDelta: {}, newEntries: [], clientDeps: [], routeId },
19
+ });
20
+
21
+ describe("BuilderRequestRouter", () => {
22
+ test("renumbers a request on the way out and restores the backend's id on the answer", () => {
23
+ const router = new BuilderRequestRouter();
24
+ router.startGeneration();
25
+
26
+ // Everything but the id travels untouched; the builder never learns it was renumbered.
27
+ const first = router.issue(routeReq(1, "/home"));
28
+ const second = router.issue(routeReq(2, "/about"));
29
+ expect(first.routeId).toBe("/home");
30
+ expect(first.id).not.toBe(second.id);
31
+ expect(router.inFlightCount).toBe(2);
32
+
33
+ expect(router.settle(routeRes(first.id, "/home"))?.id).toBe(1);
34
+ expect(router.settle(routeRes(second.id, "/about"))?.id).toBe(2);
35
+ expect(router.inFlightCount).toBe(0);
36
+ });
37
+
38
+ /**
39
+ * The defect this class exists for. `BuilderRpc` numbers from 1 in every backend process, so without
40
+ * renumbering the second generation's `id: 1` request is settled by the first generation's answer —
41
+ * a page rendered against another route's client manifest — and its own answer is then dropped.
42
+ */
43
+ test("does not deliver a dead generation's answer to the backend that replaced it", () => {
44
+ const router = new BuilderRequestRouter();
45
+ router.startGeneration();
46
+ const stale = router.issue(routeReq(1, "/old"));
47
+
48
+ router.startGeneration();
49
+ const fresh = router.issue(routeReq(1, "/new"));
50
+ expect(fresh.id).not.toBe(stale.id);
51
+
52
+ expect(router.settle(routeRes(stale.id, "/old"))).toBeNull();
53
+ const answer = router.settle(routeRes(fresh.id, "/new"));
54
+ expect(answer?.id).toBe(1);
55
+ expect(answer?.data.routeId).toBe("/new");
56
+ });
57
+
58
+ test("drops an answer that arrives twice, and one nobody asked for", () => {
59
+ const router = new BuilderRequestRouter();
60
+ router.startGeneration();
61
+ const outgoing = router.issue(routeReq(7, "/home"));
62
+
63
+ expect(router.settle(routeRes(outgoing.id, "/home"))?.id).toBe(7);
64
+ expect(router.settle(routeRes(outgoing.id, "/home"))).toBeNull();
65
+ expect(router.settle(routeRes(9999, "/home"))).toBeNull();
66
+ });
67
+
68
+ test("withdraw releases an id whose send failed", () => {
69
+ const router = new BuilderRequestRouter();
70
+ router.startGeneration();
71
+ const outgoing = router.issue(routeReq(4, "/home"));
72
+
73
+ // The caller answers the backend itself in this case, so a late builder answer must not answer it again.
74
+ router.withdraw(outgoing.id);
75
+ expect(router.inFlightCount).toBe(0);
76
+ expect(router.settle(routeRes(outgoing.id, "/home"))).toBeNull();
77
+ });
78
+
79
+ test("abandons the previous generation's requests rather than carrying them forward", () => {
80
+ const router = new BuilderRequestRouter();
81
+ router.startGeneration();
82
+ router.issue(routeReq(1, "/a"));
83
+ router.issue(routeReq(2, "/b"));
84
+ expect(router.inFlightCount).toBe(2);
85
+
86
+ expect(router.startGeneration()).toBe(2);
87
+ expect(router.inFlightCount).toBe(0);
88
+ });
89
+ });
@@ -0,0 +1,66 @@
1
+ import type { BuilderCsrReq, BuilderCsrRes, BuilderReq, BuilderRes } from "akanjs/server";
2
+
3
+ type BuilderRequest = BuilderReq | BuilderCsrReq;
4
+ type BuilderResponse = BuilderRes | BuilderCsrRes;
5
+
6
+ /**
7
+ * Correlation ids for builder requests, renumbered by the dev host so they survive a backend restart.
8
+ *
9
+ * `BuilderRpc` numbers its requests from 1 in each backend *process*, and the host relays them to a
10
+ * builder that outlives the backend. So after a restart the two generations collide on id 1: the builder
11
+ * answers the request the *previous* backend made, the host relays it, and the new backend settles its own
12
+ * request of that number with another route's manifest delta — a page rendered against client modules that
13
+ * were never built for it. The answer it was actually waiting for then arrives to an empty pending map and
14
+ * is dropped, so the correct build is discarded too.
15
+ *
16
+ * A host-owned id fixes it without touching the protocol: neither the backend nor the builder learns that
17
+ * anything was renumbered, and a response whose generation is gone is discarded instead of misdelivered.
18
+ */
19
+ export class BuilderRequestRouter {
20
+ #generation = 0;
21
+ #nextId = 1;
22
+ readonly #inFlight = new Map<number, { backendId: number; generation: number }>();
23
+
24
+ get generation(): number {
25
+ return this.#generation;
26
+ }
27
+
28
+ get inFlightCount(): number {
29
+ return this.#inFlight.size;
30
+ }
31
+
32
+ /**
33
+ * Begin a new backend generation, abandoning every request the previous one owned.
34
+ *
35
+ * Nothing is answered on the way out: the process that asked is gone, and its `BuilderRpc` went with it.
36
+ */
37
+ startGeneration(): number {
38
+ this.#generation += 1;
39
+ this.#inFlight.clear();
40
+ return this.#generation;
41
+ }
42
+
43
+ /** The request to forward to the builder, carrying this host's id in place of the backend's. */
44
+ issue<T extends BuilderRequest>(message: T): T {
45
+ const id = this.#nextId++;
46
+ this.#inFlight.set(id, { backendId: message.id, generation: this.#generation });
47
+ return { ...message, id };
48
+ }
49
+
50
+ /** Give an id back when the send failed, so the caller answers the backend itself. */
51
+ withdraw(id: number): void {
52
+ this.#inFlight.delete(id);
53
+ }
54
+
55
+ /**
56
+ * The response to forward to the backend with its own id restored, or `null` when no live backend is
57
+ * waiting for it — either it was never issued or the generation that issued it has been replaced.
58
+ */
59
+ settle<T extends BuilderResponse>(message: T): T | null {
60
+ const request = this.#inFlight.get(message.id);
61
+ if (!request) return null;
62
+ this.#inFlight.delete(message.id);
63
+ if (request.generation !== this.#generation) return null;
64
+ return { ...message, id: request.backendId };
65
+ }
66
+ }
@@ -1,4 +1,5 @@
1
1
  import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import { MemoryLimit } from "akanjs/server/memoryLimit";
2
3
  import { IncrementalBuilderHost } from "./incrementalBuilder.host";
3
4
 
4
5
  const originalSpawn = Bun.spawn;
@@ -77,6 +78,12 @@ describe("IncrementalBuilderHost", () => {
77
78
  expect(spawns[0]?.proc.kill).not.toHaveBeenCalled();
78
79
  expect(host.recycle("second request")).toBe(false);
79
80
 
81
+ // The drain refuses everything that arrives during it, so a request sent here is a request the
82
+ // developer gets an error page for. Reporting the state is what lets the host hold it instead.
83
+ expect(host.status).toBe("recycling");
84
+ expect(host.send({ type: "build-route", id: 9, routeId: "z", seeds: [], knownEntries: [] })).toBe(false);
85
+ expect(spawns[0]?.proc.send).toHaveBeenCalledTimes(1);
86
+
80
87
  // A planned exit skips the crash backoff — the dev server has no file watcher until it is back.
81
88
  spawns[0]?.options.onExit?.();
82
89
  expect(spawns).toHaveLength(2);
@@ -215,7 +222,12 @@ describe("IncrementalBuilderHost.maxRssBytes", () => {
215
222
  }
216
223
  };
217
224
 
218
- test("defaults to a dev ceiling well above a fresh boot", () => {
225
+ /**
226
+ * Only where nothing else supplies a limit, which is not true in a container: a cgroup `memory.max`
227
+ * makes `resolveMaxRssBytes` derive from that instead. Measured under `docker --memory=7g`, this
228
+ * returned 2.45GiB rather than the fallback — the assertion was about the runner, not the code.
229
+ */
230
+ test.skipIf(MemoryLimit.readCgroupBytes() !== null)("defaults to a dev ceiling well above a fresh boot", () => {
219
231
  withEnv(
220
232
  { AKAN_BUILDER_MAX_RSS_MB: undefined, AKAN_BUILDER_MAX_RSS: undefined, AKAN_MEMORY_LIMIT: undefined },
221
233
  () => {
@@ -224,6 +236,21 @@ describe("IncrementalBuilderHost.maxRssBytes", () => {
224
236
  );
225
237
  });
226
238
 
239
+ test("derives the ceiling from the sandbox's own limit, wherever it runs", () => {
240
+ // The property the fallback test cannot assert in a container, stated so it holds on every runner:
241
+ // the builder gets 35% of whatever the sandbox is allowed.
242
+ withEnv(
243
+ {
244
+ AKAN_BUILDER_MAX_RSS_MB: undefined,
245
+ AKAN_BUILDER_MAX_RSS: undefined,
246
+ AKAN_MEMORY_LIMIT: String(4 * 1024 * 1024 * 1024),
247
+ },
248
+ () => {
249
+ expect(IncrementalBuilderHost.maxRssBytes()).toBe(Math.floor(4 * 1024 * 1024 * 1024 * 0.35));
250
+ },
251
+ );
252
+ });
253
+
227
254
  test("honors an explicit override and treats 0 as unbounded", () => {
228
255
  withEnv({ AKAN_BUILDER_MAX_RSS_MB: "700" }, () => {
229
256
  expect(IncrementalBuilderHost.maxRssBytes()).toBe(700 * 1024 * 1024);
@@ -21,12 +21,22 @@ interface IncrementalBuilderHostOptions {
21
21
  onMessage: (message: BuilderMessage) => void;
22
22
  }
23
23
 
24
- type IncrementalBuilderStatus = "starting" | "ready" | "restarting" | "stopped";
24
+ /**
25
+ * `recycling` is the drain: the builder is still alive and still holds the work it accepted, but it
26
+ * refuses anything new. Saying so here is what lets the host hold those requests for the replacement
27
+ * instead of handing the developer the refusal.
28
+ */
29
+ export type IncrementalBuilderStatus = "starting" | "ready" | "recycling" | "restarting" | "stopped";
25
30
 
26
31
  interface IncrementalBuilderStartOptions {
27
32
  onExit?: () => void;
28
33
  onReady?: () => void;
29
34
  onRestartReady?: () => void;
35
+ /**
36
+ * The builder is gone and a replacement is on its way. Distinct from `onExit`, which reports the
37
+ * builder giving up: this one says the dev server is temporarily without a watcher.
38
+ */
39
+ onAway?: () => void;
30
40
  /**
31
41
  * Ask the builder to re-announce the artifact it boots with. Needed whenever a *previous* builder's
32
42
  * artifact may still be live in a running backend — after an rss recycle, and after an idle wake.
@@ -142,6 +152,9 @@ export class IncrementalBuilderHost {
142
152
  this.#startOptions.onExit?.();
143
153
  return;
144
154
  }
155
+ // Said once for both branches below, because both leave the tree unwatched until a replacement
156
+ // has primed its own index — and an edit that lands in that window is reported by nobody.
157
+ this.#startOptions.onAway?.();
145
158
  // A recycle is a planned exit, so it neither counts as a failed attempt nor waits out the
146
159
  // crash backoff — the dev server is without a watcher until the replacement is up.
147
160
  if (wasRecycle) {
@@ -182,6 +195,11 @@ export class IncrementalBuilderHost {
182
195
  const proc = this.#proc;
183
196
  if (!this.send({ type: "builder-shutdown", reason })) return false;
184
197
  this.#recycleRequested = true;
198
+ // From here the builder answers nothing new — it refuses every request that arrives during the
199
+ // drain. Leaving the status at `ready` is what used to let those requests through to be refused,
200
+ // one at a time, into the dev error page a recycle is supposed to be invisible to. `ready` the
201
+ // field is deliberately untouched: `onExit` reads it to tell a planned exit from a boot failure.
202
+ this.#status = "recycling";
185
203
  this.logger.info(`recycling builder pid=${proc.pid} (${reason})`);
186
204
  this.#recycleTimer = setTimeout(() => {
187
205
  this.#recycleTimer = null;
@@ -215,7 +233,11 @@ export class IncrementalBuilderHost {
215
233
  }
216
234
  send(message: BuilderMessage): boolean {
217
235
  if (!this.#proc || this.#status !== "ready") {
218
- this.logger.warn(`incrementalBuilderHost is ${this.#status}; cannot send ${message.type}`);
236
+ // A builder on its way back is routine and the host holds what it refuses here, so only the
237
+ // states nothing is recovering from are worth a warning.
238
+ if (this.#status === "recycling" || this.#status === "restarting")
239
+ this.logger.verbose(`incrementalBuilderHost is ${this.#status}; ${message.type} is for the replacement`);
240
+ else this.logger.warn(`incrementalBuilderHost is ${this.#status}; cannot send ${message.type}`);
219
241
  return false;
220
242
  }
221
243
  try {
@@ -1,19 +1,19 @@
1
1
  import path from "node:path";
2
- // Subpath imports only: the `@akanjs/devkit` root barrel re-exports all 41 modules, which would drag
3
- // @trapezedev/project, the @langchain stack, ssh2, ink and the cloud stack into the builder process.
2
+ // Module paths, never a barrel. The `@akanjs/devkit` root re-exports all 41 modules, which would drag
3
+ // @trapezedev/project, the @langchain stack, ssh2, ink and the cloud stack in; and `frontendBuild`'s own
4
+ // barrel reaches `cssCompiler`/`ssrBaseArtifactBuilder`, which pull tailwindcss + @tailwindcss/node
5
+ // (~40MB) into a process that then holds them for the whole dev session. Phase 2 moved css compilation
6
+ // into the batch worker, so this process has no use for them — `entryModuleGraph.test.ts` keeps it that way.
4
7
  import type { App } from "@akanjs/devkit/commandDecorators";
5
8
  import { AppExecutor, WorkspaceExecutor } from "@akanjs/devkit/executors";
6
- import {
7
- AutoImportSync,
8
- type ChangeBatch,
9
- type ClientEntryDiscovery,
10
- DevChangePlanner,
11
- DevGeneratedIndexSync,
12
- GraphClientEntryDiscovery,
13
- HmrWatcher,
14
- RouteClientBuilder,
15
- WatchRootResolver,
16
- } from "@akanjs/devkit/frontendBuild";
9
+ import { AutoImportSync } from "@akanjs/devkit/frontendBuild/autoImportSync";
10
+ import type { ClientEntryDiscovery } from "@akanjs/devkit/frontendBuild/clientBuildTypes";
11
+ import { GraphClientEntryDiscovery } from "@akanjs/devkit/frontendBuild/clientEntryDiscovery";
12
+ import { DevChangePlanner } from "@akanjs/devkit/frontendBuild/devChangePlanner";
13
+ import { DevGeneratedIndexSync } from "@akanjs/devkit/frontendBuild/devGeneratedIndexSync";
14
+ import { HmrWatcher } from "@akanjs/devkit/frontendBuild/hmrWatcher";
15
+ import { RouteClientBuilder } from "@akanjs/devkit/frontendBuild/routeClientBuilder";
16
+ import { WatchRootResolver } from "@akanjs/devkit/frontendBuild/watchRootResolver";
17
17
  import { Logger } from "akanjs/common";
18
18
  import type {
19
19
  BaseBuildArtifact,
@@ -23,6 +23,7 @@ import type {
23
23
  BuilderRes,
24
24
  BuildPhase,
25
25
  BuildRouteResultPayload,
26
+ ChangeBatch,
26
27
  } from "akanjs/server";
27
28
  import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
28
29
  import { BuildBatchRunner } from "./buildBatchRunner";