@akanjs/devkit 2.4.1-rc.2 → 2.4.1-rc.3
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 +84 -0
- package/akanApp/akanApp.host.ts +181 -5
- package/incrementalBuilder/buildBatch.proc.ts +194 -0
- package/incrementalBuilder/buildBatchProtocol.ts +53 -0
- package/incrementalBuilder/buildBatchRunner.ts +85 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +114 -9
- package/incrementalBuilder/incrementalBuilder.host.ts +72 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +238 -161
- package/integration/devStability.integration.test.ts +98 -15
- package/integration/devStabilityHarness.ts +28 -2
- 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,85 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Logger } from "akanjs/common";
|
|
3
|
+
import type { BuilderMessage } from "akanjs/server";
|
|
4
|
+
import type { BuildBatchMessage, BuildBatchRequest, BuildBatchResult } from "./buildBatchProtocol";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Runs one `BuildBatchRequest` in a fresh process and resolves with what it produced.
|
|
8
|
+
*
|
|
9
|
+
* The watcher serializes every batch through its own work queue, so this deliberately has no pool: one
|
|
10
|
+
* worker exists at a time, and it exits before the next one starts. That is the whole point — the
|
|
11
|
+
* bundler arenas `Bun.build` never frees go back to the OS with the process.
|
|
12
|
+
*
|
|
13
|
+
* A worker that dies without reporting is not fatal. Every need it was given comes back as an error, the
|
|
14
|
+
* watcher reports a red build-status for that generation, and the last-good artifact keeps serving —
|
|
15
|
+
* the same contract a failed in-process build had. It must never take the watcher down with it: the
|
|
16
|
+
* watcher is the dev server's file watcher, so nothing would notice the fix.
|
|
17
|
+
*/
|
|
18
|
+
export class BuildBatchRunner {
|
|
19
|
+
static readonly #entryCandidates = (workspaceRoot: string) => [
|
|
20
|
+
path.join(workspaceRoot, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
21
|
+
path.join(workspaceRoot, "node_modules/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
22
|
+
path.join(import.meta.dir, "buildBatch.proc.js"),
|
|
23
|
+
path.join(import.meta.dir, "buildBatch.proc.ts"),
|
|
24
|
+
];
|
|
25
|
+
#logger = new Logger("BuildBatchRunner");
|
|
26
|
+
#entry: string | null = null;
|
|
27
|
+
#workspaceRoot: string;
|
|
28
|
+
#cwd: string;
|
|
29
|
+
constructor({ workspaceRoot, cwd }: { workspaceRoot: string; cwd: string }) {
|
|
30
|
+
this.#workspaceRoot = workspaceRoot;
|
|
31
|
+
this.#cwd = cwd;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async #resolveEntry(): Promise<string> {
|
|
35
|
+
if (this.#entry) return this.#entry;
|
|
36
|
+
const candidates = BuildBatchRunner.#entryCandidates(this.#workspaceRoot);
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
if (!(await Bun.file(candidate).exists())) continue;
|
|
39
|
+
this.#entry = candidate;
|
|
40
|
+
return candidate;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`[build-batch] worker entry not found; looked in: ${candidates.join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `onMessage` receives everything the worker streams as it goes — `pages-updated`, `css-updated`,
|
|
47
|
+
* `build-status` — so the watcher can relay each one the moment it is produced instead of holding a
|
|
48
|
+
* page reload until the whole batch is done.
|
|
49
|
+
*/
|
|
50
|
+
async run(
|
|
51
|
+
request: BuildBatchRequest,
|
|
52
|
+
onMessage: (message: BuilderMessage) => void = () => undefined,
|
|
53
|
+
): Promise<BuildBatchResult> {
|
|
54
|
+
const started = Date.now();
|
|
55
|
+
const entry = await this.#resolveEntry();
|
|
56
|
+
let result: BuildBatchResult | null = null;
|
|
57
|
+
// The request travels in argv rather than over IPC so the worker can start on its first tick
|
|
58
|
+
// instead of waiting for a handshake it would have to synchronize against.
|
|
59
|
+
const proc = Bun.spawn(["bun", entry, JSON.stringify(request)], {
|
|
60
|
+
cwd: this.#cwd,
|
|
61
|
+
env: process.env,
|
|
62
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
63
|
+
serialization: "advanced",
|
|
64
|
+
ipc: (message: BuildBatchMessage | BuilderMessage) => {
|
|
65
|
+
if (!message || typeof message !== "object") return;
|
|
66
|
+
if (message.type === "build-batch-result") result = message.data;
|
|
67
|
+
else onMessage(message);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
const exitCode = await proc.exited;
|
|
71
|
+
if (result) {
|
|
72
|
+
this.#logger.verbose(
|
|
73
|
+
`[build-batch] generation=${request.generation} needs=${request.needs.join(",")} done in ${Date.now() - started}ms`,
|
|
74
|
+
);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
const message = `build worker exited with code ${exitCode} before reporting a result`;
|
|
78
|
+
this.#logger.error(`[build-batch] generation=${request.generation} ${message}`);
|
|
79
|
+
return {
|
|
80
|
+
generation: request.generation,
|
|
81
|
+
errors: Object.fromEntries(request.needs.map((need) => [need, message])),
|
|
82
|
+
crashed: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -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,102 @@ 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_RECYCLED).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_RECYCLED).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_RECYCLED).toBeUndefined();
|
|
94
|
+
|
|
95
|
+
host.stop();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("only recycles a builder that is ready", () => {
|
|
99
|
+
const spawns = mockSpawns();
|
|
100
|
+
const host = new IncrementalBuilderHost({
|
|
101
|
+
app: { cwdPath: "/tmp/app" } as never,
|
|
102
|
+
entry: "/tmp/builder.ts",
|
|
103
|
+
env: {},
|
|
104
|
+
onMessage: () => undefined,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
host.start();
|
|
108
|
+
expect(host.recycle("while still booting")).toBe(false);
|
|
109
|
+
expect(spawns[0]?.proc.send).not.toHaveBeenCalled();
|
|
110
|
+
|
|
111
|
+
host.stop();
|
|
112
|
+
expect(host.recycle("after stop")).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("IncrementalBuilderHost.maxRssBytes", () => {
|
|
117
|
+
const withEnv = (values: Record<string, string | undefined>, fn: () => void) => {
|
|
118
|
+
const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]));
|
|
119
|
+
try {
|
|
120
|
+
for (const [key, value] of Object.entries(values)) {
|
|
121
|
+
if (value === undefined) delete process.env[key];
|
|
122
|
+
else process.env[key] = value;
|
|
123
|
+
}
|
|
124
|
+
fn();
|
|
125
|
+
} finally {
|
|
126
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
127
|
+
if (value === undefined) delete process.env[key];
|
|
128
|
+
else process.env[key] = value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
test("defaults to a dev ceiling well above a fresh boot", () => {
|
|
134
|
+
withEnv(
|
|
135
|
+
{ AKAN_BUILDER_MAX_RSS_MB: undefined, AKAN_BUILDER_MAX_RSS: undefined, AKAN_MEMORY_LIMIT: undefined },
|
|
136
|
+
() => {
|
|
137
|
+
expect(IncrementalBuilderHost.maxRssBytes()).toBe(1_200 * 1024 * 1024);
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("honors an explicit override and treats 0 as unbounded", () => {
|
|
143
|
+
withEnv({ AKAN_BUILDER_MAX_RSS_MB: "700" }, () => {
|
|
144
|
+
expect(IncrementalBuilderHost.maxRssBytes()).toBe(700 * 1024 * 1024);
|
|
145
|
+
});
|
|
146
|
+
withEnv({ AKAN_BUILDER_MAX_RSS_MB: "0" }, () => {
|
|
147
|
+
expect(IncrementalBuilderHost.maxRssBytes()).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("takes a share of a declared memory limit, leaving room for the other dev processes", () => {
|
|
152
|
+
withEnv({ AKAN_BUILDER_MAX_RSS_MB: undefined, AKAN_BUILDER_MAX_RSS: undefined, AKAN_MEMORY_LIMIT: "4gb" }, () => {
|
|
153
|
+
expect(IncrementalBuilderHost.maxRssBytes()).toBe(Math.floor(4 * 1024 ** 3 * 0.35));
|
|
154
|
+
});
|
|
155
|
+
});
|
|
51
156
|
});
|
|
@@ -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;
|
|
@@ -30,6 +32,16 @@ interface IncrementalBuilderStartOptions {
|
|
|
30
32
|
export class IncrementalBuilderHost {
|
|
31
33
|
static readonly #restartBaseDelayMs = 1_000;
|
|
32
34
|
static readonly #restartMaxDelayMs = 30_000;
|
|
35
|
+
/**
|
|
36
|
+
* A builder that has stopped answering has to be replaced anyway; killing it after this long turns
|
|
37
|
+
* a wedged drain into an ordinary restart instead of leaving the recycle stuck forever.
|
|
38
|
+
*/
|
|
39
|
+
static readonly #recycleDrainTimeoutMs = 30_000;
|
|
40
|
+
/**
|
|
41
|
+
* Dev has no memory limit to derive a fraction from, so this is what bounds the builder there. Well
|
|
42
|
+
* above a fresh boot (~300-600MB depending on app size) with room for one full rebuild on top.
|
|
43
|
+
*/
|
|
44
|
+
static readonly #devMaxRssBytes = 1_200 * 1024 * 1024;
|
|
33
45
|
logger = new Logger("IncrementalBuilderHost");
|
|
34
46
|
entry: string;
|
|
35
47
|
env: Record<string, string>;
|
|
@@ -40,6 +52,9 @@ export class IncrementalBuilderHost {
|
|
|
40
52
|
#status: IncrementalBuilderStatus = "stopped";
|
|
41
53
|
#restartAttempts = 0;
|
|
42
54
|
#restartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
55
|
+
#recycleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
56
|
+
#recycleRequested: boolean = false;
|
|
57
|
+
#spawnAfterRecycle: boolean = false;
|
|
43
58
|
#manualStop = false;
|
|
44
59
|
#startOptions: IncrementalBuilderStartOptions = {};
|
|
45
60
|
constructor({ app, entry, env, onMessage }: IncrementalBuilderHostOptions) {
|
|
@@ -61,10 +76,14 @@ export class IncrementalBuilderHost {
|
|
|
61
76
|
#spawn(isRestart: boolean) {
|
|
62
77
|
this.#status = isRestart ? "restarting" : "starting";
|
|
63
78
|
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.
|
|
81
|
+
const afterRecycle = this.#spawnAfterRecycle;
|
|
82
|
+
this.#spawnAfterRecycle = false;
|
|
64
83
|
let proc!: Bun.Subprocess<"ignore", "inherit", "inherit">;
|
|
65
84
|
proc = Bun.spawn(["bun", this.entry], {
|
|
66
85
|
cwd: this.app.cwdPath,
|
|
67
|
-
env: { ...this.env, AKAN_WATCH: "1" },
|
|
86
|
+
env: { ...this.env, AKAN_WATCH: "1", ...(afterRecycle ? { AKAN_BUILDER_RECYCLED: "1" } : {}) },
|
|
68
87
|
stdio: ["ignore", "inherit", "inherit"],
|
|
69
88
|
ipc: (msg: BuilderMessage) => {
|
|
70
89
|
if (this.#proc !== proc) return;
|
|
@@ -83,6 +102,8 @@ export class IncrementalBuilderHost {
|
|
|
83
102
|
if (this.#proc !== proc) return;
|
|
84
103
|
this.#proc = null;
|
|
85
104
|
const wasReady = this.ready;
|
|
105
|
+
const wasRecycle = this.#recycleRequested;
|
|
106
|
+
this.#clearRecycle();
|
|
86
107
|
this.ready = false;
|
|
87
108
|
if (this.#manualStop || this.#status === "stopped") return;
|
|
88
109
|
if (!wasReady) {
|
|
@@ -90,6 +111,14 @@ export class IncrementalBuilderHost {
|
|
|
90
111
|
this.#startOptions.onExit?.();
|
|
91
112
|
return;
|
|
92
113
|
}
|
|
114
|
+
// A recycle is a planned exit, so it neither counts as a failed attempt nor waits out the
|
|
115
|
+
// crash backoff — the dev server is without a watcher until the replacement is up.
|
|
116
|
+
if (wasRecycle) {
|
|
117
|
+
this.logger.verbose("builder exited for a recycle; spawning its replacement now");
|
|
118
|
+
this.#spawnAfterRecycle = true;
|
|
119
|
+
this.#spawn(true);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
93
122
|
this.#scheduleRestart();
|
|
94
123
|
},
|
|
95
124
|
});
|
|
@@ -112,6 +141,47 @@ export class IncrementalBuilderHost {
|
|
|
112
141
|
this.#spawn(true);
|
|
113
142
|
}, delay);
|
|
114
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Ask the builder to drain its queues and exit so the OS reclaims the bundler arenas `Bun.build`
|
|
146
|
+
* never gives back; `onExit` then spawns the replacement. Graceful rather than `kill()` so a rebuild
|
|
147
|
+
* in flight is never truncated, with a watchdog for a builder that stops answering.
|
|
148
|
+
*/
|
|
149
|
+
recycle(reason: string): boolean {
|
|
150
|
+
if (!this.#proc || this.#status !== "ready" || this.#recycleRequested) return false;
|
|
151
|
+
const proc = this.#proc;
|
|
152
|
+
if (!this.send({ type: "builder-shutdown", reason })) return false;
|
|
153
|
+
this.#recycleRequested = true;
|
|
154
|
+
this.logger.info(`recycling builder pid=${proc.pid} (${reason})`);
|
|
155
|
+
this.#recycleTimer = setTimeout(() => {
|
|
156
|
+
this.#recycleTimer = null;
|
|
157
|
+
if (this.#proc !== proc) return;
|
|
158
|
+
this.logger.warn(
|
|
159
|
+
`builder pid=${proc.pid} did not exit within ${IncrementalBuilderHost.#recycleDrainTimeoutMs}ms of the recycle request; killing it`,
|
|
160
|
+
);
|
|
161
|
+
proc.kill();
|
|
162
|
+
}, IncrementalBuilderHost.#recycleDrainTimeoutMs);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
#clearRecycle() {
|
|
166
|
+
this.#recycleRequested = false;
|
|
167
|
+
if (!this.#recycleTimer) return;
|
|
168
|
+
clearTimeout(this.#recycleTimer);
|
|
169
|
+
this.#recycleTimer = null;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* RSS at which the builder is recycled: an explicit override, else a share of the container's limit
|
|
173
|
+
* (the builder is one of several dev processes), else the dev default. Set
|
|
174
|
+
* `AKAN_BUILDER_MAX_RSS_MB=0` to leave it unbounded.
|
|
175
|
+
*/
|
|
176
|
+
static maxRssBytes(): number | null {
|
|
177
|
+
if (process.env.AKAN_BUILDER_MAX_RSS_MB === "0") return null;
|
|
178
|
+
return MemoryLimit.resolveMaxRssBytes({
|
|
179
|
+
megabytesEnv: "AKAN_BUILDER_MAX_RSS_MB",
|
|
180
|
+
bytesEnv: "AKAN_BUILDER_MAX_RSS",
|
|
181
|
+
limitFraction: 0.35,
|
|
182
|
+
fallbackBytes: IncrementalBuilderHost.#devMaxRssBytes,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
115
185
|
send(message: BuilderMessage): boolean {
|
|
116
186
|
if (!this.#proc || this.#status !== "ready") {
|
|
117
187
|
this.logger.warn(`incrementalBuilderHost is ${this.#status}; cannot send ${message.type}`);
|
|
@@ -129,6 +199,7 @@ export class IncrementalBuilderHost {
|
|
|
129
199
|
}
|
|
130
200
|
stop() {
|
|
131
201
|
this.#manualStop = true;
|
|
202
|
+
this.#clearRecycle();
|
|
132
203
|
if (this.#restartTimer) {
|
|
133
204
|
clearTimeout(this.#restartTimer);
|
|
134
205
|
this.#restartTimer = null;
|