@akanjs/devkit 3.0.0-beta.24 → 3.0.0-beta.26

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.
Files changed (58) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/akanApp/akanApp.host.ts +113 -23
  3. package/akanApp/devBootLatch.test.ts +39 -0
  4. package/akanApp/devBootLatch.ts +39 -0
  5. package/akanApp/devHostPolicy.test.ts +71 -1
  6. package/akanApp/devHostPolicy.ts +29 -4
  7. package/cloud/globalConfig.ts +3 -17
  8. package/csrE2e/csrE2eHarness.fixture.ts +66 -11
  9. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  10. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  11. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  12. package/fileSys.ts +16 -1
  13. package/frontendBuild/buildRouteClient.test.ts +61 -0
  14. package/frontendBuild/bundleDefine.test.ts +12 -0
  15. package/frontendBuild/bundleDefine.ts +5 -1
  16. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  17. package/frontendBuild/clientEntryDiscovery.ts +117 -70
  18. package/frontendBuild/csrArtifactBuilder.ts +3 -6
  19. package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
  20. package/frontendBuild/csrDevArtifactWriter.ts +158 -24
  21. package/frontendBuild/csrDevBundler.ts +44 -205
  22. package/frontendBuild/csrDevModuleCompiler.test.ts +89 -0
  23. package/frontendBuild/csrDevModuleCompiler.ts +56 -10
  24. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  25. package/frontendBuild/csrDevPatcher.ts +548 -0
  26. package/frontendBuild/csrDevPaths.ts +81 -1
  27. package/frontendBuild/csrDevResolver.test.ts +239 -0
  28. package/frontendBuild/csrDevResolver.ts +119 -18
  29. package/frontendBuild/csrDevTypes.ts +16 -1
  30. package/frontendBuild/csrEntryFiles.ts +24 -0
  31. package/frontendBuild/cssCompiler.ts +2 -4
  32. package/frontendBuild/devRegistryBundler.ts +192 -0
  33. package/frontendBuild/hmrWatcher.test.ts +51 -0
  34. package/frontendBuild/hmrWatcher.ts +17 -3
  35. package/frontendBuild/index.ts +3 -0
  36. package/frontendBuild/pagesBundleBuilder.ts +24 -1
  37. package/frontendBuild/pagesEntrySourceGenerator.ts +9 -2
  38. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  39. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  40. package/frontendBuild/routeClientBuilder.ts +79 -18
  41. package/frontendBuild/serverGraphFile.test.ts +77 -0
  42. package/frontendBuild/serverGraphFile.ts +93 -0
  43. package/frontendBuild/ssrDevBundler.ts +85 -0
  44. package/incrementalBuilder/buildBatch.proc.ts +94 -16
  45. package/incrementalBuilder/buildBatchProtocol.ts +6 -3
  46. package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
  47. package/incrementalBuilder/buildBatchRunner.ts +17 -8
  48. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  49. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  50. package/incrementalBuilder/incrementalBuilder.host.test.ts +35 -1
  51. package/incrementalBuilder/incrementalBuilder.host.ts +50 -6
  52. package/incrementalBuilder/incrementalBuilder.proc.test.ts +190 -2
  53. package/incrementalBuilder/incrementalBuilder.proc.ts +560 -60
  54. package/integration/devStability.integration.test.ts +25 -7
  55. package/package.json +2 -2
  56. package/transforms/rscUseClientTransform.ts +7 -0
  57. package/transforms/transforms.test.ts +13 -0
  58. package/transforms/useClientBundlePlugin.ts +4 -1
@@ -22,7 +22,7 @@ crosses its ceiling is replaced when it is next idle — never mid-work.
22
22
 
23
23
  | process | explicit (MiB) | explicit (bytes) | share of the limit | fallback with no limit |
24
24
  |---|---|---|---|---|
25
- | incremental builder | `AKAN_BUILDER_MAX_RSS_MB` | `AKAN_BUILDER_MAX_RSS` | **0.35** | 1200MB (dev) |
25
+ | incremental builder | `AKAN_BUILDER_MAX_RSS_MB` | `AKAN_BUILDER_MAX_RSS` | **0.35** | 2048MB (dev) |
26
26
  | RSC worker | `AKAN_RSC_WORKER_MAX_RSS_MB` | `AKAN_RSC_WORKER_MAX_RSS` | **0.55** | 768MB (dev), unbounded (production) |
27
27
 
28
28
  `AKAN_BUILDER_MAX_RSS_MB=0` leaves the builder unbounded, which is the escape hatch for an app whose
@@ -37,8 +37,10 @@ this repo's own `apps/akan`). None of those has a ceiling, and none is subtracte
37
37
 
38
38
  In practice this holds because the two ceilings are rarely at their limit simultaneously and the build
39
39
  worker exits. But if you are sizing a container to a hard number, size it against the *sum of observed
40
- peaks*, not against these fractions. Measured floors, for reference: builder 134-202MB at rest and
41
- ~490MB after a route build, RSC worker ~142MB after boot, ~247MB per route build.
40
+ peaks*, not against these fractions. Measured on `apps/minimal` (macOS, 2026-09-29), for reference: the
41
+ builder ~120MB at rest after boot, ~540MB right after its first route build (the bundler hands most of it
42
+ back after ~20s idle), ~250MB while it patches saves into the SSR registry; the RSC worker ~75MB after boot
43
+ and ~110MB after route builds; the build worker ~900MB for the SSR registry's boot build.
42
44
 
