@akanjs/devkit 2.4.1-rc.4 → 2.4.1-rc.6
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/incrementalBuilder/buildBatch.proc.ts +34 -1
- package/incrementalBuilder/buildBatchProtocol.ts +13 -2
- package/incrementalBuilder/builderChannel.test.ts +144 -0
- package/incrementalBuilder/builderChannel.ts +72 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +72 -26
- package/integration/devStability.integration.test.ts +15 -3
- package/package.json +2 -2
- package/incrementalBuilder/builderReply.test.ts +0 -73
- package/incrementalBuilder/builderReply.ts +0 -30
|
@@ -4,7 +4,13 @@ import type { App } from "@akanjs/devkit/commandDecorators";
|
|
|
4
4
|
// eager import is paid on every save. Measured on a 177-route app: `executors` 24ms, `frontendBuild`
|
|
5
5
|
// ~110ms, app config 5ms.
|
|
6
6
|
import { AppExecutor, WorkspaceExecutor } from "@akanjs/devkit/executors";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
CsrArtifactBuilder,
|
|
9
|
+
CssCompiler,
|
|
10
|
+
FontOptimizer,
|
|
11
|
+
PagesBundleBuilder,
|
|
12
|
+
SsrBaseArtifactBuilder,
|
|
13
|
+
} from "@akanjs/devkit/frontendBuild";
|
|
8
14
|
import { Logger } from "akanjs/common";
|
|
9
15
|
import type { BuilderMessage, BuildPhase } from "akanjs/server";
|
|
10
16
|
import type { BuildBatchRequest, BuildBatchResult, OptimizedFonts, PagesBatchCssAssets } from "./buildBatchProtocol";
|
|
@@ -34,6 +40,8 @@ class BuildBatch {
|
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
async run(): Promise<BuildBatchResult> {
|
|
43
|
+
// `base` arrives alone, from a builder that cannot serve anything until it finishes.
|
|
44
|
+
if (this.#request.needs.includes("base")) await this.#buildBase();
|
|
37
45
|
// Ordered the way the watcher used to run them: csr before pages so a csr failure cannot delay the
|
|
38
46
|
// pages bundle the browser is waiting on, and css last because it depends on the rebuilt client.
|
|
39
47
|
if (this.#request.needs.includes("csr")) await this.#buildCsr();
|
|
@@ -48,6 +56,12 @@ class BuildBatch {
|
|
|
48
56
|
* never had, and it would move every artifact write into the window right before the watcher reports
|
|
49
57
|
* the generation complete — which is where a save issued immediately afterwards gets dropped by Bun's
|
|
50
58
|
* recursive `fs.watch` (`local/optimize-resource/06-watcher-dropped-event.md`).
|
|
59
|
+
*
|
|
60
|
+
* A bare `process.send` is safe here, unlike in the watcher, for one reason: this process ends by
|
|
61
|
+
* returning from `main`, and a natural exit flushes a pending ipc write (measured: 1MB delivered
|
|
62
|
+
* 20/20). It is `process.exit` that discards one — so adding an explicit exit to this file, at the end
|
|
63
|
+
* of `main` or anywhere after an emit, would silently start dropping `css-updated` payloads. Route
|
|
64
|
+
* sends through `BuilderChannel` if that ever becomes necessary.
|
|
51
65
|
*/
|
|
52
66
|
#emit(message: BuilderMessage): void {
|
|
53
67
|
process.send?.(message);
|
|
@@ -66,6 +80,25 @@ class BuildBatch {
|
|
|
66
80
|
});
|
|
67
81
|
}
|
|
68
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The boot build. Streams nothing: a builder is not serving yet, so there is no phase board to update
|
|
85
|
+
* and no browser to reload — the watcher learns the outcome from the batch result, and a failure there
|
|
86
|
+
* is what puts it into degraded watch mode.
|
|
87
|
+
*/
|
|
88
|
+
async #buildBase(): Promise<void> {
|
|
89
|
+
const started = Date.now();
|
|
90
|
+
try {
|
|
91
|
+
const { artifact, optimizedFonts } = await new SsrBaseArtifactBuilder(this.#app).build();
|
|
92
|
+
this.#result.artifact = artifact;
|
|
93
|
+
this.#result.optimizedFonts = optimizedFonts;
|
|
94
|
+
this.#logger.verbose(`base-artifact ok buildId=${artifact.pagesBundleBuildId} (${Date.now() - started}ms)`);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
97
|
+
this.#logger.error(`base-artifact failed: ${message}`);
|
|
98
|
+
this.#result.errors.base = message;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
69
102
|
async #buildCsr(): Promise<void> {
|
|
70
103
|
const started = Date.now();
|
|
71
104
|
try {
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import type { FontOptimizer } from "@akanjs/devkit/frontendBuild";
|
|
2
|
-
import type { CssPayload, PagesBundlePayload } from "akanjs/server";
|
|
2
|
+
import type { BaseBuildArtifact, CssPayload, PagesBundlePayload } from "akanjs/server";
|
|
3
3
|
|
|
4
4
|
export type OptimizedFonts = Awaited<ReturnType<FontOptimizer["optimize"]>>;
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* `base` is the boot build (`SsrBaseArtifactBuilder`) and is never batched with the others: it is what a
|
|
8
|
+
* builder runs before it can serve anything, where the rest are per-save work. It earns its place here
|
|
9
|
+
* for the same reason as the others — measured on `apps/akan`, it retains **+1143 MB** of bundler arena
|
|
10
|
+
* that only a process exit returns, which was 65 % of the builder's whole idle footprint.
|
|
11
|
+
*/
|
|
12
|
+
export type BuildBatchNeed = "base" | "pages" | "css" | "csr";
|
|
7
13
|
|
|
8
14
|
/**
|
|
9
15
|
* One generation of build work, handed to a process that exits when it is done.
|
|
@@ -44,6 +50,11 @@ export interface BuildBatchResult {
|
|
|
44
50
|
generation: number;
|
|
45
51
|
cssAssets?: PagesBatchCssAssets;
|
|
46
52
|
optimizedFonts?: OptimizedFonts;
|
|
53
|
+
/**
|
|
54
|
+
* Present only for a `base` batch. The same object the worker wrote to `base-artifact.json`, returned
|
|
55
|
+
* here so the watcher does not have to read its own boot build back off disk.
|
|
56
|
+
*/
|
|
57
|
+
artifact?: BaseBuildArtifact;
|
|
47
58
|
errors: Partial<Record<BuildBatchNeed, string>>;
|
|
48
59
|
/** The worker died before reporting, so it streamed no `build-status` of its own for these needs. */
|
|
49
60
|
crashed?: boolean;
|
|
@@ -0,0 +1,144 @@
|
|
|
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 { BuilderChannel } from "./builderChannel";
|
|
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
|
+
type SendMode = "drain" | "await" | "bare";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run a child that sends what the builder sends and then exits the way the recycle drain does, and
|
|
17
|
+
* report what the parent actually received.
|
|
18
|
+
*
|
|
19
|
+
* `payload` picks the message shape, because shape decides whether a bare send survives: `manifest` is a
|
|
20
|
+
* map of many short strings (a `build-route-res` delta), `css` is one long string (a base64 stylesheet).
|
|
21
|
+
*/
|
|
22
|
+
const sendThenExit = async (
|
|
23
|
+
bytes: number,
|
|
24
|
+
{ mode, payload }: { mode: SendMode; payload: "manifest" | "css" },
|
|
25
|
+
): Promise<Array<{ type?: string }>> => {
|
|
26
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "builder-channel-"));
|
|
27
|
+
tempDirs.push(dir);
|
|
28
|
+
const entry = path.join(dir, "child.ts");
|
|
29
|
+
const modulePath = JSON.stringify(path.join(import.meta.dir, "builderChannel"));
|
|
30
|
+
const build =
|
|
31
|
+
payload === "manifest"
|
|
32
|
+
? [
|
|
33
|
+
'const chunk = "x".repeat(200);',
|
|
34
|
+
"const moduleMap: Record<string, string> = {};",
|
|
35
|
+
`for (let i = 0; i * 210 < ${bytes}; i++) moduleMap["chunk-" + i] = chunk;`,
|
|
36
|
+
'const msg = { type: "build-route-res", id: 7, ok: true, data: { ssrManifestDelta: moduleMap } };',
|
|
37
|
+
]
|
|
38
|
+
: [
|
|
39
|
+
`const css = "y".repeat(${bytes});`,
|
|
40
|
+
'const msg = { type: "css-updated", data: { cssAssets: {}, cssBase64ByUrl: { "/a.css": css } } };',
|
|
41
|
+
];
|
|
42
|
+
const sendLine = {
|
|
43
|
+
drain: "BuilderChannel.emit(msg as never);\nawait BuilderChannel.drain();",
|
|
44
|
+
await: "await BuilderChannel.send(msg as never);",
|
|
45
|
+
bare: "process.send?.(msg);",
|
|
46
|
+
}[mode];
|
|
47
|
+
await Bun.write(
|
|
48
|
+
entry,
|
|
49
|
+
[`import { BuilderChannel } from ${modulePath};`, ...build, sendLine, "process.exit(0);"].join("\n"),
|
|
50
|
+
);
|
|
51
|
+
const received: Array<{ type?: string }> = [];
|
|
52
|
+
const proc = Bun.spawn(["bun", entry], {
|
|
53
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
54
|
+
// The mode the dev host actually spawns the builder with; it changes where the loss cliff sits.
|
|
55
|
+
serialization: "advanced",
|
|
56
|
+
ipc: (message) => {
|
|
57
|
+
received.push(message as { type?: string });
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
await proc.exited;
|
|
61
|
+
// Messages land before the exit callback, never after, but leave room for a straggler to prove it.
|
|
62
|
+
await Bun.sleep(50);
|
|
63
|
+
return received;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
describe("BuilderChannel", () => {
|
|
67
|
+
test("delivers a reply too large for the pipe buffer before the process exits", async () => {
|
|
68
|
+
expect(await sendThenExit(1_000_000, { mode: "await", payload: "manifest" })).toMatchObject([{ id: 7 }]);
|
|
69
|
+
// A manifest delta is usually well past the buffer, but the small case must keep working too.
|
|
70
|
+
expect(await sendThenExit(0, { mode: "await", payload: "manifest" })).toMatchObject([{ id: 7 }]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a drained event survives the exit even though nobody awaited it", async () => {
|
|
74
|
+
// The event path: `css-updated` is relayed with no caller to await it, and the recycle drain exits
|
|
75
|
+
// milliseconds later. Only `drain()` stands between the two.
|
|
76
|
+
expect(await sendThenExit(200_000, { mode: "drain", payload: "css" })).toMatchObject([{ type: "css-updated" }]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("without the flush wait the same messages are lost, which is why this class exists", async () => {
|
|
80
|
+
// Controls, not requirements: if a future bun flushes ipc writes on exit, these fail and say so.
|
|
81
|
+
expect(await sendThenExit(1_000_000, { mode: "bare", payload: "manifest" })).toEqual([]);
|
|
82
|
+
// 20KB of css, far below the 64KB the manifest shape survives — one long string dies much earlier,
|
|
83
|
+
// so no size threshold would have been safe to special-case.
|
|
84
|
+
expect(await sendThenExit(20_000, { mode: "bare", payload: "css" })).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("drain resolves only once every tracked send has flushed", async () => {
|
|
88
|
+
const send = process.send;
|
|
89
|
+
const flushes: Array<() => void> = [];
|
|
90
|
+
try {
|
|
91
|
+
(process as { send?: unknown }).send = (_msg: unknown, _h: unknown, _o: unknown, cb: () => void) => {
|
|
92
|
+
flushes.push(cb);
|
|
93
|
+
return true;
|
|
94
|
+
};
|
|
95
|
+
BuilderChannel.emit({ type: "builder-ready" });
|
|
96
|
+
BuilderChannel.emit({ type: "builder-ready" });
|
|
97
|
+
let drained = false;
|
|
98
|
+
const draining = BuilderChannel.drain().then((count) => {
|
|
99
|
+
drained = true;
|
|
100
|
+
return count;
|
|
101
|
+
});
|
|
102
|
+
flushes[0]?.();
|
|
103
|
+
await Bun.sleep(1);
|
|
104
|
+
expect(drained).toBe(false);
|
|
105
|
+
flushes[1]?.();
|
|
106
|
+
expect(await draining).toBe(2);
|
|
107
|
+
} finally {
|
|
108
|
+
(process as { send?: unknown }).send = send;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a send started while draining is drained too", async () => {
|
|
113
|
+
const send = process.send;
|
|
114
|
+
const flushes: Array<() => void> = [];
|
|
115
|
+
try {
|
|
116
|
+
(process as { send?: unknown }).send = (_msg: unknown, _h: unknown, _o: unknown, cb: () => void) => {
|
|
117
|
+
flushes.push(cb);
|
|
118
|
+
return true;
|
|
119
|
+
};
|
|
120
|
+
BuilderChannel.emit({ type: "builder-ready" });
|
|
121
|
+
const draining = BuilderChannel.drain();
|
|
122
|
+
// What `#reportMetrics` does from a work item's `finally`, after the drain has begun.
|
|
123
|
+
BuilderChannel.emit({ type: "builder-metrics", data: { rssBytes: 1, generation: 1, workCount: 1 } });
|
|
124
|
+
for (let i = 0; i < 4 && flushes.length; i++) {
|
|
125
|
+
flushes.shift()?.();
|
|
126
|
+
await Bun.sleep(1);
|
|
127
|
+
}
|
|
128
|
+
expect(await draining).toBe(2);
|
|
129
|
+
} finally {
|
|
130
|
+
(process as { send?: unknown }).send = send;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("resolves instead of hanging when there is no ipc channel", async () => {
|
|
135
|
+
const send = process.send;
|
|
136
|
+
try {
|
|
137
|
+
(process as { send?: typeof process.send }).send = undefined;
|
|
138
|
+
await BuilderChannel.send({ type: "build-csr-res", id: 1, ok: true });
|
|
139
|
+
expect(await BuilderChannel.drain()).toBe(0);
|
|
140
|
+
} finally {
|
|
141
|
+
(process as { send?: typeof process.send }).send = send;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { BuilderMessage } from "akanjs/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every message the builder sends its host, and the record of which of those writes have actually left
|
|
5
|
+
* the process.
|
|
6
|
+
*
|
|
7
|
+
* `process.exit` discards an ipc write that has not flushed, and this process exits routinely — the
|
|
8
|
+
* recycle that keeps its RSS under the ceiling ends in `process.exit(0)`. Whether a given message
|
|
9
|
+
* survives that depends on its *shape*, not only its size. Measured on bun 1.3.14 (darwin,
|
|
10
|
+
* `serialization: "advanced"`), send-then-exit-immediately, 20 rounds each:
|
|
11
|
+
*
|
|
12
|
+
* - one long string: 8KB delivered 20/20, 16KB delivered **0/20**
|
|
13
|
+
* - many short strings: 60KB delivered 20/20, 100KB delivered **0/20**
|
|
14
|
+
*
|
|
15
|
+
* `css-updated` is the first shape (a base64 stylesheet per url) and `build-route-res` the second (a
|
|
16
|
+
* manifest delta), so neither sits safely under a threshold worth relying on. Nothing here assumes one:
|
|
17
|
+
* every send reports when it flushed, and `drain` is what `shutdown` awaits before exiting.
|
|
18
|
+
*
|
|
19
|
+
* A natural exit does *not* have this problem — a process that simply returns from `main` flushes 1MB
|
|
20
|
+
* 20/20 — which is why the disposable build worker needs none of this. Only an explicit `process.exit`
|
|
21
|
+
* cuts a write short.
|
|
22
|
+
*/
|
|
23
|
+
export class BuilderChannel {
|
|
24
|
+
/**
|
|
25
|
+
* Bound so a runtime that ever stops invoking the flush callback costs one late message instead of
|
|
26
|
+
* wedging the shutdown drain until the host's kill watchdog fires.
|
|
27
|
+
*/
|
|
28
|
+
static readonly #flushTimeoutMs = 5_000;
|
|
29
|
+
/** Sends that have not flushed yet; `drain` only ever needs to await them all. */
|
|
30
|
+
static readonly #flushing = new Set<Promise<void>>();
|
|
31
|
+
|
|
32
|
+
/** Send, and resolve once the write has left this process. */
|
|
33
|
+
static send(message: BuilderMessage): Promise<void> {
|
|
34
|
+
const send = process.send;
|
|
35
|
+
if (!send) return Promise.resolve();
|
|
36
|
+
const flushed = new Promise<void>((resolve) => {
|
|
37
|
+
const timer = setTimeout(resolve, BuilderChannel.#flushTimeoutMs);
|
|
38
|
+
// A failed send has nothing to retry: the host answers an unanswered request id itself when this
|
|
39
|
+
// process exits, and a lost event is superseded by the next generation's.
|
|
40
|
+
send.call(process, message, undefined, undefined, () => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
resolve();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
BuilderChannel.#flushing.add(flushed);
|
|
46
|
+
void flushed.then(() => BuilderChannel.#flushing.delete(flushed));
|
|
47
|
+
return flushed;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Send an event no caller awaits. Tracked exactly like `send`, which is the whole point: a relayed
|
|
52
|
+
* `css-updated` has nobody to await it, so `drain` is the only thing standing between it and the exit.
|
|
53
|
+
*/
|
|
54
|
+
static emit(message: BuilderMessage): void {
|
|
55
|
+
void BuilderChannel.send(message);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve once every send handed over so far has flushed, and report how many were still in flight.
|
|
60
|
+
* Sends started *while* draining are covered too — `#reportMetrics` can fire from a work item's
|
|
61
|
+
* `finally`, after the drain has already begun.
|
|
62
|
+
*/
|
|
63
|
+
static async drain(): Promise<number> {
|
|
64
|
+
let flushed = 0;
|
|
65
|
+
while (BuilderChannel.#flushing.size) {
|
|
66
|
+
const pending = [...BuilderChannel.#flushing];
|
|
67
|
+
flushed += pending.length;
|
|
68
|
+
await Promise.all(pending);
|
|
69
|
+
}
|
|
70
|
+
return flushed;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
GraphClientEntryDiscovery,
|
|
13
13
|
HmrWatcher,
|
|
14
14
|
RouteClientBuilder,
|
|
15
|
-
SsrBaseArtifactBuilder,
|
|
16
15
|
WatchRootResolver,
|
|
17
16
|
} from "@akanjs/devkit/frontendBuild";
|
|
18
17
|
import { Logger } from "akanjs/common";
|
|
@@ -27,7 +26,7 @@ import type {
|
|
|
27
26
|
} from "akanjs/server";
|
|
28
27
|
import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
|
|
29
28
|
import { BuildBatchRunner } from "./buildBatchRunner";
|
|
30
|
-
import {
|
|
29
|
+
import { BuilderChannel } from "./builderChannel";
|
|
31
30
|
import { prepareDevWatchBatch } from "./devWatchBatch";
|
|
32
31
|
|
|
33
32
|
interface IncrementalBuilderOptions {
|
|
@@ -89,7 +88,7 @@ class IncrementalBuilder {
|
|
|
89
88
|
*/
|
|
90
89
|
async handleBuildRoute(msg: BuilderReq): Promise<void> {
|
|
91
90
|
await this.#enqueueWork(`build-route:${msg.routeId}`, async () =>
|
|
92
|
-
|
|
91
|
+
BuilderChannel.send(await this.#handleBuildRoute(msg)),
|
|
93
92
|
);
|
|
94
93
|
}
|
|
95
94
|
|
|
@@ -132,7 +131,7 @@ class IncrementalBuilder {
|
|
|
132
131
|
{ generation, ok, files, message }: { generation?: number; ok: boolean; files?: string[]; message?: string },
|
|
133
132
|
): void {
|
|
134
133
|
if (typeof generation !== "number") return;
|
|
135
|
-
|
|
134
|
+
BuilderChannel.emit({
|
|
136
135
|
type: "build-status",
|
|
137
136
|
data: {
|
|
138
137
|
generation,
|
|
@@ -170,7 +169,7 @@ class IncrementalBuilder {
|
|
|
170
169
|
*/
|
|
171
170
|
#reportMetrics(): void {
|
|
172
171
|
if (!this.#idle || this.#shuttingDown) return;
|
|
173
|
-
|
|
172
|
+
BuilderChannel.emit({
|
|
174
173
|
type: "builder-metrics",
|
|
175
174
|
data: { rssBytes: process.memoryUsage.rss(), generation: this.#generation, workCount: this.#workCount },
|
|
176
175
|
});
|
|
@@ -195,7 +194,14 @@ class IncrementalBuilder {
|
|
|
195
194
|
}
|
|
196
195
|
await this.#workQueue.catch(() => undefined);
|
|
197
196
|
await this.#cssRebuildQueue.catch(() => undefined);
|
|
198
|
-
|
|
197
|
+
// Drained queues do not mean the host has the results. The events those work items produced are the
|
|
198
|
+
// largest messages this process sends, and `process.exit` discards an ipc write that has not
|
|
199
|
+
// flushed — a `css-updated` relayed milliseconds before this line would be dropped with no error
|
|
200
|
+
// anywhere, leaving the backend serving the previous bundle. See `BuilderChannel`.
|
|
201
|
+
const flushed = await BuilderChannel.drain();
|
|
202
|
+
this.#logger.info(
|
|
203
|
+
`drained in ${Date.now() - started}ms${flushed ? ` after flushing ${flushed} ipc write(s)` : ""}; exiting for recycle`,
|
|
204
|
+
);
|
|
199
205
|
process.exit(0);
|
|
200
206
|
}
|
|
201
207
|
|
|
@@ -311,7 +317,7 @@ class IncrementalBuilder {
|
|
|
311
317
|
|
|
312
318
|
if (hasSyncErrors) {
|
|
313
319
|
this.#sendBuildStatus("barrel", { generation, ok: false, files, message: indexSync.errors.join("\n") });
|
|
314
|
-
|
|
320
|
+
BuilderChannel.emit(event);
|
|
315
321
|
return;
|
|
316
322
|
}
|
|
317
323
|
if (indexSync.changedFiles.length > 0) this.#sendBuildStatus("barrel", { generation, ok: true, files });
|
|
@@ -347,7 +353,7 @@ class IncrementalBuilder {
|
|
|
347
353
|
needs.push("css");
|
|
348
354
|
}
|
|
349
355
|
|
|
350
|
-
|
|
356
|
+
BuilderChannel.emit(event);
|
|
351
357
|
|
|
352
358
|
if (needs.length > 0) await this.#runBatch({ generation, needs, changedFiles: files });
|
|
353
359
|
// A css-only batch keeps its debounce: those arrive in bursts while a stylesheet is edited, and
|
|
@@ -374,15 +380,18 @@ class IncrementalBuilder {
|
|
|
374
380
|
}): Promise<BuildBatchResult> {
|
|
375
381
|
const started = Date.now();
|
|
376
382
|
const result = await this.#batchRunner.run(await this.#batchRequest({ generation, needs, changedFiles }), (msg) =>
|
|
377
|
-
|
|
383
|
+
BuilderChannel.emit(msg),
|
|
378
384
|
);
|
|
379
385
|
if (result.optimizedFonts) this.#optimizedFonts = result.optimizedFonts;
|
|
380
386
|
if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
|
|
381
387
|
// A worker that died before reporting streamed no build-status of its own, so report one per need
|
|
382
388
|
// it was given: the generation must go red rather than look like it silently succeeded.
|
|
383
389
|
if (result.crashed) {
|
|
390
|
+
// `base` is excluded because it is not a `BuildPhase`: a boot build has no phase board to fail, and
|
|
391
|
+
// it never travels through here — `#buildBootDeps` runs it and throws into the degraded-boot path.
|
|
384
392
|
for (const need of needs)
|
|
385
|
-
|
|
393
|
+
if (need !== "base")
|
|
394
|
+
this.#sendBuildStatus(need, { generation, ok: false, files: changedFiles, message: result.errors[need] });
|
|
386
395
|
}
|
|
387
396
|
if (needs.includes("css")) this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
|
|
388
397
|
return result;
|
|
@@ -413,7 +422,7 @@ class IncrementalBuilder {
|
|
|
413
422
|
|
|
414
423
|
async boot(): Promise<void> {
|
|
415
424
|
if (this.#watch) await this.installWatcher();
|
|
416
|
-
|
|
425
|
+
BuilderChannel.emit({ type: "builder-ready" });
|
|
417
426
|
this.#logger.verbose(`ready (watch=${this.#watch})`);
|
|
418
427
|
}
|
|
419
428
|
|
|
@@ -441,7 +450,9 @@ class IncrementalBuilder {
|
|
|
441
450
|
async announceBootState(): Promise<void> {
|
|
442
451
|
const generation = ++this.#generation;
|
|
443
452
|
const reason = "builder-recycle" as const;
|
|
444
|
-
|
|
453
|
+
// Awaited rather than emitted: this runs during a recycle, so the host may ask this builder to shut
|
|
454
|
+
// down at any moment, and "announced boot state" must mean the announcement left the process.
|
|
455
|
+
await BuilderChannel.send({
|
|
445
456
|
type: "pages-updated",
|
|
446
457
|
data: {
|
|
447
458
|
bundlePath: this.#artifact.pagesBundlePath,
|
|
@@ -460,7 +471,7 @@ class IncrementalBuilder {
|
|
|
460
471
|
]),
|
|
461
472
|
),
|
|
462
473
|
);
|
|
463
|
-
|
|
474
|
+
await BuilderChannel.send({
|
|
464
475
|
type: "css-updated",
|
|
465
476
|
data: { cssAssets, cssBase64ByUrl, generation, changedFiles: [], reason },
|
|
466
477
|
});
|
|
@@ -483,12 +494,12 @@ class IncrementalBuilder {
|
|
|
483
494
|
const error = result.errors.csr;
|
|
484
495
|
if (error) {
|
|
485
496
|
this.#logger.error(`csr-build failed: ${error}`);
|
|
486
|
-
await
|
|
497
|
+
await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: false, error });
|
|
487
498
|
return;
|
|
488
499
|
}
|
|
489
500
|
this.#csrActive = true;
|
|
490
501
|
this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
|
|
491
|
-
await
|
|
502
|
+
await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: true });
|
|
492
503
|
});
|
|
493
504
|
}
|
|
494
505
|
|
|
@@ -500,10 +511,40 @@ class IncrementalBuilder {
|
|
|
500
511
|
return process.env.AKAN_DEV_CSR_REBUILD === "1";
|
|
501
512
|
}
|
|
502
513
|
|
|
503
|
-
|
|
504
|
-
|
|
514
|
+
/**
|
|
515
|
+
* Build the boot artifact in a process that exits afterwards, and keep only the serializable result.
|
|
516
|
+
*
|
|
517
|
+
* This runs in a worker for the same reason every other build does, and it was the largest single
|
|
518
|
+
* holdout: measured on `apps/akan`, `SsrBaseArtifactBuilder.build()` retains **+1143 MB** that
|
|
519
|
+
* `Bun.gc(true)` cannot touch, which is 65 % of the builder's post-boot RSS. Nothing was lost by
|
|
520
|
+
* moving it — the builder only ever kept `artifact` and `optimizedFonts`, both plain data, and the
|
|
521
|
+
* artifact is written to `base-artifact.json` regardless.
|
|
522
|
+
*
|
|
523
|
+
* `GraphClientEntryDiscovery.create` stays here because route builds need it live, and it costs
|
|
524
|
+
* nothing to keep: measured at **0 ms and 0 MB**, because it builds its graph lazily on first use.
|
|
525
|
+
*/
|
|
526
|
+
static async #buildBootDeps(app: App, runner: BuildBatchRunner): Promise<IncrementalBuilderBootDeps> {
|
|
527
|
+
const result = await runner.run({
|
|
528
|
+
appName: app.name,
|
|
529
|
+
workspaceRoot: app.workspace.workspaceRoot,
|
|
530
|
+
repoName: app.workspace.repoName,
|
|
531
|
+
generation: 0,
|
|
532
|
+
needs: ["base"],
|
|
533
|
+
changedFiles: [],
|
|
534
|
+
// Discovered by the worker: at boot the watcher has no validated keys to seed, and the boot build
|
|
535
|
+
// globs them itself anyway.
|
|
536
|
+
pageKeys: null,
|
|
537
|
+
optimizedFonts: null,
|
|
538
|
+
cssAssets: null,
|
|
539
|
+
artifactDir: path.resolve(`${app.cwdPath}/.akan/artifact`),
|
|
540
|
+
});
|
|
541
|
+
// A failed boot build has to throw, not degrade quietly: `main` catches this to enter the degraded
|
|
542
|
+
// watch mode that keeps the dev server alive until the error is fixed.
|
|
543
|
+
if (result.errors.base) throw new Error(result.errors.base);
|
|
544
|
+
if (!result.artifact || !result.optimizedFonts)
|
|
545
|
+
throw new Error("boot build reported success without an artifact; the build worker likely died");
|
|
505
546
|
const discovery = await GraphClientEntryDiscovery.create(app);
|
|
506
|
-
return { artifact, optimizedFonts, discovery };
|
|
547
|
+
return { artifact: result.artifact, optimizedFonts: result.optimizedFonts, discovery };
|
|
507
548
|
}
|
|
508
549
|
|
|
509
550
|
/**
|
|
@@ -535,18 +576,19 @@ class IncrementalBuilder {
|
|
|
535
576
|
app: App,
|
|
536
577
|
bootError: unknown,
|
|
537
578
|
logger: Logger,
|
|
579
|
+
runner: BuildBatchRunner,
|
|
538
580
|
): Promise<{ builder: IncrementalBuilder; changedFiles: string[] }> {
|
|
539
581
|
const firstMessage = bootError instanceof Error ? bootError.message : String(bootError);
|
|
540
582
|
logger.error(`boot build failed; entering degraded watch mode until the error is fixed: ${firstMessage}`);
|
|
541
583
|
let generation = 0;
|
|
542
584
|
const sendFailure = (files: string[], message: string) => {
|
|
543
|
-
|
|
585
|
+
BuilderChannel.emit({
|
|
544
586
|
type: "build-status",
|
|
545
587
|
data: { generation, phase: "pages", ok: false, files, message: `Boot build failed: ${message}` },
|
|
546
588
|
});
|
|
547
589
|
};
|
|
548
590
|
sendFailure([], firstMessage);
|
|
549
|
-
|
|
591
|
+
BuilderChannel.emit({ type: "builder-ready" });
|
|
550
592
|
return new Promise((resolve, reject) => {
|
|
551
593
|
void (async () => {
|
|
552
594
|
const roots = await new WatchRootResolver(app).resolve();
|
|
@@ -559,7 +601,7 @@ class IncrementalBuilder {
|
|
|
559
601
|
try {
|
|
560
602
|
// A broken akan.config.ts caches its import failure; re-import it before rebuilding.
|
|
561
603
|
if (new Set(batch.kinds).has("config")) await app.getConfig({ refresh: true });
|
|
562
|
-
const deps = await IncrementalBuilder.#buildBootDeps(app);
|
|
604
|
+
const deps = await IncrementalBuilder.#buildBootDeps(app, runner);
|
|
563
605
|
const builder = new IncrementalBuilder({ app, watch: true, initialGeneration: generation, ...deps });
|
|
564
606
|
watcher.stop();
|
|
565
607
|
logger.info(`boot build recovered generation=${generation}`);
|
|
@@ -602,7 +644,7 @@ class IncrementalBuilder {
|
|
|
602
644
|
if (msg.type === "build-route") {
|
|
603
645
|
const error = builder?.shuttingDown ? recyclingError : bootingError;
|
|
604
646
|
if (!builder || builder.shuttingDown) {
|
|
605
|
-
|
|
647
|
+
BuilderChannel.emit({ type: "build-route-res", id: msg.id, ok: false, error });
|
|
606
648
|
return;
|
|
607
649
|
}
|
|
608
650
|
void builder.handleBuildRoute(msg);
|
|
@@ -611,24 +653,28 @@ class IncrementalBuilder {
|
|
|
611
653
|
if (msg.type === "build-csr") {
|
|
612
654
|
const error = builder?.shuttingDown ? recyclingError : bootingError;
|
|
613
655
|
if (!builder || builder.shuttingDown) {
|
|
614
|
-
|
|
656
|
+
BuilderChannel.emit({ type: "build-csr-res", id: msg.id, ok: false, error });
|
|
615
657
|
return;
|
|
616
658
|
}
|
|
617
659
|
void builder.handleBuildCsr(msg);
|
|
618
660
|
}
|
|
619
661
|
});
|
|
620
662
|
// The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
|
|
621
|
-
// as an orphaned watcher that keeps rebuilding for nobody.
|
|
663
|
+
// as an orphaned watcher that keeps rebuilding for nobody. Nothing is drained here on purpose —
|
|
664
|
+
// there is no longer anyone on the other end to flush to.
|
|
622
665
|
process.on("disconnect", () => {
|
|
623
666
|
logger.warn("host IPC channel closed; exiting builder");
|
|
624
667
|
process.exit(0);
|
|
625
668
|
});
|
|
626
669
|
let recoveredFiles: string[] | null = null;
|
|
670
|
+
// Owned by `main` rather than the instance: the boot build has to run before an instance exists, and
|
|
671
|
+
// a degraded boot re-runs it once per file change until it succeeds.
|
|
672
|
+
const bootRunner = new BuildBatchRunner({ workspaceRoot, cwd: app.cwdPath });
|
|
627
673
|
try {
|
|
628
|
-
builder = new IncrementalBuilder({ app, watch, ...(await IncrementalBuilder.#buildBootDeps(app)) });
|
|
674
|
+
builder = new IncrementalBuilder({ app, watch, ...(await IncrementalBuilder.#buildBootDeps(app, bootRunner)) });
|
|
629
675
|
} catch (err) {
|
|
630
676
|
if (!watch) throw err;
|
|
631
|
-
const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger);
|
|
677
|
+
const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner);
|
|
632
678
|
builder = recovered.builder;
|
|
633
679
|
recoveredFiles = recovered.changedFiles;
|
|
634
680
|
}
|
|
@@ -566,9 +566,21 @@ describe("dev resource budgets", () => {
|
|
|
566
566
|
const idleBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
567
567
|
// Nothing should be building at idle, so the disposable worker must not be resident.
|
|
568
568
|
expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
|
|
569
|
+
// Printed so a run that came close to its budget says so, instead of being indistinguishable from a
|
|
570
|
+
// comfortable one — the same reason the recycle and idle-suspend guards report their numbers.
|
|
571
|
+
console.info(
|
|
572
|
+
`[budget-guard] idle tree ${Math.round(idleTotal / MB)}MB (builder ${Math.round((idleBuilder?.rssBytes ?? 0) / MB)}MB, rest ${Math.round(idleWithoutBuilder / MB)}MB)`,
|
|
573
|
+
);
|
|
574
|
+
// Split in two because the two halves have very different variance. The builder legitimately swings
|
|
575
|
+
// from a ~130MB fresh floor to ~520MB once it has built a route (`Bun.build` arenas the RSS-ceiling
|
|
576
|
+
// recycle is what bounds), so a tight total would flake. Measured 929-959MB total / ~414MB without
|
|
577
|
+
// the builder; the old 1000MB total came from a phase-1 topology where the fixture measured ~670MB
|
|
578
|
+
// and is only ~4% above what the current topology legitimately uses.
|
|
579
|
+
expect(idleTotal).toBeLessThan(1_200 * MB);
|
|
580
|
+
// This is the half that catches what the budget exists for: an eager import lands in the dev host or
|
|
581
|
+
// the backend, not in the builder's arenas. The cheapest reintroduction is ~30MB and the devkit
|
|
582
|
+
// barrel cycle was 236MB, so this bound still fails on the latter.
|
|
583
|
+
expect(idleWithoutBuilder).toBeLessThan(600 * MB);
|
|
572
584
|
|
|
573
585
|
const start = host.markLog();
|
|
574
586
|
for (let i = 1; i <= 3; i++) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "2.4.1-rc.
|
|
3
|
+
"version": "2.4.1-rc.6",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"@langchain/openai": "^1.4.6",
|
|
45
45
|
"@tailwindcss/node": "^4.3.0",
|
|
46
46
|
"@trapezedev/project": "^7.1.4",
|
|
47
|
-
"akanjs": "2.4.1-rc.
|
|
47
|
+
"akanjs": "2.4.1-rc.6",
|
|
48
48
|
"chalk": "^5.6.2",
|
|
49
49
|
"commander": "^14.0.3",
|
|
50
50
|
"daisyui": "5.5.23",
|
|
@@ -1,73 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,30 +0,0 @@
|
|
|
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
|
-
}
|