@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.
- package/akanApp/akanApp.host.test.ts +283 -2
- package/akanApp/akanApp.host.ts +578 -12
- package/executors.test.ts +60 -0
- package/executors.ts +11 -0
- package/frontendBuild/fontOptimizer.test.ts +111 -0
- package/frontendBuild/fontOptimizer.ts +102 -17
- package/frontendBuild/hmrWatcher.test.ts +191 -0
- package/frontendBuild/hmrWatcher.ts +176 -5
- package/frontendBuild/index.ts +1 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +280 -0
- package/frontendBuild/sourceMtimeIndex.ts +326 -0
- package/incrementalBuilder/buildBatch.proc.ts +194 -0
- package/incrementalBuilder/buildBatchProtocol.ts +53 -0
- package/incrementalBuilder/buildBatchRunner.ts +85 -0
- package/incrementalBuilder/builderReply.test.ts +73 -0
- package/incrementalBuilder/builderReply.ts +30 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +199 -9
- package/incrementalBuilder/incrementalBuilder.host.ts +119 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +260 -170
- package/integration/devStability.integration.test.ts +308 -78
- package/integration/devStabilityHarness.test.ts +111 -0
- package/integration/devStabilityHarness.ts +555 -40
- package/local/optimize-resource/ipcprobe/child.ts +1 -0
- package/local/optimize-resource/ipcprobe/parent.ts +11 -0
- package/package.json +2 -2
|
@@ -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
|
|
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;
|