@akanjs/devkit 3.0.0-beta.2 → 3.0.0-beta.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +6 -8
- package/agentsIndex.test.ts +4 -7
- package/agentsIndex.ts +6 -22
- package/akanApp/BackendImportGraph.test.ts +8 -22
- package/akanApp/BackendImportGraph.ts +1 -20
- package/akanApp/akanApp.host.test.ts +1 -3
- package/akanApp/akanApp.host.ts +82 -254
- package/akanApp/devHostPolicy.test.ts +10 -34
- package/akanApp/devHostPolicy.ts +12 -98
- package/akanConfig/akanConfig.test.ts +85 -69
- package/akanConfig/akanConfig.ts +69 -153
- package/akanConfig/types.ts +1 -0
- package/akanContext.ts +106 -159
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.test.ts +5 -3
- package/applicationBuildRunner.ts +54 -61
- package/applicationReleasePackager.ts +24 -44
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +9 -30
- package/artifact/implicitRootLayout.ts +34 -106
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/artifact/routeSeedIndex.ts +11 -44
- package/biome.base.json +6 -1
- package/biomeBase.ts +2 -5
- package/capacitorApp.test.ts +32 -16
- package/capacitorApp.ts +110 -238
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +77 -39
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +26 -5
- package/cloud/globalConfig.ts +70 -32
- package/codeAgent/agent/AkanCodeServices.ts +97 -0
- package/codeAgent/agent/AkanContextFiles.ts +94 -0
- package/codeAgent/agent/CodeAgent.ts +551 -0
- package/codeAgent/agent/CodeAgentAsks.ts +66 -0
- package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
- package/codeAgent/agent/CodeAgentGate.ts +93 -0
- package/codeAgent/agent/CodeAgentProxy.ts +82 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
- package/codeAgent/agent/CodeAgentUi.ts +47 -0
- package/codeAgent/agent/CodeMailbox.ts +148 -0
- package/codeAgent/agent/CodeSessionFork.ts +56 -0
- package/codeAgent/agent/CodeSessionIndex.ts +78 -0
- package/codeAgent/agent/SubagentPool.ts +214 -0
- package/codeAgent/agent/akanCodeModel.ts +47 -0
- package/codeAgent/agent/akanCodePaths.ts +31 -0
- package/codeAgent/agent/akanContextFiles.test.ts +114 -0
- package/codeAgent/agent/akanSystemPrompt.ts +46 -0
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +523 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/agent/codeMailbox.test.ts +76 -0
- package/codeAgent/agent/codeSessionFork.test.ts +58 -0
- package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
- package/codeAgent/agent/consoleToStderr.ts +8 -0
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +82 -0
- package/codeAgent/feedback/PreviewChrome.ts +82 -0
- package/codeAgent/feedback/PreviewFeedback.ts +46 -0
- package/codeAgent/feedback/PreviewView.ts +95 -0
- package/codeAgent/feedback/TurnFeedback.ts +98 -0
- package/codeAgent/feedback/VerifyFeedback.ts +40 -0
- package/codeAgent/feedback/previewChrome.test.ts +73 -0
- package/codeAgent/index.ts +34 -0
- package/codeAgent/skills/akan-code/SKILL.md +130 -0
- package/codeAgent/skills/akan-debug/SKILL.md +42 -0
- package/codeAgent/skills/akan-deps/SKILL.md +50 -0
- package/codeAgent/skills/akan-err/SKILL.md +39 -0
- package/codeAgent/skills/akan-module/SKILL.md +45 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
- package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
- package/codeAgent/skills/akan-service/SKILL.md +34 -0
- package/codeAgent/skills/akan-store/SKILL.md +67 -0
- package/codeAgent/skills/akan-ui/SKILL.md +63 -0
- package/codeAgent/skills/akan-validate/SKILL.md +44 -0
- package/codeAgent/tools/AkanCli.ts +50 -0
- package/codeAgent/tools/AkanCodePlugins.ts +136 -0
- package/codeAgent/tools/AkanEditScope.ts +48 -0
- package/codeAgent/tools/AkanEnvKeys.ts +50 -0
- package/codeAgent/tools/AkanToolPack.ts +92 -0
- package/codeAgent/tools/AkanVerifier.ts +48 -0
- package/codeAgent/tools/AskToolPack.ts +99 -0
- package/codeAgent/tools/MailToolPack.ts +76 -0
- package/codeAgent/tools/McpClient.ts +187 -0
- package/codeAgent/tools/McpOAuth.ts +233 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
- package/codeAgent/tools/McpServerConfig.ts +129 -0
- package/codeAgent/tools/McpSignIn.ts +158 -0
- package/codeAgent/tools/McpTokenStore.ts +81 -0
- package/codeAgent/tools/McpToolPack.ts +199 -0
- package/codeAgent/tools/SessionToolPack.ts +91 -0
- package/codeAgent/tools/WebToolPack.ts +147 -0
- package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
- package/codeAgent/tools/askToolPack.test.ts +116 -0
- package/codeAgent/tools/mcpOAuth.test.ts +612 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
- package/codegenLock.test.ts +4 -13
- package/codegenLock.ts +3 -17
- package/commandDecorators/argMeta.ts +1 -5
- package/commandDecorators/camelToKebabCase.ts +1 -0
- package/commandDecorators/command.ts +46 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +2 -10
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +68 -145
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +31 -80
- package/devkitUtils.test.ts +48 -20
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +132 -106
- package/executors.ts +194 -484
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +5 -3
- package/fileSys.ts +6 -8
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +27 -38
- package/frontendBuild/autoImportSync.ts +31 -65
- package/frontendBuild/buildRouteClient.test.ts +62 -37
- package/frontendBuild/bundleDefine.ts +12 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.ts +6 -41
- package/frontendBuild/csrArtifactBuilder.ts +6 -25
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +29 -78
- package/frontendBuild/cssImportResolver.ts +6 -19
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/fontOptimizer.test.ts +6 -14
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +46 -69
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +8 -32
- package/frontendBuild/hmrWatcher.ts +14 -71
- package/frontendBuild/pagesBundleBuilder.ts +4 -29
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/routeClientBuilder.ts +23 -46
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
- package/frontendBuild/styleContract.ts +1 -8
- package/frontendBuild/themeValidator.ts +4 -26
- package/frontendBuild/watchRootResolver.test.ts +23 -42
- package/frontendBuild/watchRootResolver.ts +5 -27
- package/getRelatedCnsts.ts +12 -92
- package/incrementalBuilder/buildBatch.proc.ts +22 -57
- package/incrementalBuilder/buildBatchProtocol.ts +8 -36
- package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
- package/incrementalBuilder/buildBatchRunner.ts +10 -31
- package/incrementalBuilder/builderChannel.test.ts +24 -49
- package/incrementalBuilder/builderChannel.ts +6 -37
- package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
- package/incrementalBuilder/builderRequestRouter.ts +5 -24
- package/incrementalBuilder/devWatchBatch.test.ts +21 -24
- package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
- package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +49 -161
- package/index.ts +5 -17
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +47 -194
- package/integration/devStabilityHarness.test.ts +13 -28
- package/integration/devStabilityHarness.ts +65 -330
- package/integration/ssrMemoryProbe.ts +41 -167
- package/libSource.test.ts +4 -15
- package/libSource.ts +19 -31
- package/lint/gritRules.test.ts +8 -24
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/linter.test.ts +1 -2
- package/linter.ts +26 -116
- package/mobile/mobileTarget.ts +14 -13
- package/package.json +10 -5
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +46 -0
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +56 -0
- package/platformTest/IdleWatchedProcess.ts +73 -0
- package/platformTest/LinuxDockerTarget.ts +105 -0
- package/platformTest/PlatformTestRun.test.ts +64 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +142 -0
- package/platformTest/TestSnapshot.test.ts +72 -0
- package/platformTest/TestSnapshot.ts +103 -0
- package/platformTest/WindowsSshTarget.ts +151 -0
- package/prompter.ts +3 -13
- package/qualityScanner.test.ts +9 -26
- package/qualityScanner.ts +197 -285
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +7 -42
- package/scanInfo.ts +87 -216
- package/selectModel.ts +1 -2
- package/semver.ts +3 -5
- package/slicePlanner.test.ts +6 -24
- package/slicePlanner.ts +11 -32
- package/spinner.test.ts +3 -8
- package/spinner.ts +4 -16
- package/sshTunnel.ts +1 -6
- package/ssrScanner.test.ts +1 -6
- package/ssrScanner.ts +103 -113
- package/stripAnsi.ts +3 -0
- package/subspace.test.ts +30 -25
- package/subspace.ts +81 -115
- package/subspaceConfig.ts +11 -9
- package/testHelpers.ts +194 -0
- package/transforms/asyncDefaultExportDetector.ts +3 -11
- package/transforms/barrelAnalyzer.ts +43 -135
- package/transforms/barrelImportsPlugin.ts +38 -175
- package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
- package/transforms/externalizeFrameworkPlugin.ts +13 -87
- package/transforms/moduleSyntax.ts +32 -0
- package/transforms/rscUseClientTransform.ts +35 -20
- package/transforms/transforms.test.ts +153 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +2 -22
- package/typeChecker.ts +7 -63
- package/types.ts +1 -0
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +101 -110
- package/workflow/index.ts +0 -1
- package/workflow/moduleIndex.test.ts +4 -15
- package/workflow/moduleIndex.ts +9 -49
- package/workflow/plan.ts +18 -25
- package/workflow/render.ts +80 -96
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +52 -69
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.ts +4 -19
- package/abstractCompactor.ts +0 -112
- package/aiEditor.test.ts +0 -60
- package/aiEditor.ts +0 -387
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/guideline.ts +0 -19
- package/local/optimize-resource/ipcprobe/child.ts +0 -1
- package/local/optimize-resource/ipcprobe/parent.ts +0 -11
- package/workflow/primitive.ts +0 -59
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -3,8 +3,7 @@ import type { BuilderMessage, BuilderMetrics, BuildPhase, ChangeBatch, DevBuildS
|
|
|
3
3
|
import type { App } from "../commandDecorators";
|
|
4
4
|
import { createTunnel } from "../createTunnel";
|
|
5
5
|
import { WorkspaceExecutor } from "../executors";
|
|
6
|
-
//
|
|
7
|
-
// tailwind stack, which is exactly what a suspended dev host must not be holding.
|
|
6
|
+
// Not via `../frontendBuild`: that barrel loads typescript and tailwind, which a suspended dev host must not hold.
|
|
8
7
|
import { HmrWatcher } from "../frontendBuild/hmrWatcher";
|
|
9
8
|
import { WatchRootResolver } from "../frontendBuild/watchRootResolver";
|
|
10
9
|
import { type DevStdioMode, IncrementalBuilderHost } from "../incrementalBuilder";
|
|
@@ -48,10 +47,11 @@ import {
|
|
|
48
47
|
|
|
49
48
|
const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr"]);
|
|
50
49
|
|
|
50
|
+
const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
|
|
51
|
+
|
|
51
52
|
const BACKEND_RESTART_DEBOUNCE_MS = 120;
|
|
52
53
|
|
|
53
|
-
//
|
|
54
|
-
// is never SIGKILLed while its replicas are still shutting down — that's what strands orphans.
|
|
54
|
+
// Above the gateway's ~5s child-shutdown wait: a gateway SIGKILLed mid-shutdown strands orphan replicas.
|
|
55
55
|
const BACKEND_GRACEFUL_TIMEOUT_MS = 8_000;
|
|
56
56
|
|
|
57
57
|
const BACKEND_RECOVERY_BASE_DELAY_MS = 1_000;
|
|
@@ -64,23 +64,15 @@ const BUILDER_READY_TIMEOUT_MS = 150000;
|
|
|
64
64
|
|
|
65
65
|
const BUILDER_START_MAX_ATTEMPTS = 3;
|
|
66
66
|
|
|
67
|
-
//
|
|
68
|
-
// still on their way to the builder, so an over-ceiling builder is replaced only once it goes quiet.
|
|
67
|
+
// Recycling mid-burst of saves would drop watcher events still on their way to the builder.
|
|
69
68
|
const BUILDER_RSS_RECYCLE_QUIET_MS = 750;
|
|
70
69
|
|
|
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.
|
|
70
|
+
// Linux returns about half of the bundler arenas after ~10-15s idle (macOS none), so re-read the peak before recycling.
|
|
77
71
|
const BUILDER_RSS_SETTLE_MS = 20_000;
|
|
78
72
|
|
|
79
|
-
/** Reading one process's rss is a millisecond of work; anything near this is a `ps` that is stuck. */
|
|
80
73
|
const PS_RSS_TIMEOUT_MS = 2_000;
|
|
81
74
|
|
|
82
|
-
// The builder is the file watcher
|
|
83
|
-
// backend the recovery loop never gives up — it only backs off.
|
|
75
|
+
// The builder is the file watcher, so unlike the backend its recovery never gives up; it only backs off.
|
|
84
76
|
const BUILDER_RECOVERY_BASE_DELAY_MS = 2_000;
|
|
85
77
|
|
|
86
78
|
const BUILDER_RECOVERY_MAX_DELAY_MS = 60_000;
|
|
@@ -118,7 +110,6 @@ export class AkanAppHost {
|
|
|
118
110
|
#lastRssRecycleAtMono: number | null = null;
|
|
119
111
|
#rssCeilingTightReports = 0;
|
|
120
112
|
#rssCeilingTightWarned = false;
|
|
121
|
-
/** Invalidates an in-flight settle check when anything else moves the builder underneath it. */
|
|
122
113
|
#rssSettleToken = 0;
|
|
123
114
|
#rssRecycleOver: { rssBytes: number; ceilingBytes: number } | null = null;
|
|
124
115
|
#rssCeilingAbandoned = false;
|
|
@@ -132,9 +123,7 @@ export class AkanAppHost {
|
|
|
132
123
|
#wokeAtMono: number | null = null;
|
|
133
124
|
#idleWatcher: HmrWatcher | null = null;
|
|
134
125
|
#suspendedChanges: ChangeBatch | null = null;
|
|
135
|
-
/** The stat sweep taken when the builder went away, awaited by the take; see `#openBuilderGap`. */
|
|
136
126
|
#builderGapStamp: Promise<SourceFingerprints | null> | null = null;
|
|
137
|
-
/** Requests that arrived while suspended, answered by the builder that the wake brings up. */
|
|
138
127
|
#pendingBuilderMessages: BuilderMessage[] = [];
|
|
139
128
|
readonly #builderRequests = new BuilderRequestRouter();
|
|
140
129
|
constructor(
|
|
@@ -171,24 +160,18 @@ export class AkanAppHost {
|
|
|
171
160
|
async stop() {
|
|
172
161
|
this.#cancelIdleSuspend();
|
|
173
162
|
this.#stopIdleWatcher();
|
|
174
|
-
|
|
175
|
-
clearTimeout(this.#restartTimer);
|
|
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
|
-
}
|
|
163
|
+
this.#clearRestartTimers();
|
|
186
164
|
// Before the backend goes away, while it can still receive the answer.
|
|
187
165
|
this.#failPendingBuilderMessages("dev server is shutting down");
|
|
188
166
|
await this.#stopBackend();
|
|
189
167
|
this.#stopBuilder();
|
|
190
168
|
return this;
|
|
191
169
|
}
|
|
170
|
+
#clearRestartTimers() {
|
|
171
|
+
for (const timer of [this.#restartTimer, this.#backendRecoveryTimer, this.#builderRecoveryTimer])
|
|
172
|
+
if (timer) clearTimeout(timer);
|
|
173
|
+
this.#restartTimer = this.#backendRecoveryTimer = this.#builderRecoveryTimer = null;
|
|
174
|
+
}
|
|
192
175
|
kill() {
|
|
193
176
|
void this.stop();
|
|
194
177
|
}
|
|
@@ -199,8 +182,7 @@ export class AkanAppHost {
|
|
|
199
182
|
return await createTunnel(type, { app: this.app, environment });
|
|
200
183
|
}
|
|
201
184
|
#startBackend(startStatus: { generation?: number; files: string[] } | null = null) {
|
|
202
|
-
// Before the spawn:
|
|
203
|
-
// this one, which numbers its requests from 1 all over again.
|
|
185
|
+
// Before the spawn: the new backend numbers its requests from 1 again, so old answers must not reach it.
|
|
204
186
|
this.#builderRequests.startGeneration();
|
|
205
187
|
this.#discardPendingBuilderMessages("the backend restarted while the builder was away");
|
|
206
188
|
this.#backendStartStatus = startStatus;
|
|
@@ -224,8 +206,7 @@ export class AkanAppHost {
|
|
|
224
206
|
return;
|
|
225
207
|
}
|
|
226
208
|
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.
|
|
209
|
+
// The gateway reports replica boot failures (crash loops, port conflicts) as build-status.
|
|
229
210
|
const status = this.#recordBackendBuildStatus({
|
|
230
211
|
generation: normalizeBackendReportedGeneration(msg.data.generation),
|
|
231
212
|
ok: msg.data.ok,
|
|
@@ -251,8 +232,7 @@ export class AkanAppHost {
|
|
|
251
232
|
this.#backend = backend;
|
|
252
233
|
this.logger.verbose(`backend spawned pid=${backend.pid}`);
|
|
253
234
|
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.
|
|
235
|
+
// Undrained pipes swallow runtime errors and leave the crash-loop stderr tail empty.
|
|
256
236
|
void this.#forwardBackendStream(backend.stderr as unknown as ReadableStream<Uint8Array> | undefined, "stderr");
|
|
257
237
|
void this.#forwardBackendStream(backend.stdout as unknown as ReadableStream<Uint8Array> | undefined, "stdout");
|
|
258
238
|
}
|
|
@@ -265,12 +245,7 @@ export class AkanAppHost {
|
|
|
265
245
|
this.#backendStderrTail.splice(0, this.#backendStderrTail.length - BACKEND_STDERR_TAIL_LIMIT);
|
|
266
246
|
}
|
|
267
247
|
}
|
|
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
|
-
*/
|
|
248
|
+
// Verbatim, not re-logged: a level floor would swallow runtime output and re-rendering doubles the timestamp.
|
|
274
249
|
async #forwardBackendStream(stream: ReadableStream<Uint8Array> | undefined | null, kind: "stdout" | "stderr") {
|
|
275
250
|
if (!stream) return;
|
|
276
251
|
const decoder = new TextDecoder();
|
|
@@ -480,11 +455,7 @@ export class AkanAppHost {
|
|
|
480
455
|
}
|
|
481
456
|
this.#sendToBackend(message);
|
|
482
457
|
}
|
|
483
|
-
|
|
484
|
-
* A recycled builder re-announces the artifact it booted with, because the backend read
|
|
485
|
-
* `base-artifact.json` once and never re-reads it. Dropping the announcement when the hashes match
|
|
486
|
-
* is what keeps the common case — a recycle with no concurrent edit — invisible to browsers.
|
|
487
|
-
*/
|
|
458
|
+
// The backend reads `base-artifact.json` once, so a recycled builder re-announces it; unchanged hashes are dropped.
|
|
488
459
|
#shouldRelayRecycledState(
|
|
489
460
|
message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
|
|
490
461
|
): boolean {
|
|
@@ -499,7 +470,6 @@ export class AkanAppHost {
|
|
|
499
470
|
#handleBuilderMetrics(metrics: BuilderMetrics): void {
|
|
500
471
|
if (this.#rssCeilingAbandoned) return;
|
|
501
472
|
const ceilingBytes = IncrementalBuilderHost.maxRssBytes();
|
|
502
|
-
const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
|
|
503
473
|
const decision = decideBuilderRssRecycle({
|
|
504
474
|
rssBytes: metrics.rssBytes,
|
|
505
475
|
ceilingBytes,
|
|
@@ -521,9 +491,7 @@ export class AkanAppHost {
|
|
|
521
491
|
this.#rssCeilingTightReports += 1;
|
|
522
492
|
if (this.#rssCeilingTightWarned || !shouldWarnBuilderRssCeilingTight(this.#rssCeilingTightReports)) return;
|
|
523
493
|
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.
|
|
494
|
+
// Warn only: the per-interval recycle is the one bound between the bundler's arenas and the sandbox limit.
|
|
527
495
|
this.logger.warn(
|
|
528
496
|
`[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
497
|
);
|
|
@@ -534,14 +502,7 @@ export class AkanAppHost {
|
|
|
534
502
|
{ rssBytes: metrics.rssBytes, ceilingBytes: ceilingBytes ?? 0 },
|
|
535
503
|
);
|
|
536
504
|
}
|
|
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
|
-
*/
|
|
505
|
+
// Read from the OS at ready: a metrics report arrives only after a build, measuring the work, not the floor.
|
|
545
506
|
async #checkRecycledBuilderFloor(): Promise<void> {
|
|
546
507
|
if (this.#rssCeilingAbandoned || this.#lastRssRecycleAtMono === null) return;
|
|
547
508
|
const ceilingBytes = IncrementalBuilderHost.maxRssBytes();
|
|
@@ -550,18 +511,15 @@ export class AkanAppHost {
|
|
|
550
511
|
const freshRssBytes = await AkanAppHost.readProcessRssBytes(pid);
|
|
551
512
|
if (!isRssCeilingUnreachable(freshRssBytes, ceilingBytes)) return;
|
|
552
513
|
this.#rssCeilingAbandoned = true;
|
|
553
|
-
const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
|
|
554
514
|
this.logger.error(
|
|
555
515
|
`[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
516
|
);
|
|
557
517
|
}
|
|
558
|
-
/** Waits for the builder to go quiet, so a recycle never lands in the middle of a burst of saves. */
|
|
559
518
|
#armRssRecycle(reason: string, over?: { rssBytes: number; ceilingBytes: number }): void {
|
|
560
519
|
if (this.#rssRecycleReason !== reason)
|
|
561
520
|
this.logger.verbose(`[builder-recycle] armed (${reason}); replacing the builder once it stays quiet`);
|
|
562
521
|
this.#rssRecycleReason = reason;
|
|
563
|
-
//
|
|
564
|
-
// losing the sample there would silently skip the settle check for exactly the bursty case.
|
|
522
|
+
// A field, not the closure: `#handleInvalidate` re-arms with the reason alone and must keep the sample.
|
|
565
523
|
if (over) this.#rssRecycleOver = over;
|
|
566
524
|
if (this.#rssRecycleTimer) clearTimeout(this.#rssRecycleTimer);
|
|
567
525
|
this.#rssRecycleTimer = setTimeout(() => {
|
|
@@ -578,22 +536,15 @@ export class AkanAppHost {
|
|
|
578
536
|
void this.#recycleBuilderForRssWhenStillOver(pendingReason, pendingOver);
|
|
579
537
|
}, BUILDER_RSS_RECYCLE_QUIET_MS);
|
|
580
538
|
}
|
|
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
539
|
async #recycleBuilderForRssWhenStillOver(
|
|
587
540
|
reason: string,
|
|
588
541
|
{ rssBytes, ceilingBytes }: { rssBytes: number; ceilingBytes: number },
|
|
589
542
|
): Promise<void> {
|
|
590
|
-
const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
|
|
591
543
|
if (decideBuilderRssSettle({ rssBytes, ceilingBytes }) === "recycle-now") {
|
|
592
544
|
this.#recycleBuilderForRss(reason);
|
|
593
545
|
return;
|
|
594
546
|
}
|
|
595
547
|
const pid = this.#builder?.pid;
|
|
596
|
-
// Without a readable pid there is nothing to re-check, so keep the original behaviour.
|
|
597
548
|
if (!pid) {
|
|
598
549
|
this.#recycleBuilderForRss(reason);
|
|
599
550
|
return;
|
|
@@ -604,7 +555,6 @@ export class AkanAppHost {
|
|
|
604
555
|
`[builder-recycle] holding ${Math.round(BUILDER_RSS_SETTLE_MS / 1000)}s to see whether the allocator returns it (${reason})`,
|
|
605
556
|
);
|
|
606
557
|
await Bun.sleep(BUILDER_RSS_SETTLE_MS);
|
|
607
|
-
// Anything that touched the builder meanwhile — a new batch, a recycle, a suspend — invalidates this.
|
|
608
558
|
if (token !== this.#rssSettleToken || this.#builder?.pid !== pid || this.#suspended || this.#waking) {
|
|
609
559
|
this.logger.verbose("[builder-recycle] settle check abandoned; the builder moved on");
|
|
610
560
|
return;
|
|
@@ -620,29 +570,29 @@ export class AkanAppHost {
|
|
|
620
570
|
settledBytes === null ? reason : `${reason}; still ${asMib(settledBytes)}MiB after settling`,
|
|
621
571
|
);
|
|
622
572
|
}
|
|
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
|
-
*/
|
|
573
|
+
/** Null when unreadable, meaning "no new information", never zero. */
|
|
628
574
|
static async readProcessRssBytes(pid: number): Promise<number | null> {
|
|
575
|
+
if (process.platform === "win32")
|
|
576
|
+
return await AkanAppHost.#readRssKbVia(
|
|
577
|
+
["tasklist", "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"],
|
|
578
|
+
AkanAppHost.#tasklistRssKb,
|
|
579
|
+
);
|
|
629
580
|
const status = await Bun.file(`/proc/${pid}/status`)
|
|
630
581
|
.text()
|
|
631
582
|
.catch(() => null);
|
|
632
583
|
const vmRssKb = status === null ? null : /VmRSS:\s+(\d+) kB/.exec(status)?.[1];
|
|
633
584
|
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> {
|
|
585
|
+
return await AkanAppHost.#readRssKbVia(["ps", "-o", "rss=", "-p", String(pid)], (output) => Number(output.trim()));
|
|
586
|
+
}
|
|
587
|
+
// Bounded: a `ps` that hangs under load would silently cancel the recycle awaiting it.
|
|
588
|
+
static async #readRssKbVia(
|
|
589
|
+
command: string[],
|
|
590
|
+
parseKb: (output: string) => number,
|
|
591
|
+
timeoutMs = PS_RSS_TIMEOUT_MS,
|
|
592
|
+
): Promise<number | null> {
|
|
643
593
|
let proc: Bun.Subprocess<"ignore", "pipe", "ignore">;
|
|
644
594
|
try {
|
|
645
|
-
proc = Bun.spawn(
|
|
595
|
+
proc = Bun.spawn(command, { stdio: ["ignore", "pipe", "ignore"] });
|
|
646
596
|
} catch {
|
|
647
597
|
return null;
|
|
648
598
|
}
|
|
@@ -650,7 +600,7 @@ export class AkanAppHost {
|
|
|
650
600
|
try {
|
|
651
601
|
const output = await new Response(proc.stdout).text();
|
|
652
602
|
await proc.exited;
|
|
653
|
-
const rssKb =
|
|
603
|
+
const rssKb = parseKb(output);
|
|
654
604
|
return Number.isFinite(rssKb) && rssKb > 0 ? rssKb * 1024 : null;
|
|
655
605
|
} catch {
|
|
656
606
|
return null;
|
|
@@ -658,6 +608,11 @@ export class AkanAppHost {
|
|
|
658
608
|
clearTimeout(killer);
|
|
659
609
|
}
|
|
660
610
|
}
|
|
611
|
+
// `Mem Usage` is the working set (what `process.memoryUsage.rss()` reports on Windows) in locale-grouped KB.
|
|
612
|
+
static #tasklistRssKb(output: string): number {
|
|
613
|
+
const memUsage = /"([^"]*)"\s*$/m.exec(output)?.[1];
|
|
614
|
+
return memUsage ? Number(memUsage.replace(/\D/g, "")) : Number.NaN;
|
|
615
|
+
}
|
|
661
616
|
#cancelRssRecycle(): void {
|
|
662
617
|
this.#rssRecycleReason = null;
|
|
663
618
|
this.#rssRecycleOver = null;
|
|
@@ -667,14 +622,10 @@ export class AkanAppHost {
|
|
|
667
622
|
clearTimeout(this.#rssRecycleTimer);
|
|
668
623
|
this.#rssRecycleTimer = null;
|
|
669
624
|
}
|
|
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
|
-
*/
|
|
625
|
+
/** `AKAN_DEV_IDLE_SUSPEND_MS=0` keeps the builder resident for the whole session. */
|
|
674
626
|
static idleSuspendMs(): number | null {
|
|
675
627
|
return resolveIdleSuspendMs(process.env.AKAN_DEV_IDLE_SUSPEND_MS);
|
|
676
628
|
}
|
|
677
|
-
/** Every builder message and every request for one counts as the dev server being in use. */
|
|
678
629
|
#markDevActivity(): void {
|
|
679
630
|
if (this.#suspended || this.#waking) return;
|
|
680
631
|
this.#armIdleSuspend();
|
|
@@ -708,8 +659,7 @@ export class AkanAppHost {
|
|
|
708
659
|
this.#armIdleSuspend();
|
|
709
660
|
return;
|
|
710
661
|
}
|
|
711
|
-
// Watch before stopping
|
|
712
|
-
// would wake the dev server until the next one.
|
|
662
|
+
// Watch before stopping: an edit in the gap would be lost, and nothing would wake the dev server.
|
|
713
663
|
if (!(await this.#startIdleWatcher())) {
|
|
714
664
|
this.#armIdleSuspend();
|
|
715
665
|
return;
|
|
@@ -742,13 +692,11 @@ export class AkanAppHost {
|
|
|
742
692
|
void this.#wakeFromIdle(`${batch.files.length} file(s) changed`);
|
|
743
693
|
},
|
|
744
694
|
});
|
|
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.
|
|
695
|
+
// Awaited so the mtime baseline exists before the first edit; a file missing from the batch is never rebuilt.
|
|
747
696
|
await watcher.start();
|
|
748
697
|
this.#idleWatcher = watcher;
|
|
749
698
|
return true;
|
|
750
699
|
} catch (err) {
|
|
751
|
-
// Better to keep paying for the builder than to suspend into a dev server that cannot notice edits.
|
|
752
700
|
this.logger.warn(
|
|
753
701
|
`[idle-suspend] could not install the idle watcher; staying awake: ${err instanceof Error ? err.message : String(err)}`,
|
|
754
702
|
);
|
|
@@ -771,10 +719,6 @@ export class AkanAppHost {
|
|
|
771
719
|
kinds: new Set([...current.kinds, ...batch.kinds]),
|
|
772
720
|
};
|
|
773
721
|
}
|
|
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
722
|
async #wakeFromIdle(reason: string): Promise<void> {
|
|
779
723
|
if (!this.#suspended || this.#waking) return;
|
|
780
724
|
this.#waking = true;
|
|
@@ -788,8 +732,6 @@ export class AkanAppHost {
|
|
|
788
732
|
await this.#applyIdleWake(batch);
|
|
789
733
|
this.logger.info(`[idle-suspend] awake in ${Math.round(performance.now() - startedAtMono)}ms`);
|
|
790
734
|
} 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
735
|
this.logger.error(
|
|
794
736
|
`[idle-suspend] wake failed; recovering the builder: ${err instanceof Error ? err.message : String(err)}`,
|
|
795
737
|
);
|
|
@@ -800,8 +742,7 @@ export class AkanAppHost {
|
|
|
800
742
|
this.#wokeAtMono = performance.now();
|
|
801
743
|
this.#flushPendingBuilderMessages();
|
|
802
744
|
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.
|
|
745
|
+
// A wake often leaves the backend state untouched, so nothing else would tell a supervisor it came back.
|
|
805
746
|
this.#emitDevEvent(devHostStateOf(this.#backendLifecycleState, this.#backendGaveUp));
|
|
806
747
|
}
|
|
807
748
|
}
|
|
@@ -809,8 +750,7 @@ export class AkanAppHost {
|
|
|
809
750
|
const files = batch?.files ?? [];
|
|
810
751
|
if (shouldRefreshConfigOnIdleWake(batch)) {
|
|
811
752
|
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.
|
|
753
|
+
// Replaces the backend too, and a baseline kept past its own gap costs a restart at the next one.
|
|
814
754
|
this.#discardBuilderGap("config change replaces the backend anyway");
|
|
815
755
|
await this.#recycleDevChildren(
|
|
816
756
|
{ type: "invalidate", kinds: [...(batch?.kinds ?? [])], files },
|
|
@@ -823,39 +763,18 @@ export class AkanAppHost {
|
|
|
823
763
|
// Refresh before deciding: a file created while suspended is not in the graph yet.
|
|
824
764
|
if (files.length > 0) await this.#backendGraph.refresh();
|
|
825
765
|
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.
|
|
766
|
+
// Merged: the watcher's batch and the stamps overlap on the ordinary one-save-during-suspend case.
|
|
828
767
|
const missed = await this.#takeBuilderGapChanges();
|
|
829
|
-
const backendFiles = [...new Set([...files.filter((file) => this.#
|
|
768
|
+
const backendFiles = [...new Set([...files.filter((file) => this.#backendGraph.has(file)), ...missed])];
|
|
830
769
|
if (backendFiles.length === 0) return;
|
|
831
770
|
this.logger.verbose(`[idle-suspend] ${backendFiles.length} backend file(s) changed while suspended`);
|
|
832
771
|
this.#scheduleBackendRestart({ files: backendFiles, roles: [] });
|
|
833
772
|
}
|
|
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
|
-
*/
|
|
773
|
+
// Until a builder is back nothing watches (Bun's `fs.watch` drops events, a new builder primes from disk), so
|
|
774
|
+
// stamp the backend graph now. Earliest open wins: over-reporting costs a restart, under-reporting stale code.
|
|
854
775
|
#openBuilderGap(reason: string): void {
|
|
855
776
|
if (this.#builderGapStamp) return;
|
|
856
777
|
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
778
|
this.logger.verbose(`[builder-gap] no backend graph yet; a save during this ${reason} goes unnoticed`);
|
|
860
779
|
return;
|
|
861
780
|
}
|
|
@@ -872,7 +791,6 @@ export class AkanAppHost {
|
|
|
872
791
|
return null;
|
|
873
792
|
});
|
|
874
793
|
}
|
|
875
|
-
/** Which backend files moved while the builder was away. Consumes the baseline. */
|
|
876
794
|
async #takeBuilderGapChanges(): Promise<string[]> {
|
|
877
795
|
const stamping = this.#builderGapStamp;
|
|
878
796
|
this.#builderGapStamp = null;
|
|
@@ -880,8 +798,6 @@ export class AkanAppHost {
|
|
|
880
798
|
if (!before) return [];
|
|
881
799
|
const moved = filesChangedSince(before, await this.#backendGraph.fingerprint());
|
|
882
800
|
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
801
|
this.logger.verbose(`[builder-gap] none of the ${before.size} stamped backend file(s) moved`);
|
|
886
802
|
return moved;
|
|
887
803
|
}
|
|
@@ -890,19 +806,12 @@ export class AkanAppHost {
|
|
|
890
806
|
);
|
|
891
807
|
return moved;
|
|
892
808
|
}
|
|
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
|
-
*/
|
|
809
|
+
// A baseline left open is compared against the next gap, where everything saved since reads as changed.
|
|
900
810
|
#discardBuilderGap(reason: string): void {
|
|
901
811
|
if (!this.#builderGapStamp) return;
|
|
902
812
|
this.#builderGapStamp = null;
|
|
903
813
|
this.logger.verbose(`[builder-gap] stamps dropped (${reason})`);
|
|
904
814
|
}
|
|
905
|
-
/** The restart path's half of the above: the wake path merges its own file list in instead. */
|
|
906
815
|
async #restartBackendForGapChanges(): Promise<void> {
|
|
907
816
|
const moved = await this.#takeBuilderGapChanges();
|
|
908
817
|
if (moved.length === 0) return;
|
|
@@ -915,69 +824,45 @@ export class AkanAppHost {
|
|
|
915
824
|
for (const message of pending) this.#sendToBuilder(message);
|
|
916
825
|
}
|
|
917
826
|
|
|
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
|
-
*/
|
|
827
|
+
// Held requests carry the departing backend's ids, which the new one reissues from 1, so a replay would
|
|
828
|
+
// misdeliver answers; nothing is answered because the old backend is already stopped.
|
|
937
829
|
#discardPendingBuilderMessages(reason: string): void {
|
|
938
830
|
const held = this.#pendingBuilderMessages.splice(0);
|
|
939
831
|
if (held.length === 0) return;
|
|
940
832
|
this.logger.verbose(`[builder] dropped ${held.length} held builder request(s): ${reason}`);
|
|
941
833
|
}
|
|
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
834
|
#failPendingBuilderMessages(reason: string): void {
|
|
947
835
|
const held = this.#pendingBuilderMessages.splice(0);
|
|
948
836
|
if (held.length === 0) return;
|
|
949
837
|
this.logger.warn(`failing ${held.length} held builder request(s): ${reason}`);
|
|
950
|
-
for (const message of held)
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
838
|
+
for (const message of held) this.#failBuilderRequest(message, reason);
|
|
839
|
+
}
|
|
840
|
+
#failBuilderRequest(message: BuilderMessage, error: string): boolean {
|
|
841
|
+
if (message.type === "build-route")
|
|
842
|
+
this.#sendToBackend({ type: "build-route-res", id: message.id, ok: false, error });
|
|
843
|
+
else if (message.type === "build-csr")
|
|
844
|
+
this.#sendToBackend({ type: "build-csr-res", id: message.id, ok: false, error });
|
|
845
|
+
else return false;
|
|
846
|
+
return true;
|
|
956
847
|
}
|
|
957
848
|
#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.
|
|
849
|
+
// Dropping the recycle costs nothing: the next build re-reports an over-ceiling rss and re-arms it.
|
|
961
850
|
if (this.#pendingRecycle || this.#restartTimer) {
|
|
962
851
|
this.logger.verbose(`[builder-recycle] skipped (${reason}); a dev restart is already pending`);
|
|
963
852
|
return;
|
|
964
853
|
}
|
|
965
854
|
if (!this.#builder?.recycle(reason)) return;
|
|
966
855
|
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.
|
|
856
|
+
// Stamped at the request, not the exit: a draining builder has already stopped taking work.
|
|
969
857
|
this.#openBuilderGap("builder recycle");
|
|
970
858
|
}
|
|
971
859
|
async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
|
|
972
860
|
this.#logDevPlan(message);
|
|
973
|
-
// More batches are on the way, so push the recycle out until the dev server settles.
|
|
974
861
|
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.
|
|
862
|
+
// Checked first: a dev-host restart also recycles the builder and backend.
|
|
977
863
|
const wantsDevHostRestart = shouldRestartDevHostByDevPlan(message);
|
|
978
864
|
const pending = this.#pendingRecycle;
|
|
979
|
-
// A
|
|
980
|
-
// fix lands; css-only batches cannot heal a compile error, so they never resume it.
|
|
865
|
+
// A deferred recycle resumes on the next code batch; a css-only batch cannot heal a compile error.
|
|
981
866
|
if (wantsDevHostRestart || shouldRestartBuilderByDevPlan(message) || (pending && message.kinds.includes("code"))) {
|
|
982
867
|
const refreshConfig = wantsDevHostRestart || (pending?.refreshConfig ?? false);
|
|
983
868
|
const merged = pending ? mergeInvalidateMessages(pending.message, message) : message;
|
|
@@ -1021,11 +906,7 @@ export class AkanAppHost {
|
|
|
1021
906
|
this.#recordBuildStatus(status);
|
|
1022
907
|
this.#sendOrQueueBuildStatus(status);
|
|
1023
908
|
}
|
|
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
|
-
*/
|
|
909
|
+
// The backend returns on the last-good artifact so the error overlay stays reachable.
|
|
1029
910
|
#resurrectDevChildren(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
|
|
1030
911
|
const generation = message.devPlan?.generation ?? message.generation;
|
|
1031
912
|
if (!this.#backend) this.#startBackend({ generation, files: message.files });
|
|
@@ -1055,8 +936,6 @@ export class AkanAppHost {
|
|
|
1055
936
|
}
|
|
1056
937
|
this.#builderRecoveryAttempts = 0;
|
|
1057
938
|
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
939
|
void this.#restartBackendForGapChanges();
|
|
1061
940
|
const status: DevBuildStatus = {
|
|
1062
941
|
generation: reason.generation ?? this.#nextBackendBuildStatusGeneration(),
|
|
@@ -1071,7 +950,6 @@ export class AkanAppHost {
|
|
|
1071
950
|
this.#startBackend({ generation: reason.generation, files: reason.files });
|
|
1072
951
|
}
|
|
1073
952
|
}
|
|
1074
|
-
/** A backend that gave up recovering on broken code gets one fresh chance whenever a build goes green. */
|
|
1075
953
|
#reviveBackendAfterGreenBuild(status: DevBuildStatus): void {
|
|
1076
954
|
if (!status.ok || !this.#backendGaveUp || this.#backend || this.#backendRecoveryTimer) return;
|
|
1077
955
|
this.logger.info(`[backend-recovery] build went green (generation=${status.generation}); retrying backend`);
|
|
@@ -1085,13 +963,8 @@ export class AkanAppHost {
|
|
|
1085
963
|
);
|
|
1086
964
|
await this.#recycleDevChildren(message);
|
|
1087
965
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
* re-runs the prepare step so env and codegen reflect the new config, then recycles the builder
|
|
1091
|
-
* and backend. The config module is re-imported with a cache-busting query; modules it imports
|
|
1092
|
-
* keep their cached instances, so a change inside an imported plugin file still needs a manual
|
|
1093
|
-
* `akan start` restart.
|
|
1094
|
-
*/
|
|
966
|
+
// The config is re-imported with a cache-busting query, but modules it imports stay cached: a change inside an
|
|
967
|
+
// imported plugin file still needs a manual `akan start` restart.
|
|
1095
968
|
async #restartDevHost(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<void> {
|
|
1096
969
|
const generation = message.devPlan?.generation ?? message.generation;
|
|
1097
970
|
this.logger.warn(
|
|
@@ -1104,18 +977,7 @@ export class AkanAppHost {
|
|
|
1104
977
|
{ refreshConfig = false }: { refreshConfig?: boolean } = {},
|
|
1105
978
|
): Promise<void> {
|
|
1106
979
|
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
|
-
}
|
|
980
|
+
this.#clearRestartTimers();
|
|
1119
981
|
this.#builderRecoveryAttempts = 0;
|
|
1120
982
|
this.#pendingRestartReason = null;
|
|
1121
983
|
this.#lastGoodFrontend = {};
|
|
@@ -1125,8 +987,7 @@ export class AkanAppHost {
|
|
|
1125
987
|
this.#stopBuilder();
|
|
1126
988
|
if (refreshConfig) {
|
|
1127
989
|
await this.app.getConfig({ refresh: true });
|
|
1128
|
-
// Merge
|
|
1129
|
-
// (e.g. REDIS_HOST from the tunnel), and the spawned children must keep seeing them.
|
|
990
|
+
// Merge, not replace: `start()` added values prepare does not produce (REDIS_HOST from the tunnel).
|
|
1130
991
|
const { env } = await this.app.prepareCommand("start");
|
|
1131
992
|
Object.assign(this.env, env);
|
|
1132
993
|
}
|
|
@@ -1134,12 +995,7 @@ export class AkanAppHost {
|
|
|
1134
995
|
await this.#startBuilder();
|
|
1135
996
|
this.#startBackend({ generation, files: message.files });
|
|
1136
997
|
}
|
|
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
|
-
*/
|
|
998
|
+
// `supersede`: a replacement builder's generation restarts at 0, so its announcement would look stale.
|
|
1143
999
|
#recordLastGood(
|
|
1144
1000
|
message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
|
|
1145
1001
|
{ supersede = false }: { supersede?: boolean } = {},
|
|
@@ -1202,7 +1058,6 @@ export class AkanAppHost {
|
|
|
1202
1058
|
this.#sendToBackend({ type: "build-status", data: status });
|
|
1203
1059
|
}
|
|
1204
1060
|
}
|
|
1205
|
-
/** One log line per planned generation, regardless of which action branch handles it. */
|
|
1206
1061
|
#logDevPlan(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
|
|
1207
1062
|
if (!message.devPlan) return;
|
|
1208
1063
|
const { generation, roles, actions, reasonByFile } = message.devPlan;
|
|
@@ -1219,7 +1074,7 @@ export class AkanAppHost {
|
|
|
1219
1074
|
return shouldRestart;
|
|
1220
1075
|
}
|
|
1221
1076
|
if (message.kinds.includes("code")) await this.#backendGraph.refresh();
|
|
1222
|
-
if (message.files.some((file) => this.#
|
|
1077
|
+
if (message.files.some((file) => this.#backendGraph.has(file))) return true;
|
|
1223
1078
|
if (!this.#backendGraph.lastRefreshSucceeded) {
|
|
1224
1079
|
const fallbackFiles = message.files.filter((file) =>
|
|
1225
1080
|
isLegacyBackendFallbackFile(file, this.app.workspace.workspaceRoot),
|
|
@@ -1233,9 +1088,6 @@ export class AkanAppHost {
|
|
|
1233
1088
|
}
|
|
1234
1089
|
return false;
|
|
1235
1090
|
}
|
|
1236
|
-
#isBackendFile(file: string): boolean {
|
|
1237
|
-
return this.#backendGraph.has(file);
|
|
1238
|
-
}
|
|
1239
1091
|
async #startBuilder({
|
|
1240
1092
|
announceBootState = false,
|
|
1241
1093
|
}: {
|
|
@@ -1267,7 +1119,6 @@ export class AkanAppHost {
|
|
|
1267
1119
|
this.app.verbose(`[cli] builder failed before ready; retrying (${attempt + 1}/${BUILDER_START_MAX_ATTEMPTS})`);
|
|
1268
1120
|
}
|
|
1269
1121
|
}
|
|
1270
|
-
// Out of attempts: no builder is coming, so anything held for one is waiting on nothing.
|
|
1271
1122
|
this.#failPendingBuilderMessages("builder failed to start");
|
|
1272
1123
|
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1273
1124
|
}
|
|
@@ -1312,54 +1163,31 @@ export class AkanAppHost {
|
|
|
1312
1163
|
#sendToBuilder(message: BuilderMessage): void {
|
|
1313
1164
|
this.#markDevActivity();
|
|
1314
1165
|
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
1166
|
this.#pendingBuilderMessages.push(message);
|
|
1318
1167
|
void this.#wakeFromIdle(`${message.type} arrived while suspended`);
|
|
1319
1168
|
return;
|
|
1320
1169
|
}
|
|
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.
|
|
1170
|
+
// In `env`, which every builder spawn re-reads, so a restarted builder stays armed for a mobile dev session.
|
|
1324
1171
|
if (message.type === "build-csr" && this.env.AKAN_DEV_CSR_REBUILD !== "1") {
|
|
1325
1172
|
Object.assign(this.env, { AKAN_DEV_CSR_REBUILD: "1" });
|
|
1326
1173
|
this.logger.verbose(`[csr] armed dev CSR rebuilds (${message.reason})`);
|
|
1327
1174
|
}
|
|
1328
|
-
// Renumbered
|
|
1329
|
-
// asked; the failure replies below still use `message.id`, which is that backend's own.
|
|
1175
|
+
// Renumbered per backend generation; the failure replies below use that backend's own `message.id`.
|
|
1330
1176
|
if (message.type === "build-route" || message.type === "build-csr") {
|
|
1331
1177
|
const outgoing = this.#builderRequests.issue(message);
|
|
1332
1178
|
if (this.#builder?.send(outgoing)) return;
|
|
1333
1179
|
this.#builderRequests.withdraw(outgoing.id);
|
|
1334
1180
|
} else if (this.#builder?.send(message)) return;
|
|
1335
1181
|
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.
|
|
1182
|
+
// A returning builder is a gap, not a failure: hold the request (`BuilderRpc`'s timeout still bounds it).
|
|
1341
1183
|
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
|
-
});
|
|
1184
|
+
this.#pendingBuilderMessages.push(message);
|
|
1185
|
+
this.logger.verbose(
|
|
1186
|
+
`[builder] holding ${message.type} until the builder is ready (${this.#pendingBuilderMessages.length} waiting)`,
|
|
1187
|
+
);
|
|
1361
1188
|
return;
|
|
1362
1189
|
}
|
|
1190
|
+
if (this.#failBuilderRequest(message, `builder is ${status}; reload after the builder is ready`)) return;
|
|
1363
1191
|
this.logger.warn("akanAppHost builder is not running");
|
|
1364
1192
|
}
|
|
1365
1193
|
#stopBuilder(): void {
|