@akanjs/devkit 3.0.0-beta.2 → 3.0.0-beta.20
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/README.md +1 -0
- package/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +6 -8
- package/agentsIndex.test.ts +4 -7
- package/agentsIndex.ts +6 -22
- package/akanApp/BackendImportGraph.test.ts +8 -22
- package/akanApp/BackendImportGraph.ts +1 -20
- package/akanApp/akanApp.host.test.ts +1 -3
- package/akanApp/akanApp.host.ts +82 -254
- package/akanApp/devHostPolicy.test.ts +10 -34
- package/akanApp/devHostPolicy.ts +12 -98
- package/akanConfig/akanConfig.test.ts +85 -69
- package/akanConfig/akanConfig.ts +69 -153
- package/akanConfig/types.ts +1 -0
- package/akanContext.ts +106 -159
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.test.ts +5 -3
- package/applicationBuildRunner.ts +54 -61
- package/applicationReleasePackager.ts +24 -44
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +9 -30
- package/artifact/implicitRootLayout.ts +34 -106
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/artifact/routeSeedIndex.ts +11 -44
- package/biome.base.json +6 -1
- package/biomeBase.ts +2 -5
- package/capacitorApp.test.ts +32 -16
- package/capacitorApp.ts +110 -238
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +77 -39
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +26 -5
- package/cloud/globalConfig.ts +70 -32
- package/codeAgent/agent/AkanCodeServices.ts +97 -0
- package/codeAgent/agent/AkanContextFiles.ts +94 -0
- package/codeAgent/agent/CodeAgent.ts +551 -0
- package/codeAgent/agent/CodeAgentAsks.ts +66 -0
- package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
- package/codeAgent/agent/CodeAgentGate.ts +93 -0
- package/codeAgent/agent/CodeAgentProxy.ts +82 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
- package/codeAgent/agent/CodeAgentUi.ts +47 -0
- package/codeAgent/agent/CodeMailbox.ts +148 -0
- package/codeAgent/agent/CodeSessionFork.ts +56 -0
- package/codeAgent/agent/CodeSessionIndex.ts +78 -0
- package/codeAgent/agent/SubagentPool.ts +214 -0
- package/codeAgent/agent/akanCodeModel.ts +47 -0
- package/codeAgent/agent/akanCodePaths.ts +31 -0
- package/codeAgent/agent/akanContextFiles.test.ts +114 -0
- package/codeAgent/agent/akanSystemPrompt.ts +46 -0
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +523 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/agent/codeMailbox.test.ts +76 -0
- package/codeAgent/agent/codeSessionFork.test.ts +58 -0
- package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
- package/codeAgent/agent/consoleToStderr.ts +8 -0
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +82 -0
- package/codeAgent/feedback/PreviewChrome.ts +82 -0
- package/codeAgent/feedback/PreviewFeedback.ts +46 -0
- package/codeAgent/feedback/PreviewView.ts +95 -0
- package/codeAgent/feedback/TurnFeedback.ts +98 -0
- package/codeAgent/feedback/VerifyFeedback.ts +40 -0
- package/codeAgent/feedback/previewChrome.test.ts +73 -0
- package/codeAgent/index.ts +34 -0
- package/codeAgent/skills/akan-code/SKILL.md +130 -0
- package/codeAgent/skills/akan-debug/SKILL.md +42 -0
- package/codeAgent/skills/akan-deps/SKILL.md +50 -0
- package/codeAgent/skills/akan-err/SKILL.md +39 -0
- package/codeAgent/skills/akan-module/SKILL.md +45 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
- package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
- package/codeAgent/skills/akan-service/SKILL.md +34 -0
- package/codeAgent/skills/akan-store/SKILL.md +67 -0
- package/codeAgent/skills/akan-ui/SKILL.md +63 -0
- package/codeAgent/skills/akan-validate/SKILL.md +44 -0
- package/codeAgent/tools/AkanCli.ts +50 -0
- package/codeAgent/tools/AkanCodePlugins.ts +136 -0
- package/codeAgent/tools/AkanEditScope.ts +48 -0
- package/codeAgent/tools/AkanEnvKeys.ts +50 -0
- package/codeAgent/tools/AkanToolPack.ts +92 -0
- package/codeAgent/tools/AkanVerifier.ts +48 -0
- package/codeAgent/tools/AskToolPack.ts +99 -0
- package/codeAgent/tools/MailToolPack.ts +76 -0
- package/codeAgent/tools/McpClient.ts +187 -0
- package/codeAgent/tools/McpOAuth.ts +233 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
- package/codeAgent/tools/McpServerConfig.ts +129 -0
- package/codeAgent/tools/McpSignIn.ts +158 -0
- package/codeAgent/tools/McpTokenStore.ts +81 -0
- package/codeAgent/tools/McpToolPack.ts +199 -0
- package/codeAgent/tools/SessionToolPack.ts +91 -0
- package/codeAgent/tools/WebToolPack.ts +147 -0
- package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
- package/codeAgent/tools/askToolPack.test.ts +116 -0
- package/codeAgent/tools/mcpOAuth.test.ts +612 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
- package/codegenLock.test.ts +4 -13
- package/codegenLock.ts +3 -17
- package/commandDecorators/argMeta.ts +1 -5
- package/commandDecorators/camelToKebabCase.ts +1 -0
- package/commandDecorators/command.ts +46 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +2 -10
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +68 -145
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +31 -80
- package/devkitUtils.test.ts +48 -20
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +132 -106
- package/executors.ts +194 -484
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +5 -3
- package/fileSys.ts +6 -8
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +27 -38
- package/frontendBuild/autoImportSync.ts +31 -65
- package/frontendBuild/buildRouteClient.test.ts +62 -37
- package/frontendBuild/bundleDefine.ts +12 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.ts +6 -41
- package/frontendBuild/csrArtifactBuilder.ts +6 -25
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +29 -78
- package/frontendBuild/cssImportResolver.ts +6 -19
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/fontOptimizer.test.ts +6 -14
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +46 -69
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +8 -32
- package/frontendBuild/hmrWatcher.ts +14 -71
- package/frontendBuild/pagesBundleBuilder.ts +4 -29
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/routeClientBuilder.ts +23 -46
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
- package/frontendBuild/styleContract.ts +1 -8
- package/frontendBuild/themeValidator.ts +4 -26
- package/frontendBuild/watchRootResolver.test.ts +23 -42
- package/frontendBuild/watchRootResolver.ts +5 -27
- package/getRelatedCnsts.ts +12 -92
- package/incrementalBuilder/buildBatch.proc.ts +22 -57
- package/incrementalBuilder/buildBatchProtocol.ts +8 -36
- package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
- package/incrementalBuilder/buildBatchRunner.ts +10 -31
- package/incrementalBuilder/builderChannel.test.ts +24 -49
- package/incrementalBuilder/builderChannel.ts +6 -37
- package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
- package/incrementalBuilder/builderRequestRouter.ts +5 -24
- package/incrementalBuilder/devWatchBatch.test.ts +21 -24
- package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
- package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +49 -161
- package/index.ts +5 -17
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +47 -194
- package/integration/devStabilityHarness.test.ts +13 -28
- package/integration/devStabilityHarness.ts +65 -330
- package/integration/ssrMemoryProbe.ts +41 -167
- package/libSource.test.ts +4 -15
- package/libSource.ts +19 -31
- package/lint/gritRules.test.ts +8 -24
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/linter.test.ts +1 -2
- package/linter.ts +26 -116
- package/mobile/mobileTarget.ts +14 -13
- package/package.json +10 -5
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +46 -0
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +56 -0
- package/platformTest/IdleWatchedProcess.ts +73 -0
- package/platformTest/LinuxDockerTarget.ts +105 -0
- package/platformTest/PlatformTestRun.test.ts +64 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +142 -0
- package/platformTest/TestSnapshot.test.ts +72 -0
- package/platformTest/TestSnapshot.ts +103 -0
- package/platformTest/WindowsSshTarget.ts +151 -0
- package/prompter.ts +3 -13
- package/qualityScanner.test.ts +9 -26
- package/qualityScanner.ts +197 -285
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +7 -42
- package/scanInfo.ts +87 -216
- package/selectModel.ts +1 -2
- package/semver.ts +3 -5
- package/slicePlanner.test.ts +6 -24
- package/slicePlanner.ts +11 -32
- package/spinner.test.ts +3 -8
- package/spinner.ts +4 -16
- package/sshTunnel.ts +1 -6
- package/ssrScanner.test.ts +1 -6
- package/ssrScanner.ts +103 -113
- package/stripAnsi.ts +3 -0
- package/subspace.test.ts +30 -25
- package/subspace.ts +81 -115
- package/subspaceConfig.ts +11 -9
- package/testHelpers.ts +194 -0
- package/transforms/asyncDefaultExportDetector.ts +3 -11
- package/transforms/barrelAnalyzer.ts +43 -135
- package/transforms/barrelImportsPlugin.ts +38 -175
- package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
- package/transforms/externalizeFrameworkPlugin.ts +13 -87
- package/transforms/moduleSyntax.ts +32 -0
- package/transforms/rscUseClientTransform.ts +35 -20
- package/transforms/transforms.test.ts +153 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +2 -22
- package/typeChecker.ts +7 -63
- package/types.ts +1 -0
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +101 -110
- package/workflow/index.ts +0 -1
- package/workflow/moduleIndex.test.ts +4 -15
- package/workflow/moduleIndex.ts +9 -49
- package/workflow/plan.ts +18 -25
- package/workflow/render.ts +80 -96
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +52 -69
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.ts +4 -19
- package/abstractCompactor.ts +0 -112
- package/aiEditor.test.ts +0 -60
- package/aiEditor.ts +0 -387
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/guideline.ts +0 -19
- package/local/optimize-resource/ipcprobe/child.ts +0 -1
- package/local/optimize-resource/ipcprobe/parent.ts +0 -11
- package/workflow/primitive.ts +0 -59
|
@@ -3,9 +3,7 @@ import { DevGeneratedIndexSync } from "../frontendBuild";
|
|
|
3
3
|
import { DevStabilityHarness, type DevStabilityHmrProbe } from "./devStabilityHarness";
|
|
4
4
|
|
|
5
5
|
const integrationEnabled = process.env.AKAN_DEV_STABILITY_INTEGRATION === "1";
|
|
6
|
-
//
|
|
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.
|
|
6
|
+
// Contention pushes a cold dev-server boot from ~3s to nearly a minute; 120s ran out mid-restart.
|
|
9
7
|
const INTEGRATION_TIMEOUT_MS = 180_000;
|
|
10
8
|
const MB = 1024 * 1024;
|
|
11
9
|
const harnesses: DevStabilityHarness[] = [];
|
|
@@ -38,15 +36,7 @@ const isBuildStatus =
|
|
|
38
36
|
"status" in msg &&
|
|
39
37
|
msg.status === status;
|
|
40
38
|
|
|
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
|
-
*/
|
|
39
|
+
// The hub does not replay (`akanjs/server/hmr/wsHub.ts`), so a miss across a probe reconnect proves nothing.
|
|
50
40
|
const expectHmrMessage = async (
|
|
51
41
|
probe: DevStabilityHmrProbe,
|
|
52
42
|
mark: number,
|
|
@@ -117,21 +107,12 @@ const waitForProcessesGone = async (pids: number[], timeoutMs = 15_000): Promise
|
|
|
117
107
|
return false;
|
|
118
108
|
};
|
|
119
109
|
|
|
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
|
-
*/
|
|
110
|
+
// `cleanup()` can overrun Bun's default 5s hook budget, and a hook timeout fails the already-passed test.
|
|
126
111
|
const HOOK_TIMEOUT_MS = 60_000;
|
|
127
112
|
|
|
128
113
|
beforeAll(async () => {
|
|
129
114
|
if (!integrationEnabled) return;
|
|
130
|
-
// A run
|
|
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.
|
|
115
|
+
// A killed run leaves its fixture and dev host behind, holding ports and shifting every app index.
|
|
135
116
|
const swept = await DevStabilityHarness.sweepAbandonedFixtures(DevStabilityHarness.defaultWorkspaceRoot);
|
|
136
117
|
if (swept.length) console.info(`[harness] swept ${swept.length} abandoned fixture(s): ${swept.join(", ")}`);
|
|
137
118
|
}, HOOK_TIMEOUT_MS);
|
|
@@ -143,14 +124,8 @@ afterEach(async () => {
|
|
|
143
124
|
afterAll(() => {
|
|
144
125
|
if (!integrationEnabled) return;
|
|
145
126
|
const { edits, retried } = DevStabilityHarness.editStats();
|
|
146
|
-
//
|
|
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.
|
|
127
|
+
// A retry is a save Bun's `fs.watch` dropped — a product bug users hit too, which this suite passes around.
|
|
150
128
|
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
129
|
const slowest = DevStabilityHarness.waitStats();
|
|
155
130
|
if (slowest.length)
|
|
156
131
|
console.info(`[harness] slowest waits: ${slowest.map((wait) => `${wait.ms}ms ${wait.label}`).join(" | ")}`);
|
|
@@ -227,13 +202,7 @@ describe("dev stability integration harness", () => {
|
|
|
227
202
|
);
|
|
228
203
|
const generation = evidence[1];
|
|
229
204
|
await host.waitForLogSince(mark, new RegExp(`\\[backend-reload\\].*generation=${generation}`));
|
|
230
|
-
//
|
|
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.
|
|
205
|
+
// Backend-side: the restart closes the probe's socket, and a reconnect does not replay what it missed.
|
|
237
206
|
await host.waitForLogSince(mark, new RegExp(`\\[SSR\\] pages-updated.*generation=${generation}`));
|
|
238
207
|
await harness.waitForHttpText("updated-shared-marker");
|
|
239
208
|
hmr?.close();
|
|
@@ -265,8 +234,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
265
234
|
});
|
|
266
235
|
`,
|
|
267
236
|
),
|
|
268
|
-
//
|
|
269
|
-
// than a retry.
|
|
237
|
+
// Each attempt recycles the builder and the backend, so patience is cheaper than a retry.
|
|
270
238
|
{ evidence: /\[dev-plan\].*actions=.*restart-builder/, attempts: 2, evidenceTimeoutMs: 30_000 },
|
|
271
239
|
);
|
|
272
240
|
|
|
@@ -297,8 +265,7 @@ const config: AppConfig = { externalLibs: [] };
|
|
|
297
265
|
export default config;
|
|
298
266
|
`,
|
|
299
267
|
),
|
|
300
|
-
//
|
|
301
|
-
// before concluding the event was dropped rather than merely slow.
|
|
268
|
+
// Every attempt restarts the whole dev host, so wait longer before calling the event dropped.
|
|
302
269
|
{ evidence: /\[dev-plan\].*actions=.*restart-dev-host/, attempts: 2, evidenceTimeoutMs: 30_000 },
|
|
303
270
|
);
|
|
304
271
|
|
|
@@ -338,8 +305,7 @@ export default config;
|
|
|
338
305
|
await harness.waitForHttpText("initial-client-marker");
|
|
339
306
|
const recoveryHmrMark = hmr?.mark() ?? 0;
|
|
340
307
|
|
|
341
|
-
//
|
|
342
|
-
// 3-way parallel run all three shards timed out here on 60s of completely empty log output.
|
|
308
|
+
// Lands right after the failed build's write burst, where Bun's watcher drops events.
|
|
343
309
|
const { mark: recoveryMark } = await harness.editUntilSeen(host, (attempt) =>
|
|
344
310
|
harness.writeFile(
|
|
345
311
|
"ui/ClientMarker.tsx",
|
|
@@ -359,8 +325,7 @@ export default config;
|
|
|
359
325
|
integrationTest("barrel add/delete includes generated indexes in watch generation", async () => {
|
|
360
326
|
const harness = await createHarness();
|
|
361
327
|
const sync = new DevGeneratedIndexSync({ workspaceRoot: harness.workspaceRoot });
|
|
362
|
-
// `common`
|
|
363
|
-
// fixture file must follow its own casing convention or the barrel deliberately skips it.
|
|
328
|
+
// Each facet's barrel skips a file that breaks its casing convention (`common` camelCase, `ui` PascalCase).
|
|
364
329
|
const facets = [
|
|
365
330
|
{ facet: "common", moduleName: "tmpExample", fileName: "tmpExample.ts", exportName: "commonTmpExample" },
|
|
366
331
|
{ facet: "ui", moduleName: "TmpExample", fileName: "TmpExample.tsx", exportName: "TmpExample" },
|
|
@@ -398,8 +363,7 @@ export default config;
|
|
|
398
363
|
integrationTest("backend boot failure stops the crash loop, surfaces build-status, and recovers on fix", async () => {
|
|
399
364
|
const harness = await createHarness();
|
|
400
365
|
const host = await harness.startHost();
|
|
401
|
-
//
|
|
402
|
-
// from lib/), so a module-level throw here breaks every replica boot.
|
|
366
|
+
// `akan start` regenerates server.ts from lib/, so a module-level throw here breaks every replica boot.
|
|
403
367
|
const { mark: failureMark } = await harness.editUntilSeen(host, (attempt) =>
|
|
404
368
|
harness.writeFile(
|
|
405
369
|
"lib/_fixture/fixture.service.ts",
|
|
@@ -412,9 +376,7 @@ throw new Error("intentional-backend-boot-crash-${attempt}");
|
|
|
412
376
|
),
|
|
413
377
|
);
|
|
414
378
|
|
|
415
|
-
// The gateway abandons the replica after three failed boots instead of retrying forever...
|
|
416
379
|
await host.waitForLogSince(failureMark, /\[child-crash-loop\].*failed 3 consecutive boots/);
|
|
417
|
-
// ...and the failure reaches the dev host's build-status pipeline (HMR overlay path).
|
|
418
380
|
await host.waitForLogSince(failureMark, /\[build-status\].*phase=backend.*ok=false/);
|
|
419
381
|
expect(host.proc.killed).toBe(false);
|
|
420
382
|
|
|
@@ -451,8 +413,7 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
|
|
|
451
413
|
|
|
452
414
|
process.kill(gatewayPid, "SIGKILL");
|
|
453
415
|
|
|
454
|
-
//
|
|
455
|
-
// otherwise keep holding their ws ports and break every subsequent boot).
|
|
416
|
+
// An orphaned child would keep holding its ws port and break every later boot.
|
|
456
417
|
expect(await waitForProcessesGone(childPids)).toBe(true);
|
|
457
418
|
|
|
458
419
|
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
@@ -467,8 +428,7 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
|
|
|
467
428
|
integrationTest("occupied preferred ws port falls back to an ephemeral port and stays bootable", async () => {
|
|
468
429
|
const harness = await createHarness();
|
|
469
430
|
const port = await harness.resolvePort();
|
|
470
|
-
//
|
|
471
|
-
// the way an orphaned replica from a killed run would.
|
|
431
|
+
// Child 0's deterministic ws port, held the way an orphaned replica from a killed run would hold it.
|
|
472
432
|
const blocker = Bun.serve({ port: port + 10_000, fetch: () => new Response("occupied") });
|
|
473
433
|
try {
|
|
474
434
|
const host = await harness.startHost();
|
|
@@ -491,34 +451,12 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
|
|
|
491
451
|
});
|
|
492
452
|
});
|
|
493
453
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
* every regression here multiplies by the number of tenants. Budgets are deliberately loose (they
|
|
497
|
-
* carry headroom over the measured values) — they exist to catch a *reintroduced* eager import or an
|
|
498
|
-
* unbounded per-save ratchet, not to pin exact numbers. Raising one should be a visible diff.
|
|
499
|
-
*
|
|
500
|
-
* Deliberately two tests, not one per property: each boots a full dev server, and four boots after
|
|
501
|
-
* the eleven tests above pushed cold boot from 3s to 21-55s through sheer machine contention, which
|
|
502
|
-
* silently exhausted the waits and looked like product failures. Both tests therefore assert several
|
|
503
|
-
* related properties against a single boot, with explicit generous waits. Run the block on its own
|
|
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.
|
|
512
|
-
*/
|
|
454
|
+
// Loose budgets that catch a reintroduced eager import or a per-save ratchet, not exact numbers. Absolute RSS is
|
|
455
|
+
// not parallel-safe: run this block alone on an idle machine (`-t "dev resource budgets"`), never loosen it.
|
|
513
456
|
describe("dev resource budgets", () => {
|
|
514
457
|
const BOOT_MS = 150_000;
|
|
515
458
|
const WAIT_MS = 90_000;
|
|
516
|
-
|
|
517
|
-
* Measured on this fixture at **260-516ms** per save. Set ~6× above that, like every budget here: it is
|
|
518
|
-
* looking for a path that got slower by a factor — a boot build that stopped being avoidable, a worker
|
|
519
|
-
* spawn that stopped being warm — not for jitter on a busy laptop. `apps/akan`, ~25× this fixture's
|
|
520
|
-
* pages bundle, took ~1.5s per save when the plan started.
|
|
521
|
-
*/
|
|
459
|
+
// ~6x the measured 260-516ms per save: catches a path that got slower by a factor, not jitter.
|
|
522
460
|
const SAVE_LATENCY_BUDGET_MS = 3_000;
|
|
523
461
|
const budgetTest = (name: string, fn: () => Promise<void>): void => {
|
|
524
462
|
if (integrationEnabled) test(name, fn, 300_000);
|
|
@@ -533,9 +471,7 @@ describe("dev resource budgets", () => {
|
|
|
533
471
|
// A full minified browser-target build of every page, only reachable via `/__csr` and `?csr=true`.
|
|
534
472
|
expect(host.logs.join("")).not.toMatch(/\[csr-build\] output ->/);
|
|
535
473
|
|
|
536
|
-
//
|
|
537
|
-
// for the app to actually serve first: `backend ready` fires before the gateway routes to the
|
|
538
|
-
// replica, and a too-early request 503s without reaching the router that arms CSR.
|
|
474
|
+
// `backend ready` fires before the gateway routes to the replica; an earlier request 503s without arming CSR.
|
|
539
475
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
540
476
|
const armMark = host.markLog();
|
|
541
477
|
const res = await fetch(`http://127.0.0.1:${port}/?csr=true`);
|
|
@@ -543,11 +479,7 @@ describe("dev resource budgets", () => {
|
|
|
543
479
|
expect(await res.text()).toContain("<html");
|
|
544
480
|
expect(host.logs.join("").slice(armMark)).toMatch(/csr-build ok on demand/);
|
|
545
481
|
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
// `editUntilSeen` rather than a bare save, because the CSR build above emits a whole minified tree
|
|
549
|
-
// into `.akan/artifact/csr` and a save landing in that window is dropped by Bun 100% of the time
|
|
550
|
-
// (`local/optimize-resource/06-watcher-dropped-event.md`).
|
|
482
|
+
// `editUntilSeen`: Bun always drops a save landing in the CSR build's write burst.
|
|
551
483
|
const { mark: resyncMark } = await harness.editUntilSeen(host, (attempt) =>
|
|
552
484
|
harness.replaceText(
|
|
553
485
|
"ui/ClientMarker.tsx",
|
|
@@ -561,8 +493,7 @@ describe("dev resource budgets", () => {
|
|
|
561
493
|
budgetTest("bounds the rsc worker and the tree across repeated saves", async () => {
|
|
562
494
|
const harness = await createHarness();
|
|
563
495
|
const host = await harness.startHost({
|
|
564
|
-
// Recycle on the second reload
|
|
565
|
-
// default-threshold run would take, and take the burst-coalescing floor out of the equation.
|
|
496
|
+
// Recycle on the second reload instead of the default tenth, with no burst-coalescing floor.
|
|
566
497
|
timeoutMs: BOOT_MS,
|
|
567
498
|
env: { AKAN_RSC_WORKER_MAX_RELOADS: "1", AKAN_RSC_WORKER_MIN_RECYCLE_INTERVAL_MS: "1" },
|
|
568
499
|
});
|
|
@@ -571,22 +502,13 @@ describe("dev resource budgets", () => {
|
|
|
571
502
|
const idleTotal = await DevStabilityHarness.processTreeRssBytes(host.proc.pid);
|
|
572
503
|
const idleWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, { excludeBuilder: true });
|
|
573
504
|
const idleBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
574
|
-
// Nothing should be building at idle, so the disposable worker must not be resident.
|
|
575
505
|
expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
|
|
576
|
-
// Printed so a run that came close to its budget says so, instead of being indistinguishable from a
|
|
577
|
-
// comfortable one — the same reason the recycle and idle-suspend guards report their numbers.
|
|
578
506
|
console.info(
|
|
579
507
|
`[budget-guard] idle tree ${Math.round(idleTotal / MB)}MB (builder ${Math.round((idleBuilder?.rssBytes ?? 0) / MB)}MB, rest ${Math.round(idleWithoutBuilder / MB)}MB)`,
|
|
580
508
|
);
|
|
581
|
-
// Split
|
|
582
|
-
// from a ~130MB fresh floor to ~520MB once it has built a route (`Bun.build` arenas the RSS-ceiling
|
|
583
|
-
// recycle is what bounds), so a tight total would flake. Measured 929-959MB total / ~414MB without
|
|
584
|
-
// the builder; the old 1000MB total came from a phase-1 topology where the fixture measured ~670MB
|
|
585
|
-
// and is only ~4% above what the current topology legitimately uses.
|
|
509
|
+
// Split: the builder alone swings ~130-520MB with `Bun.build` arenas, so a tight total would flake.
|
|
586
510
|
expect(idleTotal).toBeLessThan(1_200 * MB);
|
|
587
|
-
//
|
|
588
|
-
// the backend, not in the builder's arenas. The cheapest reintroduction is ~30MB and the devkit
|
|
589
|
-
// barrel cycle was 236MB, so this bound still fails on the latter.
|
|
511
|
+
// An eager import lands here, in the dev host or backend, not in the builder's arenas.
|
|
590
512
|
expect(idleWithoutBuilder).toBeLessThan(600 * MB);
|
|
591
513
|
|
|
592
514
|
const start = host.markLog();
|
|
@@ -599,53 +521,39 @@ describe("dev resource budgets", () => {
|
|
|
599
521
|
const afterSave = host.logs.join("").slice(mark);
|
|
600
522
|
expect(afterSave).toMatch(/csr-rebundle skipped/);
|
|
601
523
|
expect(afterSave).not.toMatch(/csr-rebundle ok/);
|
|
602
|
-
//
|
|
603
|
-
// applying the reload after that, and it writes into the watched tree too. `editUntilSeen` handles
|
|
604
|
-
// the drop window itself, but these two waits are still what makes each iteration a whole
|
|
605
|
-
// generation, which is what the RSS deltas below are measured across.
|
|
524
|
+
// The backend applies the reload after the builder finishes; wait for both so each save is a whole generation.
|
|
606
525
|
await host.waitForLogSince(mark, /css-rebuild checked/, WAIT_MS);
|
|
607
526
|
await host.waitForLogSince(mark, /\[hmr\] backend apply/, WAIT_MS);
|
|
608
527
|
}
|
|
609
528
|
|
|
610
|
-
// Each
|
|
611
|
-
// never evicts — so without a recycle the worker grows for the life of the process.
|
|
529
|
+
// Each reload re-imports the pages bundle under a fresh `?v=`, and Bun's ESM registry never evicts.
|
|
612
530
|
await host.waitForLogSince(start, /rolling recycle worker reason=pages-reload-accumulation/, WAIT_MS);
|
|
613
531
|
|
|
614
|
-
// The dev host, gateway, replica and rsc worker must all stay flat across saves.
|
|
615
532
|
const afterWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, {
|
|
616
533
|
excludeBuilder: true,
|
|
617
534
|
});
|
|
618
535
|
expect(afterWithoutBuilder - idleWithoutBuilder).toBeLessThan(120 * MB);
|
|
619
536
|
|
|
620
|
-
//
|
|
621
|
-
// native arenas no GC reclaims, which made it grow ~120MB per save on this fixture; every build
|
|
622
|
-
// that scales per save now runs in a process that exits, so its memory goes back to the OS.
|
|
537
|
+
// Every per-save build runs in a worker that exits, so the builder must stay flat too.
|
|
623
538
|
const afterBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
624
539
|
expect(afterBuilder?.pid).toBe(idleBuilder?.pid);
|
|
625
540
|
expect((afterBuilder?.rssBytes ?? 0) - (idleBuilder?.rssBytes ?? 0)).toBeLessThan(30 * MB);
|
|
626
|
-
// The worker is transient: three generations built, and none of them is still around.
|
|
627
541
|
expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
|
|
628
542
|
});
|
|
629
543
|
|
|
630
544
|
budgetTest("recycles the builder at an unmeetable ceiling and keeps developing through it", async () => {
|
|
631
545
|
const harness = await createHarness();
|
|
632
|
-
// Deliberately
|
|
633
|
-
// disposable worker means the builder no longer grows into a ceiling, so a ceiling it is already
|
|
634
|
-
// over is the only way left to drive the recycle path end to end — and it is also the case the
|
|
635
|
-
// escape hatch exists for: an app whose boot floor simply does not fit under the limit.
|
|
546
|
+
// Deliberately below the post-boot builder: it no longer grows into a ceiling, so it must start over one.
|
|
636
547
|
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
|
|
637
548
|
const start = host.markLog();
|
|
638
549
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
639
550
|
|
|
640
|
-
// One save
|
|
641
|
-
// the recycle from that report. The old pid comes from the log rather than from `ps`, so this does
|
|
642
|
-
// not race the swap it is about to observe.
|
|
551
|
+
// One save arms the recycle; the old pid comes from the log, not `ps`, so it cannot race the swap.
|
|
643
552
|
const { mark: firstSave } = await harness.editUntilSeen(host, (attempt) =>
|
|
644
553
|
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-1-${attempt}`),
|
|
645
554
|
);
|
|
646
555
|
await host.waitForLogSince(firstSave, /pages-rebundle ok/, WAIT_MS);
|
|
647
556
|
|
|
648
|
-
// The host decides, the builder drains rather than being killed, and the replacement comes up.
|
|
649
557
|
const recycleLog = await host.waitForLogSince(
|
|
650
558
|
start,
|
|
651
559
|
/recycling builder pid=(\d+) \((rss=\d+MiB>=200MiB after \d+ build\(s\))\)/,
|
|
@@ -654,19 +562,14 @@ describe("dev resource budgets", () => {
|
|
|
654
562
|
await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
|
|
655
563
|
await host.waitForLogSince(start, /builder spawned pid=\d+ .*restart=1/, WAIT_MS);
|
|
656
564
|
await host.waitForLogSince(start, /builder ready after restart/, WAIT_MS);
|
|
657
|
-
// The backend read `base-artifact.json` once at boot, so the replacement
|
|
658
|
-
// it booted with or the backend keeps serving the artifact of the builder that just exited.
|
|
565
|
+
// The backend read `base-artifact.json` once at boot, so the replacement must re-announce its boot state.
|
|
659
566
|
await host.waitForLogSince(start, /announced boot state after recycle/, WAIT_MS);
|
|
660
567
|
|
|
661
568
|
const recycled = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
662
569
|
expect(recycled).not.toBeNull();
|
|
663
570
|
expect(String(recycled?.pid)).not.toBe(recycleLog[1]);
|
|
664
571
|
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
// Waiting for readiness above is load-bearing, not padding. The watcher is installed at the end of
|
|
668
|
-
// the boot build, so a save during the recycle is seen by neither builder — this test lost one
|
|
669
|
-
// exactly that way.
|
|
572
|
+
// The readiness waits above are load-bearing: a save during the recycle is seen by neither builder.
|
|
670
573
|
const postRecycle = await harness.editUntilSeen(host, (attempt) =>
|
|
671
574
|
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-after-recycle-${attempt}`),
|
|
672
575
|
);
|
|
@@ -676,9 +579,7 @@ describe("dev resource budgets", () => {
|
|
|
676
579
|
);
|
|
677
580
|
await harness.waitForHttpText("marker-after-recycle", WAIT_MS);
|
|
678
581
|
|
|
679
|
-
//
|
|
680
|
-
// is the shape of every app the ceiling is derived for — a 1.2GB sandbox gives the builder ~420MB
|
|
681
|
-
// and a single route build costs ~247MB. So the host says the ceiling is tight...
|
|
582
|
+
// A tight ceiling is reported, and still enforced (recycles are throttled), never disabled for the session.
|
|
682
583
|
for (let i = 1; i <= 3; i++) {
|
|
683
584
|
const { mark } = await harness.editUntilSeen(host, (attempt) =>
|
|
684
585
|
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-settled-${i}-${attempt}`),
|
|
@@ -686,80 +587,53 @@ describe("dev resource budgets", () => {
|
|
|
686
587
|
await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
|
|
687
588
|
}
|
|
688
589
|
await host.waitForLogSince(start, /ceiling costs about one boot build per interval/, WAIT_MS);
|
|
689
|
-
// ...and goes on enforcing it. This used to disable the ceiling for the rest of the session on the
|
|
690
|
-
// same evidence, which on a small sandbox means nothing bounds the builder from the first page load
|
|
691
|
-
// onwards. Recycling is throttled to one per interval; that throttle is the answer to the cost, not
|
|
692
|
-
// dropping the bound.
|
|
693
590
|
expect(host.logs.join("").slice(start)).not.toMatch(/no longer enforcing it this session/);
|
|
694
591
|
});
|
|
695
592
|
|
|
696
|
-
// The guard the memory work did not have. Every budget above asserts RSS; none of them asserts that a
|
|
697
|
-
// page still renders while the machinery those budgets require is mid-swap — and that gap is where two
|
|
698
|
-
// shipped defects lived (`local/optimize-resource/19-shutdown-and-restart-races.md`).
|
|
699
593
|
budgetTest("serves a page requested while the builder is being replaced", async () => {
|
|
700
594
|
const harness = await createHarness();
|
|
701
|
-
// Same unmeetable ceiling as the guard above, for the same reason: a builder that no longer grows
|
|
702
|
-
// into a ceiling has to start under one for the recycle path to run end to end.
|
|
703
595
|
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
|
|
704
596
|
const port = await harness.resolvePort();
|
|
705
597
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
706
598
|
|
|
707
599
|
const start = host.markLog();
|
|
708
|
-
// One save
|
|
709
|
-
// has to ask the builder again rather than being served from cache, and it makes the builder report
|
|
710
|
-
// an rss over the ceiling, which is what arms the recycle.
|
|
600
|
+
// One save both evicts the route's cached client entries and arms the recycle.
|
|
711
601
|
const { mark } = await harness.editUntilSeen(host, (attempt) =>
|
|
712
602
|
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-through-recycle-${attempt}`),
|
|
713
603
|
);
|
|
714
604
|
await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS);
|
|
715
605
|
|
|
716
|
-
//
|
|
717
|
-
// has to be held for the replacement exactly like one that arrives after the process is gone. Timing
|
|
718
|
-
// decides which of the two windows it actually lands in — the drain of an idle builder is short, and
|
|
719
|
-
// the log is polled — so what is asserted is the outcome both windows must produce. The drain itself
|
|
720
|
-
// is pinned deterministically one layer down, in `incrementalBuilder.host.test.ts`.
|
|
606
|
+
// Timing decides whether this lands in the drain or after the exit, so assert what both must produce.
|
|
721
607
|
await host.waitForLogSince(start, /recycling builder pid=\d+/, WAIT_MS);
|
|
722
|
-
// Not awaited
|
|
723
|
-
// put every assertion below on the far side of the window they are about.
|
|
608
|
+
// Not awaited: a held request returns only once the replacement is up.
|
|
724
609
|
const drainRequest = fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(WAIT_MS) }).then(
|
|
725
610
|
async (response) => ({ status: response.status, html: await response.text() }),
|
|
726
611
|
);
|
|
727
612
|
|
|
728
|
-
//
|
|
729
|
-
// anything for seconds — which is exactly the window a developer's reload lands in, and the point of
|
|
730
|
-
// anchoring on the spawn rather than on the exit: the gap after it is wide and known, not a race.
|
|
613
|
+
// Anchored on the spawn: the replacement's boot build leaves a wide, known window rather than a race.
|
|
731
614
|
await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
|
|
732
615
|
await host.waitForLogSince(start, /builder spawned pid=\d+ .*restart=1/, WAIT_MS);
|
|
733
616
|
|
|
734
617
|
const holdMark = host.markLog();
|
|
735
618
|
const startedAtMono = performance.now();
|
|
736
|
-
//
|
|
737
|
-
// request above just populated — it has to reach a builder that is not there yet.
|
|
619
|
+
// Never requested before, so no route cache can answer it; it has to reach a builder that is not there yet.
|
|
738
620
|
const res = await fetch(`http://127.0.0.1:${port}/second`, { signal: AbortSignal.timeout(WAIT_MS) });
|
|
739
621
|
const html = await res.text();
|
|
740
622
|
const heldMs = Math.round(performance.now() - startedAtMono);
|
|
741
623
|
|
|
742
|
-
//
|
|
743
|
-
// same request is answered **500** by the dev error page, and nothing retries on its own — the tab
|
|
744
|
-
// stays broken until it is reloaded by hand. Measured both ways before this guard was committed.
|
|
624
|
+
// Without the hold this is a 500 from the dev error page, which never retries on its own.
|
|
745
625
|
expect(res.status).toBe(200);
|
|
746
626
|
expect(html).toContain("marker-through-recycle");
|
|
747
627
|
expect(html).not.toContain("reload after the builder is ready");
|
|
748
|
-
//
|
|
749
|
-
// replacement is already ready never reaches the path under test, and everything above holds anyway.
|
|
628
|
+
// Proves the request reached the hold, rather than arriving after the replacement was already ready.
|
|
750
629
|
expect(host.logs.join("").slice(holdMark)).toMatch(/holding build-route until the builder is ready/);
|
|
751
630
|
|
|
752
|
-
// The one fired at the drain, whichever side of the exit it landed on.
|
|
753
631
|
const drained = await drainRequest;
|
|
754
632
|
expect(drained.status).toBe(200);
|
|
755
633
|
expect(drained.html).toContain("marker-through-recycle");
|
|
756
634
|
console.info(`[restart-guard] page held ${heldMs}ms across the recycle, then rendered`);
|
|
757
635
|
});
|
|
758
636
|
|
|
759
|
-
// The other half of a resource budget, and the half this plan spent: every megabyte above was bought
|
|
760
|
-
// with latency — a worker process spawned per save, a boot build per recycle and per wake, a hold
|
|
761
|
-
// window in front of requests that land in one. Nothing measured what that cost, so a change that
|
|
762
|
-
// traded another second per save for another 50MB would have passed every guard in this block.
|
|
763
637
|
budgetTest("keeps a save's round trip inside its budget", async () => {
|
|
764
638
|
const harness = await createHarness();
|
|
765
639
|
const host = await harness.startHost({ timeoutMs: BOOT_MS });
|
|
@@ -768,45 +642,37 @@ describe("dev resource budgets", () => {
|
|
|
768
642
|
const samples: number[] = [];
|
|
769
643
|
for (let i = 1; i <= 3; i++) {
|
|
770
644
|
let savedAtMono = 0;
|
|
771
|
-
// Timed
|
|
772
|
-
// `editUntilSeen` exists — is measured from the attempt that actually landed, not from the first.
|
|
645
|
+
// Timed inside the mutate callback, so a retried save is measured from the attempt that landed.
|
|
773
646
|
const { mark } = await harness.editUntilSeen(host, async (attempt) => {
|
|
774
647
|
await harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-latency-${i}-${attempt}`);
|
|
775
648
|
savedAtMono = performance.now();
|
|
776
649
|
});
|
|
777
|
-
//
|
|
778
|
-
// waiting for. Deliberately not the browser's refresh message: that one is only published when a
|
|
779
|
-
// client is connected, so measuring it would measure a WebSocket probe's reconnects as well.
|
|
650
|
+
// Not the refresh message: that is published only while a client is connected.
|
|
780
651
|
await host.waitForLogSince(mark, /\[hmr\] backend apply/, WAIT_MS);
|
|
781
652
|
samples.push(performance.now() - savedAtMono);
|
|
782
653
|
}
|
|
783
654
|
|
|
784
655
|
const rounded = samples.map((ms) => Math.round(ms));
|
|
785
656
|
console.info(`[latency-guard] save -> new code live ${rounded.join("ms, ")}ms`);
|
|
786
|
-
//
|
|
787
|
-
// hide a first save that pays for something the rest do not.
|
|
657
|
+
// The worst save, not a median, which would hide a first save that pays for something the rest do not.
|
|
788
658
|
expect(Math.max(...rounded)).toBeLessThan(SAVE_LATENCY_BUDGET_MS);
|
|
789
659
|
});
|
|
790
660
|
|
|
791
661
|
budgetTest("suspends the builder when the dev server goes idle and wakes it on the next edit", async () => {
|
|
792
662
|
const harness = await createHarness();
|
|
793
|
-
// 3s stands in for the 5min default
|
|
794
|
-
// to actually reach idle inside a test.
|
|
663
|
+
// 3s stands in for the 5min default so the dev server reaches idle inside a test.
|
|
795
664
|
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_DEV_IDLE_SUSPEND_MS: "3000" } });
|
|
796
665
|
const start = host.markLog();
|
|
797
666
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
798
667
|
|
|
799
|
-
//
|
|
800
|
-
// the backend wrote a watched file, this would never fire.
|
|
668
|
+
// Never fires if anything periodic (timed metrics, a watched-file write) keeps the dev server busy.
|
|
801
669
|
await host.waitForLogSince(start, /\[idle-suspend\] no build activity for \d+s; released the builder/, WAIT_MS);
|
|
802
670
|
const suspendedBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
803
671
|
expect(suspendedBuilder).toBeNull();
|
|
804
672
|
// Only build capacity suspends — the backend keeps serving the preview URL.
|
|
805
673
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
806
674
|
|
|
807
|
-
//
|
|
808
|
-
// suspend itself churned, so this is squarely inside Bun's drop window — it is what failed here in a
|
|
809
|
-
// 3-way parallel run, on 60s of empty log output.
|
|
675
|
+
// A fresh watcher over a tree the suspend just churned: squarely inside Bun's drop window.
|
|
810
676
|
const { mark } = await harness.editUntilSeen(
|
|
811
677
|
host,
|
|
812
678
|
(attempt) => harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-after-wake-${attempt}`),
|
|
@@ -821,7 +687,6 @@ describe("dev resource budgets", () => {
|
|
|
821
687
|
await harness.waitForHttpText("marker-after-wake", WAIT_MS);
|
|
822
688
|
console.info(`[idle-suspend-guard] woke in ${awake[1]}ms; builder back at pid=${wokenBuilder?.pid}`);
|
|
823
689
|
|
|
824
|
-
// And it is a normal dev server again afterwards.
|
|
825
690
|
const postWake = await harness.editUntilSeen(host, (attempt) =>
|
|
826
691
|
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-postwake-${attempt}`),
|
|
827
692
|
);
|
|
@@ -838,8 +703,7 @@ describe("dev resource budgets", () => {
|
|
|
838
703
|
await host.waitForLogSince(start, /\[idle-suspend\] .*released the builder/, WAIT_MS);
|
|
839
704
|
expect(await DevStabilityHarness.builderProcess(host.proc.pid)).toBeNull();
|
|
840
705
|
|
|
841
|
-
// A
|
|
842
|
-
// the request must not be answered with "builder is stopped" the way a dead builder's would be.
|
|
706
|
+
// A request only the builder can serve also wakes it, and must not be answered "builder is stopped".
|
|
843
707
|
const mark = host.markLog();
|
|
844
708
|
const port = await harness.resolvePort();
|
|
845
709
|
const status = await fetch(`http://127.0.0.1:${port}/__csr`)
|
|
@@ -847,19 +711,13 @@ describe("dev resource budgets", () => {
|
|
|
847
711
|
.catch(() => 0);
|
|
848
712
|
|
|
849
713
|
await host.waitForLogSince(mark, /\[idle-suspend\] waking \(build-csr arrived while suspended\)/, WAIT_MS);
|
|
850
|
-
// `[builder]
|
|
851
|
-
// so the replay says what it did rather than which of the two reasons put the request there. The wake
|
|
852
|
-
// line above is what distinguishes them, and it is asserted first for that reason.
|
|
714
|
+
// `[builder]`: the same queue holds requests across a restart too; the wake line above tells them apart.
|
|
853
715
|
await host.waitForLogSince(mark, /\[builder\] replaying 1 request\(s\) held while the builder was away/, WAIT_MS);
|
|
854
716
|
expect(status).toBe(200);
|
|
855
717
|
expect(await DevStabilityHarness.builderProcess(host.proc.pid)).not.toBeNull();
|
|
856
718
|
});
|
|
857
719
|
|
|
858
|
-
//
|
|
859
|
-
// nothing was *watching* across it. The suspend stops its watcher before the replacement builder has
|
|
860
|
-
// primed its index, and the replacement primes from the disk it finds — so a save in between is
|
|
861
|
-
// baseline to it, reported by nobody, and the backend goes on running the code it replaced. Phase 2
|
|
862
|
-
// made this window routine by recycling the builder on every rss ceiling crossing.
|
|
720
|
+
// Nothing watches between the suspend stopping its watcher and the replacement priming its index from disk.
|
|
863
721
|
budgetTest("restarts the backend for a save that lands while the builder is away", async () => {
|
|
864
722
|
const harness = await createHarness();
|
|
865
723
|
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_DEV_IDLE_SUSPEND_MS: "3000" } });
|
|
@@ -869,13 +727,9 @@ describe("dev resource budgets", () => {
|
|
|
869
727
|
|
|
870
728
|
const mark = host.markLog();
|
|
871
729
|
const port = await harness.resolvePort();
|
|
872
|
-
//
|
|
873
|
-
// builder is ready, so a write issued while it is in flight lands squarely inside the gap. Its own
|
|
874
|
-
// status is not asserted here — the write below restarts the backend, which drops the connection.
|
|
730
|
+
// The clock: in flight until the replacement is ready; its status is moot once the backend restarts.
|
|
875
731
|
const request = fetch(`http://127.0.0.1:${port}/__csr`).catch(() => null);
|
|
876
|
-
//
|
|
877
|
-
// orphaned once `akan start` regenerates `server.ts`, so it is not in the backend graph at all and an
|
|
878
|
-
// edit to it restarts the backend by path role instead.
|
|
732
|
+
// Not `srvkit/backendMarker.ts`: the regenerated `server.ts` orphans it, so it restarts by path role only.
|
|
879
733
|
await harness.writeFile(
|
|
880
734
|
"lib/_fixture/fixture.service.ts",
|
|
881
735
|
`import { serve } from "akanjs/service";
|
|
@@ -888,7 +742,6 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
|
|
|
888
742
|
|
|
889
743
|
await host.waitForLogSince(mark, /\[builder-gap\] 1 backend file\(s\) changed while the builder was away/, WAIT_MS);
|
|
890
744
|
await host.waitForLogSince(mark, /\[backend-reload\]/, WAIT_MS);
|
|
891
|
-
// And back to a working dev server, rather than one stuck restarting.
|
|
892
745
|
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/, WAIT_MS);
|
|
893
746
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
894
747
|
});
|