@akanjs/devkit 2.4.1-rc.7 → 2.4.1

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.
@@ -17,7 +17,8 @@ import { WorkspaceExecutor } from "../executors";
17
17
  // tailwind stack, which is exactly what a suspended dev host must not be holding.
18
18
  import { HmrWatcher } from "../frontendBuild/hmrWatcher";
19
19
  import { WatchRootResolver } from "../frontendBuild/watchRootResolver";
20
- import { IncrementalBuilderHost } from "../incrementalBuilder";
20
+ import { IncrementalBuilderHost, type IncrementalBuilderStatus } from "../incrementalBuilder";
21
+ import { BuilderRequestRouter } from "../incrementalBuilder/builderRequestRouter";
21
22
 
22
23
  const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr"]);
23
24
  const BACKEND_RESTART_DEBOUNCE_MS = 120;
@@ -30,11 +31,17 @@ const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
30
31
  const BACKEND_STDERR_TAIL_LIMIT = 40;
31
32
  const BUILDER_READY_TIMEOUT_MS = 150000;
32
33
  const BUILDER_START_MAX_ATTEMPTS = 3;
34
+ /**
35
+ * How many requests may wait for a builder that is coming back. Generous — a page load asks for
36
+ * several routes — but finite, so a builder that never returns cannot grow this without bound. Past
37
+ * it, requests are failed as they were before, which is the behaviour this limit falls back to.
38
+ */
39
+ const HELD_BUILDER_REQUEST_LIMIT = 64;
33
40
  // Save-on-keystroke arrives as a burst of batches. Recycling mid-burst would drop the watcher events
34
41
  // still on their way to the builder, so an over-ceiling builder is replaced only once it goes quiet.
35
42
  const BUILDER_RSS_RECYCLE_QUIET_MS = 750;
36
43
  const BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS = 30_000;
37
- const BUILDER_INEFFECTIVE_RSS_RECYCLE_LIMIT = 2;
44
+ const BUILDER_TIGHT_RSS_REPORT_LIMIT = 2;
38
45
  // Linux hands the bundler arenas back on its own after ~10-15s idle — measured at 46-59% of the
39
46
  // builder's peak (`local/optimize-resource/09-linux-retention-measurement.md`) — while macOS returns
40
47
  // none of it. The builder only reports RSS at work-completion points, so the sample a recycle is armed
@@ -42,6 +49,8 @@ const BUILDER_INEFFECTIVE_RSS_RECYCLE_LIMIT = 2;
42
49
  // paying a cold boot build for memory the OS was about to return anyway. On macOS the re-read returns
43
50
  // the same value, so this only ever costs the delay.
44
51
  const BUILDER_RSS_SETTLE_MS = 20_000;
52
+ /** Reading one process's rss is a millisecond of work; anything near this is a `ps` that is stuck. */
53
+ const PS_RSS_TIMEOUT_MS = 2_000;
45
54
  // Far enough above the ceiling that no purge would rescue it; recycle without waiting.
46
55
  const BUILDER_RSS_HARD_MULTIPLE = 1.5;
47
56
  // A sandbox between user turns pays for a watcher that is watching nothing change. Suspending build
@@ -325,14 +334,51 @@ export const shouldRefreshConfigOnIdleWake = (batch: ChangeBatch | null): boolea
325
334
  !!batch && batch.kinds.has("config");
326
335
 
