@akanjs/devkit 2.4.1-rc.2 → 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,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
+ }
@@ -10,17 +10,24 @@ afterEach(() => {
10
10
  mock.restore();
11
11
  });
12
12
 
13
+ interface SpawnRecord {
14
+ proc: { pid: number; send: ReturnType<typeof mock>; kill: ReturnType<typeof mock>; killed: boolean };
15
+ options: { ipc?: (message: unknown) => void; onExit?: () => void; env?: Record<string, string> };
16
+ }
17
+
18
+ const mockSpawns = (): SpawnRecord[] => {
19
+ const spawns: SpawnRecord[] = [];
20
+ (Bun as unknown as { spawn: typeof Bun.spawn }).spawn = mock((_, options) => {
21
+ const proc = { pid: 10_000 + spawns.length, send: mock(), kill: mock(), killed: false };
22
+ spawns.push({ proc, options: options as SpawnRecord["options"] });
23
+ return proc as never;
24
+ }) as never;
25
+ return spawns;
26
+ };
27
+
13
28
  describe("IncrementalBuilderHost", () => {
14
29
  test("restarts after a ready builder exits", async () => {
15
- const spawns: Array<{
16
- proc: { pid: number; send: ReturnType<typeof mock>; kill: ReturnType<typeof mock>; killed: boolean };
17
- options: { ipc?: (message: unknown) => void; onExit?: () => void };
18
- }> = [];
19
- (Bun as unknown as { spawn: typeof Bun.spawn }).spawn = mock((_, options) => {
20
- const proc = { pid: 10_000 + spawns.length, send: mock(), kill: mock(), killed: false };
21
- spawns.push({ proc, options: options as { ipc?: (message: unknown) => void; onExit?: () => void } });
22
- return proc as never;
23
- }) as never;
30
+ const spawns = mockSpawns();
24
31
 
25
32
  const onReady = mock();
26
33
  const onRestartReady = mock();
@@ -48,4 +55,187 @@ describe("IncrementalBuilderHost", () => {
48
55
 
49
56
  host.stop();
50
57
  });
58
+
59
+ test("recycles a ready builder gracefully and replaces it immediately", async () => {
60
+ const spawns = mockSpawns();
61
+ const onRestartReady = mock();
62
+ const host = new IncrementalBuilderHost({
63
+ app: { cwdPath: "/tmp/app" } as never,
64
+ entry: "/tmp/builder.ts",
65
+ env: {},
66
+ onMessage: () => undefined,
67
+ });
68
+
69
+ host.start({ onRestartReady });
70
+ spawns[0]?.options.ipc?.({ type: "builder-ready" });
71
+ expect(spawns[0]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBeUndefined();
72
+
73
+ const reason = "rss=1300MiB>=1200MiB after 3 build(s)";
74
+ expect(host.recycle(reason)).toBe(true);
75
+ // Graceful: the builder is asked to drain, not killed, so a rebuild in flight still completes.
76
+ expect(spawns[0]?.proc.send).toHaveBeenCalledWith({ type: "builder-shutdown", reason });
77
+ expect(spawns[0]?.proc.kill).not.toHaveBeenCalled();
78
+ expect(host.recycle("second request")).toBe(false);
79
+
80
+ // A planned exit skips the crash backoff — the dev server has no file watcher until it is back.
81
+ spawns[0]?.options.onExit?.();
82
+ expect(spawns).toHaveLength(2);
83
+ expect(spawns[1]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBe("1");
84
+ spawns[1]?.options.ipc?.({ type: "builder-ready" });
85
+ expect(onRestartReady).toHaveBeenCalledTimes(1);
86
+ expect(host.status).toBe("ready");
87
+
88
+ // The flag is per-recycle: a later crash restart must not re-announce a boot artifact.
89
+ spawns[1]?.options.onExit?.();
90
+ expect(spawns).toHaveLength(2);
91
+ await wait(1_050);
92
+ expect(spawns).toHaveLength(3);
93
+ expect(spawns[2]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBeUndefined();
94
+
95
+ host.stop();
96
+ });
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
+
183
+ test("only recycles a builder that is ready", () => {
184
+ const spawns = mockSpawns();
185
+ const host = new IncrementalBuilderHost({
186
+ app: { cwdPath: "/tmp/app" } as never,
187
+ entry: "/tmp/builder.ts",
188
+ env: {},
189
+ onMessage: () => undefined,
190
+ });
191
+
192
+ host.start();
193
+ expect(host.recycle("while still booting")).toBe(false);
194
+ expect(spawns[0]?.proc.send).not.toHaveBeenCalled();
195
+
196
+ host.stop();
197
+ expect(host.recycle("after stop")).toBe(false);
198
+ });
199
+ });
200
+
201
+ describe("IncrementalBuilderHost.maxRssBytes", () => {
202
+ const withEnv = (values: Record<string, string | undefined>, fn: () => void) => {
203
+ const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]));
204
+ try {
205
+ for (const [key, value] of Object.entries(values)) {
206
+ if (value === undefined) delete process.env[key];
207
+ else process.env[key] = value;
208
+ }
209
+ fn();
210
+ } finally {
211
+ for (const [key, value] of Object.entries(previous)) {
212
+ if (value === undefined) delete process.env[key];
213
+ else process.env[key] = value;
214
+ }
215
+ }
216
+ };
217
+
218
+ test("defaults to a dev ceiling well above a fresh boot", () => {
219
+ withEnv(
220
+ { AKAN_BUILDER_MAX_RSS_MB: undefined, AKAN_BUILDER_MAX_RSS: undefined, AKAN_MEMORY_LIMIT: undefined },
221
+ () => {
222
+ expect(IncrementalBuilderHost.maxRssBytes()).toBe(1_200 * 1024 * 1024);
223
+ },
224
+ );
225
+ });
226
+
227
+ test("honors an explicit override and treats 0 as unbounded", () => {
228
+ withEnv({ AKAN_BUILDER_MAX_RSS_MB: "700" }, () => {
229
+ expect(IncrementalBuilderHost.maxRssBytes()).toBe(700 * 1024 * 1024);
230
+ });
231
+ withEnv({ AKAN_BUILDER_MAX_RSS_MB: "0" }, () => {
232
+ expect(IncrementalBuilderHost.maxRssBytes()).toBeNull();
233
+ });
234
+ });
235
+
236
+ test("takes a share of a declared memory limit, leaving room for the other dev processes", () => {
237
+ withEnv({ AKAN_BUILDER_MAX_RSS_MB: undefined, AKAN_BUILDER_MAX_RSS: undefined, AKAN_MEMORY_LIMIT: "4gb" }, () => {
238
+ expect(IncrementalBuilderHost.maxRssBytes()).toBe(Math.floor(4 * 1024 ** 3 * 0.35));
239
+ });
240
+ });
51
241
  });
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { Logger } from "akanjs/common";
3
3
  import type { BuilderMessage } from "akanjs/server";
4
+ import { MemoryLimit } from "akanjs/server/memoryLimit";
4
5
  import type { App } from "../commandDecorators";
5
6
 
6
7
  const builderMsgTypeSet = new Set<BuilderMessage["type"]>([
@@ -11,6 +12,7 @@ const builderMsgTypeSet = new Set<BuilderMessage["type"]>([
11
12
  "css-updated",
12
13
  "pages-updated",
13
14
  "build-status",
15
+ "builder-metrics",
14
16
  ]);
15
17
  interface IncrementalBuilderHostOptions {
16
18
  app: App;
@@ -25,11 +27,26 @@ interface IncrementalBuilderStartOptions {
25
27
  onExit?: () => void;
26
28
  onReady?: () => void;
27
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;
28
35
  }
29
36
 
30
37
  export class IncrementalBuilderHost {
31
38
  static readonly #restartBaseDelayMs = 1_000;
32
39
  static readonly #restartMaxDelayMs = 30_000;
40
+ /**
41
+ * A builder that has stopped answering has to be replaced anyway; killing it after this long turns
42
+ * a wedged drain into an ordinary restart instead of leaving the recycle stuck forever.
43
+ */
44
+ static readonly #recycleDrainTimeoutMs = 30_000;
45
+ /**
46
+ * Dev has no memory limit to derive a fraction from, so this is what bounds the builder there. Well
47
+ * above a fresh boot (~300-600MB depending on app size) with room for one full rebuild on top.
48
+ */
49
+ static readonly #devMaxRssBytes = 1_200 * 1024 * 1024;
33
50
  logger = new Logger("IncrementalBuilderHost");
34
51
  entry: string;
35
52
  env: Record<string, string>;
@@ -40,7 +57,21 @@ export class IncrementalBuilderHost {
40
57
  #status: IncrementalBuilderStatus = "stopped";
41
58
  #restartAttempts = 0;
42
59
  #restartTimer: ReturnType<typeof setTimeout> | null = null;
60
+ #recycleTimer: ReturnType<typeof setTimeout> | null = null;
61
+ #recycleRequested: boolean = false;
62
+ #spawnAfterRecycle: boolean = false;
43
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">();
44
75
  #startOptions: IncrementalBuilderStartOptions = {};
45
76
  constructor({ app, entry, env, onMessage }: IncrementalBuilderHostOptions) {
46
77
  this.app = app;
@@ -51,24 +82,38 @@ export class IncrementalBuilderHost {
51
82
  get status() {
52
83
  return this.#status;
53
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
+ }
54
93
  start(options: IncrementalBuilderStartOptions = {}) {
55
94
  if (this.#proc) this.stop();
56
95
  this.#manualStop = false;
57
96
  this.#startOptions = options;
97
+ this.#spawnAfterRecycle = options.announceBootState ?? false;
58
98
  this.#spawn(false);
59
99
  return this;
60
100
  }
61
101
  #spawn(isRestart: boolean) {
62
102
  this.#status = isRestart ? "restarting" : "starting";
63
103
  this.ready = false;
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.
106
+ const afterRecycle = this.#spawnAfterRecycle;
107
+ this.#spawnAfterRecycle = false;
64
108
  let proc!: Bun.Subprocess<"ignore", "inherit", "inherit">;
65
109
  proc = Bun.spawn(["bun", this.entry], {
66
110
  cwd: this.app.cwdPath,
67
- env: { ...this.env, AKAN_WATCH: "1" },
111
+ env: { ...this.env, AKAN_WATCH: "1", ...(afterRecycle ? { AKAN_BUILDER_ANNOUNCE_BOOT: "1" } : {}) },
68
112
  stdio: ["ignore", "inherit", "inherit"],
69
113
  ipc: (msg: BuilderMessage) => {
70
114
  if (this.#proc !== proc) return;
71
115
  if (!msg || typeof msg !== "object") return;
116
+ if (msg.type === "build-route-res" || msg.type === "build-csr-res") this.#inFlight.delete(msg.id);
72
117
  if (builderMsgTypeSet.has(msg.type)) this.#onMessage(msg);
73
118
  if (msg.type === "builder-ready" && !this.ready) {
74
119
  this.ready = true;
@@ -83,13 +128,28 @@ export class IncrementalBuilderHost {
83
128
  if (this.#proc !== proc) return;
84
129
  this.#proc = null;
85
130
  const wasReady = this.ready;
131
+ const wasRecycle = this.#recycleRequested;
132
+ this.#clearRecycle();
86
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
+ );
87
139
  if (this.#manualStop || this.#status === "stopped") return;
88
140
  if (!wasReady) {
89
141
  this.#status = "stopped";
90
142
  this.#startOptions.onExit?.();
91
143
  return;
92
144
  }
145
+ // A recycle is a planned exit, so it neither counts as a failed attempt nor waits out the
146
+ // crash backoff — the dev server is without a watcher until the replacement is up.
147
+ if (wasRecycle) {
148
+ this.logger.verbose("builder exited for a recycle; spawning its replacement now");
149
+ this.#spawnAfterRecycle = true;
150
+ this.#spawn(true);
151
+ return;
152
+ }
93
153
  this.#scheduleRestart();
94
154
  },
95
155
  });
@@ -112,6 +172,47 @@ export class IncrementalBuilderHost {
112
172
  this.#spawn(true);
113
173
  }, delay);
114
174
  }
175
+ /**
176
+ * Ask the builder to drain its queues and exit so the OS reclaims the bundler arenas `Bun.build`
177
+ * never gives back; `onExit` then spawns the replacement. Graceful rather than `kill()` so a rebuild
178
+ * in flight is never truncated, with a watchdog for a builder that stops answering.
179
+ */
180
+ recycle(reason: string): boolean {
181
+ if (!this.#proc || this.#status !== "ready" || this.#recycleRequested) return false;
182
+ const proc = this.#proc;
183
+ if (!this.send({ type: "builder-shutdown", reason })) return false;
184
+ this.#recycleRequested = true;
185
+ this.logger.info(`recycling builder pid=${proc.pid} (${reason})`);
186
+ this.#recycleTimer = setTimeout(() => {
187
+ this.#recycleTimer = null;
188
+ if (this.#proc !== proc) return;
189
+ this.logger.warn(
190
+ `builder pid=${proc.pid} did not exit within ${IncrementalBuilderHost.#recycleDrainTimeoutMs}ms of the recycle request; killing it`,
191
+ );
192
+ proc.kill();
193
+ }, IncrementalBuilderHost.#recycleDrainTimeoutMs);
194
+ return true;
195
+ }
196
+ #clearRecycle() {
197
+ this.#recycleRequested = false;
198
+ if (!this.#recycleTimer) return;
199
+ clearTimeout(this.#recycleTimer);
200
+ this.#recycleTimer = null;
201
+ }
202
+ /**
203
+ * RSS at which the builder is recycled: an explicit override, else a share of the container's limit
204
+ * (the builder is one of several dev processes), else the dev default. Set
205
+ * `AKAN_BUILDER_MAX_RSS_MB=0` to leave it unbounded.
206
+ */
207
+ static maxRssBytes(): number | null {
208
+ if (process.env.AKAN_BUILDER_MAX_RSS_MB === "0") return null;
209
+ return MemoryLimit.resolveMaxRssBytes({
210
+ megabytesEnv: "AKAN_BUILDER_MAX_RSS_MB",
211
+ bytesEnv: "AKAN_BUILDER_MAX_RSS",
212
+ limitFraction: 0.35,
213
+ fallbackBytes: IncrementalBuilderHost.#devMaxRssBytes,
214
+ });
215
+ }
115
216
  send(message: BuilderMessage): boolean {
116
217
  if (!this.#proc || this.#status !== "ready") {
117
218
  this.logger.warn(`incrementalBuilderHost is ${this.#status}; cannot send ${message.type}`);
@@ -119,6 +220,7 @@ export class IncrementalBuilderHost {
119
220
  }
120
221
  try {
121
222
  this.#proc.send(message);
223
+ if (message.type === "build-route" || message.type === "build-csr") this.#inFlight.set(message.id, message.type);
122
224
  return true;
123
225
  } catch (error) {
124
226
  this.logger.warn(
@@ -127,8 +229,24 @@ export class IncrementalBuilderHost {
127
229
  return false;
128
230
  }
129
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
+ }
130
246
  stop() {
131
247
  this.#manualStop = true;
248
+ this.#clearRecycle();
249
+ this.#failInFlight("builder was stopped before answering");
132
250
  if (this.#restartTimer) {
133
251
  clearTimeout(this.#restartTimer);
134
252
  this.#restartTimer = null;