43
45
  ## Recycling behaviour
44
46
 
@@ -65,10 +67,17 @@ which is the one case where replacing it again cannot help; it says that too, an
65
67
 
66
68
  | variable | default | what it does |
67
69
  |---|---|---|
68
- | `AKAN_DEV_CSR_REBUILD` | off | Rebuild the CSR artifact on every save. Armed automatically by the first `/__csr` or `?csr=true` request, which is what a mobile WebView session does — set it explicitly only to have it from boot. |
70
+ | `AKAN_DEV_CSR_REBUILD` | off | Rebuild the CSR artifact on every save. Armed automatically by the first `/__csr` or `?csr=true` request, which is what a mobile WebView session does — set it explicitly only to have it from boot. Set at boot, its CSR build runs beside the SSR registry's: on apps/akan the builder and its workers peak at about 2.7GB instead of 1.3GB, and the next boot wave waits for both builds. |
71
+ | `AKAN_DEV_CSR` | `registry` | How the dev CSR page is built. `registry` serves it as a module registry that patches the modules a save changed; `artifact` brings back the single-file bundle, which reloads on every save. |
72
+ | `AKAN_DEV_CSR_PATCHER` | on | `off` sends every save of a dev module registry — the CSR page's, and the one SSR pages load their client code from (`.akan/artifact/ssr-dev`) — to a disposable build worker instead of patching it in the resident builder. The patcher keeps each registry in memory (on minimal the CSR one adds about 170MB to the builder's median RSS and 210MB to its peak, flat across saves) and hands whole-app builds to a worker: a first build, a new npm module, a bare import no file of its package resolved before, a config change, a signal or dictionary save (their metadata is inlined at build time), and a registry whose last whole build was cut short. A misspelled import fails in the builder, like a missing file. A builder that dies mid-patch turns it off until the dev server next replaces its builder and backend together (a config, signal or dictionary change). |
73
+ | `AKAN_DEV_WATCH_DEBOUNCE_MS` | `30` | How long the file watcher collects changes before it hands a batch over. Every save waits this long before anything builds. A second write inside the window joins the same batch; one after it starts another batch, which reads the file's latest content, so a format-on-save costs a second build rather than a wrong one. |
69
74
  | `AKAN_BUILDER_RPC_TIMEOUT_MS` | `120000` | How long the backend waits for a builder answer. Generous on purpose: a cold CSR build of every page legitimately takes tens of seconds. |
70
75
  | `AKAN_SERVER_PAGES_SPLITTING` | off | Emit the server pages bundle as chunks instead of one file. Experimental — the memory/latency trade has not been measured on a real app. |
71
76
 
77
+ Two more are read by the dev server for its end-to-end tests only; never set them. `AKAN_DEV_SSR_ARM_DELAY_MS`
78
+ holds the SSR registry's boot build back, and `AKAN_CSR_DEV_APP_WRITE_DELAY_MS` holds a registry's `app.js`
79
+ back after a patch is announced.
80
+
72
81
  ## Observability
73
82
 
74
83
  | variable | default | what it does |
@@ -80,31 +89,36 @@ which is the one case where replacing it again cannot help; it says that too, an
80
89
  ## Several apps at once
81
90
 
82
91
  `akan start a,b` runs one dev host per app under a supervisor, so **every number above multiplies by the number
83
- of apps** — there is no shared builder and no shared RSC worker. Measured on this repo, right after both apps
92
+ of apps** — there is no shared builder and no shared RSC worker. Measured on this repo 8 seconds after both apps
84
93
  finished booting:
85
94
 
86
95
  | process | akan | minimal |
87
96
  |---|---|---|
88
- | dev host | 101MB | 94MB |
89
- | incremental builder | 596MB | 532MB |
90
- | backend | 35MB | 38MB |
91
- | RSC worker | 190MB | 81MB |
92
-
93
- Plus ~51MB for the supervisor itself: **~1.7GB for two apps at their peak.** The builders are almost all of
94
- that, and they are also the part that goes away — `AKAN_DEV_IDLE_SUSPEND_MS` releases each one independently, so
95
- a session where you are editing one app settles to roughly one builder plus ~190MB per idle app. **Multi-app is
96
- sized against idle suspend being on**; setting it to `0` keeps every builder resident for the whole session.
97
+ | dev host | 279MB | 196MB |
98
+ | incremental builder | 182MB | 114MB |
99
+ | backend (gateway and replica) | 123MB | 130MB |
100
+ | RSC worker | 162MB | 75MB |
101
+
102
+ Plus ~75MB for the supervisor itself: **~1.3GB for two apps at rest.** The peak is the boot, not the rest: the
103
+ build workers — the base build, then the SSR registry's — take a whole akan dev host to ~1.9GB and minimal's to
104
+ ~1.4GB, and booted one after the other the two peaked at 2.2GB. The builders are also the part that goes away —
105
+ `AKAN_DEV_IDLE_SUSPEND_MS` releases each one independently, so a session where you are editing one app keeps one
106
+ builder, and every idle app keeps only its dev host, backend and RSC worker. **Multi-app is sized against idle
107
+ suspend being on**; setting it to `0` keeps every builder resident for the whole session.
97
108
 
98
109
  Two things bound the peak rather than the floor:
99
110
 
