@akanjs/devkit 3.0.0-beta.25 → 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.
@@ -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
 
@@ -133,13 +135,14 @@ AKAN_MEMORY_LIMIT=1200mb # builder gets ~420MB, rsc worker ~660MB
133
135
  AKAN_DEV_IDLE_SUSPEND_MS=300000 # release the builder after 5 idle minutes
134
136
  ```
135
137
 
136
- Two things to expect at that size. The builder crosses 420MB during ordinary work — one route build
137
- 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
138
140
  replacement costing a boot build that requests wait through rather than fail. And the build worker's
139
141
  peak is not covered by any of these ceilings; if the kernel OOM-kills it, the dev server survives with a
140
142
  red build for that generation and the log names the signal. On a boot whose `.akan` was cleared, the SSR
141
- registry's first build is such a worker: a killed one leaves no registry, and every later save retries that
142
- same whole build, so a container that cannot fit it once cannot fit it at all.
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.
143
146
 
144
147
  Raising `AKAN_BUILDER_MAX_RSS_MB` above the derived share trades memory for fewer boot builds. Setting
145
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";
@@ -93,6 +94,8 @@ export class AkanAppHost {
93
94
  readonly #bootLatch = new DevBootLatch(() => this.#onDevEvent?.({ app: this.app.name, booted: true }));
94
95
  #backend: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe"> | null = null;
95
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;
96
99
  #backendReady = false;
97
100
  #plannedBackendStops = new WeakSet<Bun.Subprocess<"ignore", "inherit", "inherit">>();
98
101
  #restartTimer: ReturnType<typeof setTimeout> | null = null;
@@ -101,7 +104,16 @@ export class AkanAppHost {
101
104
  #backendGaveUp = false;
102
105
  #backendLifecycleState: BackendLifecycleState = "stopped";
103
106
  #pendingRestartReason: BackendRestartReason | null = null;
104
- #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;
105
117
  #builderRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
106
118
  #builderRecoveryAttempts = 0;
107
119
  #backendStartStatus: { generation?: number; files: string[] } | null = null;
@@ -163,6 +175,9 @@ export class AkanAppHost {
163
175
  return this;
164
176
  }
165
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;
166
181
  this.#cancelIdleSuspend();
167
182
  this.#stopIdleWatcher();
168
183
  this.#clearRestartTimers();
@@ -430,12 +445,14 @@ export class AkanAppHost {
430
445
  });
431
446
  }
432
447
  async #handleBuilderMessage(message: BuilderMessage) {
448
+ if (this.#stopping) return;
433
449
  this.#markDevActivity();
434
450
  this.#trackBuilderGeneration(message);
435
451
  if (message.type === "build-status") {
436
452
  this.#recordBuildStatus(message.data);
437
453
  this.#sendOrQueueBuildStatus(message.data);
438
454
  this.#reviveBackendAfterGreenBuild(message.data);
455
+ await this.#resumeFailedRecycle(message.data);
439
456
  return;
440
457
  }
441
458
  if (message.type === "builder-metrics") {
@@ -778,12 +795,8 @@ export class AkanAppHost {
778
795
  this.logger.verbose("[idle-suspend] config changed while suspended; restarting the dev host");
779
796
  // Replaces the backend too, and a baseline kept past its own gap costs a restart at the next one.
780
797
  this.#discardBuilderGap("config change replaces the backend anyway");
781
- await this.#recycleDevChildren(
782
- { type: "invalidate", kinds: [...(batch?.kinds ?? [])], files },
783
- {
784
- refreshConfig: true,
785
- },
786
- );
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);
787
800
  return;
788
801
  }
789
802
  // Refresh before deciding: a file created while suspended is not in the graph yet.
@@ -889,7 +902,8 @@ export class AkanAppHost {
889
902
  const wantsDevHostRestart = shouldRestartDevHostByDevPlan(message);
890
903
  const pending = this.#pendingRecycle;
891
904
  // A deferred recycle resumes on the next code batch; a css-only batch cannot heal a compile error.
892
- 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) {
893
907
  const refreshConfig = wantsDevHostRestart || (pending?.refreshConfig ?? false);
894
908
  const merged = pending ? mergeInvalidateMessages(pending.message, message) : message;
895
909
  const generation = message.devPlan?.generation ?? message.generation;
@@ -897,14 +911,7 @@ export class AkanAppHost {
897
911
  this.#deferRecycle(merged, { refreshConfig, generation });
898
912
  return;
899
913
  }
900
- this.#pendingRecycle = null;
901
- try {
902
- if (refreshConfig) await this.#restartDevHost(merged);
903
- else await this.#restartDevChildren(merged);
904
- } catch (err) {
905
- this.#recordDevHostRestartFailure(merged, err, refreshConfig ? "Config" : "Runtime metadata");
906
- this.#resurrectDevChildren(merged);
907
- }
914
+ await this.#applyRecycle(merged, refreshConfig);
908
915
  return;
909
916
  }
910
917
  if (await this.#shouldRestartBackend(message)) {
@@ -913,6 +920,38 @@ export class AkanAppHost {
913
920
  }
914
921
  this.#sendToBackend(message);
915
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
+ }
916
955
  #deferRecycle(
917
956
  message: Extract<BuilderMessage, { type: "invalidate" }>,
918
957
  { refreshConfig, generation }: { refreshConfig: boolean; generation?: number },
@@ -963,8 +1002,13 @@ export class AkanAppHost {
963
1002
  this.#builderRecoveryAttempts = 0;
964
1003
  this.logger.info("[builder-recovery] builder recovered");
965
1004
  void this.#restartBackendForGapChanges();
1005
+ const failedAfterApply = this.#restartFailedAfterApply;
1006
+ this.#restartFailedAfterApply = null;
966
1007
  const status: DevBuildStatus = {
967
- generation: reason.generation ?? this.#nextBackendBuildStatusGeneration(),
1008
+ generation:
1009
+ failedAfterApply !== null
1010
+ ? this.#nextBackendBuildStatusGeneration(failedAfterApply + 1)
1011
+ : (reason.generation ?? this.#nextBackendBuildStatusGeneration()),
968
1012
  phase: "scan",
969
1013
  ok: true,
970
1014
  files: reason.files,
@@ -991,16 +1035,19 @@ export class AkanAppHost {
991
1035
  }
992
1036
  // The config is re-imported with a cache-busting query, but modules it imports stay cached: a change inside an
993
1037
  // imported plugin file still needs a manual `akan start` restart.
994
- 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> {
995
1042
  const generation = message.devPlan?.generation ?? message.generation;
996
1043
  this.logger.warn(
997
1044
  `[dev-host] config change detected; restarting dev host generation=${generation ?? "(unknown)"} files=${message.files.length}`,
998
1045
  );
999
- await this.#recycleDevChildren(message, { refreshConfig: true });
1046
+ await this.#recycleDevChildren(message, { refreshConfig: true, progress });
1000
1047
  }
1001
1048
  async #recycleDevChildren(
1002
1049
  message: Extract<BuilderMessage, { type: "invalidate" }>,
1003
- { refreshConfig = false }: { refreshConfig?: boolean } = {},
1050
+ { refreshConfig = false, progress }: { refreshConfig?: boolean; progress?: { applied: boolean } } = {},
1004
1051
  ): Promise<void> {
1005
1052
  const generation = message.devPlan?.generation ?? message.generation;
1006
1053
  this.#clearRestartTimers();
@@ -1011,11 +1058,13 @@ export class AkanAppHost {
1011
1058
  this.#pendingBuildStatusReplay = [];
1012
1059
  await this.#stopBackend();
1013
1060
  this.#stopBuilder();
1061
+ this.#patcherOff = false;
1014
1062
  if (refreshConfig) {
1015
1063
  await this.app.getConfig({ refresh: true });
1016
1064
  // Merge, not replace: `start()` added values prepare does not produce (REDIS_HOST from the tunnel).
1017
1065
  const { env } = await this.app.prepareCommand("start");
1018
1066
  Object.assign(this.env, env);
1067
+ if (progress) progress.applied = true;
1019
1068
  }
1020
1069
  await this.#backendGraph.refresh();
1021
1070
  await this.#startBuilder();
@@ -1044,19 +1093,25 @@ export class AkanAppHost {
1044
1093
  message: Extract<BuilderMessage, { type: "invalidate" }>,
1045
1094
  err: unknown,
1046
1095
  kind: "Config" | "Runtime metadata",
1047
- ): void {
1096
+ { applied }: { applied: boolean },
1097
+ ): number {
1048
1098
  const generation = message.devPlan?.generation ?? message.generation ?? this.#nextBackendBuildStatusGeneration();
1049
1099
  const detail = err instanceof Error ? err.message : String(err);
1050
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";
1051
1103
  const status: DevBuildStatus = {
1052
1104
  generation,
1053
1105
  phase: "scan",
1054
1106
  ok: false,
1055
1107
  files: message.files,
1056
- 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.`,
1057
1111
  };
