@akanjs/devkit 2.4.1-rc.2 → 2.4.1-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/akanApp/akanApp.host.test.ts +283 -2
- package/akanApp/akanApp.host.ts +578 -12
- package/executors.test.ts +60 -0
- package/executors.ts +11 -0
- package/frontendBuild/fontOptimizer.test.ts +111 -0
- package/frontendBuild/fontOptimizer.ts +102 -17
- package/frontendBuild/hmrWatcher.test.ts +191 -0
- package/frontendBuild/hmrWatcher.ts +176 -5
- package/frontendBuild/index.ts +1 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +280 -0
- package/frontendBuild/sourceMtimeIndex.ts +326 -0
- package/incrementalBuilder/buildBatch.proc.ts +194 -0
- package/incrementalBuilder/buildBatchProtocol.ts +53 -0
- package/incrementalBuilder/buildBatchRunner.ts +85 -0
- package/incrementalBuilder/builderReply.test.ts +73 -0
- package/incrementalBuilder/builderReply.ts +30 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +199 -9
- package/incrementalBuilder/incrementalBuilder.host.ts +119 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +260 -170
- package/integration/devStability.integration.test.ts +308 -78
- package/integration/devStabilityHarness.test.ts +111 -0
- package/integration/devStabilityHarness.ts +555 -40
- package/local/optimize-resource/ipcprobe/child.ts +1 -0
- package/local/optimize-resource/ipcprobe/parent.ts +11 -0
- package/package.json +2 -2
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { DevGeneratedIndexSync } from "../frontendBuild";
|
|
3
|
-
import { DevStabilityHarness } from "./devStabilityHarness";
|
|
3
|
+
import { DevStabilityHarness, type DevStabilityHmrProbe } from "./devStabilityHarness";
|
|
4
4
|
|
|
5
5
|
const integrationEnabled = process.env.AKAN_DEV_STABILITY_INTEGRATION === "1";
|
|
6
|
-
|
|
6
|
+
// Every test here boots a real dev server, and contention pushes a cold boot from ~3s to 21-55s
|
|
7
|
+
// (`05-phase1-results.md`). At 120s a loaded machine ran out of budget mid-restart and reported it as a
|
|
8
|
+
// product failure, which is the exact ambiguity this file exists to remove.
|
|
9
|
+
const INTEGRATION_TIMEOUT_MS = 180_000;
|
|
7
10
|
const MB = 1024 * 1024;
|
|
8
11
|
const harnesses: DevStabilityHarness[] = [];
|
|
9
12
|
|
|
@@ -35,6 +38,30 @@ const isBuildStatus =
|
|
|
35
38
|
"status" in msg &&
|
|
36
39
|
msg.status === status;
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Assert an HMR message arrives — unless the socket dropped while waiting.
|
|
43
|
+
*
|
|
44
|
+
* The hub does not replay (`akanjs/server/hmr/wsHub.ts`): anything published while the probe was
|
|
45
|
+
* reconnecting is simply gone, so a miss across a reconnect says nothing about the product. A real browser
|
|
46
|
+
* covers the same gap by reloading when the `hello` buildId moved, not by expecting the message. Under
|
|
47
|
+
* parallel load the socket flaps repeatedly, and this is what that looks like from the probe:
|
|
48
|
+
* `socket=closed reconnects=5 since-mark=[build-status,hello,hello,rsc-refresh,…]`.
|
|
49
|
+
*/
|
|
50
|
+
const expectHmrMessage = async (
|
|
51
|
+
probe: DevStabilityHmrProbe,
|
|
52
|
+
mark: number,
|
|
53
|
+
predicate: (message: unknown) => boolean,
|
|
54
|
+
what: string,
|
|
55
|
+
): Promise<void> => {
|
|
56
|
+
const reconnectsBefore = probe.reconnects;
|
|
57
|
+
const seen = await probe
|
|
58
|
+
.waitForMessageSince(mark, predicate, 20_000)
|
|
59
|
+
.then(() => true)
|
|
60
|
+
.catch(() => false);
|
|
61
|
+
if (seen || probe.reconnects !== reconnectsBefore) return;
|
|
62
|
+
throw new Error(`${what} never reached the HMR socket, and the connection held the whole time`);
|
|
63
|
+
};
|
|
64
|
+
|
|
38
65
|
const waitForFileIncludes = async (filePath: string, text: string, timeoutMs = 5_000): Promise<string | null> => {
|
|
39
66
|
const started = Date.now();
|
|
40
67
|
while (Date.now() - started < timeoutMs) {
|
|
@@ -90,8 +117,43 @@ const waitForProcessesGone = async (pids: number[], timeoutMs = 15_000): Promise
|
|
|
90
117
|
return false;
|
|
91
118
|
};
|
|
92
119
|
|
|
120
|
+
/**
|
|
121
|
+
* `cleanup()` waits up to 3s for a SIGTERM'd dev host to exit and shells out to `ps` twice, and on a
|
|
122
|
+
* loaded machine that overruns Bun's default 5s hook budget. A hook timeout fails the test that had
|
|
123
|
+
* already passed, which reads exactly like a product failure — two of the five failures in a 3-way
|
|
124
|
+
* parallel run were this and nothing else.
|
|
125
|
+
*/
|
|
126
|
+
const HOOK_TIMEOUT_MS = 60_000;
|
|
127
|
+
|
|
128
|
+
beforeAll(async () => {
|
|
129
|
+
if (!integrationEnabled) return;
|
|
130
|
+
// A run killed mid-test (a `-t` filter interrupted, an editor stopping the runner) leaves its fixture
|
|
131
|
+
// in `apps/` and its dev host running. Both interfere with every later run: the process holds ports
|
|
132
|
+
// and rebuilds a deleted app, and the directory shifts the app index every port prediction derives
|
|
133
|
+
// from. Only fixtures whose owning test pid is gone are swept, so this is safe with a suite running
|
|
134
|
+
// alongside.
|
|
135
|
+
const swept = await DevStabilityHarness.sweepAbandonedFixtures(DevStabilityHarness.defaultWorkspaceRoot);
|
|
136
|
+
if (swept.length) console.info(`[harness] swept ${swept.length} abandoned fixture(s): ${swept.join(", ")}`);
|
|
137
|
+
}, HOOK_TIMEOUT_MS);
|
|
138
|
+
|
|
93
139
|
afterEach(async () => {
|
|
94
140
|
await Promise.all(harnesses.splice(0).map((harness) => harness.cleanup()));
|
|
141
|
+
}, HOOK_TIMEOUT_MS);
|
|
142
|
+
|
|
143
|
+
afterAll(() => {
|
|
144
|
+
if (!integrationEnabled) return;
|
|
145
|
+
const { edits, retried } = DevStabilityHarness.editStats();
|
|
146
|
+
// Reported on purpose, every run. A retry means a real save produced no rebuild whatsoever — Bun's
|
|
147
|
+
// dropped `fs.watch` event (`local/optimize-resource/06-watcher-dropped-event.md`), which users hit too
|
|
148
|
+
// through format-on-save and save-all. A non-zero number here means this suite is passing *around* a
|
|
149
|
+
// product bug rather than because it is absent, and it is the signal for fixing that bug properly.
|
|
150
|
+
console.info(`[harness] ${edits} observed edit(s), ${retried} needed a retry after a dropped watch event`);
|
|
151
|
+
// Every wait is individually bounded but their budgets sum past the per-test timeout, so a loaded round
|
|
152
|
+
// can kill a test without any single wait failing — and Bun then reports only "this test timed out". These
|
|
153
|
+
// are the numbers that say how close a green run came, and which step to look at when one dies.
|
|
154
|
+
const slowest = DevStabilityHarness.waitStats();
|
|
155
|
+
if (slowest.length)
|
|
156
|
+
console.info(`[harness] slowest waits: ${slowest.map((wait) => `${wait.ms}ms ${wait.label}`).join(" | ")}`);
|
|
95
157
|
});
|
|
96
158
|
|
|
97
159
|
describe("dev stability integration harness", () => {
|
|
@@ -99,10 +161,14 @@ describe("dev stability integration harness", () => {
|
|
|
99
161
|
const harness = await createHarness();
|
|
100
162
|
const host = await harness.startHost();
|
|
101
163
|
const hmr = await harness.tryConnectHmrProbe();
|
|
102
|
-
const mark = host.markLog();
|
|
103
164
|
const hmrMark = hmr?.mark() ?? 0;
|
|
104
165
|
|
|
105
|
-
await harness.
|
|
166
|
+
const { mark } = await harness.editUntilSeen(host, (attempt) =>
|
|
167
|
+
harness.writeFile(
|
|
168
|
+
"srvkit/backendMarker.ts",
|
|
169
|
+
`export const backendMarker = "updated-backend-marker-${attempt}";\n`,
|
|
170
|
+
),
|
|
171
|
+
);
|
|
106
172
|
|
|
107
173
|
await host.waitForLogSince(mark, /\[backend-reload\]|Shutting down gracefully|stopping backend/);
|
|
108
174
|
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
@@ -122,15 +188,21 @@ describe("dev stability integration harness", () => {
|
|
|
122
188
|
hmr?.close();
|
|
123
189
|
return;
|
|
124
190
|
}
|
|
125
|
-
const mark = host.markLog();
|
|
126
191
|
const hmrMark = hmr?.mark() ?? 0;
|
|
127
192
|
|
|
128
|
-
await harness.
|
|
193
|
+
const { mark } = await harness.editUntilSeen(
|
|
194
|
+
host,
|
|
195
|
+
(attempt) =>
|
|
196
|
+
harness.replaceText(
|
|
197
|
+
"ui/ClientMarker.tsx",
|
|
198
|
+
/(initial|updated)-client-marker(-\d+)?/,
|
|
199
|
+
`updated-client-marker-${attempt}`,
|
|
200
|
+
),
|
|
201
|
+
{ evidence: /\[dev-plan\].*roles=.*client.*actions=.*rebuild-client/ },
|
|
202
|
+
);
|
|
129
203
|
|
|
130
|
-
await host.waitForLogSince(mark, /\[dev-plan\].*roles=.*client.*actions=.*rebuild-client/);
|
|
131
204
|
if (hmr) {
|
|
132
|
-
|
|
133
|
-
expect(message).toBeTruthy();
|
|
205
|
+
await expectHmrMessage(hmr, hmrMark, isRefreshMessage, "a client refresh");
|
|
134
206
|
} else {
|
|
135
207
|
await host.waitForLogSince(mark, /\[hmr\].*(client-refresh|rsc-refresh|reload)|\[SSR\] pages-updated/);
|
|
136
208
|
}
|
|
@@ -148,24 +220,21 @@ describe("dev stability integration harness", () => {
|
|
|
148
220
|
hmr?.close();
|
|
149
221
|
return;
|
|
150
222
|
}
|
|
151
|
-
const mark =
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const plan = await host.waitForLogSince(
|
|
157
|
-
mark,
|
|
158
|
-
/\[dev-plan\] generation=(\d+).*roles=.*shared.*actions=.*rebuild-client.*restart-backend/,
|
|
223
|
+
const { mark, evidence } = await harness.editUntilSeen(
|
|
224
|
+
host,
|
|
225
|
+
(attempt) => harness.replaceText("common/marker.ts", /"[^"]*"/, `"updated-shared-marker-${attempt}"`),
|
|
226
|
+
{ evidence: /\[dev-plan\] generation=(\d+).*roles=.*shared.*actions=.*rebuild-client.*restart-backend/ },
|
|
159
227
|
);
|
|
160
|
-
const generation =
|
|
228
|
+
const generation = evidence[1];
|
|
161
229
|
await host.waitForLogSince(mark, new RegExp(`\\[backend-reload\\].*generation=${generation}`));
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
230
|
+
// Asserted backend-side, not through the probe. A shared edit restarts the backend, which closes the
|
|
231
|
+
// socket the probe opened; the probe reconnects, but a reconnect does not replay what was published
|
|
232
|
+
// while it was down, and a real browser handles that by reloading when the `hello` buildId moved
|
|
233
|
+
// (`akanjs/server/hmr/clientScript.ts`) rather than by expecting the message. Requiring a probe
|
|
234
|
+
// message here only held while the client rebuild happened to finish before the restart killed the
|
|
235
|
+
// connection — a race this test lost the moment builds moved into a worker process and took ~240ms
|
|
236
|
+
// longer to start.
|
|
237
|
+
await host.waitForLogSince(mark, new RegExp(`\\[SSR\\] pages-updated.*generation=${generation}`));
|
|
169
238
|
await harness.waitForHttpText("updated-shared-marker");
|
|
170
239
|
hmr?.close();
|
|
171
240
|
});
|
|
@@ -180,23 +249,27 @@ describe("dev stability integration harness", () => {
|
|
|
180
249
|
hmr?.close();
|
|
181
250
|
return;
|
|
182
251
|
}
|
|
183
|
-
const mark =
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
252
|
+
const { mark } = await harness.editUntilSeen(
|
|
253
|
+
host,
|
|
254
|
+
(attempt) =>
|
|
255
|
+
harness.writeFile(
|
|
256
|
+
"lib/_fixture/fixture.dictionary.ts",
|
|
257
|
+
`import { serviceDictionary } from "akanjs/dictionary";
|
|
188
258
|
|
|
189
259
|
import type { FixtureEndpoint } from "./fixture.signal";
|
|
190
260
|
|
|
191
261
|
export const dictionary = serviceDictionary(["en", "ko"])
|
|
192
262
|
.endpoint<FixtureEndpoint>(() => ({}))
|
|
193
263
|
.translate({
|
|
194
|
-
hello: ["Updated Dictionary", "업데이트 사전"],
|
|
264
|
+
hello: ["Updated Dictionary ${attempt}", "업데이트 사전 ${attempt}"],
|
|
195
265
|
});
|
|
196
266
|
`,
|
|
267
|
+
),
|
|
268
|
+
// As with the config edit: each attempt recycles the builder and the backend, so patience is cheaper
|
|
269
|
+
// than a retry.
|
|
270
|
+
{ evidence: /\[dev-plan\].*actions=.*restart-builder/, attempts: 2, evidenceTimeoutMs: 30_000 },
|
|
197
271
|
);
|
|
198
272
|
|
|
199
|
-
await host.waitForLogSince(mark, /\[dev-plan\].*actions=.*restart-builder/);
|
|
200
273
|
await host.waitForLogSince(mark, /\[dev-host\] recycling builder\/backend for runtime metadata/);
|
|
201
274
|
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
202
275
|
await harness.waitForHttpText("initial-shared-marker");
|
|
@@ -212,18 +285,23 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
212
285
|
expect(host.proc.killed).toBe(false);
|
|
213
286
|
return;
|
|
214
287
|
}
|
|
215
|
-
const mark =
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
288
|
+
const { mark } = await harness.editUntilSeen(
|
|
289
|
+
host,
|
|
290
|
+
(attempt) =>
|
|
291
|
+
harness.writeFile(
|
|
292
|
+
"akan.config.ts",
|
|
293
|
+
`import type { AppConfig } from "akanjs";
|
|
294
|
+
|
|
295
|
+
// edit ${attempt}
|
|
221
296
|
const config: AppConfig = { externalLibs: [] };
|
|
222
297
|
export default config;
|
|
223
298
|
`,
|
|
299
|
+
),
|
|
300
|
+
// Re-applying this edit is expensive — every attempt restarts the whole dev host — so wait longer
|
|
301
|
+
// before concluding the event was dropped rather than merely slow.
|
|
302
|
+
{ evidence: /\[dev-plan\].*actions=.*restart-dev-host/, attempts: 2, evidenceTimeoutMs: 30_000 },
|
|
224
303
|
);
|
|
225
304
|
|
|
226
|
-
await host.waitForLogSince(mark, /\[dev-plan\].*actions=.*restart-dev-host/);
|
|
227
305
|
await host.waitForLogSince(mark, /\[dev-host\] config change detected; restarting dev host/);
|
|
228
306
|
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
229
307
|
await harness.waitForHttpText("initial-shared-marker");
|
|
@@ -240,35 +318,40 @@ export default config;
|
|
|
240
318
|
hmr?.close();
|
|
241
319
|
return;
|
|
242
320
|
}
|
|
243
|
-
const failureMark = host.markLog();
|
|
244
321
|
const failureHmrMark = hmr?.mark() ?? 0;
|
|
245
322
|
|
|
246
|
-
await harness.
|
|
247
|
-
|
|
248
|
-
|
|
323
|
+
const { mark: failureMark } = await harness.editUntilSeen(host, (attempt) =>
|
|
324
|
+
harness.writeFile(
|
|
325
|
+
"ui/ClientMarker.tsx",
|
|
326
|
+
`export function ClientMarker() {
|
|
327
|
+
// broken ${attempt}
|
|
249
328
|
return <p>broken</p>
|
|
250
329
|
`,
|
|
330
|
+
),
|
|
251
331
|
);
|
|
252
332
|
|
|
253
333
|
await host.waitForLogSince(
|
|
254
334
|
failureMark,
|
|
255
335
|
/\[build-status\].*phase=pages.*ok=false|\[build-status\].*phase=csr.*ok=false/,
|
|
256
336
|
);
|
|
257
|
-
if (hmr) await hmr
|
|
337
|
+
if (hmr) await expectHmrMessage(hmr, failureHmrMark, isBuildStatus("error"), "the build failure");
|
|
258
338
|
await harness.waitForHttpText("initial-client-marker");
|
|
259
|
-
const recoveryMark = host.markLog();
|
|
260
339
|
const recoveryHmrMark = hmr?.mark() ?? 0;
|
|
261
340
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
341
|
+
// The edit that used to be dropped: it lands right after the failed build's write burst, and in a
|
|
342
|
+
// 3-way parallel run all three shards timed out here on 60s of completely empty log output.
|
|
343
|
+
const { mark: recoveryMark } = await harness.editUntilSeen(host, (attempt) =>
|
|
344
|
+
harness.writeFile(
|
|
345
|
+
"ui/ClientMarker.tsx",
|
|
346
|
+
`export function ClientMarker() {
|
|
347
|
+
return <p data-testid="client-marker">recovered-client-marker-${attempt}</p>;
|
|
266
348
|
}
|
|
267
349
|
`,
|
|
350
|
+
),
|
|
268
351
|
);
|
|
269
352
|
|
|
270
353
|
await host.waitForLogSince(recoveryMark, /\[build-status\].*ok=true/);
|
|
271
|
-
if (hmr) await hmr
|
|
354
|
+
if (hmr) await expectHmrMessage(hmr, recoveryHmrMark, isBuildStatus("ok"), "the build recovery");
|
|
272
355
|
await harness.waitForHttpText("recovered-client-marker");
|
|
273
356
|
hmr?.close();
|
|
274
357
|
});
|
|
@@ -315,18 +398,18 @@ export default config;
|
|
|
315
398
|
integrationTest("backend boot failure stops the crash loop, surfaces build-status, and recovers on fix", async () => {
|
|
316
399
|
const harness = await createHarness();
|
|
317
400
|
const host = await harness.startHost();
|
|
318
|
-
const failureMark = host.markLog();
|
|
319
|
-
|
|
320
401
|
// The service file is part of the generated server graph (`akan start` regenerates server.ts
|
|
321
402
|
// from lib/), so a module-level throw here breaks every replica boot.
|
|
322
|
-
await harness.
|
|
323
|
-
|
|
324
|
-
|
|
403
|
+
const { mark: failureMark } = await harness.editUntilSeen(host, (attempt) =>
|
|
404
|
+
harness.writeFile(
|
|
405
|
+
"lib/_fixture/fixture.service.ts",
|
|
406
|
+
`import { serve } from "akanjs/service";
|
|
325
407
|
|
|
326
408
|
export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
|
|
327
409
|
|
|
328
|
-
throw new Error("intentional-backend-boot-crash");
|
|
410
|
+
throw new Error("intentional-backend-boot-crash-${attempt}");
|
|
329
411
|
`,
|
|
412
|
+
),
|
|
330
413
|
);
|
|
331
414
|
|
|
332
415
|
// The gateway abandons the replica after three failed boots instead of retrying forever...
|
|
@@ -335,13 +418,15 @@ throw new Error("intentional-backend-boot-crash");
|
|
|
335
418
|
await host.waitForLogSince(failureMark, /\[build-status\].*phase=backend.*ok=false/);
|
|
336
419
|
expect(host.proc.killed).toBe(false);
|
|
337
420
|
|
|
338
|
-
const recoveryMark =
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
421
|
+
const { mark: recoveryMark } = await harness.editUntilSeen(host, (attempt) =>
|
|
422
|
+
harness.writeFile(
|
|
423
|
+
"lib/_fixture/fixture.service.ts",
|
|
424
|
+
`import { serve } from "akanjs/service";
|
|
342
425
|
|
|
426
|
+
// recovery ${attempt}
|
|
343
427
|
export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
|
|
344
428
|
`,
|
|
429
|
+
),
|
|
345
430
|
);
|
|
346
431
|
|
|
347
432
|
await host.waitForLogSince(recoveryMark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
@@ -417,6 +502,13 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
|
|
|
417
502
|
* silently exhausted the waits and looked like product failures. Both tests therefore assert several
|
|
418
503
|
* related properties against a single boot, with explicit generous waits. Run the block on its own
|
|
419
504
|
* (`-t "dev resource budgets"`) when timing matters.
|
|
505
|
+
*
|
|
506
|
+
* **This block is not parallel-safe, and cannot be made so.** It asserts *absolute* resident memory, so
|
|
507
|
+
* several dev servers competing for the machine changes what the number means rather than adding noise
|
|
508
|
+
* around it — a 3-way parallel run reported 178MB against the 120MB budget. That is a property of the
|
|
509
|
+
* measurement, not flakiness: run this block on an otherwise idle machine and parallelise the behavioural
|
|
510
|
+
* block above. Loosening a budget to survive a parallel run would throw away the regression signal the
|
|
511
|
+
* budget exists for.
|
|
420
512
|
*/
|
|
421
513
|
describe("dev resource budgets", () => {
|
|
422
514
|
const BOOT_MS = 150_000;
|
|
@@ -446,14 +538,16 @@ describe("dev resource budgets", () => {
|
|
|
446
538
|
|
|
447
539
|
// Armed: from here on every save rebuilds CSR, which is what keeps a live mobile session working.
|
|
448
540
|
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
541
|
+
// `editUntilSeen` rather than a bare save, because the CSR build above emits a whole minified tree
|
|
542
|
+
// into `.akan/artifact/csr` and a save landing in that window is dropped by Bun 100% of the time
|
|
543
|
+
// (`local/optimize-resource/06-watcher-dropped-event.md`).
|
|
544
|
+
const { mark: resyncMark } = await harness.editUntilSeen(host, (attempt) =>
|
|
545
|
+
harness.replaceText(
|
|
546
|
+
"ui/ClientMarker.tsx",
|
|
547
|
+
/(initial|csr-armed)-[\w-]*marker(-\d+)?/,
|
|
548
|
+
`csr-armed-marker-${attempt}`,
|
|
549
|
+
),
|
|
550
|
+
);
|
|
457
551
|
await host.waitForLogSince(resyncMark, /csr-rebundle ok/, WAIT_MS);
|
|
458
552
|
});
|
|
459
553
|
|
|
@@ -469,36 +563,172 @@ describe("dev resource budgets", () => {
|
|
|
469
563
|
|
|
470
564
|
const idleTotal = await DevStabilityHarness.processTreeRssBytes(host.proc.pid);
|
|
471
565
|
const idleWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, { excludeBuilder: true });
|
|
566
|
+
const idleBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
567
|
+
// Nothing should be building at idle, so the disposable worker must not be resident.
|
|
568
|
+
expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
|
|
472
569
|
// Measured ~670MB for this fixture; the headroom covers machine variance, not a reintroduced
|
|
473
570
|
// eager import (the cheapest of those is ~30MB, and the devkit barrel cycle was 236MB).
|
|
474
571
|
expect(idleTotal).toBeLessThan(1_000 * MB);
|
|
475
572
|
|
|
476
573
|
const start = host.markLog();
|
|
477
574
|
for (let i = 1; i <= 3; i++) {
|
|
478
|
-
const mark =
|
|
479
|
-
|
|
575
|
+
const { mark } = await harness.editUntilSeen(host, (attempt) =>
|
|
576
|
+
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-${i}-${attempt}`),
|
|
577
|
+
);
|
|
480
578
|
await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS);
|
|
481
579
|
// CSR was never requested in this fixture, so no save may pay for a CSR rebuild.
|
|
482
580
|
const afterSave = host.logs.join("").slice(mark);
|
|
483
581
|
expect(afterSave).toMatch(/csr-rebundle skipped/);
|
|
484
582
|
expect(afterSave).not.toMatch(/csr-rebundle ok/);
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
583
|
+
// Waiting for the builder alone is not enough before the next iteration: the backend is still
|
|
584
|
+
// applying the reload after that, and it writes into the watched tree too. `editUntilSeen` handles
|
|
585
|
+
// the drop window itself, but these two waits are still what makes each iteration a whole
|
|
586
|
+
// generation, which is what the RSS deltas below are measured across.
|
|
489
587
|
await host.waitForLogSince(mark, /css-rebuild checked/, WAIT_MS);
|
|
588
|
+
await host.waitForLogSince(mark, /\[hmr\] backend apply/, WAIT_MS);
|
|
490
589
|
}
|
|
491
590
|
|
|
492
591
|
// Each in-place reload re-imports the pages bundle under a fresh `?v=`, and Bun's ESM registry
|
|
493
592
|
// never evicts — so without a recycle the worker grows for the life of the process.
|
|
494
593
|
await host.waitForLogSince(start, /rolling recycle worker reason=pages-reload-accumulation/, WAIT_MS);
|
|
495
594
|
|
|
496
|
-
// The dev host, gateway, replica and rsc worker must all stay flat across saves.
|
|
497
|
-
// still expected to grow — `Bun.build` retains native arenas that no GC reclaims, which the
|
|
498
|
-
// bounded-builder work addresses — so it is excluded here rather than silently tolerated.
|
|
595
|
+
// The dev host, gateway, replica and rsc worker must all stay flat across saves.
|
|
499
596
|
const afterWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, {
|
|
500
597
|
excludeBuilder: true,
|
|
501
598
|
});
|
|
502
599
|
expect(afterWithoutBuilder - idleWithoutBuilder).toBeLessThan(120 * MB);
|
|
600
|
+
|
|
601
|
+
// And so must the builder. It used to be excluded from this budget because `Bun.build` retains
|
|
602
|
+
// native arenas no GC reclaims, which made it grow ~120MB per save on this fixture; every build
|
|
603
|
+
// that scales per save now runs in a process that exits, so its memory goes back to the OS.
|
|
604
|
+
const afterBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
605
|
+
expect(afterBuilder?.pid).toBe(idleBuilder?.pid);
|
|
606
|
+
expect((afterBuilder?.rssBytes ?? 0) - (idleBuilder?.rssBytes ?? 0)).toBeLessThan(30 * MB);
|
|
607
|
+
// The worker is transient: three generations built, and none of them is still around.
|
|
608
|
+
expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
budgetTest("recycles the builder at an unmeetable ceiling and keeps developing through it", async () => {
|
|
612
|
+
const harness = await createHarness();
|
|
613
|
+
// Deliberately *below* this fixture's post-boot builder. Moving every per-save build into a
|
|
614
|
+
// disposable worker means the builder no longer grows into a ceiling, so a ceiling it is already
|
|
615
|
+
// over is the only way left to drive the recycle path end to end — and it is also the case the
|
|
616
|
+
// escape hatch exists for: an app whose boot floor simply does not fit under the limit.
|
|
617
|
+
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
|
|
618
|
+
const start = host.markLog();
|
|
619
|
+
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
620
|
+
|
|
621
|
+
// One save is enough: the builder reports its rss as soon as the batch drains, and the host arms
|
|
622
|
+
// the recycle from that report. The old pid comes from the log rather than from `ps`, so this does
|
|
623
|
+
// not race the swap it is about to observe.
|
|
624
|
+
const { mark: firstSave } = await harness.editUntilSeen(host, (attempt) =>
|
|
625
|
+
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-1-${attempt}`),
|
|
626
|
+
);
|
|
627
|
+
await host.waitForLogSince(firstSave, /pages-rebundle ok/, WAIT_MS);
|
|
628
|
+
|
|
629
|
+
// The host decides, the builder drains rather than being killed, and the replacement comes up.
|
|
630
|
+
const recycleLog = await host.waitForLogSince(
|
|
631
|
+
start,
|
|
632
|
+
/recycling builder pid=(\d+) \((rss=\d+MiB>=200MiB after \d+ build\(s\))\)/,
|
|
633
|
+
WAIT_MS,
|
|
634
|
+
);
|
|
635
|
+
await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
|
|
636
|
+
await host.waitForLogSince(start, /builder spawned pid=\d+ .*restart=1/, WAIT_MS);
|
|
637
|
+
await host.waitForLogSince(start, /builder ready after restart/, WAIT_MS);
|
|
638
|
+
// The backend read `base-artifact.json` once at boot, so the replacement has to re-announce what
|
|
639
|
+
// it booted with or the backend keeps serving the artifact of the builder that just exited.
|
|
640
|
+
await host.waitForLogSince(start, /announced boot state after recycle/, WAIT_MS);
|
|
641
|
+
|
|
642
|
+
const recycled = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
643
|
+
expect(recycled).not.toBeNull();
|
|
644
|
+
expect(String(recycled?.pid)).not.toBe(recycleLog[1]);
|
|
645
|
+
|
|
646
|
+
// And the dev server is still a dev server: the replacement watches, rebuilds and serves.
|
|
647
|
+
//
|
|
648
|
+
// Waiting for readiness above is load-bearing, not padding. The watcher is installed at the end of
|
|
649
|
+
// the boot build, so a save during the recycle is seen by neither builder — this test lost one
|
|
650
|
+
// exactly that way.
|
|
651
|
+
const postRecycle = await harness.editUntilSeen(host, (attempt) =>
|
|
652
|
+
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-after-recycle-${attempt}`),
|
|
653
|
+
);
|
|
654
|
+
await host.waitForLogSince(postRecycle.mark, /pages-rebundle ok/, WAIT_MS);
|
|
655
|
+
console.info(
|
|
656
|
+
`[recycle-guard] ${recycleLog[2]}; builder ${recycleLog[1]} -> ${recycled?.pid} at ${Math.round((recycled?.rssBytes ?? 0) / MB)}MiB; post-recycle save took ${postRecycle.attempts} attempt(s)`,
|
|
657
|
+
);
|
|
658
|
+
await harness.waitForHttpText("marker-after-recycle", WAIT_MS);
|
|
659
|
+
|
|
660
|
+
// A replacement that is still over the ceiling proves the ceiling cannot be met, and the host has
|
|
661
|
+
// to stop rather than recycle forever. Two reports inside the minimum interval is the threshold.
|
|
662
|
+
for (let i = 1; i <= 3; i++) {
|
|
663
|
+
const { mark } = await harness.editUntilSeen(host, (attempt) =>
|
|
664
|
+
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-settled-${i}-${attempt}`),
|
|
665
|
+
);
|
|
666
|
+
await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
|
|
667
|
+
}
|
|
668
|
+
await host.waitForLogSince(start, /ceiling cannot be met for this app/, WAIT_MS);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
budgetTest("suspends the builder when the dev server goes idle and wakes it on the next edit", async () => {
|
|
672
|
+
const harness = await createHarness();
|
|
673
|
+
// 3s stands in for the 5min default: the machinery is the same, and the guard needs the dev server
|
|
674
|
+
// to actually reach idle inside a test.
|
|
675
|
+
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_DEV_IDLE_SUSPEND_MS: "3000" } });
|
|
676
|
+
const start = host.markLog();
|
|
677
|
+
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
678
|
+
|
|
679
|
+
// Nothing periodic may keep the dev server "busy": if the builder reported metrics on a timer, or
|
|
680
|
+
// the backend wrote a watched file, this would never fire.
|
|
681
|
+
await host.waitForLogSince(start, /\[idle-suspend\] no build activity for \d+s; released the builder/, WAIT_MS);
|
|
682
|
+
const suspendedBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
683
|
+
expect(suspendedBuilder).toBeNull();
|
|
684
|
+
// Only build capacity suspends — the backend keeps serving the preview URL.
|
|
685
|
+
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
686
|
+
|
|
687
|
+
// The edit that has to wake it. A suspended host has just installed a fresh watcher over a tree the
|
|
688
|
+
// suspend itself churned, so this is squarely inside Bun's drop window — it is what failed here in a
|
|
689
|
+
// 3-way parallel run, on 60s of empty log output.
|
|
690
|
+
const { mark } = await harness.editUntilSeen(
|
|
691
|
+
host,
|
|
692
|
+
(attempt) => harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-after-wake-${attempt}`),
|
|
693
|
+
{ evidence: /\[idle-suspend\] waking/ },
|
|
694
|
+
);
|
|
695
|
+
const awake = await host.waitForLogSince(mark, /\[idle-suspend\] awake in (\d+)ms/, WAIT_MS);
|
|
696
|
+
// The woken builder rebuilds from disk, so the edit that woke it is in the artifact it announces.
|
|
697
|
+
await host.waitForLogSince(mark, /announced boot state after recycle/, WAIT_MS);
|
|
698
|
+
const wokenBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
699
|
+
expect(wokenBuilder).not.toBeNull();
|
|
700
|
+
expect(wokenBuilder?.pid).not.toBe(suspendedBuilder?.pid);
|
|
701
|
+
await harness.waitForHttpText("marker-after-wake", WAIT_MS);
|
|
702
|
+
console.info(`[idle-suspend-guard] woke in ${awake[1]}ms; builder back at pid=${wokenBuilder?.pid}`);
|
|
703
|
+
|
|
704
|
+
// And it is a normal dev server again afterwards.
|
|
705
|
+
const postWake = await harness.editUntilSeen(host, (attempt) =>
|
|
706
|
+
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-postwake-${attempt}`),
|
|
707
|
+
);
|
|
708
|
+
await host.waitForLogSince(postWake.mark, /pages-rebundle ok/, WAIT_MS);
|
|
709
|
+
console.info(`[idle-suspend-guard] post-wake save took ${postWake.attempts} attempt(s)`);
|
|
710
|
+
await harness.waitForHttpText("marker-postwake", WAIT_MS);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
budgetTest("holds a request that needs a build until the wake finishes, instead of failing it", async () => {
|
|
714
|
+
const harness = await createHarness();
|
|
715
|
+
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_DEV_IDLE_SUSPEND_MS: "3000" } });
|
|
716
|
+
const start = host.markLog();
|
|
717
|
+
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
718
|
+
await host.waitForLogSince(start, /\[idle-suspend\] .*released the builder/, WAIT_MS);
|
|
719
|
+
expect(await DevStabilityHarness.builderProcess(host.proc.pid)).toBeNull();
|
|
720
|
+
|
|
721
|
+
// A browser asking for something the backend cannot serve on its own is the other wake trigger, and
|
|
722
|
+
// the request must not be answered with "builder is stopped" the way a dead builder's would be.
|
|
723
|
+
const mark = host.markLog();
|
|
724
|
+
const port = await harness.resolvePort();
|
|
725
|
+
const status = await fetch(`http://127.0.0.1:${port}/__csr`)
|
|
726
|
+
.then((res) => res.status)
|
|
727
|
+
.catch(() => 0);
|
|
728
|
+
|
|
729
|
+
await host.waitForLogSince(mark, /\[idle-suspend\] waking \(build-csr arrived while suspended\)/, WAIT_MS);
|
|
730
|
+
await host.waitForLogSince(mark, /\[idle-suspend\] replaying 1 request\(s\) held during the wake/, WAIT_MS);
|
|
731
|
+
expect(status).toBe(200);
|
|
732
|
+
expect(await DevStabilityHarness.builderProcess(host.proc.pid)).not.toBeNull();
|
|
503
733
|
});
|
|
504
734
|
});
|