@akanjs/devkit 3.0.0-beta.3 → 3.0.0-beta.30
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 +2 -2
- package/README.md +3 -2
- 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 +404 -141
- package/akanConfig/akanConfig.ts +317 -274
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +21 -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 +269 -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 +177 -0
- package/mobile/desktopBin.ts +143 -0
- package/mobile/desktopServerStage.test.ts +204 -0
- package/mobile/desktopServerStage.ts +138 -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 +151 -0
- package/mobile/nativeApp.ts +405 -0
- package/mobile/nativeAppLine.test.ts +69 -0
- package/mobile/nativeAppLine.ts +71 -0
- package/mobile/nativeConfig.test.ts +414 -0
- package/mobile/nativeConfig.ts +296 -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 +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 +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
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { Logger } from "akanjs/common";
|
|
2
3
|
import type { BuilderMessage, BuilderMetrics, BuildPhase, ChangeBatch, DevBuildStatus } from "akanjs/server";
|
|
3
4
|
import type { App } from "../commandDecorators";
|
|
4
5
|
import { createTunnel } from "../createTunnel";
|
|
5
6
|
import { WorkspaceExecutor } from "../executors";
|
|
6
|
-
//
|
|
7
|
-
// tailwind stack, which is exactly what a suspended dev host must not be holding.
|
|
7
|
+
// Not via `../frontendBuild`: that barrel loads typescript and tailwind, which a suspended dev host must not hold.
|
|
8
8
|
import { HmrWatcher } from "../frontendBuild/hmrWatcher";
|
|
9
9
|
import { WatchRootResolver } from "../frontendBuild/watchRootResolver";
|
|
10
10
|
import { type DevStdioMode, IncrementalBuilderHost } from "../incrementalBuilder";
|
|
11
11
|
import { BuilderRequestRouter } from "../incrementalBuilder/builderRequestRouter";
|
|
12
12
|
import { BackendImportGraph } from "./BackendImportGraph";
|
|
13
|
+
import { DevBootLatch } from "./devBootLatch";
|
|
13
14
|
import {
|
|
14
15
|
type BackendLifecycleState,
|
|
15
16
|
type BackendRestartReason,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
type SourceFingerprints,
|
|
36
37
|
shouldAbandonBackendRecovery,
|
|
37
38
|
shouldHoldForReturningBuilder,
|
|
39
|
+
shouldKeepBuildFailure,
|
|
38
40
|
shouldMarkBuildPhaseRecovered,
|
|
39
41
|
shouldQueueBuildStatusReplay,
|
|
40
42
|
shouldRefreshConfigOnIdleWake,
|
|
@@ -48,10 +50,11 @@ import {
|
|
|
48
50
|
|
|
49
51
|
const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr"]);
|
|
50
52
|
|
|
53
|
+
const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
|
|
54
|
+
|
|
51
55
|
const BACKEND_RESTART_DEBOUNCE_MS = 120;
|
|
52
56
|
|
|
53
|
-
//
|
|
54
|
-
// is never SIGKILLed while its replicas are still shutting down — that's what strands orphans.
|
|
57
|
+
// Above the gateway's ~5s child-shutdown wait: a gateway SIGKILLed mid-shutdown strands orphan replicas.
|
|
55
58
|
const BACKEND_GRACEFUL_TIMEOUT_MS = 8_000;
|
|
56
59
|
|
|
57
60
|
const BACKEND_RECOVERY_BASE_DELAY_MS = 1_000;
|
|
@@ -64,23 +67,15 @@ const BUILDER_READY_TIMEOUT_MS = 150000;
|
|
|
64
67
|
|
|
65
68
|
const BUILDER_START_MAX_ATTEMPTS = 3;
|
|
66
69
|
|
|
67
|
-
//
|
|
68
|
-
// still on their way to the builder, so an over-ceiling builder is replaced only once it goes quiet.
|
|
70
|
+
// Recycling mid-burst of saves would drop watcher events still on their way to the builder.
|
|
69
71
|
const BUILDER_RSS_RECYCLE_QUIET_MS = 750;
|
|
70
72
|
|
|
71
|
-
// Linux
|
|
72
|
-
// builder's peak (`local/optimize-resource/09-linux-retention-measurement.md`) — while macOS returns
|
|
73
|
-
// none of it. The builder only reports RSS at work-completion points, so the sample a recycle is armed
|
|
74
|
-
// from is the peak. Waiting out the purge and re-reading before committing is what stops the host
|
|
75
|
-
// paying a cold boot build for memory the OS was about to return anyway. On macOS the re-read returns
|
|
76
|
-
// the same value, so this only ever costs the delay.
|
|
73
|
+
// Linux returns about half of the bundler arenas after ~10-15s idle (macOS none), so re-read the peak before recycling.
|
|
77
74
|
const BUILDER_RSS_SETTLE_MS = 20_000;
|
|
78
75
|
|
|
79
|
-
/** Reading one process's rss is a millisecond of work; anything near this is a `ps` that is stuck. */
|
|
80
76
|
const PS_RSS_TIMEOUT_MS = 2_000;
|
|
81
77
|
|
|
82
|
-
// The builder is the file watcher
|
|
83
|
-
// backend the recovery loop never gives up — it only backs off.
|
|
78
|
+
// The builder is the file watcher, so unlike the backend its recovery never gives up; it only backs off.
|
|
84
79
|
const BUILDER_RECOVERY_BASE_DELAY_MS = 2_000;
|
|
85
80
|
|
|
86
81
|
const BUILDER_RECOVERY_MAX_DELAY_MS = 60_000;
|
|
@@ -96,8 +91,11 @@ export class AkanAppHost {
|
|
|
96
91
|
readonly env: Record<string, string>;
|
|
97
92
|
readonly #onDevEvent: ((event: DevHostEvent) => void) | null;
|
|
98
93
|
#lastDevState: DevHostState | null = null;
|
|
94
|
+
readonly #bootLatch = new DevBootLatch(() => this.#onDevEvent?.({ app: this.app.name, booted: true }));
|
|
99
95
|
#backend: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe"> | null = null;
|
|
100
96
|
#builder: IncrementalBuilderHost | null = null;
|
|
97
|
+
//? Outlives the builder host, which idle wake and recovery replace; a config or metadata restart clears it.
|
|
98
|
+
#patcherOff = false;
|
|
101
99
|
#backendReady = false;
|
|
102
100
|
#plannedBackendStops = new WeakSet<Bun.Subprocess<"ignore", "inherit", "inherit">>();
|
|
103
101
|
#restartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -106,7 +104,19 @@ export class AkanAppHost {
|
|
|
106
104
|
#backendGaveUp = false;
|
|
107
105
|
#backendLifecycleState: BackendLifecycleState = "stopped";
|
|
108
106
|
#pendingRestartReason: BackendRestartReason | null = null;
|
|
109
|
-
|
|
107
|
+
//? One replacement at a time: `#stopBackend` clears `#backend` only once the old process exits, so a second restart
|
|
108
|
+
//? inside that wait signals the same pid and spawns a second backend, and the one the host stops tracking keeps the port.
|
|
109
|
+
#backendRestart: Promise<void> | null = null;
|
|
110
|
+
#pendingRecycle: {
|
|
111
|
+
message: Extract<BuilderMessage, { type: "invalidate" }>;
|
|
112
|
+
refreshConfig: boolean;
|
|
113
|
+
failed?: boolean;
|
|
114
|
+
failedGeneration?: number;
|
|
115
|
+
} | null = null;
|
|
116
|
+
//? A restart that failed after its change applied (the builder or backend did not come up): the builder's return is
|
|
117
|
+
//? its recovery, reported past the failure's generation so the scan overlay clears.
|
|
118
|
+
#restartFailedAfterApply: number | null = null;
|
|
119
|
+
#stopping = false;
|
|
110
120
|
#builderRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
111
121
|
#builderRecoveryAttempts = 0;
|
|
112
122
|
#backendStartStatus: { generation?: number; files: string[] } | null = null;
|
|
@@ -118,7 +128,6 @@ export class AkanAppHost {
|
|
|
118
128
|
#lastRssRecycleAtMono: number | null = null;
|
|
119
129
|
#rssCeilingTightReports = 0;
|
|
120
130
|
#rssCeilingTightWarned = false;
|
|
121
|
-
/** Invalidates an in-flight settle check when anything else moves the builder underneath it. */
|
|
122
131
|
#rssSettleToken = 0;
|
|
123
132
|
#rssRecycleOver: { rssBytes: number; ceilingBytes: number } | null = null;
|
|
124
133
|
#rssCeilingAbandoned = false;
|
|
@@ -132,11 +141,10 @@ export class AkanAppHost {
|
|
|
132
141
|
#wokeAtMono: number | null = null;
|
|
133
142
|
#idleWatcher: HmrWatcher | null = null;
|
|
134
143
|
#suspendedChanges: ChangeBatch | null = null;
|
|
135
|
-
/** The stat sweep taken when the builder went away, awaited by the take; see `#openBuilderGap`. */
|
|
136
144
|
#builderGapStamp: Promise<SourceFingerprints | null> | null = null;
|
|
137
|
-
/** Requests that arrived while suspended, answered by the builder that the wake brings up. */
|
|
138
145
|
#pendingBuilderMessages: BuilderMessage[] = [];
|
|
139
146
|
readonly #builderRequests = new BuilderRequestRouter();
|
|
147
|
+
#builderGeneration = 0;
|
|
140
148
|
constructor(
|
|
141
149
|
private readonly app: App,
|
|
142
150
|
{
|
|
@@ -154,6 +162,7 @@ export class AkanAppHost {
|
|
|
154
162
|
if (!this.#onDevEvent || state === this.#lastDevState) return;
|
|
155
163
|
this.#lastDevState = state;
|
|
156
164
|
this.#onDevEvent({ app: this.app.name, state, ...(detail ? { detail } : {}) });
|
|
165
|
+
if (state === "ready") this.#bootLatch.ready();
|
|
157
166
|
}
|
|
158
167
|
async start() {
|
|
159
168
|
if (this.#backend) await this.#stopBackend();
|
|
@@ -169,26 +178,25 @@ export class AkanAppHost {
|
|
|
169
178
|
return this;
|
|
170
179
|
}
|
|
171
180
|
async stop() {
|
|
181
|
+
//? Nothing reapplies once it stops: a pages ok received meanwhile would bring children back after it.
|
|
182
|
+
this.#stopping = true;
|
|
183
|
+
this.#pendingRecycle = null;
|
|
172
184
|
this.#cancelIdleSuspend();
|
|
173
185
|
this.#stopIdleWatcher();
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
this.#restartTimer = null;
|
|
177
|
-
}
|
|
178
|
-
if (this.#backendRecoveryTimer) {
|
|
179
|
-
clearTimeout(this.#backendRecoveryTimer);
|
|
180
|
-
this.#backendRecoveryTimer = null;
|
|
181
|
-
}
|
|
182
|
-
if (this.#builderRecoveryTimer) {
|
|
183
|
-
clearTimeout(this.#builderRecoveryTimer);
|
|
184
|
-
this.#builderRecoveryTimer = null;
|
|
185
|
-
}
|
|
186
|
+
this.#clearRestartTimers();
|
|
187
|
+
this.#pendingRestartReason = null;
|
|
186
188
|
// Before the backend goes away, while it can still receive the answer.
|
|
187
189
|
this.#failPendingBuilderMessages("dev server is shutting down");
|
|
190
|
+
await this.#backendRestart;
|
|
188
191
|
await this.#stopBackend();
|
|
189
192
|
this.#stopBuilder();
|
|
190
193
|
return this;
|
|
191
194
|
}
|
|
195
|
+
#clearRestartTimers() {
|
|
196
|
+
for (const timer of [this.#restartTimer, this.#backendRecoveryTimer, this.#builderRecoveryTimer])
|
|
197
|
+
if (timer) clearTimeout(timer);
|
|
198
|
+
this.#restartTimer = this.#backendRecoveryTimer = this.#builderRecoveryTimer = null;
|
|
199
|
+
}
|
|
192
200
|
kill() {
|
|
193
201
|
void this.stop();
|
|
194
202
|
}
|
|
@@ -199,8 +207,12 @@ export class AkanAppHost {
|
|
|
199
207
|
return await createTunnel(type, { app: this.app, environment });
|
|
200
208
|
}
|
|
201
209
|
#startBackend(startStatus: { generation?: number; files: string[] } | null = null) {
|
|
202
|
-
|
|
203
|
-
|
|
210
|
+
if (this.#stopping) return;
|
|
211
|
+
if (this.#backend) {
|
|
212
|
+
this.logger.warn(`backend pid=${this.#backend.pid} is still running; not starting a second one`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Before the spawn: the new backend numbers its requests from 1 again, so old answers must not reach it.
|
|
204
216
|
this.#builderRequests.startGeneration();
|
|
205
217
|
this.#discardPendingBuilderMessages("the backend restarted while the builder was away");
|
|
206
218
|
this.#backendStartStatus = startStatus;
|
|
@@ -220,12 +232,12 @@ export class AkanAppHost {
|
|
|
220
232
|
this.#setBackendLifecycleState("ready", `pid=${msg.pid}`);
|
|
221
233
|
this.#recordBackendReadyStatus();
|
|
222
234
|
this.logger.verbose(`backend ready pid=${msg.pid}`);
|
|
235
|
+
this.#forgetRouteBuildStatus();
|
|
223
236
|
this.#replayBuilderState();
|
|
224
237
|
return;
|
|
225
238
|
}
|
|
226
239
|
if (msg.type === "build-status") {
|
|
227
|
-
// The gateway reports replica boot failures (crash loops, port conflicts)
|
|
228
|
-
// they reach the build-status log and the HMR overlay like any other build failure.
|
|
240
|
+
// The gateway reports replica boot failures (crash loops, port conflicts) as build-status.
|
|
229
241
|
const status = this.#recordBackendBuildStatus({
|
|
230
242
|
generation: normalizeBackendReportedGeneration(msg.data.generation),
|
|
231
243
|
ok: msg.data.ok,
|
|
@@ -233,6 +245,8 @@ export class AkanAppHost {
|
|
|
233
245
|
message: msg.data.message,
|
|
234
246
|
});
|
|
235
247
|
this.#sendOrQueueBuildStatus(status);
|
|
248
|
+
//? A replica crash loop leaves the gateway up, waiting for an edit: no exit reaches the host's own give-up.
|
|
249
|
+
if (!status.ok) this.#emitDevEvent("failed", status.message);
|
|
236
250
|
return;
|
|
237
251
|
}
|
|
238
252
|
if (backendMsgTypeSet.has(msg.type)) this.#sendToBuilder(msg);
|
|
@@ -251,8 +265,7 @@ export class AkanAppHost {
|
|
|
251
265
|
this.#backend = backend;
|
|
252
266
|
this.logger.verbose(`backend spawned pid=${backend.pid}`);
|
|
253
267
|
if (this.stdio === "pipe") {
|
|
254
|
-
//
|
|
255
|
-
// so a runtime error is not silently swallowed, and so the tail kept for a boot failure still fills.
|
|
268
|
+
// Undrained pipes swallow runtime errors and leave the crash-loop stderr tail empty.
|
|
256
269
|
void this.#forwardBackendStream(backend.stderr as unknown as ReadableStream<Uint8Array> | undefined, "stderr");
|
|
257
270
|
void this.#forwardBackendStream(backend.stdout as unknown as ReadableStream<Uint8Array> | undefined, "stdout");
|
|
258
271
|
}
|
|
@@ -265,12 +278,7 @@ export class AkanAppHost {
|
|
|
265
278
|
this.#backendStderrTail.splice(0, this.#backendStderrTail.length - BACKEND_STDERR_TAIL_LIMIT);
|
|
266
279
|
}
|
|
267
280
|
}
|
|
268
|
-
|
|
269
|
-
* A piped child is written through verbatim rather than re-logged: a level floor here would swallow
|
|
270
|
-
* what the runtime printed, and re-rendering would double the timestamp the child already wrote. So
|
|
271
|
-
* `"pipe"` looks exactly like `"inherit"` to whoever owns this process's stdout — the only difference
|
|
272
|
-
* is `#backendStderrTail`, which is what the crash-loop diagnostic reads.
|
|
273
|
-
*/
|
|
281
|
+
// Verbatim, not re-logged: a level floor would swallow runtime output and re-rendering doubles the timestamp.
|
|
274
282
|
async #forwardBackendStream(stream: ReadableStream<Uint8Array> | undefined | null, kind: "stdout" | "stderr") {
|
|
275
283
|
if (!stream) return;
|
|
276
284
|
const decoder = new TextDecoder();
|
|
@@ -390,10 +398,18 @@ export class AkanAppHost {
|
|
|
390
398
|
if (this.#restartTimer) clearTimeout(this.#restartTimer);
|
|
391
399
|
this.#restartTimer = setTimeout(() => {
|
|
392
400
|
this.#restartTimer = null;
|
|
401
|
+
this.#backendRestart ??= this.#drainBackendRestarts().finally(() => {
|
|
402
|
+
this.#backendRestart = null;
|
|
403
|
+
});
|
|
404
|
+
}, BACKEND_RESTART_DEBOUNCE_MS);
|
|
405
|
+
}
|
|
406
|
+
//? A reason that arrived during a restart runs after it; one whose debounce is still pending waits for its timer.
|
|
407
|
+
async #drainBackendRestarts() {
|
|
408
|
+
while (this.#pendingRestartReason && !this.#restartTimer && !this.#stopping) {
|
|
393
409
|
const next = this.#pendingRestartReason;
|
|
394
410
|
this.#pendingRestartReason = null;
|
|
395
|
-
|
|
396
|
-
}
|
|
411
|
+
await this.#restartBackend(next);
|
|
412
|
+
}
|
|
397
413
|
}
|
|
398
414
|
async #restartBackend(reason: BackendRestartReason) {
|
|
399
415
|
this.logger.verbose(
|
|
@@ -449,17 +465,25 @@ export class AkanAppHost {
|
|
|
449
465
|
});
|
|
450
466
|
}
|
|
451
467
|
async #handleBuilderMessage(message: BuilderMessage) {
|
|
468
|
+
if (this.#stopping) return;
|
|
452
469
|
this.#markDevActivity();
|
|
470
|
+
this.#trackBuilderGeneration(message);
|
|
453
471
|
if (message.type === "build-status") {
|
|
454
472
|
this.#recordBuildStatus(message.data);
|
|
455
473
|
this.#sendOrQueueBuildStatus(message.data);
|
|
456
474
|
this.#reviveBackendAfterGreenBuild(message.data);
|
|
475
|
+
await this.#resumeFailedRecycle(message.data);
|
|
457
476
|
return;
|
|
458
477
|
}
|
|
459
478
|
if (message.type === "builder-metrics") {
|
|
460
479
|
this.#handleBuilderMetrics(message.data);
|
|
461
480
|
return;
|
|
462
481
|
}
|
|
482
|
+
if (message.type === "boot-armed") {
|
|
483
|
+
this.logger.verbose("[builder] boot builds settled");
|
|
484
|
+
this.#bootLatch.armed();
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
463
487
|
if (message.type === "pages-updated" || message.type === "css-updated") {
|
|
464
488
|
const recycled = message.data.reason === "builder-recycle";
|
|
465
489
|
if (recycled && !this.#shouldRelayRecycledState(message)) return;
|
|
@@ -480,11 +504,21 @@ export class AkanAppHost {
|
|
|
480
504
|
}
|
|
481
505
|
this.#sendToBackend(message);
|
|
482
506
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
507
|
+
// In `env`, which every builder spawn re-reads: a replacement (recycled, crashed, restarted) continues from here.
|
|
508
|
+
#trackBuilderGeneration(message: BuilderMessage): void {
|
|
509
|
+
const generation = AkanAppHost.#builderGenerationOf(message);
|
|
510
|
+
if (generation === undefined || generation <= this.#builderGeneration) return;
|
|
511
|
+
this.#builderGeneration = generation;
|
|
512
|
+
Object.assign(this.env, { AKAN_BUILDER_INITIAL_GENERATION: String(generation) });
|
|
513
|
+
}
|
|
514
|
+
// Not `csr-updated` / `ssr-updated`: their generation is the registry's.
|
|
515
|
+
static #builderGenerationOf(message: BuilderMessage): number | undefined {
|
|
516
|
+
if (message.type === "invalidate") return message.generation;
|
|
517
|
+
if (message.type === "build-status" || message.type === "builder-metrics") return message.data.generation;
|
|
518
|
+
if (message.type === "pages-updated" || message.type === "css-updated") return message.data.generation;
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
// The backend reads `base-artifact.json` once, so a recycled builder re-announces it; unchanged hashes are dropped.
|
|
488
522
|
#shouldRelayRecycledState(
|
|
489
523
|
message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
|
|
490
524
|
): boolean {
|
|
@@ -499,7 +533,6 @@ export class AkanAppHost {
|
|
|
499
533
|
#handleBuilderMetrics(metrics: BuilderMetrics): void {
|
|
500
534
|
if (this.#rssCeilingAbandoned) return;
|
|
501
535
|
const ceilingBytes = IncrementalBuilderHost.maxRssBytes();
|
|
502
|
-
const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
|
|
503
536
|
const decision = decideBuilderRssRecycle({
|
|
504
537
|
rssBytes: metrics.rssBytes,
|
|
505
538
|
ceilingBytes,
|
|
@@ -521,9 +554,7 @@ export class AkanAppHost {
|
|
|
521
554
|
this.#rssCeilingTightReports += 1;
|
|
522
555
|
if (this.#rssCeilingTightWarned || !shouldWarnBuilderRssCeilingTight(this.#rssCeilingTightReports)) return;
|
|
523
556
|
this.#rssCeilingTightWarned = true;
|
|
524
|
-
//
|
|
525
|
-
// interval, because that bound is the only thing standing between the bundler's arenas and the
|
|
526
|
-
// sandbox's memory limit.
|
|
557
|
+
// Warn only: the per-interval recycle is the one bound between the bundler's arenas and the sandbox limit.
|
|
527
558
|
this.logger.warn(
|
|
528
559
|
`[builder-recycle] the builder is back at ${asMib(metrics.rssBytes)}MiB within ${Math.round(BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS / 1000)}s of a recycle, so the ${asMib(ceilingBytes ?? 0)}MiB ceiling costs about one boot build per interval while you keep building. Raise AKAN_BUILDER_MAX_RSS_MB if that trade is wrong for this app, or set it to 0 to leave the builder unbounded.`,
|
|
529
560
|
);
|
|
@@ -534,14 +565,7 @@ export class AkanAppHost {
|
|
|
534
565
|
{ rssBytes: metrics.rssBytes, ceilingBytes: ceilingBytes ?? 0 },
|
|
535
566
|
);
|
|
536
567
|
}
|
|
537
|
-
|
|
538
|
-
* Ask the replacement, the moment it is ready, whether this ceiling is reachable at all.
|
|
539
|
-
*
|
|
540
|
-
* Read from the OS rather than from a metrics report, because the report the host would otherwise
|
|
541
|
-
* judge on only arrives after the builder has built something — by which point what is being measured
|
|
542
|
-
* is the work, not the floor. A floor over the ceiling means every replacement lands over it, so the
|
|
543
|
-
* recycle loop can only ever cost boot builds.
|
|
544
|
-
*/
|
|
568
|
+
// Read from the OS at ready: a metrics report arrives only after a build, measuring the work, not the floor.
|
|
545
569
|
async #checkRecycledBuilderFloor(): Promise<void> {
|
|
546
570
|
if (this.#rssCeilingAbandoned || this.#lastRssRecycleAtMono === null) return;
|
|
547
571
|
const ceilingBytes = IncrementalBuilderHost.maxRssBytes();
|
|
@@ -550,18 +574,15 @@ export class AkanAppHost {
|
|
|
550
574
|
const freshRssBytes = await AkanAppHost.readProcessRssBytes(pid);
|
|
551
575
|
if (!isRssCeilingUnreachable(freshRssBytes, ceilingBytes)) return;
|
|
552
576
|
this.#rssCeilingAbandoned = true;
|
|
553
|
-
const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
|
|
554
577
|
this.logger.error(
|
|
555
578
|
`[builder-recycle] a freshly recycled builder is already at ${asMib(freshRssBytes ?? 0)}MiB with nothing built on demand, so the ${asMib(ceilingBytes)}MiB ceiling cannot be met for this app; no longer enforcing it this session. Raise AKAN_BUILDER_MAX_RSS_MB, or set it to 0 to leave the builder unbounded.`,
|
|
556
579
|
);
|
|
557
580
|
}
|
|
558
|
-
/** Waits for the builder to go quiet, so a recycle never lands in the middle of a burst of saves. */
|
|
559
581
|
#armRssRecycle(reason: string, over?: { rssBytes: number; ceilingBytes: number }): void {
|
|
560
582
|
if (this.#rssRecycleReason !== reason)
|
|
561
583
|
this.logger.verbose(`[builder-recycle] armed (${reason}); replacing the builder once it stays quiet`);
|
|
562
584
|
this.#rssRecycleReason = reason;
|
|
563
|
-
//
|
|
564
|
-
// losing the sample there would silently skip the settle check for exactly the bursty case.
|
|
585
|
+
// A field, not the closure: `#handleInvalidate` re-arms with the reason alone and must keep the sample.
|
|
565
586
|
if (over) this.#rssRecycleOver = over;
|
|
566
587
|
if (this.#rssRecycleTimer) clearTimeout(this.#rssRecycleTimer);
|
|
567
588
|
this.#rssRecycleTimer = setTimeout(() => {
|
|
@@ -578,22 +599,15 @@ export class AkanAppHost {
|
|
|
578
599
|
void this.#recycleBuilderForRssWhenStillOver(pendingReason, pendingOver);
|
|
579
600
|
}, BUILDER_RSS_RECYCLE_QUIET_MS);
|
|
580
601
|
}
|
|
581
|
-
/**
|
|
582
|
-
* Confirms the builder is *still* over the ceiling before replacing it. The armed sample was taken
|
|
583
|
-
* the instant the builder went idle, which is its peak; where the allocator returns arenas during
|
|
584
|
-
* idle, that number is stale within seconds and recycling on it is pure cost.
|
|
585
|
-
*/
|
|
586
602
|
async #recycleBuilderForRssWhenStillOver(
|
|
587
603
|
reason: string,
|
|
588
604
|
{ rssBytes, ceilingBytes }: { rssBytes: number; ceilingBytes: number },
|
|
589
605
|
): Promise<void> {
|
|
590
|
-
const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
|
|
591
606
|
if (decideBuilderRssSettle({ rssBytes, ceilingBytes }) === "recycle-now") {
|
|
592
607
|
this.#recycleBuilderForRss(reason);
|
|
593
608
|
return;
|
|
594
609
|
}
|
|
595
610
|
const pid = this.#builder?.pid;
|
|
596
|
-
// Without a readable pid there is nothing to re-check, so keep the original behaviour.
|
|
597
611
|
if (!pid) {
|
|
598
612
|
this.#recycleBuilderForRss(reason);
|
|
599
613
|
return;
|
|
@@ -604,7 +618,6 @@ export class AkanAppHost {
|
|
|
604
618
|
`[builder-recycle] holding ${Math.round(BUILDER_RSS_SETTLE_MS / 1000)}s to see whether the allocator returns it (${reason})`,
|
|
605
619
|
);
|
|
606
620
|
await Bun.sleep(BUILDER_RSS_SETTLE_MS);
|
|
607
|
-
// Anything that touched the builder meanwhile — a new batch, a recycle, a suspend — invalidates this.
|
|
608
621
|
if (token !== this.#rssSettleToken || this.#builder?.pid !== pid || this.#suspended || this.#waking) {
|
|
609
622
|
this.logger.verbose("[builder-recycle] settle check abandoned; the builder moved on");
|
|
610
623
|
return;
|
|
@@ -620,29 +633,29 @@ export class AkanAppHost {
|
|
|
620
633
|
settledBytes === null ? reason : `${reason}; still ${asMib(settledBytes)}MiB after settling`,
|
|
621
634
|
);
|
|
622
635
|
}
|
|
623
|
-
/**
|
|
624
|
-
* Another process's RSS, read from the OS rather than asked of the process. `/proc` where it exists,
|
|
625
|
-
* `ps` otherwise (macOS has no `/proc`). Null when it cannot be read, which callers treat as
|
|
626
|
-
* "no new information" rather than as zero.
|
|
627
|
-
*/
|
|
636
|
+
/** Null when unreadable, meaning "no new information", never zero. */
|
|
628
637
|
static async readProcessRssBytes(pid: number): Promise<number | null> {
|
|
638
|
+
if (process.platform === "win32")
|
|
639
|
+
return await AkanAppHost.#readRssKbVia(
|
|
640
|
+
["tasklist", "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"],
|
|
641
|
+
AkanAppHost.#tasklistRssKb,
|
|
642
|
+
);
|
|
629
643
|
const status = await Bun.file(`/proc/${pid}/status`)
|
|
630
644
|
.text()
|
|
631
645
|
.catch(() => null);
|
|
632
646
|
const vmRssKb = status === null ? null : /VmRSS:\s+(\d+) kB/.exec(status)?.[1];
|
|
633
647
|
if (vmRssKb) return Number(vmRssKb) * 1024;
|
|
634
|
-
return await AkanAppHost.#
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
static async #readRssViaPs(pid: number, timeoutMs = PS_RSS_TIMEOUT_MS): Promise<number | null> {
|
|
648
|
+
return await AkanAppHost.#readRssKbVia(["ps", "-o", "rss=", "-p", String(pid)], (output) => Number(output.trim()));
|
|
649
|
+
}
|
|
650
|
+
// Bounded: a `ps` that hangs under load would silently cancel the recycle awaiting it.
|
|
651
|
+
static async #readRssKbVia(
|
|
652
|
+
command: string[],
|
|
653
|
+
parseKb: (output: string) => number,
|
|
654
|
+
timeoutMs = PS_RSS_TIMEOUT_MS,
|
|
655
|
+
): Promise<number | null> {
|
|
643
656
|
let proc: Bun.Subprocess<"ignore", "pipe", "ignore">;
|
|
644
657
|
try {
|
|
645
|
-
proc = Bun.spawn(
|
|
658
|
+
proc = Bun.spawn(command, { stdio: ["ignore", "pipe", "ignore"] });
|
|
646
659
|
} catch {
|
|
647
660
|
return null;
|
|
648
661
|
}
|
|
@@ -650,7 +663,7 @@ export class AkanAppHost {
|
|
|
650
663
|
try {
|
|
651
664
|
const output = await new Response(proc.stdout).text();
|
|
652
665
|
await proc.exited;
|
|
653
|
-
const rssKb =
|
|
666
|
+
const rssKb = parseKb(output);
|
|
654
667
|
return Number.isFinite(rssKb) && rssKb > 0 ? rssKb * 1024 : null;
|
|
655
668
|
} catch {
|
|
656
669
|
return null;
|
|
@@ -658,6 +671,11 @@ export class AkanAppHost {
|
|
|
658
671
|
clearTimeout(killer);
|
|
659
672
|
}
|
|
660
673
|
}
|
|
674
|
+
// `Mem Usage` is the working set (what `process.memoryUsage.rss()` reports on Windows) in locale-grouped KB.
|
|
675
|
+
static #tasklistRssKb(output: string): number {
|
|
676
|
+
const memUsage = /"([^"]*)"\s*$/m.exec(output)?.[1];
|
|
677
|
+
return memUsage ? Number(memUsage.replace(/\D/g, "")) : Number.NaN;
|
|
678
|
+
}
|
|
661
679
|
#cancelRssRecycle(): void {
|
|
662
680
|
this.#rssRecycleReason = null;
|
|
663
681
|
this.#rssRecycleOver = null;
|
|
@@ -667,14 +685,10 @@ export class AkanAppHost {
|
|
|
667
685
|
clearTimeout(this.#rssRecycleTimer);
|
|
668
686
|
this.#rssRecycleTimer = null;
|
|
669
687
|
}
|
|
670
|
-
/**
|
|
671
|
-
* How long the dev server may sit unused before its build capacity is dropped. Set
|
|
672
|
-
* `AKAN_DEV_IDLE_SUSPEND_MS=0` to keep the builder resident for the whole session.
|
|
673
|
-
*/
|
|
688
|
+
/** `AKAN_DEV_IDLE_SUSPEND_MS=0` keeps the builder resident for the whole session. */
|
|
674
689
|
static idleSuspendMs(): number | null {
|
|
675
690
|
return resolveIdleSuspendMs(process.env.AKAN_DEV_IDLE_SUSPEND_MS);
|
|
676
691
|
}
|
|
677
|
-
/** Every builder message and every request for one counts as the dev server being in use. */
|
|
678
692
|
#markDevActivity(): void {
|
|
679
693
|
if (this.#suspended || this.#waking) return;
|
|
680
694
|
this.#armIdleSuspend();
|
|
@@ -708,8 +722,7 @@ export class AkanAppHost {
|
|
|
708
722
|
this.#armIdleSuspend();
|
|
709
723
|
return;
|
|
710
724
|
}
|
|
711
|
-
// Watch before stopping
|
|
712
|
-
// would wake the dev server until the next one.
|
|
725
|
+
// Watch before stopping: an edit in the gap would be lost, and nothing would wake the dev server.
|
|
713
726
|
if (!(await this.#startIdleWatcher())) {
|
|
714
727
|
this.#armIdleSuspend();
|
|
715
728
|
return;
|
|
@@ -726,6 +739,7 @@ export class AkanAppHost {
|
|
|
726
739
|
return !!(
|
|
727
740
|
this.#pendingRecycle ||
|
|
728
741
|
this.#restartTimer ||
|
|
742
|
+
this.#backendRestart ||
|
|
729
743
|
this.#backendRecoveryTimer ||
|
|
730
744
|
this.#builderRecoveryTimer ||
|
|
731
745
|
this.#rssRecycleReason
|
|
@@ -742,13 +756,11 @@ export class AkanAppHost {
|
|
|
742
756
|
void this.#wakeFromIdle(`${batch.files.length} file(s) changed`);
|
|
743
757
|
},
|
|
744
758
|
});
|
|
745
|
-
// Awaited so the mtime baseline exists before the first edit
|
|
746
|
-
// batch's file list to the woken builder, and a file missing from it is never rebuilt at all.
|
|
759
|
+
// Awaited so the mtime baseline exists before the first edit; a file missing from the batch is never rebuilt.
|
|
747
760
|
await watcher.start();
|
|
748
761
|
this.#idleWatcher = watcher;
|
|
749
762
|
return true;
|
|
750
763
|
} catch (err) {
|
|
751
|
-
// Better to keep paying for the builder than to suspend into a dev server that cannot notice edits.
|
|
752
764
|
this.logger.warn(
|
|
753
765
|
`[idle-suspend] could not install the idle watcher; staying awake: ${err instanceof Error ? err.message : String(err)}`,
|
|
754
766
|
);
|
|
@@ -771,10 +783,6 @@ export class AkanAppHost {
|
|
|
771
783
|
kinds: new Set([...current.kinds, ...batch.kinds]),
|
|
772
784
|
};
|
|
773
785
|
}
|
|
774
|
-
/**
|
|
775
|
-
* Bring build capacity back. Reuses the paths an ordinary change would take, so a change made while
|
|
776
|
-
* suspended lands the same way it would have while awake.
|
|
777
|
-
*/
|
|
778
786
|
async #wakeFromIdle(reason: string): Promise<void> {
|
|
779
787
|
if (!this.#suspended || this.#waking) return;
|
|
780
788
|
this.#waking = true;
|
|
@@ -788,8 +796,6 @@ export class AkanAppHost {
|
|
|
788
796
|
await this.#applyIdleWake(batch);
|
|
789
797
|
this.logger.info(`[idle-suspend] awake in ${Math.round(performance.now() - startedAtMono)}ms`);
|
|
790
798
|
} catch (err) {
|
|
791
|
-
// Never leave the dev server without a builder: fall back to the ordinary recovery loop, which
|
|
792
|
-
// keeps retrying, rather than sitting suspended with no watcher.
|
|
793
799
|
this.logger.error(
|
|
794
800
|
`[idle-suspend] wake failed; recovering the builder: ${err instanceof Error ? err.message : String(err)}`,
|
|
795
801
|
);
|
|
@@ -800,8 +806,7 @@ export class AkanAppHost {
|
|
|
800
806
|
this.#wokeAtMono = performance.now();
|
|
801
807
|
this.#flushPendingBuilderMessages();
|
|
802
808
|
this.#armIdleSuspend();
|
|
803
|
-
// A wake often leaves the backend
|
|
804
|
-
// app came back and a supervisor would show it suspended for the rest of the session.
|
|
809
|
+
// A wake often leaves the backend state untouched, so nothing else would tell a supervisor it came back.
|
|
805
810
|
this.#emitDevEvent(devHostStateOf(this.#backendLifecycleState, this.#backendGaveUp));
|
|
806
811
|
}
|
|
807
812
|
}
|
|
@@ -809,53 +814,27 @@ export class AkanAppHost {
|
|
|
809
814
|
const files = batch?.files ?? [];
|
|
810
815
|
if (shouldRefreshConfigOnIdleWake(batch)) {
|
|
811
816
|
this.logger.verbose("[idle-suspend] config changed while suspended; restarting the dev host");
|
|
812
|
-
//
|
|
813
|
-
// already covered — and a baseline carried past its own gap costs a restart at the next one.
|
|
817
|
+
// Replaces the backend too, and a baseline kept past its own gap costs a restart at the next one.
|
|
814
818
|
this.#discardBuilderGap("config change replaces the backend anyway");
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
{
|
|
818
|
-
refreshConfig: true,
|
|
819
|
-
},
|
|
820
|
-
);
|
|
819
|
+
//? Through the same path as a save's: a failure is reported and stays pending, as one would be awake.
|
|
820
|
+
await this.#applyRecycle({ type: "invalidate", kinds: [...(batch?.kinds ?? [])], files }, true);
|
|
821
821
|
return;
|
|
822
822
|
}
|
|
823
823
|
// Refresh before deciding: a file created while suspended is not in the graph yet.
|
|
824
824
|
if (files.length > 0) await this.#backendGraph.refresh();
|
|
825
825
|
await this.#startBuilder({ announceBootState: true });
|
|
826
|
-
// Merged
|
|
827
|
-
// stamps are what actually moved, and they overlap on the ordinary case of one save during a suspend.
|
|
826
|
+
// Merged: the watcher's batch and the stamps overlap on the ordinary one-save-during-suspend case.
|
|
828
827
|
const missed = await this.#takeBuilderGapChanges();
|
|
829
|
-
const backendFiles = [...new Set([...files.filter((file) => this.#
|
|
828
|
+
const backendFiles = [...new Set([...files.filter((file) => this.#backendGraph.has(file)), ...missed])];
|
|
830
829
|
if (backendFiles.length === 0) return;
|
|
831
830
|
this.logger.verbose(`[idle-suspend] ${backendFiles.length} backend file(s) changed while suspended`);
|
|
832
831
|
this.#scheduleBackendRestart({ files: backendFiles, roles: [] });
|
|
833
832
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
*
|
|
837
|
-
* The suspend path installs its own watcher and the restart path has none at all, but neither is a
|
|
838
|
-
* complete answer: Bun's recursive `fs.watch` reports roughly one path per window
|
|
839
|
-
* (`local/optimize-resource/06-watcher-dropped-event.md`), and a replacement builder primes its index
|
|
840
|
-
* from the disk it finds, so anything saved in between looks original to it. A stat taken now and
|
|
841
|
-
* compared when the builder is back does not depend on an event arriving.
|
|
842
|
-
*
|
|
843
|
-
* Scoped to the import graph — the files the backend actually runs. A backend-shaped file outside it
|
|
844
|
-
* changes nothing about the running server, so missing it costs nothing, and enumerating candidates
|
|
845
|
-
* by path role instead would mean walking the tree.
|
|
846
|
-
*
|
|
847
|
-
* The earliest open wins: a baseline from further back can only over-report, and over-reporting costs
|
|
848
|
-
* a backend restart while under-reporting costs a server running code the developer already deleted.
|
|
849
|
-
*
|
|
850
|
-
* Held as the in-flight sweep rather than as its result, so a take cannot read a baseline that is
|
|
851
|
-
* still being written — the sweep is milliseconds and the gap it covers is a boot build, but "usually
|
|
852
|
-
* finishes first" is the kind of guarantee this whole mechanism exists to replace.
|
|
853
|
-
*/
|
|
833
|
+
// Until a builder is back nothing watches (Bun's `fs.watch` drops events, a new builder primes from disk), so
|
|
834
|
+
// stamp the backend graph now. Earliest open wins: over-reporting costs a restart, under-reporting stale code.
|
|
854
835
|
#openBuilderGap(reason: string): void {
|
|
855
836
|
if (this.#builderGapStamp) return;
|
|
856
837
|
if (!this.#backendGraph.ready) {
|
|
857
|
-
// No graph means no stamps at all: the host is on path-role fallback rules and does not know which
|
|
858
|
-
// files the backend runs. Said out loud, because a hole nobody can see reads like coverage.
|
|
859
838
|
this.logger.verbose(`[builder-gap] no backend graph yet; a save during this ${reason} goes unnoticed`);
|
|
860
839
|
return;
|
|
861
840
|
}
|
|
@@ -872,7 +851,6 @@ export class AkanAppHost {
|
|
|
872
851
|
return null;
|
|
873
852
|
});
|
|
874
853
|
}
|
|
875
|
-
/** Which backend files moved while the builder was away. Consumes the baseline. */
|
|
876
854
|
async #takeBuilderGapChanges(): Promise<string[]> {
|
|
877
855
|
const stamping = this.#builderGapStamp;
|
|
878
856
|
this.#builderGapStamp = null;
|
|
@@ -880,8 +858,6 @@ export class AkanAppHost {
|
|
|
880
858
|
if (!before) return [];
|
|
881
859
|
const moved = filesChangedSince(before, await this.#backendGraph.fingerprint());
|
|
882
860
|
if (moved.length === 0) {
|
|
883
|
-
// Said out loud even when the answer is "nothing", because the alternative — silence — is also
|
|
884
|
-
// what a stamp that was never taken looks like.
|
|
885
861
|
this.logger.verbose(`[builder-gap] none of the ${before.size} stamped backend file(s) moved`);
|
|
886
862
|
return moved;
|
|
887
863
|
}
|
|
@@ -890,19 +866,12 @@ export class AkanAppHost {
|
|
|
890
866
|
);
|
|
891
867
|
return moved;
|
|
892
868
|
}
|
|
893
|
-
|
|
894
|
-
* Drop the stamps without acting on them, for a path that is replacing the backend anyway.
|
|
895
|
-
*
|
|
896
|
-
* Not merely tidy: a baseline left open outlives the gap it was taken for and is compared against the
|
|
897
|
-
* *next* one, where everything saved in between reads as changed — one backend restart for work that
|
|
898
|
-
* has already been done.
|
|
899
|
-
*/
|
|
869
|
+
// A baseline left open is compared against the next gap, where everything saved since reads as changed.
|
|
900
870
|
#discardBuilderGap(reason: string): void {
|
|
901
871
|
if (!this.#builderGapStamp) return;
|
|
902
872
|
this.#builderGapStamp = null;
|
|
903
873
|
this.logger.verbose(`[builder-gap] stamps dropped (${reason})`);
|
|
904
874
|
}
|
|
905
|
-
/** The restart path's half of the above: the wake path merges its own file list in instead. */
|
|
906
875
|
async #restartBackendForGapChanges(): Promise<void> {
|
|
907
876
|
const moved = await this.#takeBuilderGapChanges();
|
|
908
877
|
if (moved.length === 0) return;
|
|
@@ -915,70 +884,47 @@ export class AkanAppHost {
|
|
|
915
884
|
for (const message of pending) this.#sendToBuilder(message);
|
|
916
885
|
}
|
|
917
886
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
this.logger.verbose(
|
|
921
|
-
`[builder] holding ${message.type} until the builder is ready (${this.#pendingBuilderMessages.length} waiting)`,
|
|
922
|
-
);
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
/**
|
|
926
|
-
* Drop everything held for a backend generation that has been replaced, answering nobody.
|
|
927
|
-
*
|
|
928
|
-
* A held request carries the id the *departing* backend gave it, and `startGeneration` only clears the
|
|
929
|
-
* router — so a replay after the restart would issue that id under the new generation and settle the
|
|
930
|
-
* builder's answer to an id the new backend is itself handing out from 1. That is exactly the
|
|
931
|
-
* misdelivery `BuilderRequestRouter` exists to prevent, reached through the hold queue instead: the
|
|
932
|
-
* new backend gets another route's manifest delta, and its real answer is dropped as unclaimed.
|
|
933
|
-
*
|
|
934
|
-
* Nothing is answered because every caller of `#startBackend` has already stopped the old backend, so
|
|
935
|
-
* a failure reply would only be dropped as undeliverable.
|
|
936
|
-
*/
|
|
887
|
+
// Held requests carry the departing backend's ids, which the new one reissues from 1, so a replay would
|
|
888
|
+
// misdeliver answers; nothing is answered because the old backend is already stopped.
|
|
937
889
|
#discardPendingBuilderMessages(reason: string): void {
|
|
938
890
|
const held = this.#pendingBuilderMessages.splice(0);
|
|
939
891
|
if (held.length === 0) return;
|
|
940
892
|
this.logger.verbose(`[builder] dropped ${held.length} held builder request(s): ${reason}`);
|
|
941
893
|
}
|
|
942
|
-
/**
|
|
943
|
-
* Answer everything still waiting on a builder that is not coming back. Held requests are otherwise
|
|
944
|
-
* invisible to the backend, which would sit on them until its own timeout with no reason given.
|
|
945
|
-
*/
|
|
946
894
|
#failPendingBuilderMessages(reason: string): void {
|
|
947
895
|
const held = this.#pendingBuilderMessages.splice(0);
|
|
948
896
|
if (held.length === 0) return;
|
|
949
897
|
this.logger.warn(`failing ${held.length} held builder request(s): ${reason}`);
|
|
950
|
-
for (const message of held)
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
898
|
+
for (const message of held) this.#failBuilderRequest(message, reason);
|
|
899
|
+
}
|
|
900
|
+
#failBuilderRequest(message: BuilderMessage, error: string): boolean {
|
|
901
|
+
if (message.type === "build-route")
|
|
902
|
+
this.#sendToBackend({ type: "build-route-res", id: message.id, ok: false, error });
|
|
903
|
+
else if (message.type === "build-csr")
|
|
904
|
+
this.#sendToBackend({ type: "build-csr-res", id: message.id, ok: false, error });
|
|
905
|
+
else return false;
|
|
906
|
+
return true;
|
|
956
907
|
}
|
|
957
908
|
#recycleBuilderForRss(reason: string): void {
|
|
958
|
-
//
|
|
959
|
-
// pending backend restart is disruption enough on its own; either way, dropping the recycle here
|
|
960
|
-
// costs nothing — the next build re-reports an over-ceiling rss and arms it again.
|
|
909
|
+
// Dropping the recycle costs nothing: the next build re-reports an over-ceiling rss and re-arms it.
|
|
961
910
|
if (this.#pendingRecycle || this.#restartTimer) {
|
|
962
911
|
this.logger.verbose(`[builder-recycle] skipped (${reason}); a dev restart is already pending`);
|
|
963
912
|
return;
|
|
964
913
|
}
|
|
965
914
|
if (!this.#builder?.recycle(reason)) return;
|
|
966
915
|
this.#lastRssRecycleAtMono = performance.now();
|
|
967
|
-
// Stamped at the request
|
|
968
|
-
// stopped taking work: whether its watcher still gets an event out is not something to rely on.
|
|
916
|
+
// Stamped at the request, not the exit: a draining builder has already stopped taking work.
|
|
969
917
|
this.#openBuilderGap("builder recycle");
|
|
970
918
|
}
|
|
971
919
|
async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
|
|
972
920
|
this.#logDevPlan(message);
|
|
973
|
-
// More batches are on the way, so push the recycle out until the dev server settles.
|
|
974
921
|
if (this.#rssRecycleReason) this.#armRssRecycle(this.#rssRecycleReason);
|
|
975
|
-
//
|
|
976
|
-
// AND re-runs the prepare step, so check it first when a batch carries both actions.
|
|
922
|
+
// Checked first: a dev-host restart also recycles the builder and backend.
|
|
977
923
|
const wantsDevHostRestart = shouldRestartDevHostByDevPlan(message);
|
|
978
924
|
const pending = this.#pendingRecycle;
|
|
979
|
-
// A
|
|
980
|
-
|
|
981
|
-
if (wantsDevHostRestart || shouldRestartBuilderByDevPlan(message) ||
|
|
925
|
+
// A deferred recycle resumes on the next code batch; a css-only batch cannot heal a compile error.
|
|
926
|
+
const resumes = !!pending && message.kinds.includes("code") && this.#touchesFailedRecycle(message.files);
|
|
927
|
+
if (wantsDevHostRestart || shouldRestartBuilderByDevPlan(message) || resumes) {
|
|
982
928
|
const refreshConfig = wantsDevHostRestart || (pending?.refreshConfig ?? false);
|
|
983
929
|
const merged = pending ? mergeInvalidateMessages(pending.message, message) : message;
|
|
984
930
|
const generation = message.devPlan?.generation ?? message.generation;
|
|
@@ -986,14 +932,7 @@ export class AkanAppHost {
|
|
|
986
932
|
this.#deferRecycle(merged, { refreshConfig, generation });
|
|
987
933
|
return;
|
|
988
934
|
}
|
|
989
|
-
this.#
|
|
990
|
-
try {
|
|
991
|
-
if (refreshConfig) await this.#restartDevHost(merged);
|
|
992
|
-
else await this.#restartDevChildren(merged);
|
|
993
|
-
} catch (err) {
|
|
994
|
-
this.#recordDevHostRestartFailure(merged, err, refreshConfig ? "Config" : "Runtime metadata");
|
|
995
|
-
this.#resurrectDevChildren(merged);
|
|
996
|
-
}
|
|
935
|
+
await this.#applyRecycle(merged, refreshConfig);
|
|
997
936
|
return;
|
|
998
937
|
}
|
|
999
938
|
if (await this.#shouldRestartBackend(message)) {
|
|
@@ -1002,6 +941,38 @@ export class AkanAppHost {
|
|
|
1002
941
|
}
|
|
1003
942
|
this.#sendToBackend(message);
|
|
1004
943
|
}
|
|
944
|
+
async #applyRecycle(message: Extract<BuilderMessage, { type: "invalidate" }>, refreshConfig: boolean): Promise<void> {
|
|
945
|
+
if (this.#stopping) return;
|
|
946
|
+
this.#pendingRecycle = null;
|
|
947
|
+
const progress = { applied: !refreshConfig };
|
|
948
|
+
try {
|
|
949
|
+
if (refreshConfig) await this.#restartDevHost(message, progress);
|
|
950
|
+
else await this.#restartDevChildren(message);
|
|
951
|
+
} catch (err) {
|
|
952
|
+
const kind = refreshConfig ? "Config" : "Runtime metadata";
|
|
953
|
+
const generation = this.#recordDevHostRestartFailure(message, err, kind, { applied: progress.applied });
|
|
954
|
+
//? Kept only when the change itself did not apply: a builder that booted degraded (a broken akan.config.ts)
|
|
955
|
+
//? takes the fixing save itself and sends no invalidate, so without this the old config would keep running.
|
|
956
|
+
if (progress.applied) this.#restartFailedAfterApply = generation;
|
|
957
|
+
else this.#pendingRecycle = { message, refreshConfig, failed: true, failedGeneration: generation };
|
|
958
|
+
this.#resurrectDevChildren(message);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
//? The first green pages build after a failed restart that touches its change is the fix: the change applies then. A
|
|
962
|
+
//? cause no save fixes (an env value) waits for the next change of those files, not for every green save.
|
|
963
|
+
async #resumeFailedRecycle(status: DevBuildStatus): Promise<void> {
|
|
964
|
+
const pending = this.#pendingRecycle;
|
|
965
|
+
if (!pending?.failed || !status.ok || status.phase !== "pages" || !this.#touchesFailedRecycle(status.files)) return;
|
|
966
|
+
//? Newer than the failure: the batch that carried the change reports its own pages ok while the restart runs.
|
|
967
|
+
if (status.generation <= (pending.failedGeneration ?? -1)) return;
|
|
968
|
+
await this.#applyRecycle(pending.message, pending.refreshConfig);
|
|
969
|
+
}
|
|
970
|
+
#touchesFailedRecycle(files: string[]): boolean {
|
|
971
|
+
const pending = this.#pendingRecycle;
|
|
972
|
+
if (!pending?.failed) return true;
|
|
973
|
+
const changed = new Set(pending.message.files);
|
|
974
|
+
return files.some((file) => changed.has(file));
|
|
975
|
+
}
|
|
1005
976
|
#deferRecycle(
|
|
1006
977
|
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
1007
978
|
{ refreshConfig, generation }: { refreshConfig: boolean; generation?: number },
|
|
@@ -1021,11 +992,7 @@ export class AkanAppHost {
|
|
|
1021
992
|
this.#recordBuildStatus(status);
|
|
1022
993
|
this.#sendOrQueueBuildStatus(status);
|
|
1023
994
|
}
|
|
1024
|
-
|
|
1025
|
-
* A failed recycle must never leave the dev server dead: bring the backend back up on the
|
|
1026
|
-
* last-good artifact so the error overlay stays reachable, and keep retrying the builder —
|
|
1027
|
-
* the builder is the file watcher, so without it no edit could ever trigger a recovery.
|
|
1028
|
-
*/
|
|
995
|
+
// The backend returns on the last-good artifact so the error overlay stays reachable.
|
|
1029
996
|
#resurrectDevChildren(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
|
|
1030
997
|
const generation = message.devPlan?.generation ?? message.generation;
|
|
1031
998
|
if (!this.#backend) this.#startBackend({ generation, files: message.files });
|
|
@@ -1055,11 +1022,14 @@ export class AkanAppHost {
|
|
|
1055
1022
|
}
|
|
1056
1023
|
this.#builderRecoveryAttempts = 0;
|
|
1057
1024
|
this.logger.info("[builder-recovery] builder recovered");
|
|
1058
|
-
// The other way a builder comes back: a wake that failed, or a start that had to be retried. Either
|
|
1059
|
-
// way the tree went unwatched, and this is the first moment there is something to act on it.
|
|
1060
1025
|
void this.#restartBackendForGapChanges();
|
|
1026
|
+
const failedAfterApply = this.#restartFailedAfterApply;
|
|
1027
|
+
this.#restartFailedAfterApply = null;
|
|
1061
1028
|
const status: DevBuildStatus = {
|
|
1062
|
-
generation:
|
|
1029
|
+
generation:
|
|
1030
|
+
failedAfterApply !== null
|
|
1031
|
+
? this.#nextBackendBuildStatusGeneration(failedAfterApply + 1)
|
|
1032
|
+
: (reason.generation ?? this.#nextBackendBuildStatusGeneration()),
|
|
1063
1033
|
phase: "scan",
|
|
1064
1034
|
ok: true,
|
|
1065
1035
|
files: reason.files,
|
|
@@ -1071,7 +1041,6 @@ export class AkanAppHost {
|
|
|
1071
1041
|
this.#startBackend({ generation: reason.generation, files: reason.files });
|
|
1072
1042
|
}
|
|
1073
1043
|
}
|
|
1074
|
-
/** A backend that gave up recovering on broken code gets one fresh chance whenever a build goes green. */
|
|
1075
1044
|
#reviveBackendAfterGreenBuild(status: DevBuildStatus): void {
|
|
1076
1045
|
if (!status.ok || !this.#backendGaveUp || this.#backend || this.#backendRecoveryTimer) return;
|
|
1077
1046
|
this.logger.info(`[backend-recovery] build went green (generation=${status.generation}); retrying backend`);
|
|
@@ -1085,61 +1054,45 @@ export class AkanAppHost {
|
|
|
1085
1054
|
);
|
|
1086
1055
|
await this.#recycleDevChildren(message);
|
|
1087
1056
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
*/
|
|
1095
|
-
async #restartDevHost(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<void> {
|
|
1057
|
+
// The config is re-imported with a cache-busting query, but modules it imports stay cached: a change inside an
|
|
1058
|
+
// imported plugin file still needs a manual `akan start` restart.
|
|
1059
|
+
async #restartDevHost(
|
|
1060
|
+
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
1061
|
+
progress?: { applied: boolean },
|
|
1062
|
+
): Promise<void> {
|
|
1096
1063
|
const generation = message.devPlan?.generation ?? message.generation;
|
|
1097
1064
|
this.logger.warn(
|
|
1098
1065
|
`[dev-host] config change detected; restarting dev host generation=${generation ?? "(unknown)"} files=${message.files.length}`,
|
|
1099
1066
|
);
|
|
1100
|
-
await this.#recycleDevChildren(message, { refreshConfig: true });
|
|
1067
|
+
await this.#recycleDevChildren(message, { refreshConfig: true, progress });
|
|
1101
1068
|
}
|
|
1102
1069
|
async #recycleDevChildren(
|
|
1103
1070
|
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
1104
|
-
{ refreshConfig = false }: { refreshConfig?: boolean } = {},
|
|
1071
|
+
{ refreshConfig = false, progress }: { refreshConfig?: boolean; progress?: { applied: boolean } } = {},
|
|
1105
1072
|
): Promise<void> {
|
|
1106
1073
|
const generation = message.devPlan?.generation ?? message.generation;
|
|
1107
|
-
|
|
1108
|
-
clearTimeout(this.#restartTimer);
|
|
1109
|
-
this.#restartTimer = null;
|
|
1110
|
-
}
|
|
1111
|
-
if (this.#backendRecoveryTimer) {
|
|
1112
|
-
clearTimeout(this.#backendRecoveryTimer);
|
|
1113
|
-
this.#backendRecoveryTimer = null;
|
|
1114
|
-
}
|
|
1115
|
-
if (this.#builderRecoveryTimer) {
|
|
1116
|
-
clearTimeout(this.#builderRecoveryTimer);
|
|
1117
|
-
this.#builderRecoveryTimer = null;
|
|
1118
|
-
}
|
|
1074
|
+
this.#clearRestartTimers();
|
|
1119
1075
|
this.#builderRecoveryAttempts = 0;
|
|
1120
1076
|
this.#pendingRestartReason = null;
|
|
1077
|
+
await this.#backendRestart;
|
|
1121
1078
|
this.#lastGoodFrontend = {};
|
|
1122
1079
|
this.#buildStatusByPhase.clear();
|
|
1123
1080
|
this.#pendingBuildStatusReplay = [];
|
|
1124
1081
|
await this.#stopBackend();
|
|
1125
1082
|
this.#stopBuilder();
|
|
1083
|
+
this.#patcherOff = false;
|
|
1126
1084
|
if (refreshConfig) {
|
|
1127
1085
|
await this.app.getConfig({ refresh: true });
|
|
1128
|
-
// Merge
|
|
1129
|
-
// (e.g. REDIS_HOST from the tunnel), and the spawned children must keep seeing them.
|
|
1086
|
+
// Merge, not replace: `start()` added values prepare does not produce (REDIS_HOST from the tunnel).
|
|
1130
1087
|
const { env } = await this.app.prepareCommand("start");
|
|
1131
1088
|
Object.assign(this.env, env);
|
|
1089
|
+
if (progress) progress.applied = true;
|
|
1132
1090
|
}
|
|
1133
1091
|
await this.#backendGraph.refresh();
|
|
1134
1092
|
await this.#startBuilder();
|
|
1135
1093
|
this.#startBackend({ generation, files: message.files });
|
|
1136
1094
|
}
|
|
1137
|
-
|
|
1138
|
-
* `supersede` bypasses the generation check for a builder that just replaced another one. Its
|
|
1139
|
-
* generation counter restarts at 0, so its announcement looks stale to `shouldReplaceLastGoodMessage`
|
|
1140
|
-
* — and leaving the old payload cached would make the next backend restart replay an artifact the
|
|
1141
|
-
* builder that produced it no longer serves.
|
|
1142
|
-
*/
|
|
1095
|
+
// `supersede`: a recycled builder's re-announcement is what is on disk now, whatever generation it carries.
|
|
1143
1096
|
#recordLastGood(
|
|
1144
1097
|
message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
|
|
1145
1098
|
{ supersede = false }: { supersede?: boolean } = {},
|
|
@@ -1162,22 +1115,29 @@ export class AkanAppHost {
|
|
|
1162
1115
|
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
1163
1116
|
err: unknown,
|
|
1164
1117
|
kind: "Config" | "Runtime metadata",
|
|
1165
|
-
|
|
1118
|
+
{ applied }: { applied: boolean },
|
|
1119
|
+
): number {
|
|
1166
1120
|
const generation = message.devPlan?.generation ?? message.generation ?? this.#nextBackendBuildStatusGeneration();
|
|
1167
1121
|
const detail = err instanceof Error ? err.message : String(err);
|
|
1168
1122
|
this.logger.warn(`[dev-host] ${kind.toLowerCase()} restart failed generation=${generation}: ${detail}`);
|
|
1123
|
+
const names = message.files.slice(0, 3).map((file) => path.basename(file));
|
|
1124
|
+
const saved = names.length > 0 ? names.join(", ") : "the changed file";
|
|
1169
1125
|
const status: DevBuildStatus = {
|
|
1170
1126
|
generation,
|
|
1171
1127
|
phase: "scan",
|
|
1172
1128
|
ok: false,
|
|
1173
1129
|
files: message.files,
|
|
1174
|
-
message:
|
|
1130
|
+
message: applied
|
|
1131
|
+
? `${kind} change applied, but the dev server failed to restart: ${detail}. It is recovering on its own.`
|
|
1132
|
+
: `${kind} change failed to apply: ${detail}. The dev server keeps running on the previous ${kind.toLowerCase()}; once it is fixed, save ${saved} again or restart the dev server to apply it.`,
|
|
1175
1133
|
};
|
|
1176
1134
|
this.#recordBuildStatus(status);
|
|
1177
1135
|
this.#sendOrQueueBuildStatus(status);
|
|
1136
|
+
return generation;
|
|
1178
1137
|
}
|
|
1179
1138
|
#recordBuildStatus(status: DevBuildStatus): void {
|
|
1180
1139
|
const recovered = shouldMarkBuildPhaseRecovered(this.#buildStatusByPhase, status);
|
|
1140
|
+
if (shouldKeepBuildFailure(this.#buildStatusByPhase, status)) return;
|
|
1181
1141
|
this.#buildStatusByPhase.set(status.phase, status);
|
|
1182
1142
|
const label = `[build-status] generation=${status.generation} phase=${status.phase} ok=${status.ok} files=${status.files.length}`;
|
|
1183
1143
|
if (status.ok) this.logger.verbose(`${label}${recovered ? " recovered=1" : ""}`);
|
|
@@ -1193,6 +1153,12 @@ export class AkanAppHost {
|
|
|
1193
1153
|
}
|
|
1194
1154
|
this.#sendToBackend({ type: "build-status", data: status });
|
|
1195
1155
|
}
|
|
1156
|
+
//? A route status belongs to the route cache of the backend that asked for the build: a new backend builds its routes
|
|
1157
|
+
//? again, and reports each one itself.
|
|
1158
|
+
#forgetRouteBuildStatus(): void {
|
|
1159
|
+
this.#buildStatusByPhase.delete("route");
|
|
1160
|
+
this.#pendingBuildStatusReplay = this.#pendingBuildStatusReplay.filter((status) => status.phase !== "route");
|
|
1161
|
+
}
|
|
1196
1162
|
#replayBuilderState(): void {
|
|
1197
1163
|
if (!this.#backendReady) return;
|
|
1198
1164
|
if (this.#lastGoodFrontend.css) this.#sendToBackend(this.#lastGoodFrontend.css);
|
|
@@ -1202,7 +1168,6 @@ export class AkanAppHost {
|
|
|
1202
1168
|
this.#sendToBackend({ type: "build-status", data: status });
|
|
1203
1169
|
}
|
|
1204
1170
|
}
|
|
1205
|
-
/** One log line per planned generation, regardless of which action branch handles it. */
|
|
1206
1171
|
#logDevPlan(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
|
|
1207
1172
|
if (!message.devPlan) return;
|
|
1208
1173
|
const { generation, roles, actions, reasonByFile } = message.devPlan;
|
|
@@ -1219,7 +1184,7 @@ export class AkanAppHost {
|
|
|
1219
1184
|
return shouldRestart;
|
|
1220
1185
|
}
|
|
1221
1186
|
if (message.kinds.includes("code")) await this.#backendGraph.refresh();
|
|
1222
|
-
if (message.files.some((file) => this.#
|
|
1187
|
+
if (message.files.some((file) => this.#backendGraph.has(file))) return true;
|
|
1223
1188
|
if (!this.#backendGraph.lastRefreshSucceeded) {
|
|
1224
1189
|
const fallbackFiles = message.files.filter((file) =>
|
|
1225
1190
|
isLegacyBackendFallbackFile(file, this.app.workspace.workspaceRoot),
|
|
@@ -1233,9 +1198,6 @@ export class AkanAppHost {
|
|
|
1233
1198
|
}
|
|
1234
1199
|
return false;
|
|
1235
1200
|
}
|
|
1236
|
-
#isBackendFile(file: string): boolean {
|
|
1237
|
-
return this.#backendGraph.has(file);
|
|
1238
|
-
}
|
|
1239
1201
|
async #startBuilder({
|
|
1240
1202
|
announceBootState = false,
|
|
1241
1203
|
}: {
|
|
@@ -1254,6 +1216,7 @@ export class AkanAppHost {
|
|
|
1254
1216
|
{
|
|
1255
1217
|
stdio: this.stdio,
|
|
1256
1218
|
onOutput: (kind, text) => void (kind === "stderr" ? process.stderr : process.stdout).write(text),
|
|
1219
|
+
patcherOff: this.#patcherOff,
|
|
1257
1220
|
},
|
|
1258
1221
|
);
|
|
1259
1222
|
try {
|
|
@@ -1267,7 +1230,6 @@ export class AkanAppHost {
|
|
|
1267
1230
|
this.app.verbose(`[cli] builder failed before ready; retrying (${attempt + 1}/${BUILDER_START_MAX_ATTEMPTS})`);
|
|
1268
1231
|
}
|
|
1269
1232
|
}
|
|
1270
|
-
// Out of attempts: no builder is coming, so anything held for one is waiting on nothing.
|
|
1271
1233
|
this.#failPendingBuilderMessages("builder failed to start");
|
|
1272
1234
|
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1273
1235
|
}
|
|
@@ -1312,59 +1274,37 @@ export class AkanAppHost {
|
|
|
1312
1274
|
#sendToBuilder(message: BuilderMessage): void {
|
|
1313
1275
|
this.#markDevActivity();
|
|
1314
1276
|
if (this.#suspended || this.#waking) {
|
|
1315
|
-
// A navigation must not fail just because the sandbox was idle — hold the request and let the
|
|
1316
|
-
// builder the wake brings up answer it.
|
|
1317
1277
|
this.#pendingBuilderMessages.push(message);
|
|
1318
1278
|
void this.#wakeFromIdle(`${message.type} arrived while suspended`);
|
|
1319
1279
|
return;
|
|
1320
1280
|
}
|
|
1321
|
-
//
|
|
1322
|
-
// session armed it and pass the flag through `env`, which is re-read on every builder spawn, so a
|
|
1323
|
-
// builder restart re-arms itself instead of silently breaking an in-progress mobile dev session.
|
|
1281
|
+
// In `env`, which every builder spawn re-reads, so a restarted builder stays armed for a mobile dev session.
|
|
1324
1282
|
if (message.type === "build-csr" && this.env.AKAN_DEV_CSR_REBUILD !== "1") {
|
|
1325
1283
|
Object.assign(this.env, { AKAN_DEV_CSR_REBUILD: "1" });
|
|
1326
1284
|
this.logger.verbose(`[csr] armed dev CSR rebuilds (${message.reason})`);
|
|
1327
1285
|
}
|
|
1328
|
-
// Renumbered
|
|
1329
|
-
// asked; the failure replies below still use `message.id`, which is that backend's own.
|
|
1286
|
+
// Renumbered per backend generation; the failure replies below use that backend's own `message.id`.
|
|
1330
1287
|
if (message.type === "build-route" || message.type === "build-csr") {
|
|
1331
1288
|
const outgoing = this.#builderRequests.issue(message);
|
|
1332
1289
|
if (this.#builder?.send(outgoing)) return;
|
|
1333
1290
|
this.#builderRequests.withdraw(outgoing.id);
|
|
1334
1291
|
} else if (this.#builder?.send(message)) return;
|
|
1335
1292
|
const status = this.#builder?.status ?? "stopped";
|
|
1336
|
-
// A
|
|
1337
|
-
// request that landed in that window is the page a developer is waiting on. Failing it here is what
|
|
1338
|
-
// produced a dead tab telling the reader to reload, with nothing retrying on its own — while the
|
|
1339
|
-
// suspend path a few lines up has always held requests for exactly this reason. `BuilderRpc`'s own
|
|
1340
|
-
// timeout still bounds the wait, so holding cannot hang a request forever.
|
|
1293
|
+
// A returning builder is a gap, not a failure: hold the request (`BuilderRpc`'s timeout still bounds it).
|
|
1341
1294
|
if (shouldHoldForReturningBuilder({ status, heldCount: this.#pendingBuilderMessages.length })) {
|
|
1342
|
-
this.#
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
this.#sendToBackend({
|
|
1347
|
-
type: "build-route-res",
|
|
1348
|
-
id: message.id,
|
|
1349
|
-
ok: false,
|
|
1350
|
-
error: `builder is ${status}; reload after the builder is ready`,
|
|
1351
|
-
});
|
|
1352
|
-
return;
|
|
1353
|
-
}
|
|
1354
|
-
if (message.type === "build-csr") {
|
|
1355
|
-
this.#sendToBackend({
|
|
1356
|
-
type: "build-csr-res",
|
|
1357
|
-
id: message.id,
|
|
1358
|
-
ok: false,
|
|
1359
|
-
error: `builder is ${status}; reload after the builder is ready`,
|
|
1360
|
-
});
|
|
1295
|
+
this.#pendingBuilderMessages.push(message);
|
|
1296
|
+
this.logger.verbose(
|
|
1297
|
+
`[builder] holding ${message.type} until the builder is ready (${this.#pendingBuilderMessages.length} waiting)`,
|
|
1298
|
+
);
|
|
1361
1299
|
return;
|
|
1362
1300
|
}
|
|
1301
|
+
if (this.#failBuilderRequest(message, `builder is ${status}; reload after the builder is ready`)) return;
|
|
1363
1302
|
this.logger.warn("akanAppHost builder is not running");
|
|
1364
1303
|
}
|
|
1365
1304
|
#stopBuilder(): void {
|
|
1366
1305
|
this.#cancelRssRecycle();
|
|
1367
1306
|
if (!this.#builder) return;
|
|
1307
|
+
if (this.#builder.patcherOff) this.#patcherOff = true;
|
|
1368
1308
|
this.#builder.stop();
|
|
1369
1309
|
this.#builder = null;
|
|
1370
1310
|
}
|