@akanjs/devkit 2.4.1-rc.3 → 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 +199 -2
- package/akanApp/akanApp.host.ts +399 -9
- 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/builderReply.test.ts +73 -0
- package/incrementalBuilder/builderReply.ts +30 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +88 -3
- package/incrementalBuilder/incrementalBuilder.host.ts +50 -3
- package/incrementalBuilder/incrementalBuilder.proc.ts +25 -12
- package/integration/devStability.integration.test.ts +245 -98
- package/integration/devStabilityHarness.test.ts +111 -0
- package/integration/devStabilityHarness.ts +528 -39
- package/package.json +2 -2
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type { Dirent, Stats } from "node:fs";
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { HmrChangeClassifier } from "./hmrChangeClassifier";
|
|
5
|
+
|
|
6
|
+
interface TrackedFile {
|
|
7
|
+
mtimeMs: number;
|
|
8
|
+
size: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A directory the index could not read, held with this mtime so every subsequent scan sees a mismatch
|
|
13
|
+
* and retries it. `NaN !== NaN`, which is exactly the "always stale" semantics wanted here.
|
|
14
|
+
*/
|
|
15
|
+
const UNREADABLE = Number.NaN;
|
|
16
|
+
|
|
17
|
+
export interface SourceMtimeIndexOptions {
|
|
18
|
+
roots: string[];
|
|
19
|
+
classifier?: HmrChangeClassifier;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A snapshot of every interesting source file's mtime under the watch roots, so the watcher can answer
|
|
24
|
+
* "what actually changed" without trusting `fs.watch` event payloads.
|
|
25
|
+
*
|
|
26
|
+
* Bun 1.3.14's recursive `fs.watch` on macOS reports roughly **one path per ~200ms coalescing window**
|
|
27
|
+
* and discards every other path changed in that window. Measured on this repo: saving 5 files together
|
|
28
|
+
* reports 1; saving 20 reports 2; a single unrelated write landing first in the window hides a
|
|
29
|
+
* concurrent save entirely. Node 22 reports all 20 on the same tree, so this is a Bun defect rather
|
|
30
|
+
* than an FSEvents limitation (`local/optimize-resource/06-watcher-dropped-event.md`).
|
|
31
|
+
*
|
|
32
|
+
* Re-stating the tracked set costs ~15ms against ~1300 source files here, where a fresh walk costs
|
|
33
|
+
* ~70ms — the walk has to visit ~40k entries (`ios/`, `android/`, `public/`) to find those 1300. So
|
|
34
|
+
* 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.
|
|
36
|
+
*/
|
|
37
|
+
export class SourceMtimeIndex {
|
|
38
|
+
readonly #roots: string[];
|
|
39
|
+
readonly #classifier: HmrChangeClassifier;
|
|
40
|
+
readonly #files = new Map<string, TrackedFile>();
|
|
41
|
+
readonly #dirs = new Map<string, number>();
|
|
42
|
+
readonly #unreadable = new Map<string, string>();
|
|
43
|
+
#primed = false;
|
|
44
|
+
#queue: Promise<unknown> = Promise.resolve();
|
|
45
|
+
|
|
46
|
+
constructor({ roots, classifier }: SourceMtimeIndexOptions) {
|
|
47
|
+
this.#roots = SourceMtimeIndex.#pruneNestedRoots(roots);
|
|
48
|
+
this.#classifier = classifier ?? new HmrChangeClassifier();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get primed(): boolean {
|
|
52
|
+
return this.#primed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get trackedFileCount(): number {
|
|
56
|
+
return this.#files.size;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Paths the index currently cannot read, as `{ path, code }`. Non-empty means change detection has a
|
|
61
|
+
* blind spot right now: anything edited underneath one of these is not reported until it recovers.
|
|
62
|
+
*
|
|
63
|
+
* Worth surfacing rather than swallowing, because the failure is silent by nature — a walk cannot tell
|
|
64
|
+
* how much it did not see. Each entry is retried on every scan and clears itself on success.
|
|
65
|
+
*/
|
|
66
|
+
get coverageGaps(): { path: string; code: string }[] {
|
|
67
|
+
return [...this.#unreadable].map(([file, code]) => ({ path: file, code }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Record the current state as the baseline. Reports nothing; call before the first `collectChanges`. */
|
|
71
|
+
async prime(): Promise<void> {
|
|
72
|
+
await this.#serialize(async () => {
|
|
73
|
+
this.#files.clear();
|
|
74
|
+
this.#dirs.clear();
|
|
75
|
+
this.#unreadable.clear();
|
|
76
|
+
await Promise.all(this.#roots.map((root) => this.#walk(root, null)));
|
|
77
|
+
this.#primed = true;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Absolute paths whose content changed (or which disappeared) since the last call, with the baseline
|
|
83
|
+
* advanced to match. Returns an empty list until primed, so a failed prime degrades to watcher events
|
|
84
|
+
* rather than reporting the entire tree as changed.
|
|
85
|
+
*/
|
|
86
|
+
async collectChanges(): Promise<string[]> {
|
|
87
|
+
return this.#serialize(async () => {
|
|
88
|
+
if (!this.#primed) return [];
|
|
89
|
+
const changed = new Set<string>();
|
|
90
|
+
await this.#collectFileChanges(changed);
|
|
91
|
+
await this.#collectDirChanges(changed);
|
|
92
|
+
return [...changed];
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Every scan runs to completion before the next begins.
|
|
98
|
+
*
|
|
99
|
+
* Overlapping scans corrupt each other rather than merely racing: one advances the baseline and prunes
|
|
100
|
+
* entries while the other is midway through a key snapshot it took earlier, so a path the first has
|
|
101
|
+
* already accounted for reads back as missing and is reported as a change that never happened. Observed
|
|
102
|
+
* directly — a file nothing had written was reported, which downstream is a wasted rebuild.
|
|
103
|
+
*/
|
|
104
|
+
async #serialize<T>(work: () => Promise<T>): Promise<T> {
|
|
105
|
+
const run = this.#queue.then(work, work);
|
|
106
|
+
// Swallowed on the chain only; the caller still sees the rejection through `run`.
|
|
107
|
+
this.#queue = run.catch(() => undefined);
|
|
108
|
+
return run;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Adopt writes the caller made itself — regenerated barrels, inserted imports — into the baseline so
|
|
113
|
+
* the next `collectChanges` does not report them as a user edit.
|
|
114
|
+
*
|
|
115
|
+
* Narrow race worth knowing: a user save of the same path landing between the caller's write and this
|
|
116
|
+
* call is baselined too, and therefore lost. It is sub-millisecond and no wider than the window the
|
|
117
|
+
* event-based watcher already had.
|
|
118
|
+
*/
|
|
119
|
+
async absorb(paths: string[]): Promise<void> {
|
|
120
|
+
await this.#serialize(async () => {
|
|
121
|
+
if (!this.#primed) return;
|
|
122
|
+
await Promise.all(
|
|
123
|
+
paths.map(async (file) => {
|
|
124
|
+
const abs = path.resolve(file);
|
|
125
|
+
const stats = await stat(abs).catch(() => null);
|
|
126
|
+
if (!stats?.isFile()) {
|
|
127
|
+
this.#files.delete(abs);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.#files.set(abs, { mtimeMs: stats.mtimeMs, size: stats.size });
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async #collectFileChanges(changed: Set<string>): Promise<void> {
|
|
137
|
+
await Promise.all(
|
|
138
|
+
[...this.#files.keys()].map(async (abs) => {
|
|
139
|
+
const { stats, err } = await SourceMtimeIndex.#statPath(abs);
|
|
140
|
+
if (!stats) {
|
|
141
|
+
// Unreadable is not deleted. Dropping it here would both invent a change nobody made and stop
|
|
142
|
+
// tracking the file, so a later real edit would go unreported.
|
|
143
|
+
if (err && !SourceMtimeIndex.#isMissing(err)) {
|
|
144
|
+
this.#unreadable.set(abs, err.code ?? "EUNKNOWN");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.#files.delete(abs);
|
|
148
|
+
this.#unreadable.delete(abs);
|
|
149
|
+
changed.add(abs);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.#unreadable.delete(abs);
|
|
153
|
+
if (!stats.isFile()) {
|
|
154
|
+
this.#files.delete(abs);
|
|
155
|
+
changed.add(abs);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const known = this.#files.get(abs);
|
|
159
|
+
if (known && known.mtimeMs === stats.mtimeMs && known.size === stats.size) return;
|
|
160
|
+
this.#files.set(abs, { mtimeMs: stats.mtimeMs, size: stats.size });
|
|
161
|
+
changed.add(abs);
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A directory's own mtime moves when an entry is added or removed but not when a tracked file's
|
|
168
|
+
* content changes, so this finds creations and deletions the file pass cannot see.
|
|
169
|
+
*/
|
|
170
|
+
async #collectDirChanges(changed: Set<string>): Promise<void> {
|
|
171
|
+
const moved: string[] = [];
|
|
172
|
+
const gone: string[] = [];
|
|
173
|
+
await Promise.all(
|
|
174
|
+
[...this.#dirs.entries()].map(async ([dir, mtimeMs]) => {
|
|
175
|
+
const { stats, err } = await SourceMtimeIndex.#statPath(dir);
|
|
176
|
+
if (!stats?.isDirectory()) {
|
|
177
|
+
if (err && !SourceMtimeIndex.#isMissing(err)) {
|
|
178
|
+
this.#markUnreadable(dir, err);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
gone.push(dir);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// `UNREADABLE` is NaN, so a directory retained from a failed read always mismatches and is
|
|
185
|
+
// rewalked here — that retry is what lets a transient failure recover on its own.
|
|
186
|
+
if (stats.mtimeMs !== mtimeMs) moved.push(dir);
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
for (const dir of gone) this.#forget(dir);
|
|
190
|
+
// Sequential: a moved directory can reveal a new subtree, and walking those in order keeps the
|
|
191
|
+
// number of concurrent `readdir` calls proportional to the change rather than to the tree.
|
|
192
|
+
for (const dir of moved) await this.#walk(dir, changed);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Re-read one directory, tracking entries that are new or changed. Recurses only into subdirectories
|
|
197
|
+
* that are new to the index; known ones are covered by their own mtime check in `#collectDirChanges`.
|
|
198
|
+
*/
|
|
199
|
+
async #walk(dir: string, changed: Set<string> | null): Promise<void> {
|
|
200
|
+
const [listing, dirStat] = await Promise.all([SourceMtimeIndex.#readdirPath(dir), SourceMtimeIndex.#statPath(dir)]);
|
|
201
|
+
const entries = listing.entries;
|
|
202
|
+
const dirStats = dirStat.stats;
|
|
203
|
+
if (!entries || !dirStats?.isDirectory()) {
|
|
204
|
+
// A directory that cannot be read is not a directory that is gone. Forgetting it here is
|
|
205
|
+
// unrecoverable: nothing re-stats a dir the index has dropped, and the parent's own mtime does not
|
|
206
|
+
// move when a child merely becomes unreadable, so the whole subtree stays invisible for the life of
|
|
207
|
+
// the process. Measured: one `EACCES` at prime silently hid 2 of 3 files, permanently.
|
|
208
|
+
const err = listing.err ?? dirStat.err;
|
|
209
|
+
if (err && !SourceMtimeIndex.#isMissing(err)) {
|
|
210
|
+
this.#markUnreadable(dir, err);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.#forget(dir);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const known = this.#dirs.has(dir);
|
|
217
|
+
this.#dirs.set(dir, dirStats.mtimeMs);
|
|
218
|
+
this.#unreadable.delete(dir);
|
|
219
|
+
const descend: string[] = [];
|
|
220
|
+
const present = new Set<string>();
|
|
221
|
+
let blind = false;
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (SourceMtimeIndex.#skipDirent(entry.name)) continue;
|
|
224
|
+
const abs = path.join(dir, entry.name);
|
|
225
|
+
if (entry.isDirectory()) {
|
|
226
|
+
descend.push(abs);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!entry.isFile()) continue;
|
|
230
|
+
if (this.#classifier.classify(abs) === "ignore") continue;
|
|
231
|
+
present.add(abs);
|
|
232
|
+
const { stats, err } = await SourceMtimeIndex.#statPath(abs);
|
|
233
|
+
if (!stats?.isFile()) {
|
|
234
|
+
// A file listed but not stat-able has no baseline, so an edit to it would compare against nothing.
|
|
235
|
+
// Leaving the directory stale is what brings it back; the file pass cannot, since it only revisits
|
|
236
|
+
// paths already tracked.
|
|
237
|
+
if (err && !SourceMtimeIndex.#isMissing(err)) {
|
|
238
|
+
this.#unreadable.set(abs, err.code ?? "EUNKNOWN");
|
|
239
|
+
blind = true;
|
|
240
|
+
}
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
this.#unreadable.delete(abs);
|
|
244
|
+
const previous = this.#files.get(abs);
|
|
245
|
+
if (previous && previous.mtimeMs === stats.mtimeMs && previous.size === stats.size) continue;
|
|
246
|
+
this.#files.set(abs, { mtimeMs: stats.mtimeMs, size: stats.size });
|
|
247
|
+
changed?.add(abs);
|
|
248
|
+
}
|
|
249
|
+
// Entries this directory used to hold and no longer does. Only worth scanning for a directory the
|
|
250
|
+
// index already knew, which during `prime` is none of them.
|
|
251
|
+
if (known) {
|
|
252
|
+
for (const abs of [...this.#files.keys()]) {
|
|
253
|
+
if (present.has(abs) || path.dirname(abs) !== dir) continue;
|
|
254
|
+
this.#files.delete(abs);
|
|
255
|
+
changed?.add(abs);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (blind) this.#dirs.set(dir, UNREADABLE);
|
|
259
|
+
// Known subdirectories carry their own mtime check, so only unseen ones need walking. Concurrent
|
|
260
|
+
// because `prime` reaches every directory through here.
|
|
261
|
+
await Promise.all(descend.filter((sub) => !this.#dirs.has(sub)).map((sub) => this.#walk(sub, changed)));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Keep a directory the index could not read, flagged for retry on every scan, and keep whatever it
|
|
266
|
+
* already knows underneath it — those files stay tracked so a real edit is still caught the moment
|
|
267
|
+
* access returns.
|
|
268
|
+
*/
|
|
269
|
+
#markUnreadable(dir: string, err: NodeJS.ErrnoException): void {
|
|
270
|
+
this.#dirs.set(dir, UNREADABLE);
|
|
271
|
+
this.#unreadable.set(dir, err.code ?? "EUNKNOWN");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Drop a directory and everything the index holds beneath it. */
|
|
275
|
+
#forget(dir: string): void {
|
|
276
|
+
const prefix = `${dir}${path.sep}`;
|
|
277
|
+
this.#dirs.delete(dir);
|
|
278
|
+
this.#unreadable.delete(dir);
|
|
279
|
+
for (const known of [...this.#dirs.keys()]) if (known.startsWith(prefix)) this.#dirs.delete(known);
|
|
280
|
+
// Otherwise a gap under a deleted directory is reported forever, since nothing revisits it to clear.
|
|
281
|
+
for (const known of [...this.#unreadable.keys()]) if (known.startsWith(prefix)) this.#unreadable.delete(known);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** `null` stats with the errno kept, so callers can tell "not there" from "could not look". */
|
|
285
|
+
static async #statPath(abs: string): Promise<{ stats: Stats | null; err: NodeJS.ErrnoException | null }> {
|
|
286
|
+
try {
|
|
287
|
+
return { stats: await stat(abs), err: null };
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return { stats: null, err: err as NodeJS.ErrnoException };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
static async #readdirPath(dir: string): Promise<{ entries: Dirent[] | null; err: NodeJS.ErrnoException | null }> {
|
|
294
|
+
try {
|
|
295
|
+
return { entries: await readdir(dir, { withFileTypes: true }), err: null };
|
|
296
|
+
} catch (err) {
|
|
297
|
+
return { entries: null, err: err as NodeJS.ErrnoException };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Whether an errno means the path genuinely is not there, which is the only case where dropping it from
|
|
303
|
+
* the baseline is right. Everything else — `EACCES`, `EIO`, `EMFILE`, a stalled network mount — means the
|
|
304
|
+
* index failed to look, and treating that as a deletion both invents a change and blinds it to real ones.
|
|
305
|
+
*/
|
|
306
|
+
static #isMissing(err: NodeJS.ErrnoException): boolean {
|
|
307
|
+
return err.code === "ENOENT" || err.code === "ENOTDIR";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Mirrors `HmrChangeClassifier`'s path rules for directory names — dotted names cover `.git` and
|
|
312
|
+
* `.akan`, which is where every build artifact lands. Symlinked entries are neither `isFile` nor
|
|
313
|
+
* `isDirectory` and so are skipped, matching `fs.watch`, which does not follow them either.
|
|
314
|
+
*/
|
|
315
|
+
static #skipDirent(name: string): boolean {
|
|
316
|
+
return !name || name.startsWith(".") || name === "node_modules";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** `WatchRootResolver` can return `apps/<app>/page` alongside `apps/`; walking both doubles the work. */
|
|
320
|
+
static #pruneNestedRoots(roots: string[]): string[] {
|
|
321
|
+
const resolved = [...new Set(roots.map((root) => path.resolve(root)))].sort();
|
|
322
|
+
return resolved.filter(
|
|
323
|
+
(root) => !resolved.some((other) => other !== root && root.startsWith(`${other}${path.sep}`)),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -68,7 +68,7 @@ describe("IncrementalBuilderHost", () => {
|
|
|
68
68
|
|
|
69
69
|
host.start({ onRestartReady });
|
|
70
70
|
spawns[0]?.options.ipc?.({ type: "builder-ready" });
|
|
71
|
-
expect(spawns[0]?.options.env?.
|
|
71
|
+
expect(spawns[0]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBeUndefined();
|
|
72
72
|
|
|
73
73
|
const reason = "rss=1300MiB>=1200MiB after 3 build(s)";
|
|
74
74
|
expect(host.recycle(reason)).toBe(true);
|
|
@@ -80,7 +80,7 @@ describe("IncrementalBuilderHost", () => {
|
|
|
80
80
|
// A planned exit skips the crash backoff — the dev server has no file watcher until it is back.
|
|
81
81
|
spawns[0]?.options.onExit?.();
|
|
82
82
|
expect(spawns).toHaveLength(2);
|
|
83
|
-
expect(spawns[1]?.options.env?.
|
|
83
|
+
expect(spawns[1]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBe("1");
|
|
84
84
|
spawns[1]?.options.ipc?.({ type: "builder-ready" });
|
|
85
85
|
expect(onRestartReady).toHaveBeenCalledTimes(1);
|
|
86
86
|
expect(host.status).toBe("ready");
|
|
@@ -90,11 +90,96 @@ describe("IncrementalBuilderHost", () => {
|
|
|
90
90
|
expect(spawns).toHaveLength(2);
|
|
91
91
|
await wait(1_050);
|
|
92
92
|
expect(spawns).toHaveLength(3);
|
|
93
|
-
expect(spawns[2]?.options.env?.
|
|
93
|
+
expect(spawns[2]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBeUndefined();
|
|
94
94
|
|
|
95
95
|
host.stop();
|
|
96
96
|
});
|
|
97
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
|
+
|
|
98
183
|
test("only recycles a builder that is ready", () => {
|
|
99
184
|
const spawns = mockSpawns();
|
|
100
185
|
const host = new IncrementalBuilderHost({
|
|
@@ -27,6 +27,11 @@ interface IncrementalBuilderStartOptions {
|
|
|
27
27
|
onExit?: () => void;
|
|
28
28
|
onReady?: () => void;
|
|
29
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;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export class IncrementalBuilderHost {
|
|
@@ -56,6 +61,17 @@ export class IncrementalBuilderHost {
|
|
|
56
61
|
#recycleRequested: boolean = false;
|
|
57
62
|
#spawnAfterRecycle: boolean = false;
|
|
58
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">();
|
|
59
75
|
#startOptions: IncrementalBuilderStartOptions = {};
|
|
60
76
|
constructor({ app, entry, env, onMessage }: IncrementalBuilderHostOptions) {
|
|
61
77
|
this.app = app;
|
|
@@ -66,28 +82,38 @@ export class IncrementalBuilderHost {
|
|
|
66
82
|
get status() {
|
|
67
83
|
return this.#status;
|
|
68
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
|
+
}
|
|
69
93
|
start(options: IncrementalBuilderStartOptions = {}) {
|
|
70
94
|
if (this.#proc) this.stop();
|
|
71
95
|
this.#manualStop = false;
|
|
72
96
|
this.#startOptions = options;
|
|
97
|
+
this.#spawnAfterRecycle = options.announceBootState ?? false;
|
|
73
98
|
this.#spawn(false);
|
|
74
99
|
return this;
|
|
75
100
|
}
|
|
76
101
|
#spawn(isRestart: boolean) {
|
|
77
102
|
this.#status = isRestart ? "restarting" : "starting";
|
|
78
103
|
this.ready = false;
|
|
79
|
-
// A
|
|
80
|
-
//
|
|
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.
|
|
81
106
|
const afterRecycle = this.#spawnAfterRecycle;
|
|
82
107
|
this.#spawnAfterRecycle = false;
|
|
83
108
|
let proc!: Bun.Subprocess<"ignore", "inherit", "inherit">;
|
|
84
109
|
proc = Bun.spawn(["bun", this.entry], {
|
|
85
110
|
cwd: this.app.cwdPath,
|
|
86
|
-
env: { ...this.env, AKAN_WATCH: "1", ...(afterRecycle ? {
|
|
111
|
+
env: { ...this.env, AKAN_WATCH: "1", ...(afterRecycle ? { AKAN_BUILDER_ANNOUNCE_BOOT: "1" } : {}) },
|
|
87
112
|
stdio: ["ignore", "inherit", "inherit"],
|
|
88
113
|
ipc: (msg: BuilderMessage) => {
|
|
89
114
|
if (this.#proc !== proc) return;
|
|
90
115
|
if (!msg || typeof msg !== "object") return;
|
|
116
|
+
if (msg.type === "build-route-res" || msg.type === "build-csr-res") this.#inFlight.delete(msg.id);
|
|
91
117
|
if (builderMsgTypeSet.has(msg.type)) this.#onMessage(msg);
|
|
92
118
|
if (msg.type === "builder-ready" && !this.ready) {
|
|
93
119
|
this.ready = true;
|
|
@@ -105,6 +131,11 @@ export class IncrementalBuilderHost {
|
|
|
105
131
|
const wasRecycle = this.#recycleRequested;
|
|
106
132
|
this.#clearRecycle();
|
|
107
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
|
+
);
|
|
108
139
|
if (this.#manualStop || this.#status === "stopped") return;
|
|
109
140
|
if (!wasReady) {
|
|
110
141
|
this.#status = "stopped";
|
|
@@ -189,6 +220,7 @@ export class IncrementalBuilderHost {
|
|
|
189
220
|
}
|
|
190
221
|
try {
|
|
191
222
|
this.#proc.send(message);
|
|
223
|
+
if (message.type === "build-route" || message.type === "build-csr") this.#inFlight.set(message.id, message.type);
|
|
192
224
|
return true;
|
|
193
225
|
} catch (error) {
|
|
194
226
|
this.logger.warn(
|
|
@@ -197,9 +229,24 @@ export class IncrementalBuilderHost {
|
|
|
197
229
|
return false;
|
|
198
230
|
}
|
|
199
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
|
+
}
|
|
200
246
|
stop() {
|
|
201
247
|
this.#manualStop = true;
|
|
202
248
|
this.#clearRecycle();
|
|
249
|
+
this.#failInFlight("builder was stopped before answering");
|
|
203
250
|
if (this.#restartTimer) {
|
|
204
251
|
clearTimeout(this.#restartTimer);
|
|
205
252
|
this.#restartTimer = null;
|