1058
1112
  this.#recordBuildStatus(status);
1059
1113
  this.#sendOrQueueBuildStatus(status);
1114
+ return generation;
1060
1115
  }
1061
1116
  #recordBuildStatus(status: DevBuildStatus): void {
1062
1117
  const recovered = shouldMarkBuildPhaseRecovered(this.#buildStatusByPhase, status);
@@ -1139,6 +1194,7 @@ export class AkanAppHost {
1139
1194
  {
1140
1195
  stdio: this.stdio,
1141
1196
  onOutput: (kind, text) => void (kind === "stderr" ? process.stderr : process.stdout).write(text),
1197
+ patcherOff: this.#patcherOff,
1142
1198
  },
1143
1199
  );
1144
1200
  try {
@@ -1226,6 +1282,7 @@ export class AkanAppHost {
1226
1282
  #stopBuilder(): void {
1227
1283
  this.#cancelRssRecycle();
1228
1284
  if (!this.#builder) return;
1285
+ if (this.#builder.patcherOff) this.#patcherOff = true;
1229
1286
  this.#builder.stop();
1230
1287
  this.#builder = null;
1231
1288
  }
@@ -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,
@@ -282,8 +284,9 @@ describe("dev idle suspend", () => {
282
284
  expect(resolveIdleSuspendMs("1500")).toBe(1_500);
283
285
  });
284
286
 
285
- 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", () => {
286
288
  expect(hasAnyBuildFailure(new Map())).toBe(false);
289
+ expect(hasAnyBuildFailure(new Map([["route", status("route", 1, false)]]))).toBe(false);
287
290
  expect(hasAnyBuildFailure(new Map([["scan", status("scan", 1, true)]]))).toBe(false);
288
291
  expect(
289
292
  hasAnyBuildFailure(
@@ -368,16 +371,53 @@ describe("build status helpers", () => {
368
371
  expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("backend", 7, true))).toBe(true);
369
372
  });
370
373
 
371
- test("a route built again and a builder that came back recover on the failure's own generation", () => {
372
- const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
373
- ["route", status("route", 4, false)],
374
- ["scan", status("scan", 42, false)],
375
- ]);
376
- expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("route", 4, true))).toBe(true);
377
- expect(shouldKeepBuildFailure(previousByPhase, status("route", 4, true))).toBe(false);
378
- expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("scan", 42, true))).toBe(true);
379
- expect(shouldKeepBuildFailure(previousByPhase, status("scan", 42, true))).toBe(false);
380
- expect(shouldKeepBuildFailure(previousByPhase, status("route", 3, true))).toBe(true);
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);
381
421
  });
