@akanjs/devkit 2.4.1-rc.6 → 2.4.1
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/CHANGELOG.md +30 -0
- package/DEV_RUNTIME_KNOBS.md +97 -0
- package/README.md +6 -0
- package/akanApp/akanApp.host.test.ts +75 -6
- package/akanApp/akanApp.host.ts +303 -25
- package/commandDecorators/command.ts +16 -1
- package/frontendBuild/clientEntryDiscovery.ts +91 -53
- package/frontendBuild/cssCandidateCache.ts +109 -0
- package/frontendBuild/cssCompiler.ts +70 -18
- package/frontendBuild/frontendBuild.test.ts +3 -0
- package/frontendBuild/hmrWatcher.ts +6 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +51 -2
- package/frontendBuild/sourceMtimeIndex.ts +66 -5
- package/incrementalBuilder/buildBatchRunner.ts +10 -1
- package/incrementalBuilder/builderChannel.test.ts +16 -7
- package/incrementalBuilder/builderRequestRouter.test.ts +89 -0
- package/incrementalBuilder/builderRequestRouter.ts +66 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +28 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +24 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +14 -13
- package/integration/devResourceProbe.ts +319 -0
- package/integration/devStability.integration.test.ts +153 -4
- package/integration/devStabilityHarness.ts +30 -5
- package/package.json +2 -2
- package/transforms/barrelImportsPlugin.ts +20 -13
|
@@ -17,6 +17,12 @@ const UNREADABLE = Number.NaN;
|
|
|
17
17
|
export interface SourceMtimeIndexOptions {
|
|
18
18
|
roots: string[];
|
|
19
19
|
classifier?: HmrChangeClassifier;
|
|
20
|
+
/**
|
|
21
|
+
* How close to "now" a directory's mtime may be before this scan's reading of it is treated as
|
|
22
|
+
* possibly stale. Raise it for a filesystem whose timestamps are coarser than the 1ms Linux stamps
|
|
23
|
+
* directories with — some network mounts stamp whole seconds.
|
|
24
|
+
*/
|
|
25
|
+
dirSettleMs?: number;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
/**
|
|
@@ -32,20 +38,41 @@ export interface SourceMtimeIndexOptions {
|
|
|
32
38
|
* Re-stating the tracked set costs ~15ms against ~1300 source files here, where a fresh walk costs
|
|
33
39
|
* ~70ms — the walk has to visit ~40k entries (`ios/`, `android/`, `public/`) to find those 1300. So
|
|
34
40
|
* changes are found by re-stating known files plus re-reading only the directories whose own mtime
|
|
35
|
-
* moved, which is what adding or removing an entry bumps
|
|
41
|
+
* moved, which is what adding or removing an entry bumps — with one exception for the window in which
|
|
42
|
+
* that mtime cannot be trusted, see `#defaultDirSettleMs`.
|
|
36
43
|
*/
|
|
37
44
|
export class SourceMtimeIndex {
|
|
45
|
+
/**
|
|
46
|
+
* How close to "now" a directory's mtime may be before this scan's reading of it is treated as
|
|
47
|
+
* possibly stale, and the directory re-read next time regardless of what its mtime says.
|
|
48
|
+
*
|
|
49
|
+
* Linux stamps directory times from a coarse clock. Measured on Bun 1.3.14 under Docker, 400
|
|
50
|
+
* back-to-back `mkdir`s left the parent's mtime unmoved **319 times on overlayfs and 324 times on
|
|
51
|
+
* ext4**, smallest observable step 1ms; macOS APFS (0.042ms) and a virtiofs bind mount (0.29ms) missed
|
|
52
|
+
* none. So a directory mutated in the same millisecond as the value this index recorded — but after the
|
|
53
|
+
* walk that recorded it — leaves no trace at all, and because its files were never tracked, later edits
|
|
54
|
+
* to them go unreported too. That is permanent for the life of the process, which is what makes it worth
|
|
55
|
+
* a second look rather than a note.
|
|
56
|
+
*
|
|
57
|
+
* 20ms covers a 1ms tick with room for a `HZ=100` kernel's 10ms, and costs one extra `readdir` per
|
|
58
|
+
* directory touched in the last 20ms — during a save, a handful.
|
|
59
|
+
*/
|
|
60
|
+
static readonly #defaultDirSettleMs = 20;
|
|
61
|
+
readonly #dirSettleMs: number;
|
|
38
62
|
readonly #roots: string[];
|
|
39
63
|
readonly #classifier: HmrChangeClassifier;
|
|
40
64
|
readonly #files = new Map<string, TrackedFile>();
|
|
41
65
|
readonly #dirs = new Map<string, number>();
|
|
42
66
|
readonly #unreadable = new Map<string, string>();
|
|
67
|
+
/** Directories whose recorded mtime was too fresh to trust, re-read on the next scan. */
|
|
68
|
+
readonly #unsettled = new Set<string>();
|
|
43
69
|
#primed = false;
|
|
44
70
|
#queue: Promise<unknown> = Promise.resolve();
|
|
45
71
|
|
|
46
|
-
constructor({ roots, classifier }: SourceMtimeIndexOptions) {
|
|
72
|
+
constructor({ roots, classifier, dirSettleMs }: SourceMtimeIndexOptions) {
|
|
47
73
|
this.#roots = SourceMtimeIndex.#pruneNestedRoots(roots);
|
|
48
74
|
this.#classifier = classifier ?? new HmrChangeClassifier();
|
|
75
|
+
this.#dirSettleMs = dirSettleMs ?? SourceMtimeIndex.#defaultDirSettleMs;
|
|
49
76
|
}
|
|
50
77
|
|
|
51
78
|
get primed(): boolean {
|
|
@@ -67,12 +94,22 @@ export class SourceMtimeIndex {
|
|
|
67
94
|
return [...this.#unreadable].map(([file, code]) => ({ path: file, code }));
|
|
68
95
|
}
|
|
69
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Whether the last scan left a directory whose mtime was too fresh to trust. The caller should scan
|
|
99
|
+
* again once the timestamp has settled, since nothing else will look at that directory until something
|
|
100
|
+
* moves its mtime — and on a coarse clock the change that should have moved it already happened.
|
|
101
|
+
*/
|
|
102
|
+
get hasUnsettledDirs(): boolean {
|
|
103
|
+
return this.#unsettled.size > 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
70
106
|
/** Record the current state as the baseline. Reports nothing; call before the first `collectChanges`. */
|
|
71
107
|
async prime(): Promise<void> {
|
|
72
108
|
await this.#serialize(async () => {
|
|
73
109
|
this.#files.clear();
|
|
74
110
|
this.#dirs.clear();
|
|
75
111
|
this.#unreadable.clear();
|
|
112
|
+
this.#unsettled.clear();
|
|
76
113
|
await Promise.all(this.#roots.map((root) => this.#walk(root, null)));
|
|
77
114
|
this.#primed = true;
|
|
78
115
|
});
|
|
@@ -168,7 +205,7 @@ export class SourceMtimeIndex {
|
|
|
168
205
|
* content changes, so this finds creations and deletions the file pass cannot see.
|
|
169
206
|
*/
|
|
170
207
|
async #collectDirChanges(changed: Set<string>): Promise<void> {
|
|
171
|
-
const moved
|
|
208
|
+
const moved = new Set<string>();
|
|
172
209
|
const gone: string[] = [];
|
|
173
210
|
await Promise.all(
|
|
174
211
|
[...this.#dirs.entries()].map(async ([dir, mtimeMs]) => {
|
|
@@ -183,9 +220,13 @@ export class SourceMtimeIndex {
|
|
|
183
220
|
}
|
|
184
221
|
// `UNREADABLE` is NaN, so a directory retained from a failed read always mismatches and is
|
|
185
222
|
// rewalked here — that retry is what lets a transient failure recover on its own.
|
|
186
|
-
if (stats.mtimeMs !== mtimeMs) moved.
|
|
223
|
+
if (stats.mtimeMs !== mtimeMs) moved.add(dir);
|
|
187
224
|
}),
|
|
188
225
|
);
|
|
226
|
+
// An unsettled directory is re-read whether or not its mtime moved: on a coarse clock a mutation
|
|
227
|
+
// that landed in the same tick as the recorded value leaves it identical, so the mtime is exactly
|
|
228
|
+
// the signal that cannot be trusted here.
|
|
229
|
+
for (const dir of this.#unsettled) if (this.#dirs.has(dir)) moved.add(dir);
|
|
189
230
|
for (const dir of gone) this.#forget(dir);
|
|
190
231
|
// Sequential: a moved directory can reveal a new subtree, and walking those in order keeps the
|
|
191
232
|
// number of concurrent `readdir` calls proportional to the change rather than to the tree.
|
|
@@ -216,6 +257,8 @@ export class SourceMtimeIndex {
|
|
|
216
257
|
const known = this.#dirs.has(dir);
|
|
217
258
|
this.#dirs.set(dir, dirStats.mtimeMs);
|
|
218
259
|
this.#unreadable.delete(dir);
|
|
260
|
+
if (this.#isUnsettled(dirStats.mtimeMs)) this.#unsettled.add(dir);
|
|
261
|
+
else this.#unsettled.delete(dir);
|
|
219
262
|
const descend: string[] = [];
|
|
220
263
|
const present = new Set<string>();
|
|
221
264
|
let blind = false;
|
|
@@ -255,7 +298,11 @@ export class SourceMtimeIndex {
|
|
|
255
298
|
changed?.add(abs);
|
|
256
299
|
}
|
|
257
300
|
}
|
|
258
|
-
|
|
301
|
+
// `UNREADABLE` already forces a re-walk every scan, so the freshness retry would only duplicate it.
|
|
302
|
+
if (blind) {
|
|
303
|
+
this.#dirs.set(dir, UNREADABLE);
|
|
304
|
+
this.#unsettled.delete(dir);
|
|
305
|
+
}
|
|
259
306
|
// Known subdirectories carry their own mtime check, so only unseen ones need walking. Concurrent
|
|
260
307
|
// because `prime` reaches every directory through here.
|
|
261
308
|
await Promise.all(descend.filter((sub) => !this.#dirs.has(sub)).map((sub) => this.#walk(sub, changed)));
|
|
@@ -269,6 +316,7 @@ export class SourceMtimeIndex {
|
|
|
269
316
|
#markUnreadable(dir: string, err: NodeJS.ErrnoException): void {
|
|
270
317
|
this.#dirs.set(dir, UNREADABLE);
|
|
271
318
|
this.#unreadable.set(dir, err.code ?? "EUNKNOWN");
|
|
319
|
+
this.#unsettled.delete(dir);
|
|
272
320
|
}
|
|
273
321
|
|
|
274
322
|
/** Drop a directory and everything the index holds beneath it. */
|
|
@@ -276,9 +324,22 @@ export class SourceMtimeIndex {
|
|
|
276
324
|
const prefix = `${dir}${path.sep}`;
|
|
277
325
|
this.#dirs.delete(dir);
|
|
278
326
|
this.#unreadable.delete(dir);
|
|
327
|
+
this.#unsettled.delete(dir);
|
|
279
328
|
for (const known of [...this.#dirs.keys()]) if (known.startsWith(prefix)) this.#dirs.delete(known);
|
|
280
329
|
// Otherwise a gap under a deleted directory is reported forever, since nothing revisits it to clear.
|
|
281
330
|
for (const known of [...this.#unreadable.keys()]) if (known.startsWith(prefix)) this.#unreadable.delete(known);
|
|
331
|
+
// Left behind, this would re-walk a path that no longer exists on every scan.
|
|
332
|
+
for (const known of [...this.#unsettled]) if (known.startsWith(prefix)) this.#unsettled.delete(known);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Whether a directory's mtime is recent enough that another mutation could share its timestamp tick.
|
|
337
|
+
*
|
|
338
|
+
* Symmetric so a filesystem clock running *ahead* of this process — a network mount, a container with a
|
|
339
|
+
* skewed host — does not read as permanently fresh and re-walk the whole tree on every scan.
|
|
340
|
+
*/
|
|
341
|
+
#isUnsettled(mtimeMs: number): boolean {
|
|
342
|
+
return Math.abs(Date.now() - mtimeMs) < this.#dirSettleMs;
|
|
282
343
|
}
|
|
283
344
|
|
|
284
345
|
/** `null` stats with the errno kept, so callers can tell "not there" from "could not look". */
|
|
@@ -74,7 +74,16 @@ export class BuildBatchRunner {
|
|
|
74
74
|
);
|
|
75
75
|
return result;
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
// A worker the kernel OOM-killed exits with code `null` and `SIGKILL`, which without the signal
|
|
78
|
+
// reads exactly like an ordinary crash — and the two have opposite fixes: one is a build error to
|
|
79
|
+
// find, the other is a memory limit to raise. The peak here is the largest transient in the tree
|
|
80
|
+
// (a boot build measured 548MB on a tenant app, 1.1GB on apps/akan), so on a small sandbox this is
|
|
81
|
+
// the process the kernel reaches for first.
|
|
82
|
+
const message = proc.signalCode
|
|
83
|
+
? `build worker was killed by ${proc.signalCode} before reporting a result${
|
|
84
|
+
proc.signalCode === "SIGKILL" ? " — most often the kernel OOM killer; check the sandbox's memory limit" : ""
|
|
85
|
+
}`
|
|
86
|
+
: `build worker exited with code ${exitCode} before reporting a result`;
|
|
78
87
|
this.#logger.error(`[build-batch] generation=${request.generation} ${message}`);
|
|
79
88
|
return {
|
|
80
89
|
generation: request.generation,
|
|
@@ -76,13 +76,22 @@ describe("BuilderChannel", () => {
|
|
|
76
76
|
expect(await sendThenExit(200_000, { mode: "drain", payload: "css" })).toMatchObject([{ type: "css-updated" }]);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
79
|
+
/**
|
|
80
|
+
* darwin only, measured: on Linux a 1MB message sent immediately before `process.exit` **arrives in
|
|
81
|
+
* full**, so the loss this control demonstrates does not reproduce there and the assertion would fail
|
|
82
|
+
* for the right reason on the wrong platform. `BuilderChannel` itself stays correct and costs nothing
|
|
83
|
+
* on Linux — the bug it prevents is one macOS developers hit and a Linux fleet does not.
|
|
84
|
+
*/
|
|
85
|
+
test.skipIf(process.platform !== "darwin")(
|
|
86
|
+
"without the flush wait the same messages are lost, which is why this class exists",
|
|
87
|
+
async () => {
|
|
88
|
+
// Controls, not requirements: if a future bun flushes ipc writes on exit, these fail and say so.
|
|
89
|
+
expect(await sendThenExit(1_000_000, { mode: "bare", payload: "manifest" })).toEqual([]);
|
|
90
|
+
// 20KB of css, far below the 64KB the manifest shape survives — one long string dies much earlier,
|
|
91
|
+
// so no size threshold would have been safe to special-case.
|
|
92
|
+
expect(await sendThenExit(20_000, { mode: "bare", payload: "css" })).toEqual([]);
|
|
93
|
+
},
|
|
94
|
+
);
|
|
86
95
|
|
|
87
96
|
test("drain resolves only once every tracked send has flushed", async () => {
|
|
88
97
|
const send = process.send;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { BuilderReq, BuilderRes } from "akanjs/server";
|
|
3
|
+
import { BuilderRequestRouter } from "./builderRequestRouter";
|
|
4
|
+
|
|
5
|
+
const routeReq = (id: number, routeId: string): BuilderReq => ({
|
|
6
|
+
type: "build-route",
|
|
7
|
+
id,
|
|
8
|
+
routeId,
|
|
9
|
+
seeds: [],
|
|
10
|
+
knownEntries: [],
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** `routeId` is what tells one generation's answer from the other's; the rest is an empty delta. */
|
|
14
|
+
const routeRes = (id: number, routeId: string): Extract<BuilderRes, { ok: true }> => ({
|
|
15
|
+
type: "build-route-res",
|
|
16
|
+
id,
|
|
17
|
+
ok: true,
|
|
18
|
+
data: { manifestDelta: {}, ssrManifestDelta: {}, newEntries: [], clientDeps: [], routeId },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("BuilderRequestRouter", () => {
|
|
22
|
+
test("renumbers a request on the way out and restores the backend's id on the answer", () => {
|
|
23
|
+
const router = new BuilderRequestRouter();
|
|
24
|
+
router.startGeneration();
|
|
25
|
+
|
|
26
|
+
// Everything but the id travels untouched; the builder never learns it was renumbered.
|
|
27
|
+
const first = router.issue(routeReq(1, "/home"));
|
|
28
|
+
const second = router.issue(routeReq(2, "/about"));
|
|
29
|
+
expect(first.routeId).toBe("/home");
|
|
30
|
+
expect(first.id).not.toBe(second.id);
|
|
31
|
+
expect(router.inFlightCount).toBe(2);
|
|
32
|
+
|
|
33
|
+
expect(router.settle(routeRes(first.id, "/home"))?.id).toBe(1);
|
|
34
|
+
expect(router.settle(routeRes(second.id, "/about"))?.id).toBe(2);
|
|
35
|
+
expect(router.inFlightCount).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The defect this class exists for. `BuilderRpc` numbers from 1 in every backend process, so without
|
|
40
|
+
* renumbering the second generation's `id: 1` request is settled by the first generation's answer —
|
|
41
|
+
* a page rendered against another route's client manifest — and its own answer is then dropped.
|
|
42
|
+
*/
|
|
43
|
+
test("does not deliver a dead generation's answer to the backend that replaced it", () => {
|
|
44
|
+
const router = new BuilderRequestRouter();
|
|
45
|
+
router.startGeneration();
|
|
46
|
+
const stale = router.issue(routeReq(1, "/old"));
|
|
47
|
+
|
|
48
|
+
router.startGeneration();
|
|
49
|
+
const fresh = router.issue(routeReq(1, "/new"));
|
|
50
|
+
expect(fresh.id).not.toBe(stale.id);
|
|
51
|
+
|
|
52
|
+
expect(router.settle(routeRes(stale.id, "/old"))).toBeNull();
|
|
53
|
+
const answer = router.settle(routeRes(fresh.id, "/new"));
|
|
54
|
+
expect(answer?.id).toBe(1);
|
|
55
|
+
expect(answer?.data.routeId).toBe("/new");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("drops an answer that arrives twice, and one nobody asked for", () => {
|
|
59
|
+
const router = new BuilderRequestRouter();
|
|
60
|
+
router.startGeneration();
|
|
61
|
+
const outgoing = router.issue(routeReq(7, "/home"));
|
|
62
|
+
|
|
63
|
+
expect(router.settle(routeRes(outgoing.id, "/home"))?.id).toBe(7);
|
|
64
|
+
expect(router.settle(routeRes(outgoing.id, "/home"))).toBeNull();
|
|
65
|
+
expect(router.settle(routeRes(9999, "/home"))).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("withdraw releases an id whose send failed", () => {
|
|
69
|
+
const router = new BuilderRequestRouter();
|
|
70
|
+
router.startGeneration();
|
|
71
|
+
const outgoing = router.issue(routeReq(4, "/home"));
|
|
72
|
+
|
|
73
|
+
// The caller answers the backend itself in this case, so a late builder answer must not answer it again.
|
|
74
|
+
router.withdraw(outgoing.id);
|
|
75
|
+
expect(router.inFlightCount).toBe(0);
|
|
76
|
+
expect(router.settle(routeRes(outgoing.id, "/home"))).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("abandons the previous generation's requests rather than carrying them forward", () => {
|
|
80
|
+
const router = new BuilderRequestRouter();
|
|
81
|
+
router.startGeneration();
|
|
82
|
+
router.issue(routeReq(1, "/a"));
|
|
83
|
+
router.issue(routeReq(2, "/b"));
|
|
84
|
+
expect(router.inFlightCount).toBe(2);
|
|
85
|
+
|
|
86
|
+
expect(router.startGeneration()).toBe(2);
|
|
87
|
+
expect(router.inFlightCount).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { BuilderCsrReq, BuilderCsrRes, BuilderReq, BuilderRes } from "akanjs/server";
|
|
2
|
+
|
|
3
|
+
type BuilderRequest = BuilderReq | BuilderCsrReq;
|
|
4
|
+
type BuilderResponse = BuilderRes | BuilderCsrRes;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Correlation ids for builder requests, renumbered by the dev host so they survive a backend restart.
|
|
8
|
+
*
|
|
9
|
+
* `BuilderRpc` numbers its requests from 1 in each backend *process*, and the host relays them to a
|
|
10
|
+
* builder that outlives the backend. So after a restart the two generations collide on id 1: the builder
|
|
11
|
+
* answers the request the *previous* backend made, the host relays it, and the new backend settles its own
|
|
12
|
+
* request of that number with another route's manifest delta — a page rendered against client modules that
|
|
13
|
+
* were never built for it. The answer it was actually waiting for then arrives to an empty pending map and
|
|
14
|
+
* is dropped, so the correct build is discarded too.
|
|
15
|
+
*
|
|
16
|
+
* A host-owned id fixes it without touching the protocol: neither the backend nor the builder learns that
|
|
17
|
+
* anything was renumbered, and a response whose generation is gone is discarded instead of misdelivered.
|
|
18
|
+
*/
|
|
19
|
+
export class BuilderRequestRouter {
|
|
20
|
+
#generation = 0;
|
|
21
|
+
#nextId = 1;
|
|
22
|
+
readonly #inFlight = new Map<number, { backendId: number; generation: number }>();
|
|
23
|
+
|
|
24
|
+
get generation(): number {
|
|
25
|
+
return this.#generation;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get inFlightCount(): number {
|
|
29
|
+
return this.#inFlight.size;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Begin a new backend generation, abandoning every request the previous one owned.
|
|
34
|
+
*
|
|
35
|
+
* Nothing is answered on the way out: the process that asked is gone, and its `BuilderRpc` went with it.
|
|
36
|
+
*/
|
|
37
|
+
startGeneration(): number {
|
|
38
|
+
this.#generation += 1;
|
|
39
|
+
this.#inFlight.clear();
|
|
40
|
+
return this.#generation;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The request to forward to the builder, carrying this host's id in place of the backend's. */
|
|
44
|
+
issue<T extends BuilderRequest>(message: T): T {
|
|
45
|
+
const id = this.#nextId++;
|
|
46
|
+
this.#inFlight.set(id, { backendId: message.id, generation: this.#generation });
|
|
47
|
+
return { ...message, id };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Give an id back when the send failed, so the caller answers the backend itself. */
|
|
51
|
+
withdraw(id: number): void {
|
|
52
|
+
this.#inFlight.delete(id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The response to forward to the backend with its own id restored, or `null` when no live backend is
|
|
57
|
+
* waiting for it — either it was never issued or the generation that issued it has been replaced.
|
|
58
|
+
*/
|
|
59
|
+
settle<T extends BuilderResponse>(message: T): T | null {
|
|
60
|
+
const request = this.#inFlight.get(message.id);
|
|
61
|
+
if (!request) return null;
|
|
62
|
+
this.#inFlight.delete(message.id);
|
|
63
|
+
if (request.generation !== this.#generation) return null;
|
|
64
|
+
return { ...message, id: request.backendId };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { MemoryLimit } from "akanjs/server/memoryLimit";
|
|
2
3
|
import { IncrementalBuilderHost } from "./incrementalBuilder.host";
|
|
3
4
|
|
|
4
5
|
const originalSpawn = Bun.spawn;
|
|
@@ -77,6 +78,12 @@ describe("IncrementalBuilderHost", () => {
|
|
|
77
78
|
expect(spawns[0]?.proc.kill).not.toHaveBeenCalled();
|
|
78
79
|
expect(host.recycle("second request")).toBe(false);
|
|
79
80
|
|
|
81
|
+
// The drain refuses everything that arrives during it, so a request sent here is a request the
|
|
82
|
+
// developer gets an error page for. Reporting the state is what lets the host hold it instead.
|
|
83
|
+
expect(host.status).toBe("recycling");
|
|
84
|
+
expect(host.send({ type: "build-route", id: 9, routeId: "z", seeds: [], knownEntries: [] })).toBe(false);
|
|
85
|
+
expect(spawns[0]?.proc.send).toHaveBeenCalledTimes(1);
|
|
86
|
+
|
|
80
87
|
// A planned exit skips the crash backoff — the dev server has no file watcher until it is back.
|
|
81
88
|
spawns[0]?.options.onExit?.();
|
|
82
89
|
expect(spawns).toHaveLength(2);
|
|
@@ -215,7 +222,12 @@ describe("IncrementalBuilderHost.maxRssBytes", () => {
|
|
|
215
222
|
}
|
|
216
223
|
};
|
|
217
224
|
|
|
218
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Only where nothing else supplies a limit, which is not true in a container: a cgroup `memory.max`
|
|
227
|
+
* makes `resolveMaxRssBytes` derive from that instead. Measured under `docker --memory=7g`, this
|
|
228
|
+
* returned 2.45GiB rather than the fallback — the assertion was about the runner, not the code.
|
|
229
|
+
*/
|
|
230
|
+
test.skipIf(MemoryLimit.readCgroupBytes() !== null)("defaults to a dev ceiling well above a fresh boot", () => {
|
|
219
231
|
withEnv(
|
|
220
232
|
{ AKAN_BUILDER_MAX_RSS_MB: undefined, AKAN_BUILDER_MAX_RSS: undefined, AKAN_MEMORY_LIMIT: undefined },
|
|
221
233
|
() => {
|
|
@@ -224,6 +236,21 @@ describe("IncrementalBuilderHost.maxRssBytes", () => {
|
|
|
224
236
|
);
|
|
225
237
|
});
|
|
226
238
|
|
|
239
|
+
test("derives the ceiling from the sandbox's own limit, wherever it runs", () => {
|
|
240
|
+
// The property the fallback test cannot assert in a container, stated so it holds on every runner:
|
|
241
|
+
// the builder gets 35% of whatever the sandbox is allowed.
|
|
242
|
+
withEnv(
|
|
243
|
+
{
|
|
244
|
+
AKAN_BUILDER_MAX_RSS_MB: undefined,
|
|
245
|
+
AKAN_BUILDER_MAX_RSS: undefined,
|
|
246
|
+
AKAN_MEMORY_LIMIT: String(4 * 1024 * 1024 * 1024),
|
|
247
|
+
},
|
|
248
|
+
() => {
|
|
249
|
+
expect(IncrementalBuilderHost.maxRssBytes()).toBe(Math.floor(4 * 1024 * 1024 * 1024 * 0.35));
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
227
254
|
test("honors an explicit override and treats 0 as unbounded", () => {
|
|
228
255
|
withEnv({ AKAN_BUILDER_MAX_RSS_MB: "700" }, () => {
|
|
229
256
|
expect(IncrementalBuilderHost.maxRssBytes()).toBe(700 * 1024 * 1024);
|
|
@@ -21,12 +21,22 @@ interface IncrementalBuilderHostOptions {
|
|
|
21
21
|
onMessage: (message: BuilderMessage) => void;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* `recycling` is the drain: the builder is still alive and still holds the work it accepted, but it
|
|
26
|
+
* refuses anything new. Saying so here is what lets the host hold those requests for the replacement
|
|
27
|
+
* instead of handing the developer the refusal.
|
|
28
|
+
*/
|
|
29
|
+
export type IncrementalBuilderStatus = "starting" | "ready" | "recycling" | "restarting" | "stopped";
|
|
25
30
|
|
|
26
31
|
interface IncrementalBuilderStartOptions {
|
|
27
32
|
onExit?: () => void;
|
|
28
33
|
onReady?: () => void;
|
|
29
34
|
onRestartReady?: () => void;
|
|
35
|
+
/**
|
|
36
|
+
* The builder is gone and a replacement is on its way. Distinct from `onExit`, which reports the
|
|
37
|
+
* builder giving up: this one says the dev server is temporarily without a watcher.
|
|
38
|
+
*/
|
|
39
|
+
onAway?: () => void;
|
|
30
40
|
/**
|
|
31
41
|
* Ask the builder to re-announce the artifact it boots with. Needed whenever a *previous* builder's
|
|
32
42
|
* artifact may still be live in a running backend — after an rss recycle, and after an idle wake.
|
|
@@ -142,6 +152,9 @@ export class IncrementalBuilderHost {
|
|
|
142
152
|
this.#startOptions.onExit?.();
|
|
143
153
|
return;
|
|
144
154
|
}
|
|
155
|
+
// Said once for both branches below, because both leave the tree unwatched until a replacement
|
|
156
|
+
// has primed its own index — and an edit that lands in that window is reported by nobody.
|
|
157
|
+
this.#startOptions.onAway?.();
|
|
145
158
|
// A recycle is a planned exit, so it neither counts as a failed attempt nor waits out the
|
|
146
159
|
// crash backoff — the dev server is without a watcher until the replacement is up.
|
|
147
160
|
if (wasRecycle) {
|
|
@@ -182,6 +195,11 @@ export class IncrementalBuilderHost {
|
|
|
182
195
|
const proc = this.#proc;
|
|
183
196
|
if (!this.send({ type: "builder-shutdown", reason })) return false;
|
|
184
197
|
this.#recycleRequested = true;
|
|
198
|
+
// From here the builder answers nothing new — it refuses every request that arrives during the
|
|
199
|
+
// drain. Leaving the status at `ready` is what used to let those requests through to be refused,
|
|
200
|
+
// one at a time, into the dev error page a recycle is supposed to be invisible to. `ready` the
|
|
201
|
+
// field is deliberately untouched: `onExit` reads it to tell a planned exit from a boot failure.
|
|
202
|
+
this.#status = "recycling";
|
|
185
203
|
this.logger.info(`recycling builder pid=${proc.pid} (${reason})`);
|
|
186
204
|
this.#recycleTimer = setTimeout(() => {
|
|
187
205
|
this.#recycleTimer = null;
|
|
@@ -215,7 +233,11 @@ export class IncrementalBuilderHost {
|
|
|
215
233
|
}
|
|
216
234
|
send(message: BuilderMessage): boolean {
|
|
217
235
|
if (!this.#proc || this.#status !== "ready") {
|
|
218
|
-
|
|
236
|
+
// A builder on its way back is routine and the host holds what it refuses here, so only the
|
|
237
|
+
// states nothing is recovering from are worth a warning.
|
|
238
|
+
if (this.#status === "recycling" || this.#status === "restarting")
|
|
239
|
+
this.logger.verbose(`incrementalBuilderHost is ${this.#status}; ${message.type} is for the replacement`);
|
|
240
|
+
else this.logger.warn(`incrementalBuilderHost is ${this.#status}; cannot send ${message.type}`);
|
|
219
241
|
return false;
|
|
220
242
|
}
|
|
221
243
|
try {
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
//
|
|
3
|
-
// @trapezedev/project, the @langchain stack, ssh2, ink and the cloud stack
|
|
2
|
+
// Module paths, never a barrel. The `@akanjs/devkit` root re-exports all 41 modules, which would drag
|
|
3
|
+
// @trapezedev/project, the @langchain stack, ssh2, ink and the cloud stack in; and `frontendBuild`'s own
|
|
4
|
+
// barrel reaches `cssCompiler`/`ssrBaseArtifactBuilder`, which pull tailwindcss + @tailwindcss/node
|
|
5
|
+
// (~40MB) into a process that then holds them for the whole dev session. Phase 2 moved css compilation
|
|
6
|
+
// into the batch worker, so this process has no use for them — `entryModuleGraph.test.ts` keeps it that way.
|
|
4
7
|
import type { App } from "@akanjs/devkit/commandDecorators";
|
|
5
8
|
import { AppExecutor, WorkspaceExecutor } from "@akanjs/devkit/executors";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
RouteClientBuilder,
|
|
15
|
-
WatchRootResolver,
|
|
16
|
-
} from "@akanjs/devkit/frontendBuild";
|
|
9
|
+
import { AutoImportSync } from "@akanjs/devkit/frontendBuild/autoImportSync";
|
|
10
|
+
import type { ClientEntryDiscovery } from "@akanjs/devkit/frontendBuild/clientBuildTypes";
|
|
11
|
+
import { GraphClientEntryDiscovery } from "@akanjs/devkit/frontendBuild/clientEntryDiscovery";
|
|
12
|
+
import { DevChangePlanner } from "@akanjs/devkit/frontendBuild/devChangePlanner";
|
|
13
|
+
import { DevGeneratedIndexSync } from "@akanjs/devkit/frontendBuild/devGeneratedIndexSync";
|
|
14
|
+
import { HmrWatcher } from "@akanjs/devkit/frontendBuild/hmrWatcher";
|
|
15
|
+
import { RouteClientBuilder } from "@akanjs/devkit/frontendBuild/routeClientBuilder";
|
|
16
|
+
import { WatchRootResolver } from "@akanjs/devkit/frontendBuild/watchRootResolver";
|
|
17
17
|
import { Logger } from "akanjs/common";
|
|
18
18
|
import type {
|
|
19
19
|
BaseBuildArtifact,
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
BuilderRes,
|
|
24
24
|
BuildPhase,
|
|
25
25
|
BuildRouteResultPayload,
|
|
26
|
+
ChangeBatch,
|
|
26
27
|
} from "akanjs/server";
|
|
27
28
|
import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
|
|
28
29
|
import { BuildBatchRunner } from "./buildBatchRunner";
|