@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
|
@@ -19,7 +19,6 @@ import { Logger } from "akanjs/common";
|
|
|
19
19
|
import type {
|
|
20
20
|
BaseBuildArtifact,
|
|
21
21
|
BuilderCsrReq,
|
|
22
|
-
BuilderCsrRes,
|
|
23
22
|
BuilderMessage,
|
|
24
23
|
BuilderReq,
|
|
25
24
|
BuilderRes,
|
|
@@ -28,6 +27,7 @@ import type {
|
|
|
28
27
|
} from "akanjs/server";
|
|
29
28
|
import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
|
|
30
29
|
import { BuildBatchRunner } from "./buildBatchRunner";
|
|
30
|
+
import { BuilderReply } from "./builderReply";
|
|
31
31
|
import { prepareDevWatchBatch } from "./devWatchBatch";
|
|
32
32
|
|
|
33
33
|
interface IncrementalBuilderOptions {
|
|
@@ -53,6 +53,7 @@ class IncrementalBuilder {
|
|
|
53
53
|
#changePlanner: DevChangePlanner;
|
|
54
54
|
#generatedIndexSync: DevGeneratedIndexSync;
|
|
55
55
|
#autoImportSync: AutoImportSync;
|
|
56
|
+
#watcher: HmrWatcher | null = null;
|
|
56
57
|
#generation = 0;
|
|
57
58
|
#csrActive = IncrementalBuilder.#csrArmedByEnv();
|
|
58
59
|
#workQueue: Promise<void> = Promise.resolve();
|
|
@@ -82,8 +83,14 @@ class IncrementalBuilder {
|
|
|
82
83
|
return `${this.#app.cwdPath}/.akan/artifact`;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Build a route and answer it. The reply is part of the work item on purpose: `shutdown` drains the
|
|
88
|
+
* work queue before exiting, so folding the flush in here is what makes "drained" mean "answered".
|
|
89
|
+
*/
|
|
90
|
+
async handleBuildRoute(msg: BuilderReq): Promise<void> {
|
|
91
|
+
await this.#enqueueWork(`build-route:${msg.routeId}`, async () =>
|
|
92
|
+
BuilderReply.send(await this.#handleBuildRoute(msg)),
|
|
93
|
+
);
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
async #handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
|
|
@@ -260,7 +267,8 @@ class IncrementalBuilder {
|
|
|
260
267
|
await this.#enqueueWork("hmr-batch", async () => this.#handleWatchBatch(appDir, artifactDir, batch));
|
|
261
268
|
},
|
|
262
269
|
});
|
|
263
|
-
watcher.start();
|
|
270
|
+
await watcher.start();
|
|
271
|
+
this.#watcher = watcher;
|
|
264
272
|
this.#logger.verbose(`watching ${roots.length} roots`);
|
|
265
273
|
}
|
|
266
274
|
|
|
@@ -276,6 +284,10 @@ class IncrementalBuilder {
|
|
|
276
284
|
if (autoImport.changedFiles.length > 0)
|
|
277
285
|
this.#logger.verbose(`[auto-import] inserted imports into ${autoImport.changedFiles.length} file(s)`);
|
|
278
286
|
const indexSync = await this.#generatedIndexSync.syncForBatch(batch.files);
|
|
287
|
+
//* Both passes above write source files, and this generation's build consumes what they wrote. Hand
|
|
288
|
+
//* them to the watcher so its verification scan does not read them back as a user edit and spend a
|
|
289
|
+
//* second generation rebuilding identical content.
|
|
290
|
+
await this.#watcher?.absorb([...autoImport.changedFiles, ...indexSync.changedFiles]);
|
|
279
291
|
const { files, kinds, expandedBatch, event, hasSyncErrors } = prepareDevWatchBatch({
|
|
280
292
|
generation,
|
|
281
293
|
batch,
|
|
@@ -460,8 +472,8 @@ class IncrementalBuilder {
|
|
|
460
472
|
* dev server only serves CSR through the opt-in `/__csr` and `?csr=true` routes — mobile local dev
|
|
461
473
|
* points a device WebView at the latter — so nothing needs the artifact until one of them is hit.
|
|
462
474
|
*/
|
|
463
|
-
async handleBuildCsr(msg: BuilderCsrReq): Promise<
|
|
464
|
-
|
|
475
|
+
async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
|
|
476
|
+
await this.#enqueueWork("build-csr", async (): Promise<void> => {
|
|
465
477
|
const started = Date.now();
|
|
466
478
|
// Messages are not relayed: an on-demand CSR build is a request/response, and the phase board
|
|
467
479
|
// never carried a csr status for it before. The error travels in the response below.
|
|
@@ -471,11 +483,12 @@ class IncrementalBuilder {
|
|
|
471
483
|
const error = result.errors.csr;
|
|
472
484
|
if (error) {
|
|
473
485
|
this.#logger.error(`csr-build failed: ${error}`);
|
|
474
|
-
|
|
486
|
+
await BuilderReply.send({ type: "build-csr-res", id: msg.id, ok: false, error });
|
|
487
|
+
return;
|
|
475
488
|
}
|
|
476
489
|
this.#csrActive = true;
|
|
477
490
|
this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
|
|
478
|
-
|
|
491
|
+
await BuilderReply.send({ type: "build-csr-res", id: msg.id, ok: true });
|
|
479
492
|
});
|
|
480
493
|
}
|
|
481
494
|
|
|
@@ -558,7 +571,7 @@ class IncrementalBuilder {
|
|
|
558
571
|
}
|
|
559
572
|
},
|
|
560
573
|
});
|
|
561
|
-
watcher.start();
|
|
574
|
+
await watcher.start();
|
|
562
575
|
logger.warn(`[degraded] watching ${roots.length} roots for a fix`);
|
|
563
576
|
})().catch(reject);
|
|
564
577
|
});
|
|
@@ -592,7 +605,7 @@ class IncrementalBuilder {
|
|
|
592
605
|
process.send?.({ type: "build-route-res", id: msg.id, ok: false, error });
|
|
593
606
|
return;
|
|
594
607
|
}
|
|
595
|
-
void builder.handleBuildRoute(msg)
|
|
608
|
+
void builder.handleBuildRoute(msg);
|
|
596
609
|
return;
|
|
597
610
|
}
|
|
598
611
|
if (msg.type === "build-csr") {
|
|
@@ -601,7 +614,7 @@ class IncrementalBuilder {
|
|
|
601
614
|
process.send?.({ type: "build-csr-res", id: msg.id, ok: false, error });
|
|
602
615
|
return;
|
|
603
616
|
}
|
|
604
|
-
void builder.handleBuildCsr(msg)
|
|
617
|
+
void builder.handleBuildCsr(msg);
|
|
605
618
|
}
|
|
606
619
|
});
|
|
607
620
|
// The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
|
|
@@ -621,7 +634,7 @@ class IncrementalBuilder {
|
|
|
621
634
|
}
|
|
622
635
|
await builder.boot();
|
|
623
636
|
if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
|
|
624
|
-
else if (process.env.
|
|
637
|
+
else if (process.env.AKAN_BUILDER_ANNOUNCE_BOOT === "1") await builder.announceBootState();
|
|
625
638
|
await builder.rearmCsrFromEnv();
|
|
626
639
|
}
|
|
627
640
|
}
|
|
@@ -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,22 +220,20 @@ describe("dev stability integration harness", () => {
|
|
|
148
220
|
hmr?.close();
|
|
149
221
|
return;
|
|
150
222
|
}
|
|
151
|
-
const mark =
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const plan = await host.waitForLogSince(
|
|
156
|
-
mark,
|
|
157
|
-
/\[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/ },
|
|
158
227
|
);
|
|
159
|
-
const generation =
|
|
228
|
+
const generation = evidence[1];
|
|
160
229
|
await host.waitForLogSince(mark, new RegExp(`\\[backend-reload\\].*generation=${generation}`));
|
|
161
|
-
// Asserted backend-side, not through the probe. A shared edit restarts the backend, which closes
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// (`akanjs/server/hmr/clientScript.ts`)
|
|
165
|
-
// rebuild happened to finish before the restart killed the
|
|
166
|
-
// moment builds moved into a worker process and took ~240ms
|
|
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.
|
|
167
237
|
await host.waitForLogSince(mark, new RegExp(`\\[SSR\\] pages-updated.*generation=${generation}`));
|
|
168
238
|
await harness.waitForHttpText("updated-shared-marker");
|
|
169
239
|
hmr?.close();
|
|
@@ -179,23 +249,27 @@ describe("dev stability integration harness", () => {
|
|
|
179
249
|
hmr?.close();
|
|
180
250
|
return;
|
|
181
251
|
}
|
|
182
|
-
const mark =
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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";
|
|
187
258
|
|
|
188
259
|
import type { FixtureEndpoint } from "./fixture.signal";
|
|
189
260
|
|
|
190
261
|
export const dictionary = serviceDictionary(["en", "ko"])
|
|
191
262
|
.endpoint<FixtureEndpoint>(() => ({}))
|
|
192
263
|
.translate({
|
|
193
|
-
hello: ["Updated Dictionary", "업데이트 사전"],
|
|
264
|
+
hello: ["Updated Dictionary ${attempt}", "업데이트 사전 ${attempt}"],
|
|
194
265
|
});
|
|
195
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 },
|
|
196
271
|
);
|
|
197
272
|
|
|
198
|
-
await host.waitForLogSince(mark, /\[dev-plan\].*actions=.*restart-builder/);
|
|
199
273
|
await host.waitForLogSince(mark, /\[dev-host\] recycling builder\/backend for runtime metadata/);
|
|
200
274
|
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
201
275
|
await harness.waitForHttpText("initial-shared-marker");
|
|
@@ -211,18 +285,23 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
211
285
|
expect(host.proc.killed).toBe(false);
|
|
212
286
|
return;
|
|
213
287
|
}
|
|
214
|
-
const mark =
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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}
|
|
220
296
|
const config: AppConfig = { externalLibs: [] };
|
|
221
297
|
export default config;
|
|
222
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 },
|
|
223
303
|
);
|
|
224
304
|
|
|
225
|
-
await host.waitForLogSince(mark, /\[dev-plan\].*actions=.*restart-dev-host/);
|
|
226
305
|
await host.waitForLogSince(mark, /\[dev-host\] config change detected; restarting dev host/);
|
|
227
306
|
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
228
307
|
await harness.waitForHttpText("initial-shared-marker");
|
|
@@ -239,35 +318,40 @@ export default config;
|
|
|
239
318
|
hmr?.close();
|
|
240
319
|
return;
|
|
241
320
|
}
|
|
242
|
-
const failureMark = host.markLog();
|
|
243
321
|
const failureHmrMark = hmr?.mark() ?? 0;
|
|
244
322
|
|
|
245
|
-
await harness.
|
|
246
|
-
|
|
247
|
-
|
|
323
|
+
const { mark: failureMark } = await harness.editUntilSeen(host, (attempt) =>
|
|
324
|
+
harness.writeFile(
|
|
325
|
+
"ui/ClientMarker.tsx",
|
|
326
|
+
`export function ClientMarker() {
|
|
327
|
+
// broken ${attempt}
|
|
248
328
|
return <p>broken</p>
|
|
249
329
|
`,
|
|
330
|
+
),
|
|
250
331
|
);
|
|
251
332
|
|
|
252
333
|
await host.waitForLogSince(
|
|
253
334
|
failureMark,
|
|
254
335
|
/\[build-status\].*phase=pages.*ok=false|\[build-status\].*phase=csr.*ok=false/,
|
|
255
336
|
);
|
|
256
|
-
if (hmr) await hmr
|
|
337
|
+
if (hmr) await expectHmrMessage(hmr, failureHmrMark, isBuildStatus("error"), "the build failure");
|
|
257
338
|
await harness.waitForHttpText("initial-client-marker");
|
|
258
|
-
const recoveryMark = host.markLog();
|
|
259
339
|
const recoveryHmrMark = hmr?.mark() ?? 0;
|
|
260
340
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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>;
|
|
265
348
|
}
|
|
266
349
|
`,
|
|
350
|
+
),
|
|
267
351
|
);
|
|
268
352
|
|
|
269
353
|
await host.waitForLogSince(recoveryMark, /\[build-status\].*ok=true/);
|
|
270
|
-
if (hmr) await hmr
|
|
354
|
+
if (hmr) await expectHmrMessage(hmr, recoveryHmrMark, isBuildStatus("ok"), "the build recovery");
|
|
271
355
|
await harness.waitForHttpText("recovered-client-marker");
|
|
272
356
|
hmr?.close();
|
|
273
357
|
});
|
|
@@ -314,18 +398,18 @@ export default config;
|
|
|
314
398
|
integrationTest("backend boot failure stops the crash loop, surfaces build-status, and recovers on fix", async () => {
|
|
315
399
|
const harness = await createHarness();
|
|
316
400
|
const host = await harness.startHost();
|
|
317
|
-
const failureMark = host.markLog();
|
|
318
|
-
|
|
319
401
|
// The service file is part of the generated server graph (`akan start` regenerates server.ts
|
|
320
402
|
// from lib/), so a module-level throw here breaks every replica boot.
|
|
321
|
-
await harness.
|
|
322
|
-
|
|
323
|
-
|
|
403
|
+
const { mark: failureMark } = await harness.editUntilSeen(host, (attempt) =>
|
|
404
|
+
harness.writeFile(
|
|
405
|
+
"lib/_fixture/fixture.service.ts",
|
|
406
|
+
`import { serve } from "akanjs/service";
|
|
324
407
|
|
|
325
408
|
export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
|
|
326
409
|
|
|
327
|
-
throw new Error("intentional-backend-boot-crash");
|
|
410
|
+
throw new Error("intentional-backend-boot-crash-${attempt}");
|
|
328
411
|
`,
|
|
412
|
+
),
|
|
329
413
|
);
|
|
330
414
|
|
|
331
415
|
// The gateway abandons the replica after three failed boots instead of retrying forever...
|
|
@@ -334,13 +418,15 @@ throw new Error("intentional-backend-boot-crash");
|
|
|
334
418
|
await host.waitForLogSince(failureMark, /\[build-status\].*phase=backend.*ok=false/);
|
|
335
419
|
expect(host.proc.killed).toBe(false);
|
|
336
420
|
|
|
337
|
-
const recoveryMark =
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
421
|
+
const { mark: recoveryMark } = await harness.editUntilSeen(host, (attempt) =>
|
|
422
|
+
harness.writeFile(
|
|
423
|
+
"lib/_fixture/fixture.service.ts",
|
|
424
|
+
`import { serve } from "akanjs/service";
|
|
341
425
|
|
|
426
|
+
// recovery ${attempt}
|
|
342
427
|
export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
|
|
343
428
|
`,
|
|
429
|
+
),
|
|
344
430
|
);
|
|
345
431
|
|
|
346
432
|
await host.waitForLogSince(recoveryMark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
@@ -416,6 +502,13 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
|
|
|
416
502
|
* silently exhausted the waits and looked like product failures. Both tests therefore assert several
|
|
417
503
|
* related properties against a single boot, with explicit generous waits. Run the block on its own
|
|
418
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.
|
|
419
512
|
*/
|
|
420
513
|
describe("dev resource budgets", () => {
|
|
421
514
|
const BOOT_MS = 150_000;
|
|
@@ -445,14 +538,16 @@ describe("dev resource budgets", () => {
|
|
|
445
538
|
|
|
446
539
|
// Armed: from here on every save rebuilds CSR, which is what keeps a live mobile session working.
|
|
447
540
|
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
+
);
|
|
456
551
|
await host.waitForLogSince(resyncMark, /csr-rebundle ok/, WAIT_MS);
|
|
457
552
|
});
|
|
458
553
|
|
|
@@ -477,21 +572,20 @@ describe("dev resource budgets", () => {
|
|
|
477
572
|
|
|
478
573
|
const start = host.markLog();
|
|
479
574
|
for (let i = 1; i <= 3; i++) {
|
|
480
|
-
const mark =
|
|
481
|
-
|
|
575
|
+
const { mark } = await harness.editUntilSeen(host, (attempt) =>
|
|
576
|
+
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-${i}-${attempt}`),
|
|
577
|
+
);
|
|
482
578
|
await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS);
|
|
483
579
|
// CSR was never requested in this fixture, so no save may pay for a CSR rebuild.
|
|
484
580
|
const afterSave = host.logs.join("").slice(mark);
|
|
485
581
|
expect(afterSave).toMatch(/csr-rebundle skipped/);
|
|
486
582
|
expect(afterSave).not.toMatch(/csr-rebundle ok/);
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
// the
|
|
490
|
-
//
|
|
491
|
-
// writes too. Wait for the backend to finish, then leave the drop window (measured under 200ms).
|
|
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.
|
|
492
587
|
await host.waitForLogSince(mark, /css-rebuild checked/, WAIT_MS);
|
|
493
588
|
await host.waitForLogSince(mark, /\[hmr\] backend apply/, WAIT_MS);
|
|
494
|
-
await Bun.sleep(300);
|
|
495
589
|
}
|
|
496
590
|
|
|
497
591
|
// Each in-place reload re-imports the pages bundle under a fresh `?v=`, and Bun's ESM registry
|
|
@@ -527,8 +621,9 @@ describe("dev resource budgets", () => {
|
|
|
527
621
|
// One save is enough: the builder reports its rss as soon as the batch drains, and the host arms
|
|
528
622
|
// the recycle from that report. The old pid comes from the log rather than from `ps`, so this does
|
|
529
623
|
// not race the swap it is about to observe.
|
|
530
|
-
const firstSave =
|
|
531
|
-
|
|
624
|
+
const { mark: firstSave } = await harness.editUntilSeen(host, (attempt) =>
|
|
625
|
+
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-1-${attempt}`),
|
|
626
|
+
);
|
|
532
627
|
await host.waitForLogSince(firstSave, /pages-rebundle ok/, WAIT_MS);
|
|
533
628
|
|
|
534
629
|
// The host decides, the builder drains rather than being killed, and the replacement comes up.
|
|
@@ -552,36 +647,88 @@ describe("dev resource budgets", () => {
|
|
|
552
647
|
//
|
|
553
648
|
// Waiting for readiness above is load-bearing, not padding. The watcher is installed at the end of
|
|
554
649
|
// the boot build, so a save during the recycle is seen by neither builder — this test lost one
|
|
555
|
-
// exactly that way.
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
for (let attempt = 1; attempt <= 4; attempt++) {
|
|
561
|
-
const mark = host.markLog();
|
|
562
|
-
await harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-after-recycle-${attempt}`);
|
|
563
|
-
const seen = await host
|
|
564
|
-
.waitForLogSince(mark, /pages-rebundle ok/, 15_000)
|
|
565
|
-
.then(() => true)
|
|
566
|
-
.catch(() => false);
|
|
567
|
-
attempts.push(`${attempt}=${seen ? "rebuilt" : "silent"}`);
|
|
568
|
-
if (seen) break;
|
|
569
|
-
await Bun.sleep(750);
|
|
570
|
-
}
|
|
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);
|
|
571
655
|
console.info(
|
|
572
|
-
`[recycle-guard] ${recycleLog[2]}; builder ${recycleLog[1]} -> ${recycled?.pid} at ${Math.round((recycled?.rssBytes ?? 0) / MB)}MiB; post-recycle
|
|
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)`,
|
|
573
657
|
);
|
|
574
|
-
expect(attempts.join(" ")).toMatch(/rebuilt/);
|
|
575
658
|
await harness.waitForHttpText("marker-after-recycle", WAIT_MS);
|
|
576
659
|
|
|
577
660
|
// A replacement that is still over the ceiling proves the ceiling cannot be met, and the host has
|
|
578
661
|
// to stop rather than recycle forever. Two reports inside the minimum interval is the threshold.
|
|
579
662
|
for (let i = 1; i <= 3; i++) {
|
|
580
|
-
await
|
|
581
|
-
|
|
582
|
-
|
|
663
|
+
const { mark } = await harness.editUntilSeen(host, (attempt) =>
|
|
664
|
+
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-settled-${i}-${attempt}`),
|
|
665
|
+
);
|
|
583
666
|
await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
|
|
584
667
|
}
|
|
585
668
|
await host.waitForLogSince(start, /ceiling cannot be met for this app/, WAIT_MS);
|
|
586
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();
|
|
733
|
+
});
|
|
587
734
|
});
|