382
422
 
383
423
  test("creates backend build-status payloads for lifecycle test hooks", () => {
@@ -465,6 +505,11 @@ describe("hasBuildFailureForGeneration", () => {
465
505
  expect(hasBuildFailureForGeneration(statusByPhase, 4)).toBe(false);
466
506
  });
467
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
+
468
513
  test("treats unknown generations and green boards as healthy", () => {
469
514
  const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["csr", status("csr", 3, false)]]);
470
515
  expect(hasBuildFailureForGeneration(statusByPhase, undefined)).toBe(false);
@@ -120,20 +120,18 @@ export const isLegacyBackendFallbackFile = (file: string, workspaceRoot: string)
120
120
  );
121
121
  };
122
122
 
123
- const recoversOnSameGeneration = (phase: BuildPhase): boolean =>
124
- phase === "backend" || phase === "scan" || phase === "route";
125
-
126
- //? A newer generation, as the backend and the tabs judge it: an ok of the failure's own generation (a route build's
127
- //? registry check that had nothing to add) says nothing about the failure. The phases whose retry reports the
128
- //? failure's generation again (a backend or builder that came back, a route built again) recover on it.
123
+ //? The backend's rule (akanjs `DevBuildRecovery`), copied: no exported akanjs subpath reaches it from the CLI bundle.
129
124
  export const shouldMarkBuildPhaseRecovered = (
130
125
  previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
131
126
  status: DevBuildStatus,
132
127
  ): boolean => {
133
128
  const previous = previousByPhase.get(status.phase);
134
- if (!previous || !status.ok || previous.ok) return false;
129
+ if (!previous || previous.ok || !status.ok) return false;
135
130
  const generation = generationValue(status.generation);
136
- return recoversOnSameGeneration(status.phase) ? generation >= previous.generation : generation > previous.generation;
131
+ if (status.phase === "route" && status.scope !== undefined && previous.scope !== undefined)
132
+ return status.scope === previous.scope && generation >= previous.generation;
133
+ if (generation !== previous.generation) return generation > previous.generation;
134
+ return status.phase === "backend";
137
135
  };