100
- - **`--concurrency` (default: what the machine allows).** Apps boot in waves, and the next wave starts only
101
- once the previous one reports ready. A cold boot build is the builder's peak, so booting `n` apps at once
102
- means `n` overlapping peaks — which is what OOM-kills a container that would have been fine with them
103
- staggered. Unset, the wave is `min(apps, half the memory budget / 900MB per app, cores / 4)`, never below
104
- one, and the session prints which — so a laptop boots its apps together and a 1.2GB container still
105
- staggers them. The memory budget is the smaller of the host's RAM and `AKAN_MEMORY_LIMIT` / the cgroup
106
- limit; `os.freemem()` is not consulted, because it counts free pages rather than reclaimable ones and
107
- reports ~0.3GB on an idle 48GB laptop.
111
+ - **`--concurrency` (default: what the machine allows).** Apps boot in waves. The next wave starts once each
112
+ app of the previous one serves and its builder's boot builds have settled — the SSR registry's, and CSR's when
113
+ `AKAN_DEV_CSR_REBUILD=1` arms it — since those workers are the largest processes of a boot. An app that never
114
+ reports them starts the next wave 30 seconds after it serves. It shows as ready, and `--open` opens it, as
115
+ soon as it serves. Booting `n` apps at once means `n` overlapping peaks — which is what OOM-kills a container
116
+ that would have been fine with them staggered. Unset, the wave is
117
+ `min(apps, half the memory budget / 1.8GB per app, cores / 4)`, never below one, and the session prints
118
+ which — so a laptop boots its apps together and a small container boots them one at a time. The 1.8GB is
119
+ apps/akan's boot peak across all its processes (measured 1.73–1.9GB). The memory budget is the smaller of
120
+ the host's RAM and `AKAN_MEMORY_LIMIT` / the cgroup limit; `os.freemem()` is not consulted, because it counts
121
+ free pages rather than reclaimable ones and reports ~0.3GB on an idle 48GB laptop.
108
122
  - **`AKAN_MEMORY_LIMIT` is per process, not per session.** Each dev host derives its builder and RSC-worker
109
123
  ceilings from it independently, so a limit sized for one app does not become a budget for four. Divide it
110
124
  yourself, or leave it unset on a laptop. The one place it is read as a session ceiling is the boot wave
@@ -113,18 +127,22 @@ Two things bound the peak rather than the floor:
113
127
 
114
128
  ## Sizing a small sandbox
115
129
 
116
- A worked example, for a 1.2GB container:
130
+ A worked example, for a 1.2GB container running an app the size of `apps/minimal` (apps/akan's boot alone
131
+ peaks near 1.8GB):
117
132
 
118
133
  ```bash
119
134
  AKAN_MEMORY_LIMIT=1200mb # builder gets ~420MB, rsc worker ~660MB
120
135
  AKAN_DEV_IDLE_SUSPEND_MS=300000 # release the builder after 5 idle minutes
121
136
  ```
122
137
 
123
- Two things to expect at that size. The builder crosses 420MB during ordinary work — one route build
124
- costs ~247MB on top of its floor — so it is replaced roughly once per 30s while you keep building, each
138
+ Two things to expect at that size. The builder crosses 420MB during ordinary work — its first route
139
+ build alone takes it to ~540MB — so it is replaced roughly once per 30s while you keep building, each
125
140
  replacement costing a boot build that requests wait through rather than fail. And the build worker's
126
141
  peak is not covered by any of these ceilings; if the kernel OOM-kills it, the dev server survives with a
127
- red build for that generation and the log names the signal.
142
+ red build for that generation and the log names the signal. On a boot whose `.akan` was cleared, the SSR
143
+ registry's first build is such a worker, peaking near 900MB on `apps/minimal`: a killed one leaves no
144
+ registry, and every later save retries that same whole build, so a container that cannot fit it once
145
+ cannot fit it at all.
128
146
 
129
147
  Raising `AKAN_BUILDER_MAX_RSS_MB` above the derived share trades memory for fewer boot builds. Setting
130
148
  it to `0` trades the bound away entirely, which on a container this size means the kernel decides
@@ -1,3 +1,4 @@
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";
@@ -9,6 +10,7 @@ import { WatchRootResolver } from "../frontendBuild/watchRootResolver";
9
10
  import { type DevStdioMode, IncrementalBuilderHost } from "../incrementalBuilder";
10
11
  import { BuilderRequestRouter } from "../incrementalBuilder/builderRequestRouter";
11
12
  import { BackendImportGraph } from "./BackendImportGraph";
