@akanjs/devkit 3.0.0-beta.9 → 3.0.0
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/DEV_RUNTIME_KNOBS.md +44 -26
- package/README.ko.md +21 -7
- package/README.md +17 -8
- 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 +56 -3
- package/akanApp/akanApp.host.ts +218 -278
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +81 -35
- package/akanApp/devHostPolicy.ts +41 -102
- package/akanConfig/akanBin.test.ts +62 -0
- package/akanConfig/akanBin.ts +78 -0
- package/akanConfig/akanConfig.test.ts +439 -141
- package/akanConfig/akanConfig.ts +359 -275
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +22 -8
- package/akanContext.ts +110 -160
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.test.ts +41 -5
- package/applicationBuildRunner.ts +71 -63
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +32 -30
- package/artifact/implicitRootLayout.ts +58 -106
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/artifact/routeSeedIndex.ts +11 -44
- package/biome.base.json +35 -2
- package/biomeBase.ts +2 -5
- 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 +71 -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/csrE2e/csrE2eHarness.fixture.ts +505 -0
- package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
- package/csrE2e/csrPageStack.e2e.test.ts +252 -0
- package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
- 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 +176 -106
- package/executors.ts +212 -498
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +5 -3
- package/fileSys.ts +22 -9
- 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 +123 -37
- package/frontendBuild/bundleDefine.test.ts +12 -0
- package/frontendBuild/bundleDefine.ts +16 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
- package/frontendBuild/clientEntryDiscovery.ts +122 -110
- package/frontendBuild/csrArtifactBuilder.ts +9 -31
- package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
- package/frontendBuild/csrDevArtifactWriter.ts +307 -0
- package/frontendBuild/csrDevBundler.test.ts +66 -0
- package/frontendBuild/csrDevBundler.ts +95 -0
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
- package/frontendBuild/csrDevModuleCompiler.ts +422 -0
- package/frontendBuild/csrDevPatcher.test.ts +572 -0
- package/frontendBuild/csrDevPatcher.ts +548 -0
- package/frontendBuild/csrDevPaths.ts +145 -0
- package/frontendBuild/csrDevResolver.test.ts +239 -0
- package/frontendBuild/csrDevResolver.ts +363 -0
- package/frontendBuild/csrDevTypes.ts +61 -0
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +31 -82
- 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/devRegistryBundler.ts +192 -0
- 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 +57 -70
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +59 -32
- package/frontendBuild/hmrWatcher.ts +31 -74
- package/frontendBuild/index.ts +4 -0
- package/frontendBuild/pagesBundleBuilder.ts +28 -30
- package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
- package/frontendBuild/registryMetadataFingerprint.ts +28 -0
- package/frontendBuild/routeClientBuilder.ts +100 -62
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/serverGraphFile.test.ts +77 -0
- package/frontendBuild/serverGraphFile.ts +93 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
- package/frontendBuild/ssrDevBundler.ts +85 -0
- 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 +147 -63
- package/incrementalBuilder/buildBatchProtocol.ts +13 -38
- package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
- package/incrementalBuilder/buildBatchRunner.ts +26 -38
- 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/builderWorkQueue.test.ts +94 -0
- package/incrementalBuilder/builderWorkQueue.ts +126 -0
- package/incrementalBuilder/devWatchBatch.test.ts +21 -24
- package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
- package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
- package/index.ts +5 -19
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +105 -199
- 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/__fixtures__/no-document-cookie/bad.tsx +4 -0
- package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
- package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
- package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
- package/lint/gritRules.test.ts +8 -24
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-document-cookie.grit +29 -0
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/lint/no-web-only-api-outside-webkit.grit +44 -0
- package/lint/no-web-storage.grit +30 -0
- package/linter.test.ts +1 -2
- package/linter.ts +26 -116
- package/mobile/desktopBin.test.ts +182 -0
- package/mobile/desktopBin.ts +144 -0
- package/mobile/desktopServerStage.test.ts +252 -0
- package/mobile/desktopServerStage.ts +178 -0
- package/mobile/index.ts +9 -0
- package/mobile/mobileTarget.test.ts +57 -8
- package/mobile/mobileTarget.ts +45 -26
- package/mobile/nativeApi.test.ts +30 -0
- package/mobile/nativeApi.ts +40 -0
- package/mobile/nativeApp.test.ts +184 -0
- package/mobile/nativeApp.ts +454 -0
- package/mobile/nativeAppLine.test.ts +69 -0
- package/mobile/nativeAppLine.ts +71 -0
- package/mobile/nativeConfig.test.ts +438 -0
- package/mobile/nativeConfig.ts +294 -0
- package/mobile/nativePluginFolders.test.ts +81 -0
- package/mobile/nativePluginFolders.ts +60 -0
- package/mobile/nativeWebDir.test.ts +80 -0
- package/mobile/nativeWebDir.ts +66 -0
- package/mobile/placeholderAppId.test.ts +13 -0
- package/mobile/placeholderAppId.ts +16 -0
- package/mobile/usageDescriptions.test.ts +27 -0
- package/mobile/usageDescriptions.ts +24 -0
- package/package.json +11 -11
- 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 +200 -286
- 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 +91 -225
- 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 +9 -25
- package/subspace.ts +48 -115
- package/subspaceConfig.ts +3 -11
- 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 +42 -20
- package/transforms/serverEnvPlugin.test.ts +103 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/transforms/transforms.test.ts +166 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +6 -23
- package/typeChecker.ts +7 -63
- package/types.ts +2 -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.test.ts +18 -3
- package/workspaceLayout.ts +22 -23
- package/abstractCompactor.ts +0 -112
- package/aiEditor.test.ts +0 -60
- package/aiEditor.ts +0 -387
- package/applicationReleasePackager.ts +0 -209
- package/capacitorApp.test.ts +0 -372
- package/capacitorApp.ts +0 -1648
- 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[] = [];
|
|
@@ -26,7 +24,7 @@ const isRefreshMessage = (msg: unknown): boolean =>
|
|
|
26
24
|
typeof msg === "object" &&
|
|
27
25
|
msg !== null &&
|
|
28
26
|
"type" in msg &&
|
|
29
|
-
(msg.type === "
|
|
27
|
+
(msg.type === "ssr-update" || msg.type === "rsc-refresh" || msg.type === "reload");
|
|
30
28
|
|
|
31
29
|
const isBuildStatus =
|
|
32
30
|
(status: "error" | "ok") =>
|
|
@@ -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(" | ")}`);
|
|
@@ -173,11 +148,46 @@ describe("dev stability integration harness", () => {
|
|
|
173
148
|
await host.waitForLogSince(mark, /\[backend-reload\]|Shutting down gracefully|stopping backend/);
|
|
174
149
|
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
175
150
|
expect(host.proc.killed).toBe(false);
|
|
176
|
-
expect(host.logs.join("").slice(mark)).not.toMatch(/\[hmr\].*
|
|
151
|
+
expect(host.logs.join("").slice(mark)).not.toMatch(/\[hmr\].*rsc-refresh|\[ssr\] registry generation/);
|
|
177
152
|
await hmr?.waitForNoMessageSince(hmrMark, isRefreshMessage);
|
|
178
153
|
hmr?.close();
|
|
179
154
|
});
|
|
180
155
|
|
|
156
|
+
integrationTest(
|
|
157
|
+
"server edits landing while a restart stops the backend leave the port to the backend it tracks",
|
|
158
|
+
async () => {
|
|
159
|
+
const harness = await createHarness();
|
|
160
|
+
const host = await harness.startHost();
|
|
161
|
+
const port = await harness.resolvePort();
|
|
162
|
+
const writeMarker = (label: string) =>
|
|
163
|
+
harness.writeFile("srvkit/backendMarker.ts", `export const backendMarker = "${label}";\n`);
|
|
164
|
+
|
|
165
|
+
const { mark } = await harness.editUntilSeen(host, (attempt) => writeMarker(`burst-first-${attempt}`), {
|
|
166
|
+
evidence: /stopping backend pid=\d+/,
|
|
167
|
+
});
|
|
168
|
+
// Each lands past the 120ms debounce, so a restart timer fires while the one before still waits for its exit.
|
|
169
|
+
for (let idx = 0; idx < 8; idx++) {
|
|
170
|
+
await writeMarker(`burst-${idx}`);
|
|
171
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const trackedPid = () =>
|
|
175
|
+
[
|
|
176
|
+
...host.logs
|
|
177
|
+
.join("")
|
|
178
|
+
.slice(mark)
|
|
179
|
+
.matchAll(/backend spawned pid=(\d+)/g),
|
|
180
|
+
].at(-1)?.[1];
|
|
181
|
+
const settled = await waitForGatewayHealth(
|
|
182
|
+
port,
|
|
183
|
+
(health) => String(health.pid) === trackedPid() && health.children.some((child) => child.ready),
|
|
184
|
+
);
|
|
185
|
+
expect(String(settled.pid)).toBe(trackedPid() ?? "");
|
|
186
|
+
const since = host.logs.join("").slice(mark);
|
|
187
|
+
expect(since).not.toMatch(/already in use|not starting a second one/);
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
181
191
|
integrationTest("client-only valid edits refresh browser state without backend restart", async () => {
|
|
182
192
|
const harness = await createHarness();
|
|
183
193
|
const host = await harness.startHost();
|
|
@@ -204,7 +214,10 @@ describe("dev stability integration harness", () => {
|
|
|
204
214
|
if (hmr) {
|
|
205
215
|
await expectHmrMessage(hmr, hmrMark, isRefreshMessage, "a client refresh");
|
|
206
216
|
} else {
|
|
207
|
-
await host.waitForLogSince(
|
|
217
|
+
await host.waitForLogSince(
|
|
218
|
+
mark,
|
|
219
|
+
/\[hmr\].*(rsc-refresh|reload)|\[ssr\] registry generation|\[SSR\] pages-updated/,
|
|
220
|
+
);
|
|
208
221
|
}
|
|
209
222
|
expect(host.logs.join("").slice(mark)).not.toMatch(/\[backend-reload\]/);
|
|
210
223
|
hmr?.close();
|
|
@@ -227,13 +240,7 @@ describe("dev stability integration harness", () => {
|
|
|
227
240
|
);
|
|
228
241
|
const generation = evidence[1];
|
|
229
242
|
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.
|
|
243
|
+
// Backend-side: the restart closes the probe's socket, and a reconnect does not replay what it missed.
|
|
237
244
|
await host.waitForLogSince(mark, new RegExp(`\\[SSR\\] pages-updated.*generation=${generation}`));
|
|
238
245
|
await harness.waitForHttpText("updated-shared-marker");
|
|
239
246
|
hmr?.close();
|
|
@@ -265,8 +272,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
265
272
|
});
|
|
266
273
|
`,
|
|
267
274
|
),
|
|
268
|
-
//
|
|
269
|
-
// than a retry.
|
|
275
|
+
// Each attempt recycles the builder and the backend, so patience is cheaper than a retry.
|
|
270
276
|
{ evidence: /\[dev-plan\].*actions=.*restart-builder/, attempts: 2, evidenceTimeoutMs: 30_000 },
|
|
271
277
|
);
|
|
272
278
|
|
|
@@ -297,8 +303,7 @@ const config: AppConfig = { externalLibs: [] };
|
|
|
297
303
|
export default config;
|
|
298
304
|
`,
|
|
299
305
|
),
|
|
300
|
-
//
|
|
301
|
-
// before concluding the event was dropped rather than merely slow.
|
|
306
|
+
// Every attempt restarts the whole dev host, so wait longer before calling the event dropped.
|
|
302
307
|
{ evidence: /\[dev-plan\].*actions=.*restart-dev-host/, attempts: 2, evidenceTimeoutMs: 30_000 },
|
|
303
308
|
);
|
|
304
309
|
|
|
@@ -338,8 +343,7 @@ export default config;
|
|
|
338
343
|
await harness.waitForHttpText("initial-client-marker");
|
|
339
344
|
const recoveryHmrMark = hmr?.mark() ?? 0;
|
|
340
345
|
|
|
341
|
-
//
|
|
342
|
-
// 3-way parallel run all three shards timed out here on 60s of completely empty log output.
|
|
346
|
+
// Lands right after the failed build's write burst, where Bun's watcher drops events.
|
|
343
347
|
const { mark: recoveryMark } = await harness.editUntilSeen(host, (attempt) =>
|
|
344
348
|
harness.writeFile(
|
|
345
349
|
"ui/ClientMarker.tsx",
|
|
@@ -359,8 +363,7 @@ export default config;
|
|
|
359
363
|
integrationTest("barrel add/delete includes generated indexes in watch generation", async () => {
|
|
360
364
|
const harness = await createHarness();
|
|
361
365
|
const sync = new DevGeneratedIndexSync({ workspaceRoot: harness.workspaceRoot });
|
|
362
|
-
// `common`
|
|
363
|
-
// fixture file must follow its own casing convention or the barrel deliberately skips it.
|
|
366
|
+
// Each facet's barrel skips a file that breaks its casing convention (`common` camelCase, `ui` PascalCase).
|
|
364
367
|
const facets = [
|
|
365
368
|
{ facet: "common", moduleName: "tmpExample", fileName: "tmpExample.ts", exportName: "commonTmpExample" },
|
|
366
369
|
{ facet: "ui", moduleName: "TmpExample", fileName: "TmpExample.tsx", exportName: "TmpExample" },
|
|
@@ -398,8 +401,7 @@ export default config;
|
|
|
398
401
|
integrationTest("backend boot failure stops the crash loop, surfaces build-status, and recovers on fix", async () => {
|
|
399
402
|
const harness = await createHarness();
|
|
400
403
|
const host = await harness.startHost();
|
|
401
|
-
//
|
|
402
|
-
// from lib/), so a module-level throw here breaks every replica boot.
|
|
404
|
+
// `akan start` regenerates server.ts from lib/, so a module-level throw here breaks every replica boot.
|
|
403
405
|
const { mark: failureMark } = await harness.editUntilSeen(host, (attempt) =>
|
|
404
406
|
harness.writeFile(
|
|
405
407
|
"lib/_fixture/fixture.service.ts",
|
|
@@ -412,9 +414,7 @@ throw new Error("intentional-backend-boot-crash-${attempt}");
|
|
|
412
414
|
),
|
|
413
415
|
);
|
|
414
416
|
|
|
415
|
-
// The gateway abandons the replica after three failed boots instead of retrying forever...
|
|
416
417
|
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
418
|
await host.waitForLogSince(failureMark, /\[build-status\].*phase=backend.*ok=false/);
|
|
419
419
|
expect(host.proc.killed).toBe(false);
|
|
420
420
|
|
|
@@ -451,8 +451,7 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
|
|
|
451
451
|
|
|
452
452
|
process.kill(gatewayPid, "SIGKILL");
|
|
453
453
|
|
|
454
|
-
//
|
|
455
|
-
// otherwise keep holding their ws ports and break every subsequent boot).
|
|
454
|
+
// An orphaned child would keep holding its ws port and break every later boot.
|
|
456
455
|
expect(await waitForProcessesGone(childPids)).toBe(true);
|
|
457
456
|
|
|
458
457
|
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
@@ -467,8 +466,7 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
|
|
|
467
466
|
integrationTest("occupied preferred ws port falls back to an ephemeral port and stays bootable", async () => {
|
|
468
467
|
const harness = await createHarness();
|
|
469
468
|
const port = await harness.resolvePort();
|
|
470
|
-
//
|
|
471
|
-
// the way an orphaned replica from a killed run would.
|
|
469
|
+
// Child 0's deterministic ws port, held the way an orphaned replica from a killed run would hold it.
|
|
472
470
|
const blocker = Bun.serve({ port: port + 10_000, fetch: () => new Response("occupied") });
|
|
473
471
|
try {
|
|
474
472
|
const host = await harness.startHost();
|
|
@@ -491,34 +489,12 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
|
|
|
491
489
|
});
|
|
492
490
|
});
|
|
493
491
|
|
|
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
|
-
*/
|
|
492
|
+
// Loose budgets that catch a reintroduced eager import or a per-save ratchet, not exact numbers. Absolute RSS is
|
|
493
|
+
// not parallel-safe: run this block alone on an idle machine (`-t "dev resource budgets"`), never loosen it.
|
|
513
494
|
describe("dev resource budgets", () => {
|
|
514
495
|
const BOOT_MS = 150_000;
|
|
515
496
|
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
|
-
*/
|
|
497
|
+
// ~6x the measured 260-516ms per save: catches a path that got slower by a factor, not jitter.
|
|
522
498
|
const SAVE_LATENCY_BUDGET_MS = 3_000;
|
|
523
499
|
const budgetTest = (name: string, fn: () => Promise<void>): void => {
|
|
524
500
|
if (integrationEnabled) test(name, fn, 300_000);
|
|
@@ -533,9 +509,7 @@ describe("dev resource budgets", () => {
|
|
|
533
509
|
// A full minified browser-target build of every page, only reachable via `/__csr` and `?csr=true`.
|
|
534
510
|
expect(host.logs.join("")).not.toMatch(/\[csr-build\] output ->/);
|
|
535
511
|
|
|
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.
|
|
512
|
+
// `backend ready` fires before the gateway routes to the replica; an earlier request 503s without arming CSR.
|
|
539
513
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
540
514
|
const armMark = host.markLog();
|
|
541
515
|
const res = await fetch(`http://127.0.0.1:${port}/?csr=true`);
|
|
@@ -543,11 +517,7 @@ describe("dev resource budgets", () => {
|
|
|
543
517
|
expect(await res.text()).toContain("<html");
|
|
544
518
|
expect(host.logs.join("").slice(armMark)).toMatch(/csr-build ok on demand/);
|
|
545
519
|
|
|
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`).
|
|
520
|
+
// `editUntilSeen`: Bun always drops a save landing in the CSR build's write burst.
|
|
551
521
|
const { mark: resyncMark } = await harness.editUntilSeen(host, (attempt) =>
|
|
552
522
|
harness.replaceText(
|
|
553
523
|
"ui/ClientMarker.tsx",
|
|
@@ -555,38 +525,32 @@ describe("dev resource budgets", () => {
|
|
|
555
525
|
`csr-armed-marker-${attempt}`,
|
|
556
526
|
),
|
|
557
527
|
);
|
|
558
|
-
|
|
528
|
+
// A worker logs `csr-dev generation=N patch`; the resident builder's patcher logs `csr-patch generation=N patch`.
|
|
529
|
+
await host.waitForLogSince(resyncMark, /csr-rebundle ok|csr-(?:dev|patch) generation=\d+ patch/, WAIT_MS);
|
|
559
530
|
});
|
|
560
531
|
|
|
561
532
|
budgetTest("bounds the rsc worker and the tree across repeated saves", async () => {
|
|
562
533
|
const harness = await createHarness();
|
|
563
534
|
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.
|
|
535
|
+
// Recycle on the second reload instead of the default tenth, with no burst-coalescing floor.
|
|
566
536
|
timeoutMs: BOOT_MS,
|
|
567
537
|
env: { AKAN_RSC_WORKER_MAX_RELOADS: "1", AKAN_RSC_WORKER_MIN_RECYCLE_INTERVAL_MS: "1" },
|
|
568
538
|
});
|
|
569
539
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
540
|
+
// The first page no longer waits for the SSR registry's boot build; the host logs when its worker is done, whether or
|
|
541
|
+
// not a backend was up to take the build's announcement.
|
|
542
|
+
await host.waitForLogSince(0, /\[builder\] boot builds settled/, WAIT_MS);
|
|
570
543
|
|
|
571
544
|
const idleTotal = await DevStabilityHarness.processTreeRssBytes(host.proc.pid);
|
|
572
545
|
const idleWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, { excludeBuilder: true });
|
|
573
546
|
const idleBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
574
|
-
// Nothing should be building at idle, so the disposable worker must not be resident.
|
|
575
547
|
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
548
|
console.info(
|
|
579
549
|
`[budget-guard] idle tree ${Math.round(idleTotal / MB)}MB (builder ${Math.round((idleBuilder?.rssBytes ?? 0) / MB)}MB, rest ${Math.round(idleWithoutBuilder / MB)}MB)`,
|
|
580
550
|
);
|
|
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.
|
|
551
|
+
// Split: the builder alone swings ~130-520MB with `Bun.build` arenas, so a tight total would flake.
|
|
586
552
|
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.
|
|
553
|
+
// An eager import lands here, in the dev host or backend, not in the builder's arenas.
|
|
590
554
|
expect(idleWithoutBuilder).toBeLessThan(600 * MB);
|
|
591
555
|
|
|
592
556
|
const start = host.markLog();
|
|
@@ -599,53 +563,39 @@ describe("dev resource budgets", () => {
|
|
|
599
563
|
const afterSave = host.logs.join("").slice(mark);
|
|
600
564
|
expect(afterSave).toMatch(/csr-rebundle skipped/);
|
|
601
565
|
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.
|
|
566
|
+
// The backend applies the reload after the builder finishes; wait for both so each save is a whole generation.
|
|
606
567
|
await host.waitForLogSince(mark, /css-rebuild checked/, WAIT_MS);
|
|
607
568
|
await host.waitForLogSince(mark, /\[hmr\] backend apply/, WAIT_MS);
|
|
608
569
|
}
|
|
609
570
|
|
|
610
|
-
// Each
|
|
611
|
-
// never evicts — so without a recycle the worker grows for the life of the process.
|
|
571
|
+
// Each reload re-imports the pages bundle under a fresh `?v=`, and Bun's ESM registry never evicts.
|
|
612
572
|
await host.waitForLogSince(start, /rolling recycle worker reason=pages-reload-accumulation/, WAIT_MS);
|
|
613
573
|
|
|
614
|
-
// The dev host, gateway, replica and rsc worker must all stay flat across saves.
|
|
615
574
|
const afterWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, {
|
|
616
575
|
excludeBuilder: true,
|
|
617
576
|
});
|
|
618
577
|
expect(afterWithoutBuilder - idleWithoutBuilder).toBeLessThan(120 * MB);
|
|
619
578
|
|
|
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.
|
|
579
|
+
// Every per-save build runs in a worker that exits, so the builder must stay flat too.
|
|
623
580
|
const afterBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
624
581
|
expect(afterBuilder?.pid).toBe(idleBuilder?.pid);
|
|
625
582
|
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
583
|
expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
|
|
628
584
|
});
|
|
629
585
|
|
|
630
586
|
budgetTest("recycles the builder at an unmeetable ceiling and keeps developing through it", async () => {
|
|
631
587
|
const harness = await createHarness();
|
|
632
|
-
//
|
|
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.
|
|
588
|
+
// Below the builder once it has served the first route build (about 380MiB), which arms the recycle.
|
|
636
589
|
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
|
|
637
590
|
const start = host.markLog();
|
|
638
591
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
639
592
|
|
|
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.
|
|
593
|
+
// One save arms the recycle; the old pid comes from the log, not `ps`, so it cannot race the swap.
|
|
643
594
|
const { mark: firstSave } = await harness.editUntilSeen(host, (attempt) =>
|
|
644
595
|
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-1-${attempt}`),
|
|
645
596
|
);
|
|
646
597
|
await host.waitForLogSince(firstSave, /pages-rebundle ok/, WAIT_MS);
|
|
647
598
|
|
|
648
|
-
// The host decides, the builder drains rather than being killed, and the replacement comes up.
|
|
649
599
|
const recycleLog = await host.waitForLogSince(
|
|
650
600
|
start,
|
|
651
601
|
/recycling builder pid=(\d+) \((rss=\d+MiB>=200MiB after \d+ build\(s\))\)/,
|
|
@@ -654,19 +604,14 @@ describe("dev resource budgets", () => {
|
|
|
654
604
|
await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
|
|
655
605
|
await host.waitForLogSince(start, /builder spawned pid=\d+ .*restart=1/, WAIT_MS);
|
|
656
606
|
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.
|
|
607
|
+
// The backend read `base-artifact.json` once at boot, so the replacement must re-announce its boot state.
|
|
659
608
|
await host.waitForLogSince(start, /announced boot state after recycle/, WAIT_MS);
|
|
660
609
|
|
|
661
610
|
const recycled = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
662
611
|
expect(recycled).not.toBeNull();
|
|
663
612
|
expect(String(recycled?.pid)).not.toBe(recycleLog[1]);
|
|
664
613
|
|
|
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.
|
|
614
|
+
// The readiness waits above are load-bearing: a save during the recycle is seen by neither builder.
|
|
670
615
|
const postRecycle = await harness.editUntilSeen(host, (attempt) =>
|
|
671
616
|
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-after-recycle-${attempt}`),
|
|
672
617
|
);
|
|
@@ -676,90 +621,72 @@ describe("dev resource budgets", () => {
|
|
|
676
621
|
);
|
|
677
622
|
await harness.waitForHttpText("marker-after-recycle", WAIT_MS);
|
|
678
623
|
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
624
|
+
// A ceiling the replacement is back over within the interval is reported, and still enforced (recycles are
|
|
625
|
+
// throttled), never disabled for the session. Whether it is back over depends on the machine's allocator, so the
|
|
626
|
+
// warning is asserted only when it is; the decision itself is unit-tested (devHostPolicy).
|
|
627
|
+
const settledFrom = host.markLog();
|
|
682
628
|
for (let i = 1; i <= 3; i++) {
|
|
683
629
|
const { mark } = await harness.editUntilSeen(host, (attempt) =>
|
|
684
630
|
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-settled-${i}-${attempt}`),
|
|
685
631
|
);
|
|
686
632
|
await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
|
|
687
633
|
}
|
|
688
|
-
await host.
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
634
|
+
const settled = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
635
|
+
const warned = /ceiling costs about one boot build per interval/.test(host.logs.join("").slice(start));
|
|
636
|
+
//? Past the interval (a slow machine) the next report recycles instead of warning: either answer settles it.
|
|
637
|
+
if ((settled?.rssBytes ?? 0) >= 200 * MB && !warned)
|
|
638
|
+
await host.waitForLogSince(
|
|
639
|
+
settledFrom,
|
|
640
|
+
/ceiling costs about one boot build per interval|recycling builder pid=\d+|skipped: the builder fell to/,
|
|
641
|
+
WAIT_MS,
|
|
642
|
+
);
|
|
693
643
|
expect(host.logs.join("").slice(start)).not.toMatch(/no longer enforcing it this session/);
|
|
694
644
|
});
|
|
695
645
|
|
|
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
646
|
budgetTest("serves a page requested while the builder is being replaced", async () => {
|
|
700
647
|
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
648
|
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
|
|
704
649
|
const port = await harness.resolvePort();
|
|
705
650
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
706
651
|
|
|
707
652
|
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.
|
|
653
|
+
// One save both evicts the route's cached client entries and arms the recycle.
|
|
711
654
|
const { mark } = await harness.editUntilSeen(host, (attempt) =>
|
|
712
655
|
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-through-recycle-${attempt}`),
|
|
713
656
|
);
|
|
714
657
|
await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS);
|
|
715
658
|
|
|
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`.
|
|
659
|
+
// Timing decides whether this lands in the drain or after the exit, so assert what both must produce.
|
|
721
660
|
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.
|
|
661
|
+
// Not awaited: a held request returns only once the replacement is up.
|
|
724
662
|
const drainRequest = fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(WAIT_MS) }).then(
|
|
725
663
|
async (response) => ({ status: response.status, html: await response.text() }),
|
|
726
664
|
);
|
|
727
665
|
|
|
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.
|
|
666
|
+
// Anchored on the spawn: the replacement's boot build leaves a wide, known window rather than a race.
|
|
731
667
|
await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
|
|
732
668
|
await host.waitForLogSince(start, /builder spawned pid=\d+ .*restart=1/, WAIT_MS);
|
|
733
669
|
|
|
734
670
|
const holdMark = host.markLog();
|
|
735
671
|
const startedAtMono = performance.now();
|
|
736
|
-
//
|
|
737
|
-
// request above just populated — it has to reach a builder that is not there yet.
|
|
672
|
+
// Never requested before, so no route cache can answer it; it has to reach a builder that is not there yet.
|
|
738
673
|
const res = await fetch(`http://127.0.0.1:${port}/second`, { signal: AbortSignal.timeout(WAIT_MS) });
|
|
739
674
|
const html = await res.text();
|
|
740
675
|
const heldMs = Math.round(performance.now() - startedAtMono);
|
|
741
676
|
|
|
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.
|
|
677
|
+
// Without the hold this is a 500 from the dev error page, which never retries on its own.
|
|
745
678
|
expect(res.status).toBe(200);
|
|
746
679
|
expect(html).toContain("marker-through-recycle");
|
|
747
680
|
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.
|
|
681
|
+
// Proves the request reached the hold, rather than arriving after the replacement was already ready.
|
|
750
682
|
expect(host.logs.join("").slice(holdMark)).toMatch(/holding build-route until the builder is ready/);
|
|
751
683
|
|
|
752
|
-
// The one fired at the drain, whichever side of the exit it landed on.
|
|
753
684
|
const drained = await drainRequest;
|
|
754
685
|
expect(drained.status).toBe(200);
|
|
755
686
|
expect(drained.html).toContain("marker-through-recycle");
|
|
756
687
|
console.info(`[restart-guard] page held ${heldMs}ms across the recycle, then rendered`);
|
|
757
688
|
});
|
|
758
689
|
|
|
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
690
|
budgetTest("keeps a save's round trip inside its budget", async () => {
|
|
764
691
|
const harness = await createHarness();
|
|
765
692
|
const host = await harness.startHost({ timeoutMs: BOOT_MS });
|
|
@@ -768,45 +695,37 @@ describe("dev resource budgets", () => {
|
|
|
768
695
|
const samples: number[] = [];
|
|
769
696
|
for (let i = 1; i <= 3; i++) {
|
|
770
697
|
let savedAtMono = 0;
|
|
771
|
-
// Timed
|
|
772
|
-
// `editUntilSeen` exists — is measured from the attempt that actually landed, not from the first.
|
|
698
|
+
// Timed inside the mutate callback, so a retried save is measured from the attempt that landed.
|
|
773
699
|
const { mark } = await harness.editUntilSeen(host, async (attempt) => {
|
|
774
700
|
await harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-latency-${i}-${attempt}`);
|
|
775
701
|
savedAtMono = performance.now();
|
|
776
702
|
});
|
|
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.
|
|
703
|
+
// Not the refresh message: that is published only while a client is connected.
|
|
780
704
|
await host.waitForLogSince(mark, /\[hmr\] backend apply/, WAIT_MS);
|
|
781
705
|
samples.push(performance.now() - savedAtMono);
|
|
782
706
|
}
|
|
783
707
|
|
|
784
708
|
const rounded = samples.map((ms) => Math.round(ms));
|
|
785
709
|
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.
|
|
710
|
+
// The worst save, not a median, which would hide a first save that pays for something the rest do not.
|
|
788
711
|
expect(Math.max(...rounded)).toBeLessThan(SAVE_LATENCY_BUDGET_MS);
|
|
789
712
|
});
|
|
790
713
|
|
|
791
714
|
budgetTest("suspends the builder when the dev server goes idle and wakes it on the next edit", async () => {
|
|
792
715
|
const harness = await createHarness();
|
|
793
|
-
// 3s stands in for the 5min default
|
|
794
|
-
// to actually reach idle inside a test.
|
|
716
|
+
// 3s stands in for the 5min default so the dev server reaches idle inside a test.
|
|
795
717
|
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_DEV_IDLE_SUSPEND_MS: "3000" } });
|
|
796
718
|
const start = host.markLog();
|
|
797
719
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
798
720
|
|
|
799
|
-
//
|
|
800
|
-
// the backend wrote a watched file, this would never fire.
|
|
721
|
+
// Never fires if anything periodic (timed metrics, a watched-file write) keeps the dev server busy.
|
|
801
722
|
await host.waitForLogSince(start, /\[idle-suspend\] no build activity for \d+s; released the builder/, WAIT_MS);
|
|
802
723
|
const suspendedBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
803
724
|
expect(suspendedBuilder).toBeNull();
|
|
804
725
|
// Only build capacity suspends — the backend keeps serving the preview URL.
|
|
805
726
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
806
727
|
|
|
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.
|
|
728
|
+
// A fresh watcher over a tree the suspend just churned: squarely inside Bun's drop window.
|
|
810
729
|
const { mark } = await harness.editUntilSeen(
|
|
811
730
|
host,
|
|
812
731
|
(attempt) => harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-after-wake-${attempt}`),
|
|
@@ -821,7 +740,6 @@ describe("dev resource budgets", () => {
|
|
|
821
740
|
await harness.waitForHttpText("marker-after-wake", WAIT_MS);
|
|
822
741
|
console.info(`[idle-suspend-guard] woke in ${awake[1]}ms; builder back at pid=${wokenBuilder?.pid}`);
|
|
823
742
|
|
|
824
|
-
// And it is a normal dev server again afterwards.
|
|
825
743
|
const postWake = await harness.editUntilSeen(host, (attempt) =>
|
|
826
744
|
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-postwake-${attempt}`),
|
|
827
745
|
);
|
|
@@ -838,8 +756,7 @@ describe("dev resource budgets", () => {
|
|
|
838
756
|
await host.waitForLogSince(start, /\[idle-suspend\] .*released the builder/, WAIT_MS);
|
|
839
757
|
expect(await DevStabilityHarness.builderProcess(host.proc.pid)).toBeNull();
|
|
840
758
|
|
|
841
|
-
// A
|
|
842
|
-
// the request must not be answered with "builder is stopped" the way a dead builder's would be.
|
|
759
|
+
// A request only the builder can serve also wakes it, and must not be answered "builder is stopped".
|
|
843
760
|
const mark = host.markLog();
|
|
844
761
|
const port = await harness.resolvePort();
|
|
845
762
|
const status = await fetch(`http://127.0.0.1:${port}/__csr`)
|
|
@@ -847,19 +764,13 @@ describe("dev resource budgets", () => {
|
|
|
847
764
|
.catch(() => 0);
|
|
848
765
|
|
|
849
766
|
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.
|
|
767
|
+
// `[builder]`: the same queue holds requests across a restart too; the wake line above tells them apart.
|
|
853
768
|
await host.waitForLogSince(mark, /\[builder\] replaying 1 request\(s\) held while the builder was away/, WAIT_MS);
|
|
854
769
|
expect(status).toBe(200);
|
|
855
770
|
expect(await DevStabilityHarness.builderProcess(host.proc.pid)).not.toBeNull();
|
|
856
771
|
});
|
|
857
772
|
|
|
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.
|
|
773
|
+
// Nothing watches between the suspend stopping its watcher and the replacement priming its index from disk.
|
|
863
774
|
budgetTest("restarts the backend for a save that lands while the builder is away", async () => {
|
|
864
775
|
const harness = await createHarness();
|
|
865
776
|
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_DEV_IDLE_SUSPEND_MS: "3000" } });
|
|
@@ -869,13 +780,9 @@ describe("dev resource budgets", () => {
|
|
|
869
780
|
|
|
870
781
|
const mark = host.markLog();
|
|
871
782
|
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.
|
|
783
|
+
// The clock: in flight until the replacement is ready; its status is moot once the backend restarts.
|
|
875
784
|
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.
|
|
785
|
+
// Not `srvkit/backendMarker.ts`: the regenerated `server.ts` orphans it, so it restarts by path role only.
|
|
879
786
|
await harness.writeFile(
|
|
880
787
|
"lib/_fixture/fixture.service.ts",
|
|
881
788
|
`import { serve } from "akanjs/service";
|
|
@@ -888,7 +795,6 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
|
|
|
888
795
|
|
|
889
796
|
await host.waitForLogSince(mark, /\[builder-gap\] 1 backend file\(s\) changed while the builder was away/, WAIT_MS);
|
|
890
797
|
await host.waitForLogSince(mark, /\[backend-reload\]/, WAIT_MS);
|
|
891
|
-
// And back to a working dev server, rather than one stuck restarting.
|
|
892
798
|
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/, WAIT_MS);
|
|
893
799
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
894
800
|
});
|