138
136
 
139
137
  /** An ok that recovers nothing leaves the failure standing, for hello and for whatever waits on a clean build. */
@@ -205,7 +203,8 @@ export const hasBuildFailureForGeneration = (
205
203
  ): boolean => {
206
204
  if (typeof generation !== "number") return false;
207
205
  for (const status of statusByPhase.values()) {
208
- if (!status.ok && status.generation === generation) return true;
206
+ //? Not a route's: a replacement builder or a restarted dev host boots without building one, so it cannot hit it.
207
+ if (!status.ok && status.phase !== "route" && status.generation === generation) return true;
209
208
  }
210
209
  return false;
211
210
  };
@@ -293,8 +292,9 @@ export const resolveIdleSuspendMs = (raw: string | undefined): number | null =>
293
292
  return Math.round(parsed);
294
293
  };
295
294
 
295
+ //? Not a route's: a broken route builds again on its next request, which wakes a suspended builder anyway.
296
296
  export const hasAnyBuildFailure = (statusByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>): boolean =>
297
- [...statusByPhase.values()].some((status) => !status.ok);
297
+ [...statusByPhase.values()].some((status) => !status.ok && status.phase !== "route");
298
298
 
299
299
  export const shouldRefreshConfigOnIdleWake = (batch: ChangeBatch | null): boolean =>
300
300
  !!batch && batch.kinds.has("config");
@@ -34,4 +34,79 @@ describe("GraphClientEntryDiscovery", () => {
34
34
  expect(await discovery.discover([file("S.tsx")])).toEqual([file("C.tsx")]);
35
35
  }
36
36
  });
