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

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,326 @@
1
+ import type { Dirent, Stats } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { HmrChangeClassifier } from "./hmrChangeClassifier";
5
+
6
+ interface TrackedFile {
7
+ mtimeMs: number;
8
+ size: number;
9
+ }
10
+
11
+ /**
12
+ * A directory the index could not read, held with this mtime so every subsequent scan sees a mismatch
13
+ * and retries it. `NaN !== NaN`, which is exactly the "always stale" semantics wanted here.
14
+ */
15
+ const UNREADABLE = Number.NaN;
16
+
17
+ export interface SourceMtimeIndexOptions {
18
+ roots: string[];
19
+ classifier?: HmrChangeClassifier;
20
+ }
21
+
22
+ /**
23
+ * A snapshot of every interesting source file's mtime under the watch roots, so the watcher can answer
24
+ * "what actually changed" without trusting `fs.watch` event payloads.
25
+ *
26
+ * Bun 1.3.14's recursive `fs.watch` on macOS reports roughly **one path per ~200ms coalescing window**
27
+ * and discards every other path changed in that window. Measured on this repo: saving 5 files together
28
+ * reports 1; saving 20 reports 2; a single unrelated write landing first in the window hides a
29
+ * concurrent save entirely. Node 22 reports all 20 on the same tree, so this is a Bun defect rather
30
+ * than an FSEvents limitation (`local/optimize-resource/06-watcher-dropped-event.md`).
31
+ *
32
+ * Re-stating the tracked set costs ~15ms against ~1300 source files here, where a fresh walk costs
33
+ * ~70ms — the walk has to visit ~40k entries (`ios/`, `android/`, `public/`) to find those 1300. So
34
+ * 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.
36
+ */
37
+ export class SourceMtimeIndex {
38
+ readonly #roots: string[];
39
+ readonly #classifier: HmrChangeClassifier;
40
+ readonly #files = new Map<string, TrackedFile>();
41
+ readonly #dirs = new Map<string, number>();
42
+ readonly #unreadable = new Map<string, string>();
43
+ #primed = false;
44
+ #queue: Promise<unknown> = Promise.resolve();
45
+
46
+ constructor({ roots, classifier }: SourceMtimeIndexOptions) {
47
+ this.#roots = SourceMtimeIndex.#pruneNestedRoots(roots);
48
+ this.#classifier = classifier ?? new HmrChangeClassifier();
49
+ }
50
+
51
+ get primed(): boolean {
52
+ return this.#primed;
53
+ }
54
+
55
+ get trackedFileCount(): number {
56
+ return this.#files.size;
57
+ }
58
+
59
+ /**
60
+ * Paths the index currently cannot read, as `{ path, code }`. Non-empty means change detection has a
61
+ * blind spot right now: anything edited underneath one of these is not reported until it recovers.
62
+ *
63
+ * Worth surfacing rather than swallowing, because the failure is silent by nature — a walk cannot tell
64
+ * how much it did not see. Each entry is retried on every scan and clears itself on success.
65
+ */
66
+ get coverageGaps(): { path: string; code: string }[] {
67
+ return [...this.#unreadable].map(([file, code]) => ({ path: file, code }));
68
+ }
69
+
70
+ /** Record the current state as the baseline. Reports nothing; call before the first `collectChanges`. */
71
+ async prime(): Promise<void> {
72
+ await this.#serialize(async () => {
73
+ this.#files.clear();
74
+ this.#dirs.clear();
75
+ this.#unreadable.clear();
76
+ await Promise.all(this.#roots.map((root) => this.#walk(root, null)));
77
+ this.#primed = true;
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Absolute paths whose content changed (or which disappeared) since the last call, with the baseline
83
+ * advanced to match. Returns an empty list until primed, so a failed prime degrades to watcher events
84
+ * rather than reporting the entire tree as changed.
85
+ */
86
+ async collectChanges(): Promise<string[]> {
87
+ return this.#serialize(async () => {
88
+ if (!this.#primed) return [];
89
+ const changed = new Set<string>();
90
+ await this.#collectFileChanges(changed);
91
+ await this.#collectDirChanges(changed);
92
+ return [...changed];
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Every scan runs to completion before the next begins.
98
+ *
99
+ * Overlapping scans corrupt each other rather than merely racing: one advances the baseline and prunes
100
+ * entries while the other is midway through a key snapshot it took earlier, so a path the first has
101
+ * already accounted for reads back as missing and is reported as a change that never happened. Observed
102
+ * directly — a file nothing had written was reported, which downstream is a wasted rebuild.
103
+ */
104
+ async #serialize<T>(work: () => Promise<T>): Promise<T> {
105
+ const run = this.#queue.then(work, work);
106
+ // Swallowed on the chain only; the caller still sees the rejection through `run`.
107
+ this.#queue = run.catch(() => undefined);
108
+ return run;
109
+ }
110
+
111
+ /**
112
+ * Adopt writes the caller made itself — regenerated barrels, inserted imports — into the baseline so
113
+ * the next `collectChanges` does not report them as a user edit.
114
+ *
115
+ * Narrow race worth knowing: a user save of the same path landing between the caller's write and this
116
+ * call is baselined too, and therefore lost. It is sub-millisecond and no wider than the window the
117
+ * event-based watcher already had.
118
+ */
119
+ async absorb(paths: string[]): Promise<void> {
120
+ await this.#serialize(async () => {
121
+ if (!this.#primed) return;
122
+ await Promise.all(
123
+ paths.map(async (file) => {
124
+ const abs = path.resolve(file);
125
+ const stats = await stat(abs).catch(() => null);
126
+ if (!stats?.isFile()) {
127
+ this.#files.delete(abs);
128
+ return;
129
+ }
130
+ this.#files.set(abs, { mtimeMs: stats.mtimeMs, size: stats.size });
131
+ }),
132
+ );
133
+ });
134
+ }
135
+
136
+ async #collectFileChanges(changed: Set<string>): Promise<void> {
137
+ await Promise.all(
138
+ [...this.#files.keys()].map(async (abs) => {
139
+ const { stats, err } = await SourceMtimeIndex.#statPath(abs);
140
+ if (!stats) {
141
+ // Unreadable is not deleted. Dropping it here would both invent a change nobody made and stop
142
+ // tracking the file, so a later real edit would go unreported.
143
+ if (err && !SourceMtimeIndex.#isMissing(err)) {
144
+ this.#unreadable.set(abs, err.code ?? "EUNKNOWN");
145
+ return;
146
+ }
147
+ this.#files.delete(abs);
148
+ this.#unreadable.delete(abs);
149
+ changed.add(abs);
150
+ return;
151
+ }
152
+ this.#unreadable.delete(abs);
153
+ if (!stats.isFile()) {
154
+ this.#files.delete(abs);
155
+ changed.add(abs);
156
+ return;
157
+ }
158
+ const known = this.#files.get(abs);
159
+ if (known && known.mtimeMs === stats.mtimeMs && known.size === stats.size) return;
160
+ this.#files.set(abs, { mtimeMs: stats.mtimeMs, size: stats.size });
161
+ changed.add(abs);
162
+ }),
163
+ );
164
+ }
165
+
166
+ /**
167
+ * A directory's own mtime moves when an entry is added or removed but not when a tracked file's
168
+ * content changes, so this finds creations and deletions the file pass cannot see.
169
+ */
170
+ async #collectDirChanges(changed: Set<string>): Promise<void> {
171
+ const moved: string[] = [];
172
+ const gone: string[] = [];
173
+ await Promise.all(
174
+ [...this.#dirs.entries()].map(async ([dir, mtimeMs]) => {
175
+ const { stats, err } = await SourceMtimeIndex.#statPath(dir);
176
+ if (!stats?.isDirectory()) {
177
+ if (err && !SourceMtimeIndex.#isMissing(err)) {
178
+ this.#markUnreadable(dir, err);
179
+ return;
180
+ }
181
+ gone.push(dir);
182
+ return;
183
+ }
184
+ // `UNREADABLE` is NaN, so a directory retained from a failed read always mismatches and is
185
+ // rewalked here — that retry is what lets a transient failure recover on its own.
186
+ if (stats.mtimeMs !== mtimeMs) moved.push(dir);
187
+ }),
188
+ );
189
+ for (const dir of gone) this.#forget(dir);
190
+ // Sequential: a moved directory can reveal a new subtree, and walking those in order keeps the
191
+ // number of concurrent `readdir` calls proportional to the change rather than to the tree.
192
+ for (const dir of moved) await this.#walk(dir, changed);
193
+ }
194
+
195
+ /**
196
+ * Re-read one directory, tracking entries that are new or changed. Recurses only into subdirectories
197
+ * that are new to the index; known ones are covered by their own mtime check in `#collectDirChanges`.
198
+ */
199
+ async #walk(dir: string, changed: Set<string> | null): Promise<void> {
200
+ const [listing, dirStat] = await Promise.all([SourceMtimeIndex.#readdirPath(dir), SourceMtimeIndex.#statPath(dir)]);
201
+ const entries = listing.entries;
202
+ const dirStats = dirStat.stats;
203
+ if (!entries || !dirStats?.isDirectory()) {
204
+ // A directory that cannot be read is not a directory that is gone. Forgetting it here is
205
+ // unrecoverable: nothing re-stats a dir the index has dropped, and the parent's own mtime does not
206
+ // move when a child merely becomes unreadable, so the whole subtree stays invisible for the life of
207
+ // the process. Measured: one `EACCES` at prime silently hid 2 of 3 files, permanently.
208
+ const err = listing.err ?? dirStat.err;
209
+ if (err && !SourceMtimeIndex.#isMissing(err)) {
210
+ this.#markUnreadable(dir, err);
211
+ return;
212
+ }
213
+ this.#forget(dir);
214
+ return;
215
+ }
216
+ const known = this.#dirs.has(dir);
217
+ this.#dirs.set(dir, dirStats.mtimeMs);
218
+ this.#unreadable.delete(dir);
219
+ const descend: string[] = [];
220
+ const present = new Set<string>();
221
+ let blind = false;
222
+ for (const entry of entries) {
223
+ if (SourceMtimeIndex.#skipDirent(entry.name)) continue;
224
+ const abs = path.join(dir, entry.name);
225
+ if (entry.isDirectory()) {
226
+ descend.push(abs);
227
+ continue;
228
+ }
229
+ if (!entry.isFile()) continue;
230
+ if (this.#classifier.classify(abs) === "ignore") continue;
231
+ present.add(abs);
232
+ const { stats, err } = await SourceMtimeIndex.#statPath(abs);
233
+ if (!stats?.isFile()) {
234
+ // A file listed but not stat-able has no baseline, so an edit to it would compare against nothing.
235
+ // Leaving the directory stale is what brings it back; the file pass cannot, since it only revisits
236
+ // paths already tracked.
237
+ if (err && !SourceMtimeIndex.#isMissing(err)) {
238
+ this.#unreadable.set(abs, err.code ?? "EUNKNOWN");
239
+ blind = true;
240
+ }
241
+ continue;
242
+ }
243
+ this.#unreadable.delete(abs);
244
+ const previous = this.#files.get(abs);
245
+ if (previous && previous.mtimeMs === stats.mtimeMs && previous.size === stats.size) continue;
246
+ this.#files.set(abs, { mtimeMs: stats.mtimeMs, size: stats.size });
247
+ changed?.add(abs);
248
+ }
249
+ // Entries this directory used to hold and no longer does. Only worth scanning for a directory the
250
+ // index already knew, which during `prime` is none of them.
251
+ if (known) {
252
+ for (const abs of [...this.#files.keys()]) {
253
+ if (present.has(abs) || path.dirname(abs) !== dir) continue;
254
+ this.#files.delete(abs);
255
+ changed?.add(abs);
256
+ }
257
+ }
258
+ if (blind) this.#dirs.set(dir, UNREADABLE);
259
+ // Known subdirectories carry their own mtime check, so only unseen ones need walking. Concurrent
260
+ // because `prime` reaches every directory through here.
261
+ await Promise.all(descend.filter((sub) => !this.#dirs.has(sub)).map((sub) => this.#walk(sub, changed)));
262
+ }
263
+
264
+ /**
265
+ * Keep a directory the index could not read, flagged for retry on every scan, and keep whatever it
266
+ * already knows underneath it — those files stay tracked so a real edit is still caught the moment
267
+ * access returns.
268
+ */
269
+ #markUnreadable(dir: string, err: NodeJS.ErrnoException): void {
270
+ this.#dirs.set(dir, UNREADABLE);
271
+ this.#unreadable.set(dir, err.code ?? "EUNKNOWN");
272
+ }
273
+
274
+ /** Drop a directory and everything the index holds beneath it. */
275
+ #forget(dir: string): void {
276
+ const prefix = `${dir}${path.sep}`;
277
+ this.#dirs.delete(dir);
278
+ this.#unreadable.delete(dir);
279
+ for (const known of [...this.#dirs.keys()]) if (known.startsWith(prefix)) this.#dirs.delete(known);
280
+ // Otherwise a gap under a deleted directory is reported forever, since nothing revisits it to clear.
281
+ for (const known of [...this.#unreadable.keys()]) if (known.startsWith(prefix)) this.#unreadable.delete(known);
282
+ }
283
+
284
+ /** `null` stats with the errno kept, so callers can tell "not there" from "could not look". */
285
+ static async #statPath(abs: string): Promise<{ stats: Stats | null; err: NodeJS.ErrnoException | null }> {
286
+ try {
287
+ return { stats: await stat(abs), err: null };
288
+ } catch (err) {
289
+ return { stats: null, err: err as NodeJS.ErrnoException };
290
+ }
291
+ }
292
+
293
+ static async #readdirPath(dir: string): Promise<{ entries: Dirent[] | null; err: NodeJS.ErrnoException | null }> {
294
+ try {
295
+ return { entries: await readdir(dir, { withFileTypes: true }), err: null };
296
+ } catch (err) {
297
+ return { entries: null, err: err as NodeJS.ErrnoException };
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Whether an errno means the path genuinely is not there, which is the only case where dropping it from
303
+ * the baseline is right. Everything else — `EACCES`, `EIO`, `EMFILE`, a stalled network mount — means the
304
+ * index failed to look, and treating that as a deletion both invents a change and blinds it to real ones.
305
+ */
306
+ static #isMissing(err: NodeJS.ErrnoException): boolean {
307
+ return err.code === "ENOENT" || err.code === "ENOTDIR";
308
+ }
309
+
310
+ /**
311
+ * Mirrors `HmrChangeClassifier`'s path rules for directory names — dotted names cover `.git` and
312
+ * `.akan`, which is where every build artifact lands. Symlinked entries are neither `isFile` nor
313
+ * `isDirectory` and so are skipped, matching `fs.watch`, which does not follow them either.
314
+ */
315
+ static #skipDirent(name: string): boolean {
316
+ return !name || name.startsWith(".") || name === "node_modules";
317
+ }
318
+
319
+ /** `WatchRootResolver` can return `apps/<app>/page` alongside `apps/`; walking both doubles the work. */
320
+ static #pruneNestedRoots(roots: string[]): string[] {
321
+ const resolved = [...new Set(roots.map((root) => path.resolve(root)))].sort();
322
+ return resolved.filter(
323
+ (root) => !resolved.some((other) => other !== root && root.startsWith(`${other}${path.sep}`)),
324
+ );
325
+ }
326
+ }
@@ -0,0 +1,73 @@
1
+ import { afterEach, describe, expect, 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 { BuilderReply } from "./builderReply";
6
+
7
+ const tempDirs: string[] = [];
8
+
9
+ afterEach(async () => {
10
+ for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true });
11
+ });
12
+
13
+ /**
14
+ * Run a child that answers a build request and exits immediately — the shape of the recycle drain
15
+ * finishing its last work item — and report whatever the parent actually received.
16
+ */
17
+ const replyThenExit = async (
18
+ bytes: number,
19
+ { awaitFlush }: { awaitFlush: boolean },
20
+ ): Promise<{ id?: number } | null> => {
21
+ const dir = await mkdtemp(path.join(os.tmpdir(), "builder-reply-"));
22
+ tempDirs.push(dir);
23
+ const entry = path.join(dir, "child.ts");
24
+ const modulePath = JSON.stringify(path.join(import.meta.dir, "builderReply"));
25
+ const reply = awaitFlush ? "await BuilderReply.send(res as never);" : "process.send?.(res);";
26
+ await Bun.write(
27
+ entry,
28
+ [
29
+ `import { BuilderReply } from ${modulePath};`,
30
+ 'const chunk = "x".repeat(200);',
31
+ "const moduleMap: Record<string, string> = {};",
32
+ `for (let i = 0; i * 210 < ${bytes}; i++) moduleMap["chunk-" + i] = chunk;`,
33
+ 'const res = { type: "build-route-res", id: 7, ok: true, data: { ssrManifestDelta: moduleMap } };',
34
+ reply,
35
+ "process.exit(0);",
36
+ ].join("\n"),
37
+ );
38
+ let received: { id?: number } | null = null;
39
+ const proc = Bun.spawn(["bun", entry], {
40
+ stdio: ["ignore", "inherit", "inherit"],
41
+ serialization: "advanced",
42
+ ipc: (message) => {
43
+ received = message as { id?: number };
44
+ },
45
+ });
46
+ await proc.exited;
47
+ // Replies land before the exit callback, never after, but leave room for a straggler to prove it.
48
+ await Bun.sleep(50);
49
+ return received;
50
+ };
51
+
52
+ describe("BuilderReply.send", () => {
53
+ test("delivers a reply too large for the pipe buffer before the process exits", async () => {
54
+ expect(await replyThenExit(1_000_000, { awaitFlush: true })).toMatchObject({ id: 7 });
55
+ // A manifest delta is usually well past the buffer, but the small case must keep working too.
56
+ expect(await replyThenExit(0, { awaitFlush: true })).toMatchObject({ id: 7 });
57
+ });
58
+
59
+ test("without the flush wait the same reply is lost, which is why this class exists", async () => {
60
+ // A control, not a requirement: if a future bun flushes ipc writes on exit, this fails and says so.
61
+ expect(await replyThenExit(1_000_000, { awaitFlush: false })).toBeNull();
62
+ });
63
+
64
+ test("resolves instead of hanging when there is no ipc channel", async () => {
65
+ const send = process.send;
66
+ try {
67
+ (process as { send?: typeof process.send }).send = undefined;
68
+ await BuilderReply.send({ type: "build-csr-res", id: 1, ok: true });
69
+ } finally {
70
+ (process as { send?: typeof process.send }).send = send;
71
+ }
72
+ });
73
+ });
@@ -0,0 +1,30 @@
1
+ import type { BuilderCsrRes, BuilderRes } from "akanjs/server";
2
+
3
+ /**
4
+ * Sends a builder's answer to a backend request and reports when it has actually left the process.
5
+ *
6
+ * `process.exit` truncates an ipc write that has not flushed yet, and anything past the pipe buffer
7
+ * (~64KiB) needs the sender to stay alive to drain it — a `build-route-res` carrying a manifest delta is
8
+ * routinely larger than that. Measured on bun 1.3: a 100KB reply sent immediately before
9
+ * `process.exit(0)` was lost 20/20 times, and arrived 20/20 times when the exit waited for the flush
10
+ * callback. That made the recycle drain, which exists to release bundler memory, eat the answer of
11
+ * whatever route build it happened to be draining.
12
+ */
13
+ export class BuilderReply {
14
+ /** Bound so a runtime that ever stops invoking the callback costs one late reply instead of wedging
15
+ * the shutdown drain until the host's kill watchdog fires. */
16
+ static readonly #flushTimeoutMs = 5_000;
17
+
18
+ static async send(res: BuilderRes | BuilderCsrRes): Promise<void> {
19
+ const send = process.send;
20
+ if (!send) return;
21
+ await new Promise<void>((resolve) => {
22
+ const timer = setTimeout(resolve, BuilderReply.#flushTimeoutMs);
23
+ // A failed send has nothing to retry — the host answers the id itself when this process exits.
24
+ send.call(process, res, undefined, undefined, () => {
25
+ clearTimeout(timer);
26
+ resolve();
27
+ });
28
+ });
29
+ }
30
+ }
@@ -68,7 +68,7 @@ describe("IncrementalBuilderHost", () => {
68
68
 
69
69
  host.start({ onRestartReady });
70
70
  spawns[0]?.options.ipc?.({ type: "builder-ready" });
71
- expect(spawns[0]?.options.env?.AKAN_BUILDER_RECYCLED).toBeUndefined();
71
+ expect(spawns[0]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBeUndefined();
72
72
 
73
73
  const reason = "rss=1300MiB>=1200MiB after 3 build(s)";
74
74
  expect(host.recycle(reason)).toBe(true);
@@ -80,7 +80,7 @@ describe("IncrementalBuilderHost", () => {
80
80
  // A planned exit skips the crash backoff — the dev server has no file watcher until it is back.
81
81
  spawns[0]?.options.onExit?.();
82
82
  expect(spawns).toHaveLength(2);
83
- expect(spawns[1]?.options.env?.AKAN_BUILDER_RECYCLED).toBe("1");
83
+ expect(spawns[1]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBe("1");
84
84
  spawns[1]?.options.ipc?.({ type: "builder-ready" });
85
85
  expect(onRestartReady).toHaveBeenCalledTimes(1);
86
86
  expect(host.status).toBe("ready");
@@ -90,11 +90,96 @@ describe("IncrementalBuilderHost", () => {
90
90
  expect(spawns).toHaveLength(2);
91
91
  await wait(1_050);
92
92
  expect(spawns).toHaveLength(3);
93
- expect(spawns[2]?.options.env?.AKAN_BUILDER_RECYCLED).toBeUndefined();
93
+ expect(spawns[2]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBeUndefined();
94
94
 
95
95
  host.stop();
96
96
  });
97
97
 
98
+ test("fails the requests a departing builder never answered", async () => {
99
+ const spawns = mockSpawns();
100
+ const messages: unknown[] = [];
101
+ const host = new IncrementalBuilderHost({
102
+ app: { cwdPath: "/tmp/app" } as never,
103
+ entry: "/tmp/builder.ts",
104
+ env: {},
105
+ onMessage: (message) => messages.push(message),
106
+ });
107
+
108
+ host.start();
109
+ spawns[0]?.options.ipc?.({ type: "builder-ready" });
110
+ expect(host.send({ type: "build-route", id: 1, routeId: "a", seeds: [], knownEntries: [] })).toBe(true);
111
+ expect(host.send({ type: "build-csr", id: 2, reason: "device webview" })).toBe(true);
112
+ // Answered before the exit, so this one must not be failed again afterwards.
113
+ expect(host.send({ type: "build-route", id: 3, routeId: "c", seeds: [], knownEntries: [] })).toBe(true);
114
+ spawns[0]?.options.ipc?.({ type: "build-route-res", id: 3, ok: true, data: { routeId: "c" } });
115
+ messages.length = 0;
116
+
117
+ host.recycle("rss=1300MiB>=1200MiB after 3 build(s)");
118
+ spawns[0]?.options.onExit?.();
119
+
120
+ // Nothing else answers these: the builder only refuses requests that arrive after it starts shutting
121
+ // down, and a kill or a truncated write sends nothing at all.
122
+ expect(messages).toEqual([
123
+ {
124
+ type: "build-route-res",
125
+ id: 1,
126
+ ok: false,
127
+ error: "builder exited to release bundler memory before answering; reload to retry",
128
+ },
129
+ {
130
+ type: "build-csr-res",
131
+ id: 2,
132
+ ok: false,
133
+ error: "builder exited to release bundler memory before answering; reload to retry",
134
+ },
135
+ ]);
136
+
137
+ // The replacement owes nothing, so its own exit stays quiet.
138
+ spawns[1]?.options.ipc?.({ type: "builder-ready" });
139
+ messages.length = 0;
140
+ spawns[1]?.options.onExit?.();
141
+ expect(messages).toEqual([]);
142
+ await wait(1_050);
143
+
144
+ host.stop();
145
+ });
146
+
147
+ test("names a crash rather than a recycle, and answers on stop too", async () => {
148
+ const spawns = mockSpawns();
149
+ const messages: unknown[] = [];
150
+ const host = new IncrementalBuilderHost({
151
+ app: { cwdPath: "/tmp/app" } as never,
152
+ entry: "/tmp/builder.ts",
153
+ env: {},
154
+ onMessage: (message) => messages.push(message),
155
+ });
156
+
157
+ host.start();
158
+ spawns[0]?.options.ipc?.({ type: "builder-ready" });
159
+ host.send({ type: "build-route", id: 1, routeId: "a", seeds: [], knownEntries: [] });
160
+ messages.length = 0;
161
+ spawns[0]?.options.onExit?.();
162
+ expect(messages).toEqual([
163
+ {
164
+ type: "build-route-res",
165
+ id: 1,
166
+ ok: false,
167
+ error: "builder exited unexpectedly before answering; reload once it is back",
168
+ },
169
+ ]);
170
+
171
+ // `stop()` clears the process before its exit callback runs, so the callback bails on its identity
172
+ // check and cannot be the only place this happens.
173
+ await wait(1_050);
174
+ spawns[1]?.options.ipc?.({ type: "builder-ready" });
175
+ messages.length = 0;
176
+ expect(host.send({ type: "build-route", id: 2, routeId: "b", seeds: [], knownEntries: [] })).toBe(true);
177
+ host.stop();
178
+ expect(messages).toEqual([
179
+ { type: "build-route-res", id: 2, ok: false, error: "builder was stopped before answering" },
180
+ ]);
181
+ });
182
+
98
183
  test("only recycles a builder that is ready", () => {
99
184
  const spawns = mockSpawns();
100
185
  const host = new IncrementalBuilderHost({
@@ -27,6 +27,11 @@ interface IncrementalBuilderStartOptions {
27
27
  onExit?: () => void;
28
28
  onReady?: () => void;
29
29
  onRestartReady?: () => void;
30
+ /**
31
+ * Ask the builder to re-announce the artifact it boots with. Needed whenever a *previous* builder's
32
+ * artifact may still be live in a running backend — after an rss recycle, and after an idle wake.
33
+ */
34
+ announceBootState?: boolean;
30
35
  }
31
36
 
32
37
  export class IncrementalBuilderHost {
@@ -56,6 +61,17 @@ export class IncrementalBuilderHost {
56
61
  #recycleRequested: boolean = false;
57
62
  #spawnAfterRecycle: boolean = false;
58
63
  #manualStop = false;
64
+ /**
65
+ * Requests handed to the running builder that it has not answered yet, keyed by the backend's
66
+ * correlation id.
67
+ *
68
+ * Nothing else answers a request whose builder exits while holding it: the builder only refuses
69
+ * requests that arrive *after* it starts shutting down, a kill or crash sends nothing at all, and even
70
+ * a clean drain races its own `process.exit`. The builder exits routinely — it is recycled whenever its
71
+ * RSS passes the ceiling — so a page request that happened to be mid route-build left the backend's
72
+ * promise pending and the browser tab spinning with no error and nothing to retry.
73
+ */
74
+ readonly #inFlight = new Map<number, "build-route" | "build-csr">();
59
75
  #startOptions: IncrementalBuilderStartOptions = {};
60
76
  constructor({ app, entry, env, onMessage }: IncrementalBuilderHostOptions) {
61
77
  this.app = app;
@@ -66,28 +82,38 @@ export class IncrementalBuilderHost {
66
82
  get status() {
67
83
  return this.#status;
68
84
  }
85
+ /**
86
+ * The running builder's pid, so the host can read its RSS from the OS between builds. The builder
87
+ * only reports its own metrics at work-completion points, which is the *peak*; on a platform that
88
+ * returns bundler arenas to the OS while idle, that sample goes stale within seconds.
89
+ */
90
+ get pid(): number | null {
91
+ return this.#proc?.pid ?? null;
92
+ }
69
93
  start(options: IncrementalBuilderStartOptions = {}) {
70
94
  if (this.#proc) this.stop();
71
95
  this.#manualStop = false;
72
96
  this.#startOptions = options;
97
+ this.#spawnAfterRecycle = options.announceBootState ?? false;
73
98
  this.#spawn(false);
74
99
  return this;
75
100
  }
76
101
  #spawn(isRestart: boolean) {
77
102
  this.#status = isRestart ? "restarting" : "starting";
78
103
  this.ready = false;
79
- // A recycled builder rebuilds every artifact, and the running backend still holds the previous
80
- // one; the flag is what tells the replacement to re-announce what it booted with.
104
+ // A fresh builder rebuilds every artifact while the running backend still holds the previous one;
105
+ // the flag is what tells it to re-announce what it booted with.
81
106
  const afterRecycle = this.#spawnAfterRecycle;
82
107
  this.#spawnAfterRecycle = false;
83
108
  let proc!: Bun.Subprocess<"ignore", "inherit", "inherit">;
84
109
  proc = Bun.spawn(["bun", this.entry], {
85
110
  cwd: this.app.cwdPath,
86
- env: { ...this.env, AKAN_WATCH: "1", ...(afterRecycle ? { AKAN_BUILDER_RECYCLED: "1" } : {}) },
111
+ env: { ...this.env, AKAN_WATCH: "1", ...(afterRecycle ? { AKAN_BUILDER_ANNOUNCE_BOOT: "1" } : {}) },
87
112
  stdio: ["ignore", "inherit", "inherit"],
88
113
  ipc: (msg: BuilderMessage) => {
89
114
  if (this.#proc !== proc) return;
90
115
  if (!msg || typeof msg !== "object") return;
116
+ if (msg.type === "build-route-res" || msg.type === "build-csr-res") this.#inFlight.delete(msg.id);
91
117
  if (builderMsgTypeSet.has(msg.type)) this.#onMessage(msg);
92
118
  if (msg.type === "builder-ready" && !this.ready) {
93
119
  this.ready = true;
@@ -105,6 +131,11 @@ export class IncrementalBuilderHost {
105
131
  const wasRecycle = this.#recycleRequested;
106
132
  this.#clearRecycle();
107
133
  this.ready = false;
134
+ this.#failInFlight(
135
+ wasRecycle
136
+ ? "builder exited to release bundler memory before answering; reload to retry"
137
+ : "builder exited unexpectedly before answering; reload once it is back",
138
+ );
108
139
  if (this.#manualStop || this.#status === "stopped") return;
109
140
  if (!wasReady) {
110
141
  this.#status = "stopped";
@@ -189,6 +220,7 @@ export class IncrementalBuilderHost {
189
220
  }
190
221
  try {
191
222
  this.#proc.send(message);
223
+ if (message.type === "build-route" || message.type === "build-csr") this.#inFlight.set(message.id, message.type);
192
224
  return true;
193
225
  } catch (error) {
194
226
  this.logger.warn(
@@ -197,9 +229,24 @@ export class IncrementalBuilderHost {
197
229
  return false;
198
230
  }
199
231
  }
232
+ /**
233
+ * Answer every request the departing builder still owed, as if it had failed them itself. `onExit`
234
+ * cannot do this alone: `stop()` clears `#proc` first, so the exit callback bails on its identity check.
235
+ */
236
+ #failInFlight(reason: string): void {
237
+ if (!this.#inFlight.size) return;
238
+ const lost = [...this.#inFlight];
239
+ this.#inFlight.clear();
240
+ this.logger.warn(`failing ${lost.length} unanswered builder request(s): ${reason}`);
241
+ for (const [id, type] of lost) {
242
+ if (type === "build-route") this.#onMessage({ type: "build-route-res", id, ok: false, error: reason });
243
+ else this.#onMessage({ type: "build-csr-res", id, ok: false, error: reason });
244
+ }
245
+ }
200
246
  stop() {
201
247
  this.#manualStop = true;
202
248
  this.#clearRecycle();
249
+ this.#failInFlight("builder was stopped before answering");
203
250
  if (this.#restartTimer) {
204
251
  clearTimeout(this.#restartTimer);
205
252
  this.#restartTimer = null;