327
336
  /**
328
- * A builder whose fresh boot already exceeds the ceiling reports `too-soon` after every recycle and
329
- * would be replaced forever without ever getting under it. After this many recycles bought no relief
330
- * the host stops enforcing the ceiling and says so, rather than looping.
337
+ * Whether a request that arrived while the builder was away should wait for the one coming back.
338
+ *
339
+ * A recycle or a crash-restart is a gap, not a failure — the request that lands in it is the page a
340
+ * developer is waiting on. A stopped builder is a different thing: nothing is bringing it back, so
341
+ * waiting would only delay the error.
342
+ *
343
+ * `recycling` is in here for the same reason as `restarting`, and was the hole this decision shipped
344
+ * with: a draining builder is still alive, so the request reached it and came back refused while the
345
+ * host still thought there was nothing to wait for.
346
+ */
347
+ const RETURNING_BUILDER_STATUSES = new Set<IncrementalBuilderStatus>(["starting", "recycling", "restarting"]);
348
+ export const shouldHoldForReturningBuilder = ({
349
+ status,
350
+ heldCount,
351
+ limit = HELD_BUILDER_REQUEST_LIMIT,
352
+ }: {
353
+ status: IncrementalBuilderStatus;
354
+ heldCount: number;
355
+ limit?: number;
356
+ }): boolean => RETURNING_BUILDER_STATUSES.has(status) && heldCount < limit;
357
+
358
+ /**
359
+ * Whether a builder that is over the ceiling again this soon after being replaced is worth saying so
360
+ * about, once. Not a reason to stop enforcing the ceiling: the minimum interval already bounds what
361
+ * this costs at one recycle per interval, and dropping the bound is how a container gets OOM-killed.
362
+ *
363
+ * This used to disable the ceiling for the session, on a count that a single page load reaches — two
364
+ * route builds, two reports, both inside the interval. That is normal work on any app whose builds sit
365
+ * above the ceiling, which is the same app the ceiling was derived for.
366
+ */
367
+ export const shouldWarnBuilderRssCeilingTight = (
368
+ reportsSinceRecycle: number,
369
+ limit = BUILDER_TIGHT_RSS_REPORT_LIMIT,
370
+ ): boolean => reportsSinceRecycle >= limit;
371
+
372
+ /**
373
+ * Whether recycling can ever bring this builder under the ceiling.
374
+ *
375
+ * Measured on a replacement the moment it is ready, before it has built anything on demand: that is
376
+ * the floor every future replacement lands on, so a floor already over the ceiling is the one case
377
+ * where the recycle loop is pure cost. It is also the case the escape hatch was always described as
378
+ * being for — the previous rule inferred it from report timing and caught ordinary work instead.
331
379
  */
332
- export const shouldAbandonBuilderRssCeiling = (
333
- ineffectiveRecycles: number,
334
- limit = BUILDER_INEFFECTIVE_RSS_RECYCLE_LIMIT,
335
- ): boolean => ineffectiveRecycles >= limit;
380
+ export const isRssCeilingUnreachable = (freshRssBytes: number | null, ceilingBytes: number | null): boolean =>
381
+ freshRssBytes !== null && ceilingBytes !== null && freshRssBytes >= ceilingBytes;
336
382
 