13
+ import { DevBootLatch } from "./devBootLatch";
12
14
  import {
13
15
  type BackendLifecycleState,
14
16
  type BackendRestartReason,
@@ -34,6 +36,7 @@ import {
34
36
  type SourceFingerprints,
35
37
  shouldAbandonBackendRecovery,
36
38
  shouldHoldForReturningBuilder,
39
+ shouldKeepBuildFailure,
37
40
  shouldMarkBuildPhaseRecovered,
38
41
  shouldQueueBuildStatusReplay,
39
42
  shouldRefreshConfigOnIdleWake,
@@ -88,8 +91,11 @@ export class AkanAppHost {
88
91
  readonly env: Record<string, string>;
89
92
  readonly #onDevEvent: ((event: DevHostEvent) => void) | null;
90
93
  #lastDevState: DevHostState | null = null;
94
+ readonly #bootLatch = new DevBootLatch(() => this.#onDevEvent?.({ app: this.app.name, booted: true }));
91
95
  #backend: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe"> | null = null;
92
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;
93
99
  #backendReady = false;
94
100
  #plannedBackendStops = new WeakSet<Bun.Subprocess<"ignore", "inherit", "inherit">>();
95
101
  #restartTimer: ReturnType<typeof setTimeout> | null = null;
@@ -98,7 +104,16 @@ export class AkanAppHost {
98
104
  #backendGaveUp = false;
99
105
  #backendLifecycleState: BackendLifecycleState = "stopped";
100
106
  #pendingRestartReason: BackendRestartReason | null = null;
101
- #pendingRecycle: { message: Extract<BuilderMessage, { type: "invalidate" }>; refreshConfig: boolean } | null = null;
107
+ #pendingRecycle: {
108
+ message: Extract<BuilderMessage, { type: "invalidate" }>;
109
+ refreshConfig: boolean;
110
+ failed?: boolean;
111
+ failedGeneration?: number;
112
+ } | null = null;
113
+ //? A restart that failed after its change applied (the builder or backend did not come up): the builder's return is
114
+ //? its recovery, reported past the failure's generation so the scan overlay clears.
115
+ #restartFailedAfterApply: number | null = null;
116
+ #stopping = false;
102
117
  #builderRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
103
118
  #builderRecoveryAttempts = 0;
104
119
  #backendStartStatus: { generation?: number; files: string[] } | null = null;
@@ -126,6 +141,7 @@ export class AkanAppHost {
126
141
  #builderGapStamp: Promise<SourceFingerprints | null> | null = null;
127
142
  #pendingBuilderMessages: BuilderMessage[] = [];
128
143
  readonly #builderRequests = new BuilderRequestRouter();
144
+ #builderGeneration = 0;
129
145
  constructor(
130
146
  private readonly app: App,
131
147
  {
@@ -143,6 +159,7 @@ export class AkanAppHost {
143
159
  if (!this.#onDevEvent || state === this.#lastDevState) return;
144
160
  this.#lastDevState = state;
145
161
  this.#onDevEvent({ app: this.app.name, state, ...(detail ? { detail } : {}) });
162
+ if (state === "ready") this.#bootLatch.ready();
146
163
  }
147
164
  async start() {
148
165
  if (this.#backend) await this.#stopBackend();
@@ -158,6 +175,9 @@ export class AkanAppHost {
158
175
  return this;
159
176
  }
160
177
  async stop() {
178
+ //? Nothing reapplies once it stops: a pages ok received meanwhile would bring children back after it.
179
+ this.#stopping = true;
180
+ this.#pendingRecycle = null;
161
181
  this.#cancelIdleSuspend();
162
182
  this.#stopIdleWatcher();
163
183
  this.#clearRestartTimers();
@@ -202,6 +222,7 @@ export class AkanAppHost {
202
222
  this.#setBackendLifecycleState("ready", `pid=${msg.pid}`);
203
223
  this.#recordBackendReadyStatus();
204
224
  this.logger.verbose(`backend ready pid=${msg.pid}`);
225
+ this.#forgetRouteBuildStatus();
205
226
  this.#replayBuilderState();
206
227
  return;
207
228
  }
@@ -424,17 +445,25 @@ export class AkanAppHost {
424
445
  });
425
446
  }
426
447
  async #handleBuilderMessage(message: BuilderMessage) {
448
+ if (this.#stopping) return;
427
449
  this.#markDevActivity();
450
+ this.#trackBuilderGeneration(message);
428
451
  if (message.type === "build-status") {
429
452
  this.#recordBuildStatus(message.data);
430
453
  this.#sendOrQueueBuildStatus(message.data);
431
454
  this.#reviveBackendAfterGreenBuild(message.data);
455
+ await this.#resumeFailedRecycle(message.data);
432
456
  return;
433
457
  }
434
458
  if (message.type === "builder-metrics") {
435
459
  this.#handleBuilderMetrics(message.data);
436
460
  return;
437
461
  }
462
+ if (message.type === "boot-armed") {
463
+ this.logger.verbose("[builder] boot builds settled");
464
+ this.#bootLatch.armed();
465
+ return;
466
+ }
438
467
  if (message.type === "pages-updated" || message.type === "css-updated") {
439
468
  const recycled = message.data.reason === "builder-recycle";
440
469
  if (recycled && !this.#shouldRelayRecycledState(message)) return;
@@ -455,6 +484,20 @@ export class AkanAppHost {
455
484
  }
456
485
  this.#sendToBackend(message);
457
486
  }
487
+ // In `env`, which every builder spawn re-reads: a replacement (recycled, crashed, restarted) continues from here.
488
+ #trackBuilderGeneration(message: BuilderMessage): void {
489
+ const generation = AkanAppHost.#builderGenerationOf(message);
490
+ if (generation === undefined || generation <= this.#builderGeneration) return;
491
+ this.#builderGeneration = generation;
492
+ Object.assign(this.env, { AKAN_BUILDER_INITIAL_GENERATION: String(generation) });
493
+ }
494
+ // Not `csr-updated` / `ssr-updated`: their generation is the registry's.
495
+ static #builderGenerationOf(message: BuilderMessage): number | undefined {
496
+ if (message.type === "invalidate") return message.generation;
497
+ if (message.type === "build-status" || message.type === "builder-metrics") return message.data.generation;
498
+ if (message.type === "pages-updated" || message.type === "css-updated") return message.data.generation;
499
+ return undefined;
500
+ }
458
501
  // The backend reads `base-artifact.json` once, so a recycled builder re-announces it; unchanged hashes are dropped.
459
502
  #shouldRelayRecycledState(
460
503
  message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
@@ -752,12 +795,8 @@ export class AkanAppHost {
752
795
  this.logger.verbose("[idle-suspend] config changed while suspended; restarting the dev host");
753
796
  // Replaces the backend too, and a baseline kept past its own gap costs a restart at the next one.
754
797
  this.#discardBuilderGap("config change replaces the backend anyway");
755
- await this.#recycleDevChildren(
756
- { type: "invalidate", kinds: [...(batch?.kinds ?? [])], files },
757
- {
758
- refreshConfig: true,
759
- },
760
- );
798
+ //? Through the same path as a save's: a failure is reported and stays pending, as one would be awake.
799
+ await this.#applyRecycle({ type: "invalidate", kinds: [...(batch?.kinds ?? [])], files }, true);
761
800
  return;
762
801
  }
763
802
  // Refresh before deciding: a file created while suspended is not in the graph yet.
@@ -863,7 +902,8 @@ export class AkanAppHost {
863
902
  const wantsDevHostRestart = shouldRestartDevHostByDevPlan(message);
864
903
  const pending = this.#pendingRecycle;
865
904
  // A deferred recycle resumes on the next code batch; a css-only batch cannot heal a compile error.
866
- if (wantsDevHostRestart || shouldRestartBuilderByDevPlan(message) || (pending && message.kinds.includes("code"))) {
905
+ const resumes = !!pending && message.kinds.includes("code") && this.#touchesFailedRecycle(message.files);
906
+ if (wantsDevHostRestart || shouldRestartBuilderByDevPlan(message) || resumes) {
867
907
  const refreshConfig = wantsDevHostRestart || (pending?.refreshConfig ?? false);
868
908
  const merged = pending ? mergeInvalidateMessages(pending.message, message) : message;
869
909
  const generation = message.devPlan?.generation ?? message.generation;
@@ -871,14 +911,7 @@ export class AkanAppHost {
871
911
  this.#deferRecycle(merged, { refreshConfig, generation });
872
912
  return;
873
913
  }
874
- this.#pendingRecycle = null;
875
- try {
876
- if (refreshConfig) await this.#restartDevHost(merged);
877
- else await this.#restartDevChildren(merged);
878
- } catch (err) {
879
- this.#recordDevHostRestartFailure(merged, err, refreshConfig ? "Config" : "Runtime metadata");
880
- this.#resurrectDevChildren(merged);
881
- }
914
+ await this.#applyRecycle(merged, refreshConfig);
882
915
  return;
883
916
  }
884
917
  if (await this.#shouldRestartBackend(message)) {
@@ -887,6 +920,38 @@ export class AkanAppHost {
887
920
  }
888
921
  this.#sendToBackend(message);
889
922
  }
923
+ async #applyRecycle(message: Extract<BuilderMessage, { type: "invalidate" }>, refreshConfig: boolean): Promise<void> {
924
+ if (this.#stopping) return;
925
+ this.#pendingRecycle = null;
926
+ const progress = { applied: !refreshConfig };
927
+ try {
928
+ if (refreshConfig) await this.#restartDevHost(message, progress);
929
+ else await this.#restartDevChildren(message);
930
+ } catch (err) {
931
+ const kind = refreshConfig ? "Config" : "Runtime metadata";
932
+ const generation = this.#recordDevHostRestartFailure(message, err, kind, { applied: progress.applied });
933
+ //? Kept only when the change itself did not apply: a builder that booted degraded (a broken akan.config.ts)
934
+ //? takes the fixing save itself and sends no invalidate, so without this the old config would keep running.
935
+ if (progress.applied) this.#restartFailedAfterApply = generation;
936
+ else this.#pendingRecycle = { message, refreshConfig, failed: true, failedGeneration: generation };
937
+ this.#resurrectDevChildren(message);
938
+ }
939
+ }
940
+ //? The first green pages build after a failed restart that touches its change is the fix: the change applies then. A
941
+ //? cause no save fixes (an env value) waits for the next change of those files, not for every green save.
942
+ async #resumeFailedRecycle(status: DevBuildStatus): Promise<void> {
943
+ const pending = this.#pendingRecycle;
944
+ if (!pending?.failed || !status.ok || status.phase !== "pages" || !this.#touchesFailedRecycle(status.files)) return;
945
+ //? Newer than the failure: the batch that carried the change reports its own pages ok while the restart runs.
946
+ if (status.generation <= (pending.failedGeneration ?? -1)) return;
947
+ await this.#applyRecycle(pending.message, pending.refreshConfig);
948
+ }
949
+ #touchesFailedRecycle(files: string[]): boolean {
950
+ const pending = this.#pendingRecycle;
951
+ if (!pending?.failed) return true;
952
+ const changed = new Set(pending.message.files);
953
+ return files.some((file) => changed.has(file));
954
+ }
890
955
  #deferRecycle(
891
956
  message: Extract<BuilderMessage, { type: "invalidate" }>,
892
957
  { refreshConfig, generation }: { refreshConfig: boolean; generation?: number },
@@ -937,8 +1002,13 @@ export class AkanAppHost {
937
1002
  this.#builderRecoveryAttempts = 0;
938
1003
  this.logger.info("[builder-recovery] builder recovered");
939
1004
  void this.#restartBackendForGapChanges();
1005
+ const failedAfterApply = this.#restartFailedAfterApply;
1006
+ this.#restartFailedAfterApply = null;
940
1007
  const status: DevBuildStatus = {
941
- generation: reason.generation ?? this.#nextBackendBuildStatusGeneration(),
1008
+ generation:
1009
+ failedAfterApply !== null
1010
+ ? this.#nextBackendBuildStatusGeneration(failedAfterApply + 1)
1011
+ : (reason.generation ?? this.#nextBackendBuildStatusGeneration()),
942
1012
  phase: "scan",
943
1013
  ok: true,
944
1014
  files: reason.files,
@@ -965,16 +1035,19 @@ export class AkanAppHost {
965
1035
  }
966
1036
  // The config is re-imported with a cache-busting query, but modules it imports stay cached: a change inside an
967
1037
  // imported plugin file still needs a manual `akan start` restart.
968
- async #restartDevHost(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<void> {
1038
+ async #restartDevHost(
1039
+ message: Extract<BuilderMessage, { type: "invalidate" }>,
1040
+ progress?: { applied: boolean },
1041
+ ): Promise<void> {
969
1042
  const generation = message.devPlan?.generation ?? message.generation;
970
1043
  this.logger.warn(
971
1044
  `[dev-host] config change detected; restarting dev host generation=${generation ?? "(unknown)"} files=${message.files.length}`,
972
1045
  );
973
- await this.#recycleDevChildren(message, { refreshConfig: true });
1046
+ await this.#recycleDevChildren(message, { refreshConfig: true, progress });
974
1047
  }
975
1048
  async #recycleDevChildren(
976
1049
  message: Extract<BuilderMessage, { type: "invalidate" }>,
977
- { refreshConfig = false }: { refreshConfig?: boolean } = {},
1050
+ { refreshConfig = false, progress }: { refreshConfig?: boolean; progress?: { applied: boolean } } = {},
978
1051
  ): Promise<void> {
979
1052
  const generation = message.devPlan?.generation ?? message.generation;
980
1053
  this.#clearRestartTimers();
@@ -985,17 +1058,19 @@ export class AkanAppHost {
985
1058
  this.#pendingBuildStatusReplay = [];
986
1059
  await this.#stopBackend();
987
1060
  this.#stopBuilder();
1061
+ this.#patcherOff = false;
988
1062
  if (refreshConfig) {
989
1063
  await this.app.getConfig({ refresh: true });
990
1064
  // Merge, not replace: `start()` added values prepare does not produce (REDIS_HOST from the tunnel).
991
1065
  const { env } = await this.app.prepareCommand("start");
992
1066
  Object.assign(this.env, env);
1067
+ if (progress) progress.applied = true;
993
1068
  }
994
1069
  await this.#backendGraph.refresh();
995
1070
  await this.#startBuilder();
996
1071
  this.#startBackend({ generation, files: message.files });
997
1072
  }
998
- // `supersede`: a replacement builder's generation restarts at 0, so its announcement would look stale.
1073
+ // `supersede`: a recycled builder's re-announcement is what is on disk now, whatever generation it carries.
999
1074
  #recordLastGood(
1000
1075
  message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
1001
1076
  { supersede = false }: { supersede?: boolean } = {},
@@ -1018,22 +1093,29 @@ export class AkanAppHost {
1018
1093
  message: Extract<BuilderMessage, { type: "invalidate" }>,
1019
1094
  err: unknown,
1020
1095
  kind: "Config" | "Runtime metadata",
1021
- ): void {
1096
+ { applied }: { applied: boolean },
1097
+ ): number {
1022
1098
  const generation = message.devPlan?.generation ?? message.generation ?? this.#nextBackendBuildStatusGeneration();
1023
1099
  const detail = err instanceof Error ? err.message : String(err);
1024
1100
  this.logger.warn(`[dev-host] ${kind.toLowerCase()} restart failed generation=${generation}: ${detail}`);
1101
+ const names = message.files.slice(0, 3).map((file) => path.basename(file));
1102
+ const saved = names.length > 0 ? names.join(", ") : "the changed file";
1025
1103
  const status: DevBuildStatus = {
1026
1104
  generation,
1027
1105
  phase: "scan",
1028
1106
  ok: false,
1029
1107
  files: message.files,
1030
- message: `${kind} change failed to apply; recovering the dev server automatically: ${detail}`,
1108
+ message: applied
1109
+ ? `${kind} change applied, but the dev server failed to restart: ${detail}. It is recovering on its own.`
1110
+ : `${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.`,
1031
1111
  };
1032
1112
  this.#recordBuildStatus(status);
1033
1113
  this.#sendOrQueueBuildStatus(status);
1114
+ return generation;
1034
1115
  }
1035
1116
  #recordBuildStatus(status: DevBuildStatus): void {
1036
1117
  const recovered = shouldMarkBuildPhaseRecovered(this.#buildStatusByPhase, status);
1118
+ if (shouldKeepBuildFailure(this.#buildStatusByPhase, status)) return;
1037
1119
  this.#buildStatusByPhase.set(status.phase, status);
1038
1120
  const label = `[build-status] generation=${status.generation} phase=${status.phase} ok=${status.ok} files=${status.files.length}`;
1039
1121
  if (status.ok) this.logger.verbose(`${label}${recovered ? " recovered=1" : ""}`);
@@ -1049,6 +1131,12 @@ export class AkanAppHost {
1049
1131
  }
1050
1132
  this.#sendToBackend({ type: "build-status", data: status });
1051
1133
  }
1134
+ //? A route status belongs to the route cache of the backend that asked for the build: a new backend builds its routes
1135
+ //? again, and reports each one itself.
1136
+ #forgetRouteBuildStatus(): void {
1137
+ this.#buildStatusByPhase.delete("route");
1138
+ this.#pendingBuildStatusReplay = this.#pendingBuildStatusReplay.filter((status) => status.phase !== "route");
1139
+ }
1052
1140
  #replayBuilderState(): void {
1053
1141
  if (!this.#backendReady) return;
1054
1142
  if (this.#lastGoodFrontend.css) this.#sendToBackend(this.#lastGoodFrontend.css);
@@ -1106,6 +1194,7 @@ export class AkanAppHost {
1106
1194
  {
1107
1195
  stdio: this.stdio,
1108
1196
  onOutput: (kind, text) => void (kind === "stderr" ? process.stderr : process.stdout).write(text),
1197
+ patcherOff: this.#patcherOff,
1109
1198
  },
1110
1199
  );
1111
1200
  try {
@@ -1193,6 +1282,7 @@ export class AkanAppHost {
1193
1282
  #stopBuilder(): void {
1194
1283
  this.#cancelRssRecycle();
1195
1284
  if (!this.#builder) return;
1285
+ if (this.#builder.patcherOff) this.#patcherOff = true;
1196
1286
  this.#builder.stop();
1197
1287
  this.#builder = null;
1198
1288
  }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DevBootLatch } from "./devBootLatch";
3
+
4
+ const latch = (waitMs = 60_000) => {
5
+ const booted: number[] = [];
6
+ return { booted, latch: new DevBootLatch(() => booted.push(Date.now()), { waitMs }) };
7
+ };
8
+
9
+ describe("DevBootLatch", () => {
10
+ test("opens once the app serves and its boot builds have settled, in either order", () => {
11
+ const readyFirst = latch();
12
+ readyFirst.latch.ready();
13
+ expect(readyFirst.booted).toHaveLength(0);
14
+ readyFirst.latch.armed();
15
+ expect(readyFirst.booted).toHaveLength(1);
16
+
17
+ const armedFirst = latch();
18
+ armedFirst.latch.armed();
19
+ expect(armedFirst.booted).toHaveLength(0);
20
+ armedFirst.latch.ready();
21
+ expect(armedFirst.booted).toHaveLength(1);
22
+ });
23
+
24
+ test("opens once per session, however often the app restarts or the builder re-arms", () => {
25
+ const { booted, latch: boot } = latch();
26
+ boot.armed();
27
+ boot.ready();
28
+ boot.ready();
29
+ boot.armed();
30
+ expect(booted).toHaveLength(1);
31
+ });
32
+
33
+ test("opens after its wait past ready when the builder never reports", async () => {
34
+ const { booted, latch: boot } = latch(20);
35
+ boot.ready();
36
+ await Bun.sleep(60);
37
+ expect(booted).toHaveLength(1);
38
+ });
39
+ });
@@ -0,0 +1,39 @@
1
+ //* An app's boot is over for the supervisor once it serves and its builder's boot builds have settled (the SSR
2
+ //* registry's, and CSR's when the env arms it): those workers are a boot's largest processes, so the next boot wave
3
+ //* starts here, while the browser opens on the ready that comes first.
4
+ export class DevBootLatch {
5
+ readonly #onBooted: () => void;
6
+ readonly #waitMs: number;
7
+ #ready = false;
8
+ #armed = false;
9
+ #booted = false;
10
+ #timer: ReturnType<typeof setTimeout> | null = null;
11
+
12
+ /** `waitMs` bounds the wait past ready: a builder that never reports (killed mid-boot) must not stall the waves. */
13
+ constructor(onBooted: () => void, { waitMs = 30_000 }: { waitMs?: number } = {}) {
14
+ this.#onBooted = onBooted;
15
+ this.#waitMs = waitMs;
16
+ }
17
+
18
+ ready(): void {
19
+ this.#ready = true;
20
+ if (!this.#armed && !this.#timer) {
21
+ this.#timer = setTimeout(() => this.armed(), this.#waitMs);
22
+ this.#timer.unref?.();
23
+ }
24
+ this.#release();
25
+ }
26
+
27
+ armed(): void {
28
+ this.#armed = true;
29
+ this.#release();
30
+ }
31
+
32
+ #release(): void {
33
+ if (this.#booted || !this.#ready || !this.#armed) return;
34
+ this.#booted = true;
35
+ if (this.#timer) clearTimeout(this.#timer);
36
+ this.#timer = null;
37
+ this.#onBooted();
38
+ }
39
+ }
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangeAction } from "akanjs/server";
3
+ //? The source itself, not the package: the published akanjs exports no such subpath, and tests are not bundled.
4
+ import { DevBuildRecovery } from "../../../akanjs/server/artifact/devBuildRecovery";
3
5
  import {
4
6
  backendRestartReasonFromMessage,
5
7
  buildStatusReplaySequence,
@@ -17,6 +19,7 @@ import {
17
19
  resolveIdleSuspendMs,
18
20
  shouldAbandonBackendRecovery,
19
21
  shouldHoldForReturningBuilder,
22
+ shouldKeepBuildFailure,
20
23
  shouldMarkBuildPhaseRecovered,
21
24
  shouldQueueBuildStatusReplay,
22
25
  shouldRefreshConfigOnIdleWake,
@@ -281,8 +284,9 @@ describe("dev idle suspend", () => {
281
284
  expect(resolveIdleSuspendMs("1500")).toBe(1_500);
282
285
  });
283
286
 
284
- test("blocks a suspend on a failure in any phase, not just the newest generation", () => {
287
+ test("blocks a suspend on a failure in any phase but a route's, not just the newest generation", () => {
285
288
  expect(hasAnyBuildFailure(new Map())).toBe(false);
289
+ expect(hasAnyBuildFailure(new Map([["route", status("route", 1, false)]]))).toBe(false);
286
290
  expect(hasAnyBuildFailure(new Map([["scan", status("scan", 1, true)]]))).toBe(false);
287
291
  expect(
288
292
  hasAnyBuildFailure(
@@ -355,6 +359,67 @@ describe("build status helpers", () => {
355
359
  expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("pages", 12, true))).toBe(true);
356
360
  });
357
361
 
362
+ test("an ok of the failure's own generation neither recovers nor replaces it, except a backend's", () => {
363
+ const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
364
+ ["ssr", status("ssr", 7, false)],
365
+ ["backend", status("backend", 7, false)],
366
+ ]);
367
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("ssr", 7, true))).toBe(false);
368
+ expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 7, true))).toBe(true);
369
+ expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 8, true))).toBe(false);
370
+ expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 7, false))).toBe(false);
371
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("backend", 7, true))).toBe(true);
372
+ });
373
+
374
+ test("the same route built again recovers at the failure's generation or later, and another route's ok never does", () => {
375
+ const routeA = (ok: boolean, generation = 4) => ({ ...status("route", generation, ok), scope: "/:lang/a" });
376
+ const routeB = (ok: boolean, generation = 4) => ({ ...status("route", generation, ok), scope: "/:lang/b" });
377
+ const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["route", routeA(false)]]);
378
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, routeA(true))).toBe(true);
379
+ expect(shouldKeepBuildFailure(previousByPhase, routeA(true))).toBe(false);
380
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, routeB(true))).toBe(false);
381
+ expect(shouldKeepBuildFailure(previousByPhase, routeB(true))).toBe(true);
382
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, routeB(true, 5))).toBe(false);
383
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, routeA(true, 5))).toBe(true);
384
+ expect(shouldKeepBuildFailure(previousByPhase, routeA(true, 3))).toBe(true);
385
+ });
386
+
387
+ test("judges recovery as the backend does (akanjs DevBuildRecovery), case for case", () => {
388
+ const at = (phase: BuildPhase, generation: number, ok: boolean, scope?: string): DevBuildStatus => ({
389
+ ...status(phase, generation, ok),
390
+ ...(scope ? { scope } : {}),
391
+ });
392
+ const cases: [DevBuildStatus | undefined, DevBuildStatus][] = [];
393
+ for (const phase of ["backend", "route", "scan", "pages", "ssr"] as BuildPhase[])
394
+ for (const [previousOk, ok] of [
395
+ [false, true],
396
+ [true, true],
397
+ [false, false],
398
+ ])
399
+ for (const generation of [3, 4, 5])
400
+ for (const [previousScope, scope] of [
401
+ [undefined, undefined],
402
+ ["/a", "/a"],
403
+ ["/a", "/b"],
404
+ ])
405
+ cases.push([at(phase, 4, previousOk, previousScope), at(phase, generation, ok, scope)]);
406
+ cases.push([undefined, at("pages", 4, true)]);
407
+ for (const [previous, next] of cases) {
408
+ const previousByPhase = new Map<BuildPhase, DevBuildStatus>(previous ? [[previous.phase, previous]] : []);
409
+ expect([previous, next, shouldMarkBuildPhaseRecovered(previousByPhase, next)]).toEqual([
410
+ previous,
411
+ next,
412
+ DevBuildRecovery.recovers(previous, next),
413
+ ]);
414
+ }
415
+ });
416
+
417
+ test("a builder that came back clears no config change still on hold, or one that failed to apply", () => {
418
+ const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["scan", status("scan", 42, false)]]);
419
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("scan", 42, true))).toBe(false);
420
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("scan", 43, true))).toBe(true);
421
+ });
422
+
358
423
  test("creates backend build-status payloads for lifecycle test hooks", () => {
359
424
  expect(
360
425
  createBackendBuildStatus({
@@ -440,6 +505,11 @@ describe("hasBuildFailureForGeneration", () => {
440
505
  expect(hasBuildFailureForGeneration(statusByPhase, 4)).toBe(false);
441
506
  });
442
507
 
508
+ test("leaves a route's failure out: nothing a replacement builder boots builds a route", () => {
509
+ const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["route", status("route", 42, false)]]);
510
+ expect(hasBuildFailureForGeneration(statusByPhase, 42)).toBe(false);
511
+ });
512
+
443
513
  test("treats unknown generations and green boards as healthy", () => {
444
514
  const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["csr", status("csr", 3, false)]]);
445
515
  expect(hasBuildFailureForGeneration(statusByPhase, undefined)).toBe(false);