37
+
38
+ const tempDir = async () => {
39
+ const dir = await mkdtemp(path.join(os.tmpdir(), "akan-discovery-"));
40
+ dirs.push(dir);
41
+ return (name: string) => path.join(dir, name);
42
+ };
43
+
44
+ test("an entry moved into a folder, or to another extension, is found once the move is invalidated", async () => {
45
+ const file = await tempDir();
46
+ await Bun.write(file("S.tsx"), 'import "./ui/Chart";\nimport "./ui/Card";\n');
47
+ await Bun.write(file("ui/Chart.tsx"), '"use client";\nexport const Chart = () => null;\n');
48
+ await Bun.write(file("ui/Card.tsx"), '"use client";\nexport const Card = () => null;\n');
49
+ const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
50
+ expect(await discovery.discover([file("S.tsx")])).toEqual([file("ui/Card.tsx"), file("ui/Chart.tsx")]);
51
+
52
+ await rm(file("ui/Chart.tsx"));
53
+ await Bun.write(file("ui/Chart/index.tsx"), '"use client";\nexport const Chart = () => null;\n');
54
+ await rm(file("ui/Card.tsx"));
55
+ await Bun.write(file("ui/Card.ts"), '"use client";\nexport const Card = () => null;\n');
56
+ discovery.invalidate([file("ui/Chart.tsx"), file("ui/Chart/index.tsx"), file("ui/Card.tsx"), file("ui/Card.ts")]);
57
+ expect(await discovery.discover([file("S.tsx")])).toEqual([file("ui/Card.ts"), file("ui/Chart/index.tsx")]);
58
+ });
59
+
60
+ test("a resolution still in flight when its target appears is not what the next walk reads", async () => {
61
+ const file = await tempDir();
62
+ await Bun.write(file("S.tsx"), 'import "./B";\n');
63
+ await Bun.write(file("B.tsx"), 'import "@demo/C";\nexport const b = 1;\n');
64
+ const gate = Promise.withResolvers<void>();
65
+ const seen = Promise.withResolvers<void>();
66
+ let calls = 0;
67
+ const resolvePackage = async () => {
68
+ calls += 1;
69
+ const exists = await Bun.file(file("C.tsx")).exists();
70
+ if (calls === 1) {
71
+ seen.resolve();
72
+ await gate.promise;
73
+ }
74
+ return exists ? { entryFile: file("C.tsx") } : null;
75
+ };
76
+ const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, resolvePackage as never);
77
+ const walk = discovery.discover([file("S.tsx")]);
78
+ await seen.promise;
79
+ await Bun.write(file("C.tsx"), '"use client";\nexport const C = () => null;\n');
80
+ discovery.invalidate([file("C.tsx")]);
81
+ gate.resolve();
82
+ await walk;
83
+ expect(await discovery.discover([file("S.tsx")])).toEqual([file("C.tsx")]);
84
+ });
85
+
86
+ test("a dense import cycle is walked once per file, not once per path", async () => {
87
+ const file = await tempDir();
88
+ const names = Array.from({ length: 12 }, (_, idx) => `M${idx}.tsx`);
89
+ for (const name of names)
90
+ await Bun.write(file(name), names.map((other) => `import "./${other.replace(".tsx", "")}";\n`).join(""));
91
+ await Bun.write(file("C.tsx"), '"use client";\nexport const C = () => null;\n');
92
+ await Bun.write(
93
+ file("M11.tsx"),
94
+ `${names.map((other) => `import "./${other.replace(".tsx", "")}";\n`).join("")}import "./C";\n`,
95
+ );
96
+ const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
97
+ const started = performance.now();
98
+ expect(await discovery.discover([file("M0.tsx")])).toEqual([file("C.tsx")]);
99
+ expect(performance.now() - started).toBeLessThan(2_000);
100
+ expect(await discovery.discover([file("M5.tsx")])).toEqual([file("C.tsx")]);
101
+ });
102
+
103
+ test("a file walked inside an import cycle keeps no partial result for a walk that starts at it", async () => {
104
+ const file = await tempDir();
105
+ await Bun.write(file("A.tsx"), 'import "./B";\nimport "./C";\n');
106
+ await Bun.write(file("B.tsx"), 'import "./A";\n');
107
+ await Bun.write(file("C.tsx"), '"use client";\nexport const C = () => null;\n');
108
+ const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
109
+ expect(await discovery.discover([file("A.tsx")])).toEqual([file("C.tsx")]);
110
+ expect(await discovery.discover([file("B.tsx")])).toEqual([file("C.tsx")]);
111
+ });
37
112
  });