337
383
  /**
338
384
  * Whether a recycled builder's re-announced boot artifact actually differs from what the backend
@@ -392,6 +438,18 @@ export const buildStatusReplaySequence = (
392
438
  latestByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
393
439
  ): DevBuildStatus[] => [...pendingReplay, ...latestByPhase.values()];
394
440
 
441
+ /** `(mtimeMs, size)` per file — what a save moves, and what a rebuild of identical content does not. */
442
+ export type SourceFingerprints = ReadonlyMap<string, string>;
443
+
444
+ /**
445
+ * Which of the files in `before` are no longer stamped the way they were.
446
+ *
447
+ * Only files present in `before` are compared. The question this answers is which *running* code went
448
+ * stale while nothing was watching, and a file that did not exist then is not running anywhere.
449
+ */
450
+ export const filesChangedSince = (before: SourceFingerprints, after: SourceFingerprints): string[] =>
451
+ [...before].filter(([file, stamp]) => after.get(file) !== stamp).map(([file]) => file);
452
+
395
453
  export class BackendImportGraph {
396
454
  readonly #app: App;
397
455
  readonly #logger: Logger;
@@ -429,6 +487,27 @@ export class BackendImportGraph {
429
487
  return this.#files.has(path.resolve(file));
430
488
  }
431
489
 
490
+ /**
491
+ * Stamp every file the backend runs, so a caller can ask later what moved.
492
+ *
493
+ * Taken when the builder goes away and compared when its replacement is up, because nothing watches
494
+ * the tree in between: the departing builder's watcher left with it, and the replacement's index
495
+ * primes from the disk it finds, so an edit that lands in the gap is *baseline* to it and is never
496
+ * reported at all. The client half of such an edit is rescued by the replacement's boot build; the
497
+ * backend half is a server left running code that no longer exists, with nothing on screen to say so.
498
+ *
499
+ * One `stat` per graph file, against a gap that costs a whole boot build anyway.
500
+ */
501
+ async fingerprint(): Promise<SourceFingerprints> {
502
+ const stamps = await Promise.all(
503
+ [...this.#files].map(async (file) => {
504
+ const stats = await stat(file).catch(() => null);
505
+ return [file, stats ? `${Math.round(stats.mtimeMs)}:${stats.size}` : "(gone)"] as const;
506
+ }),
507
+ );
508
+ return new Map(stamps);
509
+ }
510
+
432
511
  async refresh(): Promise<boolean> {
433
512
  try {
434
513
  const files = await this.#build();
@@ -547,7 +626,8 @@ export class AkanAppHost {
547
626
  #rssRecycleTimer: ReturnType<typeof setTimeout> | null = null;
548
627
  #rssRecycleReason: string | null = null;
549
628
  #lastRssRecycleAtMono: number | null = null;
550
- #rssCeilingIneffective = 0;
629
+ #rssCeilingTightReports = 0;
630
+ #rssCeilingTightWarned = false;
551
631
  /** Invalidates an in-flight settle check when anything else moves the builder underneath it. */
552
632
  #rssSettleToken = 0;
553
633
  #rssRecycleOver: { rssBytes: number; ceilingBytes: number } | null = null;
@@ -562,8 +642,11 @@ export class AkanAppHost {
562
642
  #wokeAtMono: number | null = null;
563
643
  #idleWatcher: HmrWatcher | null = null;
564
644
  #suspendedChanges: ChangeBatch | null = null;
645
+ /** The stat sweep taken when the builder went away, awaited by the take; see `#openBuilderGap`. */
646
+ #builderGapStamp: Promise<SourceFingerprints | null> | null = null;
565
647
  /** Requests that arrived while suspended, answered by the builder that the wake brings up. */
566
648
  #pendingBuilderMessages: BuilderMessage[] = [];
649
+ readonly #builderRequests = new BuilderRequestRouter();
567
650
  constructor(
568
651
  private readonly app: App,
569
652
  { env, withInk = false }: { env: Record<string, string>; withInk?: boolean },
@@ -600,6 +683,8 @@ export class AkanAppHost {
600
683
  clearTimeout(this.#builderRecoveryTimer);
601
684
  this.#builderRecoveryTimer = null;
602
685
  }
686
+ // Before the backend goes away, while it can still receive the answer.
687
+ this.#failPendingBuilderMessages("dev server is shutting down");
603
688
  await this.#stopBackend();
604
689
  this.#stopBuilder();
605
690
  return this;
@@ -614,6 +699,9 @@ export class AkanAppHost {
614
699
  return await createTunnel(type, { app: this.app, environment });
615
700
  }
616
701
  #startBackend(startStatus: { generation?: number; files: string[] } | null = null) {
702
+ // Before the spawn: from here on, a builder answer for the departing backend must not be delivered to
703
+ // this one, which numbers its requests from 1 all over again.
704
+ this.#builderRequests.startGeneration();
617
705
  this.#backendStartStatus = startStatus;
618
706
  this.#backendGaveUp = false;
619
707
  this.#setBackendLifecycleState("starting");
@@ -875,6 +963,15 @@ export class AkanAppHost {
875
963
  await this.#handleInvalidate(message);
876
964
  return;
877
965
  }
966
+ if (message.type === "build-route-res" || message.type === "build-csr-res") {
967
+ const answer = this.#builderRequests.settle(message);
968
+ if (!answer) {
969
+ this.logger.verbose(`[builder] dropped a ${message.type} no live backend is waiting for (id=${message.id})`);
970
+ return;
971
+ }
972
+ this.#sendToBackend(answer);
973
+ return;
974
+ }
878
975
  this.#sendToBackend(message);
879
976
  }
880
977
  /**
@@ -904,7 +1001,7 @@ export class AkanAppHost {
904
1001
  msSinceLastRecycle: this.#lastRssRecycleAtMono === null ? null : performance.now() - this.#lastRssRecycleAtMono,
905
1002
  });
906
1003
  if (decision === "below-ceiling") {
907
- this.#rssCeilingIneffective = 0;
1004
+ this.#rssCeilingTightReports = 0;
908
1005
  return;
909
1006
  }
910
1007
  if (decision === "unbounded") return;
@@ -915,11 +1012,14 @@ export class AkanAppHost {
915
1012
  return;
916
1013
  }
917
1014
  if (decision === "too-soon") {
918
- this.#rssCeilingIneffective += 1;
919
- if (!shouldAbandonBuilderRssCeiling(this.#rssCeilingIneffective)) return;
920
- this.#rssCeilingAbandoned = true;
921
- this.logger.error(
922
- `[builder-recycle] the builder is still at ${asMib(metrics.rssBytes)}MiB right after being recycled, so the ${asMib(ceilingBytes ?? 0)}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.`,
1015
+ this.#rssCeilingTightReports += 1;
1016
+ if (this.#rssCeilingTightWarned || !shouldWarnBuilderRssCeilingTight(this.#rssCeilingTightReports)) return;
1017
+ this.#rssCeilingTightWarned = true;
1018
+ // Said once, and only as information: the builder is still being replaced, at most once per
1019
+ // interval, because that bound is the only thing standing between the bundler's arenas and the
1020
+ // sandbox's memory limit.
1021
+ this.logger.warn(
1022
+ `[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.`,
923
1023
  );
924
1024
  return;
925
1025
  }
@@ -928,6 +1028,27 @@ export class AkanAppHost {
928
1028
  { rssBytes: metrics.rssBytes, ceilingBytes: ceilingBytes ?? 0 },
929
1029
  );
930
1030
  }
1031
+ /**
1032
+ * Ask the replacement, the moment it is ready, whether this ceiling is reachable at all.
1033
+ *
1034
+ * Read from the OS rather than from a metrics report, because the report the host would otherwise
1035
+ * judge on only arrives after the builder has built something — by which point what is being measured
1036
+ * is the work, not the floor. A floor over the ceiling means every replacement lands over it, so the
1037
+ * recycle loop can only ever cost boot builds.
1038
+ */
1039
+ async #checkRecycledBuilderFloor(): Promise<void> {
1040
+ if (this.#rssCeilingAbandoned || this.#lastRssRecycleAtMono === null) return;
1041
+ const ceilingBytes = IncrementalBuilderHost.maxRssBytes();
1042
+ const pid = this.#builder?.pid;
1043
+ if (!ceilingBytes || !pid) return;
1044
+ const freshRssBytes = await AkanAppHost.readProcessRssBytes(pid);
1045
+ if (!isRssCeilingUnreachable(freshRssBytes, ceilingBytes)) return;
1046
+ this.#rssCeilingAbandoned = true;
1047
+ const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
1048
+ this.logger.error(
1049
+ `[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.`,
1050
+ );
1051
+ }
931
1052
  /** Waits for the builder to go quiet, so a recycle never lands in the middle of a burst of saves. */
932
1053
  #armRssRecycle(reason: string, over?: { rssBytes: number; ceilingBytes: number }): void {
933
1054
  if (this.#rssRecycleReason !== reason)
@@ -1004,12 +1125,32 @@ export class AkanAppHost {
1004
1125
  .catch(() => null);
1005
1126
  const vmRssKb = status === null ? null : /VmRSS:\s+(\d+) kB/.exec(status)?.[1];
1006
1127
  if (vmRssKb) return Number(vmRssKb) * 1024;
1007
- const psOutput = await Bun.$`ps -o rss= -p ${pid}`
1008
- .quiet()
1009
- .text()
1010
- .catch(() => "");
1011
- const rssKb = Number(psOutput.trim());
1012
- return Number.isFinite(rssKb) && rssKb > 0 ? rssKb * 1024 : null;
1128
+ return await AkanAppHost.#readRssViaPs(pid);
1129
+ }
1130
+ /**
1131
+ * `ps`, bounded. An absent `ps` is already handled — it answers `null`, which callers read as "no new
1132
+ * information" but a `ps` that never answers was not: the only caller awaits it after a 20s settle,
1133
+ * so the recycle it was about to commit simply never happened, silently. The harness has hit exactly
1134
+ * this hang while shelling out to `ps` under load.
1135
+ */
1136
+ static async #readRssViaPs(pid: number, timeoutMs = PS_RSS_TIMEOUT_MS): Promise<number | null> {
1137
+ let proc: Bun.Subprocess<"ignore", "pipe", "ignore">;
1138
+ try {
1139
+ proc = Bun.spawn(["ps", "-o", "rss=", "-p", String(pid)], { stdio: ["ignore", "pipe", "ignore"] });
1140
+ } catch {
1141
+ return null;
1142
+ }
1143
+ const killer = setTimeout(() => proc.kill("SIGKILL"), timeoutMs);
1144
+ try {
1145
+ const output = await new Response(proc.stdout).text();
1146
+ await proc.exited;
1147
+ const rssKb = Number(output.trim());
1148
+ return Number.isFinite(rssKb) && rssKb > 0 ? rssKb * 1024 : null;
1149
+ } catch {
1150
+ return null;
1151
+ } finally {
1152
+ clearTimeout(killer);
1153
+ }
1013
1154
  }
1014
1155
  #cancelRssRecycle(): void {
1015
1156
  this.#rssRecycleReason = null;
@@ -1069,6 +1210,7 @@ export class AkanAppHost {
1069
1210
  }
1070
1211
  this.#suspended = true;
1071
1212
  this.#stopBuilder();
1213
+ this.#openBuilderGap("idle suspend");
1072
1214
  this.logger.info(
1073
1215
  `[idle-suspend] no build activity for ${Math.round(idleMs / 1000)}s; released the builder — the next edit or route request brings it back`,
1074
1216
  );
@@ -1157,6 +1299,9 @@ export class AkanAppHost {
1157
1299
  const files = batch?.files ?? [];
1158
1300
  if (shouldRefreshConfigOnIdleWake(batch)) {
1159
1301
  this.logger.verbose("[idle-suspend] config changed while suspended; restarting the dev host");
1302
+ // This replaces the backend along with the builder, so whatever moved during the suspend is
1303
+ // already covered — and a baseline carried past its own gap costs a restart at the next one.
1304
+ this.#discardBuilderGap("config change replaces the backend anyway");
1160
1305
  await this.#recycleDevChildren(
1161
1306
  { type: "invalidate", kinds: [...(batch?.kinds ?? [])], files },
1162
1307
  {
@@ -1168,17 +1313,120 @@ export class AkanAppHost {
1168
1313
  // Refresh before deciding: a file created while suspended is not in the graph yet.
1169
1314
  if (files.length > 0) await this.#backendGraph.refresh();
1170
1315
  await this.#startBuilder({ announceBootState: true });
1171
- const backendFiles = files.filter((file) => this.#isBackendFile(file));
1316
+ // Merged rather than restarted for separately: the batch is what the watcher managed to report, the
1317
+ // stamps are what actually moved, and they overlap on the ordinary case of one save during a suspend.
1318
+ const missed = await this.#takeBuilderGapChanges();
1319
+ const backendFiles = [...new Set([...files.filter((file) => this.#isBackendFile(file)), ...missed])];
1172
1320
  if (backendFiles.length === 0) return;
1173
1321
  this.logger.verbose(`[idle-suspend] ${backendFiles.length} backend file(s) changed while suspended`);
1174
1322
  this.#scheduleBackendRestart({ files: backendFiles, roles: [] });
1175
1323
  }
1324
+ /**
1325
+ * Remember what the backend is running, because from here until a builder is back nothing is watching.
1326
+ *
1327
+ * The suspend path installs its own watcher and the restart path has none at all, but neither is a
1328
+ * complete answer: Bun's recursive `fs.watch` reports roughly one path per window
1329
+ * (`local/optimize-resource/06-watcher-dropped-event.md`), and a replacement builder primes its index
1330
+ * from the disk it finds, so anything saved in between looks original to it. A stat taken now and
1331
+ * compared when the builder is back does not depend on an event arriving.
1332
+ *
1333
+ * Scoped to the import graph — the files the backend actually runs. A backend-shaped file outside it
1334
+ * changes nothing about the running server, so missing it costs nothing, and enumerating candidates
1335
+ * by path role instead would mean walking the tree.
1336
+ *
1337
+ * The earliest open wins: a baseline from further back can only over-report, and over-reporting costs
1338
+ * a backend restart while under-reporting costs a server running code the developer already deleted.
1339
+ *
1340
+ * Held as the in-flight sweep rather than as its result, so a take cannot read a baseline that is
1341
+ * still being written — the sweep is milliseconds and the gap it covers is a boot build, but "usually
1342
+ * finishes first" is the kind of guarantee this whole mechanism exists to replace.
1343
+ */
1344
+ #openBuilderGap(reason: string): void {
1345
+ if (this.#builderGapStamp) return;
1346
+ if (!this.#backendGraph.ready) {
1347
+ // No graph means no stamps at all: the host is on path-role fallback rules and does not know which
1348
+ // files the backend runs. Said out loud, because a hole nobody can see reads like coverage.
1349
+ this.logger.verbose(`[builder-gap] no backend graph yet; a save during this ${reason} goes unnoticed`);
1350
+ return;
1351
+ }
1352
+ this.#builderGapStamp = this.#backendGraph
1353
+ .fingerprint()
1354
+ .then((stamps) => {
1355
+ this.logger.verbose(`[builder-gap] stamped ${stamps.size} backend file(s) (${reason})`);
1356
+ return stamps;
1357
+ })
1358
+ .catch((err) => {
1359
+ this.logger.warn(
1360
+ `[builder-gap] could not stamp the backend files: ${err instanceof Error ? err.message : String(err)}`,
1361
+ );
1362
+ return null;
1363
+ });
1364
+ }
1365
+ /** Which backend files moved while the builder was away. Consumes the baseline. */
1366
+ async #takeBuilderGapChanges(): Promise<string[]> {
1367
+ const stamping = this.#builderGapStamp;
1368
+ this.#builderGapStamp = null;
1369
+ const before = stamping ? await stamping : null;
1370
+ if (!before) return [];
1371
+ const moved = filesChangedSince(before, await this.#backendGraph.fingerprint());
1372
+ if (moved.length === 0) {
1373
+ // Said out loud even when the answer is "nothing", because the alternative — silence — is also
1374
+ // what a stamp that was never taken looks like.
1375
+ this.logger.verbose(`[builder-gap] none of the ${before.size} stamped backend file(s) moved`);
1376
+ return moved;
1377
+ }
1378
+ this.logger.info(
1379
+ `[builder-gap] ${moved.length} backend file(s) changed while the builder was away; the backend is running the old ones`,
1380
+ );
1381
+ return moved;
1382
+ }
1383
+ /**
1384
+ * Drop the stamps without acting on them, for a path that is replacing the backend anyway.
1385
+ *
1386
+ * Not merely tidy: a baseline left open outlives the gap it was taken for and is compared against the
1387
+ * *next* one, where everything saved in between reads as changed — one backend restart for work that
1388
+ * has already been done.
1389
+ */
1390
+ #discardBuilderGap(reason: string): void {
1391
+ if (!this.#builderGapStamp) return;
1392
+ this.#builderGapStamp = null;
1393
+ this.logger.verbose(`[builder-gap] stamps dropped (${reason})`);
1394
+ }
1395
+ /** The restart path's half of the above: the wake path merges its own file list in instead. */
1396
+ async #restartBackendForGapChanges(): Promise<void> {
1397
+ const moved = await this.#takeBuilderGapChanges();
1398
+ if (moved.length === 0) return;
1399
+ this.#scheduleBackendRestart({ files: moved, roles: [] });
1400
+ }
1176
1401
  #flushPendingBuilderMessages(): void {
1177
1402
  const pending = this.#pendingBuilderMessages.splice(0);
1178
1403
  if (pending.length === 0) return;
1179
- this.logger.verbose(`[idle-suspend] replaying ${pending.length} request(s) held during the wake`);
1404
+ this.logger.verbose(`[builder] replaying ${pending.length} request(s) held while the builder was away`);
1180
1405
  for (const message of pending) this.#sendToBuilder(message);
1181
1406
  }
1407
+
1408
+ #holdUntilBuilderReady(message: BuilderMessage): void {
1409
+ this.#pendingBuilderMessages.push(message);
1410
+ this.logger.verbose(
1411
+ `[builder] holding ${message.type} until the builder is ready (${this.#pendingBuilderMessages.length} waiting)`,
1412
+ );
1413
+ }
1414
+
1415
+ /**
1416
+ * Answer everything still waiting on a builder that is not coming back. Held requests are otherwise
1417
+ * invisible to the backend, which would sit on them until its own timeout with no reason given.
1418
+ */
1419
+ #failPendingBuilderMessages(reason: string): void {
1420
+ const held = this.#pendingBuilderMessages.splice(0);
1421
+ if (held.length === 0) return;
1422
+ this.logger.warn(`failing ${held.length} held builder request(s): ${reason}`);
1423
+ for (const message of held) {
1424
+ if (message.type === "build-route")
1425
+ this.#sendToBackend({ type: "build-route-res", id: message.id, ok: false, error: reason });
1426
+ else if (message.type === "build-csr")
1427
+ this.#sendToBackend({ type: "build-csr-res", id: message.id, ok: false, error: reason });
1428
+ }
1429
+ }
1182
1430
  #recycleBuilderForRss(reason: string): void {
1183
1431
  // A config or runtime-metadata change already replaces the builder along with the backend, and a
1184
1432
  // pending backend restart is disruption enough on its own; either way, dropping the recycle here
@@ -1189,6 +1437,9 @@ export class AkanAppHost {
1189
1437
  }
1190
1438
  if (!this.#builder?.recycle(reason)) return;
1191
1439
  this.#lastRssRecycleAtMono = performance.now();
1440
+ // Stamped at the request rather than at the exit, because a builder that is draining has already
1441
+ // stopped taking work: whether its watcher still gets an event out is not something to rely on.
1442
+ this.#openBuilderGap("builder recycle");
1192
1443
  }
1193
1444
  async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
1194
1445
  this.#logDevPlan(message);
@@ -1277,6 +1528,9 @@ export class AkanAppHost {
1277
1528
  }
1278
1529
  this.#builderRecoveryAttempts = 0;
1279
1530
  this.logger.info("[builder-recovery] builder recovered");
1531
+ // The other way a builder comes back: a wake that failed, or a start that had to be retried. Either
1532
+ // way the tree went unwatched, and this is the first moment there is something to act on it.
1533
+ void this.#restartBackendForGapChanges();
1280
1534
  const status: DevBuildStatus = {
1281
1535
  generation: reason.generation ?? this.#nextBackendBuildStatusGeneration(),
1282
1536
  phase: "scan",
@@ -1478,6 +1732,8 @@ export class AkanAppHost {
1478
1732
  this.app.verbose(`[cli] builder failed before ready; retrying (${attempt + 1}/${BUILDER_START_MAX_ATTEMPTS})`);
1479
1733
  }
1480
1734
  }
1735
+ // Out of attempts: no builder is coming, so anything held for one is waiting on nothing.
1736
+ this.#failPendingBuilderMessages("builder failed to start");
1481
1737
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
1482
1738
  }
1483
1739
  #waitForBuilderReady(
@@ -1501,12 +1757,19 @@ export class AkanAppHost {
1501
1757
  onExit: () => {
1502
1758
  settle(() => reject(new Error(`[cli] builder exited before emitting builder-ready (attempt ${attempt})`)));
1503
1759
  },
1760
+ onAway: () => {
1761
+ this.#openBuilderGap("builder replacement");
1762
+ },
1504
1763
  onReady: () => {
1505
1764
  settle(resolve);
1765
+ this.#flushPendingBuilderMessages();
1506
1766
  },
1507
1767
  onRestartReady: () => {
1508
1768
  this.logger.verbose("[builder-recovery] builder ready after restart; replaying latest state");
1509
1769
  this.#replayBuilderState();
1770
+ this.#flushPendingBuilderMessages();
1771
+ void this.#restartBackendForGapChanges();
1772
+ void this.#checkRecycledBuilderFloor();
1510
1773
  },
1511
1774
  });
1512
1775
  });
@@ -1527,8 +1790,23 @@ export class AkanAppHost {
1527
1790
  Object.assign(this.env, { AKAN_DEV_CSR_REBUILD: "1" });
1528
1791
  this.logger.verbose(`[csr] armed dev CSR rebuilds (${message.reason})`);
1529
1792
  }
1530
- if (this.#builder?.send(message)) return;
1793
+ // Renumbered on the way out so a builder answer can be matched back to the backend generation that
1794
+ // asked; the failure replies below still use `message.id`, which is that backend's own.
1795
+ if (message.type === "build-route" || message.type === "build-csr") {
1796
+ const outgoing = this.#builderRequests.issue(message);
1797
+ if (this.#builder?.send(outgoing)) return;
1798
+ this.#builderRequests.withdraw(outgoing.id);
1799
+ } else if (this.#builder?.send(message)) return;
1531
1800
  const status = this.#builder?.status ?? "stopped";
1801
+ // A recycle or a crash-restart is a gap, not a failure: the builder is on its way back, and the
1802
+ // request that landed in that window is the page a developer is waiting on. Failing it here is what
1803
+ // produced a dead tab telling the reader to reload, with nothing retrying on its own — while the
1804
+ // suspend path a few lines up has always held requests for exactly this reason. `BuilderRpc`'s own
1805
+ // timeout still bounds the wait, so holding cannot hang a request forever.
1806
+ if (shouldHoldForReturningBuilder({ status, heldCount: this.#pendingBuilderMessages.length })) {
1807
+ this.#holdUntilBuilderReady(message);
1808
+ return;
1809
+ }
1532
1810
  if (message.type === "build-route") {
1533
1811
  this.#sendToBackend({
1534
1812
  type: "build-route-res",
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { confirm, input, select } from "@inquirer/prompts";
2
+ import type { confirm as inquirerConfirm, input as inquirerInput, select as inquirerSelect } from "@inquirer/prompts";
3
3
  import { Logger } from "akanjs/common";
4
4
  import chalk from "chalk";
5
5
  import { type Command, program } from "commander";
@@ -89,6 +89,21 @@ const normalizeEnumChoices = (enumChoices: EnumChoices) =>
89
89
  : { value: choice, name: choice.toString() },
90
90
  );
91
91
 
92
+ /**
93
+ * The interactive prompt stack, loaded on the first prompt instead of at import.
94
+ *
95
+ * `runCommands` lives in this module, so a static import put `@inquirer/prompts` (~24MB) in the CLI
96
+ * entry's chunk closure — and `akan start` holds that process for the whole dev session while never
97
+ * asking a question, because every argument it needs is already on the command line.
98
+ *
99
+ * The wrappers are typed from the real prompts so no call site changes, and `import type` leaves no
100
+ * runtime edge for the bundler to follow (`entryModuleGraph.test.ts` asserts that).
101
+ */
102
+ const prompts = async () => await import("@inquirer/prompts");
103
+ const select = ((config, context) => prompts().then((m) => m.select(config, context))) as typeof inquirerSelect;
104
+ const confirm = ((config, context) => prompts().then((m) => m.confirm(config, context))) as typeof inquirerConfirm;
105
+ const input = ((config, context) => prompts().then((m) => m.input(config, context))) as typeof inquirerInput;
106
+
92
107
  const resolveEnumChoices = async (argMeta: ArgMeta, context: CommandContext) => {
93
108
  const enumChoices = argMeta.argsOption.enum;
94
109
  if (!enumChoices) return null;