@akanjs/devkit 2.4.1-rc.2 → 2.4.1-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/akanApp/akanApp.host.test.ts +283 -2
- package/akanApp/akanApp.host.ts +578 -12
- package/executors.test.ts +60 -0
- package/executors.ts +11 -0
- package/frontendBuild/fontOptimizer.test.ts +111 -0
- package/frontendBuild/fontOptimizer.ts +102 -17
- package/frontendBuild/hmrWatcher.test.ts +191 -0
- package/frontendBuild/hmrWatcher.ts +176 -5
- package/frontendBuild/index.ts +1 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +280 -0
- package/frontendBuild/sourceMtimeIndex.ts +326 -0
- package/incrementalBuilder/buildBatch.proc.ts +194 -0
- package/incrementalBuilder/buildBatchProtocol.ts +53 -0
- package/incrementalBuilder/buildBatchRunner.ts +85 -0
- package/incrementalBuilder/builderReply.test.ts +73 -0
- package/incrementalBuilder/builderReply.ts +30 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +199 -9
- package/incrementalBuilder/incrementalBuilder.host.ts +119 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +260 -170
- package/integration/devStability.integration.test.ts +308 -78
- package/integration/devStabilityHarness.test.ts +111 -0
- package/integration/devStabilityHarness.ts +555 -40
- package/local/optimize-resource/ipcprobe/child.ts +1 -0
- package/local/optimize-resource/ipcprobe/parent.ts +11 -0
- package/package.json +2 -2
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { Logger } from "akanjs/common";
|
|
3
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
BuilderMessage,
|
|
6
|
+
BuilderMetrics,
|
|
7
|
+
BuildPhase,
|
|
8
|
+
ChangeBatch,
|
|
9
|
+
DevBuildStatus,
|
|
10
|
+
DevChangePlan,
|
|
11
|
+
DevChangeRole,
|
|
12
|
+
} from "akanjs/server";
|
|
4
13
|
import type { App } from "../commandDecorators";
|
|
5
14
|
import { createTunnel } from "../createTunnel";
|
|
6
15
|
import { WorkspaceExecutor } from "../executors";
|
|
16
|
+
// Imported by module path, not through `../frontendBuild`: that barrel pulls in `typescript` and the
|
|
17
|
+
// tailwind stack, which is exactly what a suspended dev host must not be holding.
|
|
18
|
+
import { HmrWatcher } from "../frontendBuild/hmrWatcher";
|
|
19
|
+
import { WatchRootResolver } from "../frontendBuild/watchRootResolver";
|
|
7
20
|
import { IncrementalBuilderHost } from "../incrementalBuilder";
|
|
8
21
|
|
|
9
22
|
const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr"]);
|
|
@@ -17,6 +30,25 @@ const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
|
|
|
17
30
|
const BACKEND_STDERR_TAIL_LIMIT = 40;
|
|
18
31
|
const BUILDER_READY_TIMEOUT_MS = 150000;
|
|
19
32
|
const BUILDER_START_MAX_ATTEMPTS = 3;
|
|
33
|
+
// Save-on-keystroke arrives as a burst of batches. Recycling mid-burst would drop the watcher events
|
|
34
|
+
// still on their way to the builder, so an over-ceiling builder is replaced only once it goes quiet.
|
|
35
|
+
const BUILDER_RSS_RECYCLE_QUIET_MS = 750;
|
|
36
|
+
const BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS = 30_000;
|
|
37
|
+
const BUILDER_INEFFECTIVE_RSS_RECYCLE_LIMIT = 2;
|
|
38
|
+
// Linux hands the bundler arenas back on its own after ~10-15s idle — measured at 46-59% of the
|
|
39
|
+
// builder's peak (`local/optimize-resource/09-linux-retention-measurement.md`) — while macOS returns
|
|
40
|
+
// none of it. The builder only reports RSS at work-completion points, so the sample a recycle is armed
|
|
41
|
+
// from is the peak. Waiting out the purge and re-reading before committing is what stops the host
|
|
42
|
+
// paying a cold boot build for memory the OS was about to return anyway. On macOS the re-read returns
|
|
43
|
+
// the same value, so this only ever costs the delay.
|
|
44
|
+
const BUILDER_RSS_SETTLE_MS = 20_000;
|
|
45
|
+
// Far enough above the ceiling that no purge would rescue it; recycle without waiting.
|
|
46
|
+
const BUILDER_RSS_HARD_MULTIPLE = 1.5;
|
|
47
|
+
// A sandbox between user turns pays for a watcher that is watching nothing change. Suspending build
|
|
48
|
+
// capacity after this long returns the builder's residency until the next edit or route request.
|
|
49
|
+
const DEV_IDLE_SUSPEND_MS = 300_000;
|
|
50
|
+
// A wake that immediately suspends again would flap around whatever woke it.
|
|
51
|
+
const DEV_IDLE_MIN_UPTIME_MS = 30_000;
|
|
20
52
|
// The builder is the file watcher: while it is down no edit can trigger a retry, so unlike the
|
|
21
53
|
// backend the recovery loop never gives up — it only backs off.
|
|
22
54
|
const BUILDER_RECOVERY_BASE_DELAY_MS = 2_000;
|
|
@@ -177,6 +209,152 @@ export const hasBuildFailureForGeneration = (
|
|
|
177
209
|
return false;
|
|
178
210
|
};
|
|
179
211
|
|
|
212
|
+
export type BuilderRssRecycleDecision = "unbounded" | "below-ceiling" | "build-failed" | "too-soon" | "recycle";
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Whether an over-ceiling builder should be replaced now.
|
|
216
|
+
*
|
|
217
|
+
* `Bun.build` never returns its native arenas, so the builder's RSS only comes back when the process
|
|
218
|
+
* exits. Recycling it is therefore the only bound available — but it costs a boot build, so the two
|
|
219
|
+
* cases where a recycle cannot help are excluded: a generation whose build already failed (the
|
|
220
|
+
* replacement would hit the same compile error), and a recycle so soon after the last one that the
|
|
221
|
+
* ceiling is evidently unreachable for this app.
|
|
222
|
+
*/
|
|
223
|
+
export const decideBuilderRssRecycle = ({
|
|
224
|
+
rssBytes,
|
|
225
|
+
ceilingBytes,
|
|
226
|
+
buildFailed,
|
|
227
|
+
msSinceLastRecycle,
|
|
228
|
+
minIntervalMs = BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS,
|
|
229
|
+
}: {
|
|
230
|
+
rssBytes: number;
|
|
231
|
+
ceilingBytes: number | null;
|
|
232
|
+
buildFailed: boolean;
|
|
233
|
+
msSinceLastRecycle: number | null;
|
|
234
|
+
minIntervalMs?: number;
|
|
235
|
+
}): BuilderRssRecycleDecision => {
|
|
236
|
+
if (!ceilingBytes) return "unbounded";
|
|
237
|
+
if (rssBytes < ceilingBytes) return "below-ceiling";
|
|
238
|
+
if (buildFailed) return "build-failed";
|
|
239
|
+
if (msSinceLastRecycle !== null && msSinceLastRecycle < minIntervalMs) return "too-soon";
|
|
240
|
+
return "recycle";
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
export type BuilderRssSettleDecision = "recycle-now" | "wait-and-recheck";
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Whether an armed recycle should wait out the allocator's purge window before committing. A builder
|
|
247
|
+
* far enough over the ceiling is not going to be rescued by a purge, so waiting there only delays a
|
|
248
|
+
* recycle that has to happen.
|
|
249
|
+
*/
|
|
250
|
+
export const decideBuilderRssSettle = ({
|
|
251
|
+
rssBytes,
|
|
252
|
+
ceilingBytes,
|
|
253
|
+
hardMultiple = BUILDER_RSS_HARD_MULTIPLE,
|
|
254
|
+
}: {
|
|
255
|
+
rssBytes: number;
|
|
256
|
+
ceilingBytes: number;
|
|
257
|
+
hardMultiple?: number;
|
|
258
|
+
}): BuilderRssSettleDecision => (rssBytes >= ceilingBytes * hardMultiple ? "recycle-now" : "wait-and-recheck");
|
|
259
|
+
|
|
260
|
+
export type IdleSuspendDecision =
|
|
261
|
+
| "disabled"
|
|
262
|
+
| "already-suspended"
|
|
263
|
+
| "builder-not-ready"
|
|
264
|
+
| "backend-not-ready"
|
|
265
|
+
| "build-failed"
|
|
266
|
+
| "restart-pending"
|
|
267
|
+
| "too-soon"
|
|
268
|
+
| "suspend";
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Whether the dev host may drop its build capacity now. Every "no" here is a case where suspending
|
|
272
|
+
* would either lose work or produce a wake that immediately re-suspends:
|
|
273
|
+
*
|
|
274
|
+
* - a red build means the developer is mid-fix and about to save again, and a wake would boot straight
|
|
275
|
+
* back into the same error via the degraded-boot path
|
|
276
|
+
* - a pending restart/recovery already has its own plan for the builder
|
|
277
|
+
* - `too-soon` keeps a wake from flapping around whatever triggered it
|
|
278
|
+
*/
|
|
279
|
+
export const decideIdleSuspend = ({
|
|
280
|
+
enabled,
|
|
281
|
+
suspended,
|
|
282
|
+
builderReady,
|
|
283
|
+
backendReady,
|
|
284
|
+
buildFailed,
|
|
285
|
+
restartPending,
|
|
286
|
+
msSinceWake,
|
|
287
|
+
minUptimeMs = DEV_IDLE_MIN_UPTIME_MS,
|
|
288
|
+
}: {
|
|
289
|
+
enabled: boolean;
|
|
290
|
+
suspended: boolean;
|
|
291
|
+
builderReady: boolean;
|
|
292
|
+
backendReady: boolean;
|
|
293
|
+
buildFailed: boolean;
|
|
294
|
+
restartPending: boolean;
|
|
295
|
+
msSinceWake: number | null;
|
|
296
|
+
minUptimeMs?: number;
|
|
297
|
+
}): IdleSuspendDecision => {
|
|
298
|
+
if (!enabled) return "disabled";
|
|
299
|
+
if (suspended) return "already-suspended";
|
|
300
|
+
if (!builderReady) return "builder-not-ready";
|
|
301
|
+
if (!backendReady) return "backend-not-ready";
|
|
302
|
+
if (buildFailed) return "build-failed";
|
|
303
|
+
if (restartPending) return "restart-pending";
|
|
304
|
+
if (msSinceWake !== null && msSinceWake < minUptimeMs) return "too-soon";
|
|
305
|
+
return "suspend";
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/** `undefined` env means the default is on; any non-positive value turns idle suspend off. */
|
|
309
|
+
export const resolveIdleSuspendMs = (raw: string | undefined): number | null => {
|
|
310
|
+
if (raw === undefined || raw === "") return DEV_IDLE_SUSPEND_MS;
|
|
311
|
+
const parsed = Number(raw);
|
|
312
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
313
|
+
return Math.round(parsed);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/** Any red phase blocks a suspend, unlike the rss recycle which only cares about one generation. */
|
|
317
|
+
export const hasAnyBuildFailure = (statusByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>): boolean =>
|
|
318
|
+
[...statusByPhase.values()].some((status) => !status.ok);
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A config change while suspended cannot be applied by restarting the builder alone — the dev host
|
|
322
|
+
* itself has to re-read the config, which is the same path an ordinary config save takes.
|
|
323
|
+
*/
|
|
324
|
+
export const shouldRefreshConfigOnIdleWake = (batch: ChangeBatch | null): boolean =>
|
|
325
|
+
!!batch && batch.kinds.has("config");
|
|
326
|
+
|
|
327
|
+
/**
|
|
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.
|
|
331
|
+
*/
|
|
332
|
+
export const shouldAbandonBuilderRssCeiling = (
|
|
333
|
+
ineffectiveRecycles: number,
|
|
334
|
+
limit = BUILDER_INEFFECTIVE_RSS_RECYCLE_LIMIT,
|
|
335
|
+
): boolean => ineffectiveRecycles >= limit;
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Whether a recycled builder's re-announced boot artifact actually differs from what the backend
|
|
339
|
+
* already has. Both payload identities are content hashes — `pages-[hash].js` and
|
|
340
|
+
* `<name>-[hash].css` — so an unchanged recycle produces identical ones and needs no reload. Only a
|
|
341
|
+
* save that raced the recycle moves them, and that is the case worth pushing.
|
|
342
|
+
*/
|
|
343
|
+
export const shouldRelayRecycledFrontendState = (
|
|
344
|
+
current:
|
|
345
|
+
| Extract<BuilderMessage, { type: "pages-updated" }>
|
|
346
|
+
| Extract<BuilderMessage, { type: "css-updated" }>
|
|
347
|
+
| undefined,
|
|
348
|
+
next: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
|
|
349
|
+
): boolean => {
|
|
350
|
+
if (!current || current.type !== next.type) return true;
|
|
351
|
+
if (current.type === "pages-updated" && next.type === "pages-updated")
|
|
352
|
+
return current.data.bundlePath !== next.data.bundlePath;
|
|
353
|
+
if (current.type === "css-updated" && next.type === "css-updated")
|
|
354
|
+
return JSON.stringify(current.data.cssAssets) !== JSON.stringify(next.data.cssAssets);
|
|
355
|
+
return true;
|
|
356
|
+
};
|
|
357
|
+
|
|
180
358
|
const mergeDevPlans = (current?: DevChangePlan, next?: DevChangePlan): DevChangePlan | undefined => {
|
|
181
359
|
if (!current) return next;
|
|
182
360
|
if (!next) return current;
|
|
@@ -214,7 +392,7 @@ export const buildStatusReplaySequence = (
|
|
|
214
392
|
latestByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
|
|
215
393
|
): DevBuildStatus[] => [...pendingReplay, ...latestByPhase.values()];
|
|
216
394
|
|
|
217
|
-
class BackendImportGraph {
|
|
395
|
+
export class BackendImportGraph {
|
|
218
396
|
readonly #app: App;
|
|
219
397
|
readonly #logger: Logger;
|
|
220
398
|
readonly #tsTranspiler = new Bun.Transpiler({ loader: "ts" });
|
|
@@ -222,6 +400,15 @@ class BackendImportGraph {
|
|
|
222
400
|
readonly #jsTranspiler = new Bun.Transpiler({ loader: "js" });
|
|
223
401
|
readonly #jsxTranspiler = new Bun.Transpiler({ loader: "jsx" });
|
|
224
402
|
#files = new Set<string>();
|
|
403
|
+
/**
|
|
404
|
+
* `refresh()` runs on every server-side save and on every dev-host recycle, and re-reading plus
|
|
405
|
+
* re-parsing files that did not change is the whole cost of it. Keyed on (mtimeMs, size).
|
|
406
|
+
*
|
|
407
|
+
* Specifiers are cached, not resolved paths: creating a file can change what an *unchanged* importer's
|
|
408
|
+
* specifier resolves to, and `Bun.resolveSync` is cheap next to a read plus a transpiler scan. Only
|
|
409
|
+
* the scan result is retained — never the source text.
|
|
410
|
+
*/
|
|
411
|
+
#scanCache = new Map<string, { mtimeMs: number; size: number; specifiers: Bun.Import[] }>();
|
|
225
412
|
#ready = false;
|
|
226
413
|
#lastRefreshSucceeded = false;
|
|
227
414
|
|
|
@@ -270,11 +457,10 @@ class BackendImportGraph {
|
|
|
270
457
|
const current = path.resolve(queue.pop() as string);
|
|
271
458
|
if (files.has(current)) continue;
|
|
272
459
|
if (!this.#isWorkspaceSource(current, workspaceRoot)) continue;
|
|
273
|
-
|
|
460
|
+
const imports = await this.#importsOf(current);
|
|
461
|
+
if (!imports) continue;
|
|
274
462
|
|
|
275
463
|
files.add(current);
|
|
276
|
-
const source = await Bun.file(current).text();
|
|
277
|
-
const imports = this.#scanImports(current, source);
|
|
278
464
|
const importerDir = path.dirname(current);
|
|
279
465
|
for (const imp of imports) {
|
|
280
466
|
if (!GRAPH_IMPORT_KINDS.has(imp.kind) || !imp.path || NON_SOURCE_EXT_RE.test(imp.path)) continue;
|
|
@@ -283,9 +469,23 @@ class BackendImportGraph {
|
|
|
283
469
|
queue.push(resolved);
|
|
284
470
|
}
|
|
285
471
|
}
|
|
472
|
+
// Files that dropped out of the graph would otherwise be cached for the life of the dev session.
|
|
473
|
+
for (const cached of this.#scanCache.keys()) if (!files.has(cached)) this.#scanCache.delete(cached);
|
|
286
474
|
return files;
|
|
287
475
|
}
|
|
288
476
|
|
|
477
|
+
/** Null when the file is gone, which is the existence check the walk used to make separately. */
|
|
478
|
+
async #importsOf(file: string): Promise<Bun.Import[] | null> {
|
|
479
|
+
const stats = await stat(file).catch(() => null);
|
|
480
|
+
if (!stats?.isFile()) return null;
|
|
481
|
+
const mtimeMs = Math.round(stats.mtimeMs);
|
|
482
|
+
const cached = this.#scanCache.get(file);
|
|
483
|
+
if (cached && cached.mtimeMs === mtimeMs && cached.size === stats.size) return cached.specifiers;
|
|
484
|
+
const specifiers = this.#scanImports(file, await Bun.file(file).text());
|
|
485
|
+
this.#scanCache.set(file, { mtimeMs, size: stats.size, specifiers });
|
|
486
|
+
return specifiers;
|
|
487
|
+
}
|
|
488
|
+
|
|
289
489
|
async #entrypoints(): Promise<string[]> {
|
|
290
490
|
const roots = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
|
|
291
491
|
const existing: string[] = [];
|
|
@@ -344,10 +544,26 @@ export class AkanAppHost {
|
|
|
344
544
|
#backendBuildStatusGeneration = 0;
|
|
345
545
|
#backendStderrTail: string[] = [];
|
|
346
546
|
#lastGoodFrontend: LastGoodFrontendState = {};
|
|
547
|
+
#rssRecycleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
548
|
+
#rssRecycleReason: string | null = null;
|
|
549
|
+
#lastRssRecycleAtMono: number | null = null;
|
|
550
|
+
#rssCeilingIneffective = 0;
|
|
551
|
+
/** Invalidates an in-flight settle check when anything else moves the builder underneath it. */
|
|
552
|
+
#rssSettleToken = 0;
|
|
553
|
+
#rssRecycleOver: { rssBytes: number; ceilingBytes: number } | null = null;
|
|
554
|
+
#rssCeilingAbandoned = false;
|
|
347
555
|
#buildStatusByPhase = new Map<BuildPhase, DevBuildStatus>();
|
|
348
556
|
#pendingBuildStatusReplay: DevBuildStatus[] = [];
|
|
349
557
|
#builderMessageQueue: Promise<void> = Promise.resolve();
|
|
350
558
|
#backendGraph: BackendImportGraph;
|
|
559
|
+
#idleSuspendTimer: ReturnType<typeof setTimeout> | null = null;
|
|
560
|
+
#suspended: boolean = false;
|
|
561
|
+
#waking: boolean = false;
|
|
562
|
+
#wokeAtMono: number | null = null;
|
|
563
|
+
#idleWatcher: HmrWatcher | null = null;
|
|
564
|
+
#suspendedChanges: ChangeBatch | null = null;
|
|
565
|
+
/** Requests that arrived while suspended, answered by the builder that the wake brings up. */
|
|
566
|
+
#pendingBuilderMessages: BuilderMessage[] = [];
|
|
351
567
|
constructor(
|
|
352
568
|
private readonly app: App,
|
|
353
569
|
{ env, withInk = false }: { env: Record<string, string>; withInk?: boolean },
|
|
@@ -366,9 +582,12 @@ export class AkanAppHost {
|
|
|
366
582
|
]);
|
|
367
583
|
Object.assign(this.env, { REDIS_HOST: redisHost });
|
|
368
584
|
this.#startBackend();
|
|
585
|
+
this.#armIdleSuspend();
|
|
369
586
|
return this;
|
|
370
587
|
}
|
|
371
588
|
async stop() {
|
|
589
|
+
this.#cancelIdleSuspend();
|
|
590
|
+
this.#stopIdleWatcher();
|
|
372
591
|
if (this.#restartTimer) {
|
|
373
592
|
clearTimeout(this.#restartTimer);
|
|
374
593
|
this.#restartTimer = null;
|
|
@@ -636,22 +855,345 @@ export class AkanAppHost {
|
|
|
636
855
|
});
|
|
637
856
|
}
|
|
638
857
|
async #handleBuilderMessage(message: BuilderMessage) {
|
|
858
|
+
this.#markDevActivity();
|
|
639
859
|
if (message.type === "build-status") {
|
|
640
860
|
this.#recordBuildStatus(message.data);
|
|
641
861
|
this.#sendOrQueueBuildStatus(message.data);
|
|
642
862
|
this.#reviveBackendAfterGreenBuild(message.data);
|
|
643
863
|
return;
|
|
644
864
|
}
|
|
645
|
-
if (message.type === "
|
|
646
|
-
|
|
865
|
+
if (message.type === "builder-metrics") {
|
|
866
|
+
this.#handleBuilderMetrics(message.data);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (message.type === "pages-updated" || message.type === "css-updated") {
|
|
870
|
+
const recycled = message.data.reason === "builder-recycle";
|
|
871
|
+
if (recycled && !this.#shouldRelayRecycledState(message)) return;
|
|
872
|
+
this.#recordLastGood(message, { supersede: recycled });
|
|
873
|
+
}
|
|
647
874
|
if (message.type === "invalidate") {
|
|
648
875
|
await this.#handleInvalidate(message);
|
|
649
876
|
return;
|
|
650
877
|
}
|
|
651
878
|
this.#sendToBackend(message);
|
|
652
879
|
}
|
|
880
|
+
/**
|
|
881
|
+
* A recycled builder re-announces the artifact it booted with, because the backend read
|
|
882
|
+
* `base-artifact.json` once and never re-reads it. Dropping the announcement when the hashes match
|
|
883
|
+
* is what keeps the common case — a recycle with no concurrent edit — invisible to browsers.
|
|
884
|
+
*/
|
|
885
|
+
#shouldRelayRecycledState(
|
|
886
|
+
message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
|
|
887
|
+
): boolean {
|
|
888
|
+
const current = message.type === "pages-updated" ? this.#lastGoodFrontend.pages : this.#lastGoodFrontend.css;
|
|
889
|
+
if (shouldRelayRecycledFrontendState(current, message)) {
|
|
890
|
+
this.logger.verbose(`[builder-recycle] ${message.type} moved during the recycle; pushing it to the backend`);
|
|
891
|
+
return true;
|
|
892
|
+
}
|
|
893
|
+
this.logger.verbose(`[builder-recycle] ${message.type} unchanged after the recycle; backend left as is`);
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
#handleBuilderMetrics(metrics: BuilderMetrics): void {
|
|
897
|
+
if (this.#rssCeilingAbandoned) return;
|
|
898
|
+
const ceilingBytes = IncrementalBuilderHost.maxRssBytes();
|
|
899
|
+
const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
|
|
900
|
+
const decision = decideBuilderRssRecycle({
|
|
901
|
+
rssBytes: metrics.rssBytes,
|
|
902
|
+
ceilingBytes,
|
|
903
|
+
buildFailed: hasBuildFailureForGeneration(this.#buildStatusByPhase, metrics.generation),
|
|
904
|
+
msSinceLastRecycle: this.#lastRssRecycleAtMono === null ? null : performance.now() - this.#lastRssRecycleAtMono,
|
|
905
|
+
});
|
|
906
|
+
if (decision === "below-ceiling") {
|
|
907
|
+
this.#rssCeilingIneffective = 0;
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (decision === "unbounded") return;
|
|
911
|
+
if (decision === "build-failed") {
|
|
912
|
+
this.logger.verbose(
|
|
913
|
+
`[builder-recycle] deferred: generation=${metrics.generation} has a failing build, so a replacement would hit the same error`,
|
|
914
|
+
);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
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.`,
|
|
923
|
+
);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
this.#armRssRecycle(
|
|
927
|
+
`rss=${asMib(metrics.rssBytes)}MiB>=${asMib(ceilingBytes ?? 0)}MiB after ${metrics.workCount} build(s)`,
|
|
928
|
+
{ rssBytes: metrics.rssBytes, ceilingBytes: ceilingBytes ?? 0 },
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
/** Waits for the builder to go quiet, so a recycle never lands in the middle of a burst of saves. */
|
|
932
|
+
#armRssRecycle(reason: string, over?: { rssBytes: number; ceilingBytes: number }): void {
|
|
933
|
+
if (this.#rssRecycleReason !== reason)
|
|
934
|
+
this.logger.verbose(`[builder-recycle] armed (${reason}); replacing the builder once it stays quiet`);
|
|
935
|
+
this.#rssRecycleReason = reason;
|
|
936
|
+
// Held in a field, not the closure: `#handleInvalidate` re-arms mid-burst with the reason alone, and
|
|
937
|
+
// losing the sample there would silently skip the settle check for exactly the bursty case.
|
|
938
|
+
if (over) this.#rssRecycleOver = over;
|
|
939
|
+
if (this.#rssRecycleTimer) clearTimeout(this.#rssRecycleTimer);
|
|
940
|
+
this.#rssRecycleTimer = setTimeout(() => {
|
|
941
|
+
this.#rssRecycleTimer = null;
|
|
942
|
+
const pendingReason = this.#rssRecycleReason;
|
|
943
|
+
const pendingOver = this.#rssRecycleOver;
|
|
944
|
+
this.#rssRecycleReason = null;
|
|
945
|
+
this.#rssRecycleOver = null;
|
|
946
|
+
if (!pendingReason) return;
|
|
947
|
+
if (!pendingOver) {
|
|
948
|
+
this.#recycleBuilderForRss(pendingReason);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
void this.#recycleBuilderForRssWhenStillOver(pendingReason, pendingOver);
|
|
952
|
+
}, BUILDER_RSS_RECYCLE_QUIET_MS);
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Confirms the builder is *still* over the ceiling before replacing it. The armed sample was taken
|
|
956
|
+
* the instant the builder went idle, which is its peak; where the allocator returns arenas during
|
|
957
|
+
* idle, that number is stale within seconds and recycling on it is pure cost.
|
|
958
|
+
*/
|
|
959
|
+
async #recycleBuilderForRssWhenStillOver(
|
|
960
|
+
reason: string,
|
|
961
|
+
{ rssBytes, ceilingBytes }: { rssBytes: number; ceilingBytes: number },
|
|
962
|
+
): Promise<void> {
|
|
963
|
+
const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
|
|
964
|
+
if (decideBuilderRssSettle({ rssBytes, ceilingBytes }) === "recycle-now") {
|
|
965
|
+
this.#recycleBuilderForRss(reason);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const pid = this.#builder?.pid;
|
|
969
|
+
// Without a readable pid there is nothing to re-check, so keep the original behaviour.
|
|
970
|
+
if (!pid) {
|
|
971
|
+
this.#recycleBuilderForRss(reason);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
this.#rssSettleToken += 1;
|
|
975
|
+
const token = this.#rssSettleToken;
|
|
976
|
+
this.logger.verbose(
|
|
977
|
+
`[builder-recycle] holding ${Math.round(BUILDER_RSS_SETTLE_MS / 1000)}s to see whether the allocator returns it (${reason})`,
|
|
978
|
+
);
|
|
979
|
+
await Bun.sleep(BUILDER_RSS_SETTLE_MS);
|
|
980
|
+
// Anything that touched the builder meanwhile — a new batch, a recycle, a suspend — invalidates this.
|
|
981
|
+
if (token !== this.#rssSettleToken || this.#builder?.pid !== pid || this.#suspended || this.#waking) {
|
|
982
|
+
this.logger.verbose("[builder-recycle] settle check abandoned; the builder moved on");
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
const settledBytes = await AkanAppHost.readProcessRssBytes(pid);
|
|
986
|
+
if (settledBytes !== null && settledBytes < ceilingBytes) {
|
|
987
|
+
this.logger.info(
|
|
988
|
+
`[builder-recycle] skipped: the builder fell to ${asMib(settledBytes)}MiB (ceiling ${asMib(ceilingBytes)}MiB) on its own, so a recycle would have cost a boot build for nothing`,
|
|
989
|
+
);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
this.#recycleBuilderForRss(
|
|
993
|
+
settledBytes === null ? reason : `${reason}; still ${asMib(settledBytes)}MiB after settling`,
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Another process's RSS, read from the OS rather than asked of the process. `/proc` where it exists,
|
|
998
|
+
* `ps` otherwise (macOS has no `/proc`). Null when it cannot be read, which callers treat as
|
|
999
|
+
* "no new information" rather than as zero.
|
|
1000
|
+
*/
|
|
1001
|
+
static async readProcessRssBytes(pid: number): Promise<number | null> {
|
|
1002
|
+
const status = await Bun.file(`/proc/${pid}/status`)
|
|
1003
|
+
.text()
|
|
1004
|
+
.catch(() => null);
|
|
1005
|
+
const vmRssKb = status === null ? null : /VmRSS:\s+(\d+) kB/.exec(status)?.[1];
|
|
1006
|
+
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;
|
|
1013
|
+
}
|
|
1014
|
+
#cancelRssRecycle(): void {
|
|
1015
|
+
this.#rssRecycleReason = null;
|
|
1016
|
+
this.#rssRecycleOver = null;
|
|
1017
|
+
// Also drops any settle check already waiting, which would otherwise recycle after the cancel.
|
|
1018
|
+
this.#rssSettleToken += 1;
|
|
1019
|
+
if (!this.#rssRecycleTimer) return;
|
|
1020
|
+
clearTimeout(this.#rssRecycleTimer);
|
|
1021
|
+
this.#rssRecycleTimer = null;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* How long the dev server may sit unused before its build capacity is dropped. Set
|
|
1025
|
+
* `AKAN_DEV_IDLE_SUSPEND_MS=0` to keep the builder resident for the whole session.
|
|
1026
|
+
*/
|
|
1027
|
+
static idleSuspendMs(): number | null {
|
|
1028
|
+
return resolveIdleSuspendMs(process.env.AKAN_DEV_IDLE_SUSPEND_MS);
|
|
1029
|
+
}
|
|
1030
|
+
/** Every builder message and every request for one counts as the dev server being in use. */
|
|
1031
|
+
#markDevActivity(): void {
|
|
1032
|
+
if (this.#suspended || this.#waking) return;
|
|
1033
|
+
this.#armIdleSuspend();
|
|
1034
|
+
}
|
|
1035
|
+
#armIdleSuspend(): void {
|
|
1036
|
+
const idleMs = AkanAppHost.idleSuspendMs();
|
|
1037
|
+
this.#cancelIdleSuspend();
|
|
1038
|
+
if (idleMs === null) return;
|
|
1039
|
+
this.#idleSuspendTimer = setTimeout(() => {
|
|
1040
|
+
this.#idleSuspendTimer = null;
|
|
1041
|
+
void this.#suspendWhenIdle(idleMs);
|
|
1042
|
+
}, idleMs);
|
|
1043
|
+
}
|
|
1044
|
+
#cancelIdleSuspend(): void {
|
|
1045
|
+
if (!this.#idleSuspendTimer) return;
|
|
1046
|
+
clearTimeout(this.#idleSuspendTimer);
|
|
1047
|
+
this.#idleSuspendTimer = null;
|
|
1048
|
+
}
|
|
1049
|
+
async #suspendWhenIdle(idleMs: number): Promise<void> {
|
|
1050
|
+
const decision = decideIdleSuspend({
|
|
1051
|
+
enabled: true,
|
|
1052
|
+
suspended: this.#suspended,
|
|
1053
|
+
builderReady: this.#builder?.status === "ready",
|
|
1054
|
+
backendReady: this.#backendReady,
|
|
1055
|
+
buildFailed: hasAnyBuildFailure(this.#buildStatusByPhase),
|
|
1056
|
+
restartPending: this.#restartPending,
|
|
1057
|
+
msSinceWake: this.#wokeAtMono === null ? null : performance.now() - this.#wokeAtMono,
|
|
1058
|
+
});
|
|
1059
|
+
if (decision !== "suspend") {
|
|
1060
|
+
this.logger.verbose(`[idle-suspend] skipped (${decision}); re-arming`);
|
|
1061
|
+
this.#armIdleSuspend();
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
// Watch before stopping, never after: an edit landing in the gap would be lost, and nothing
|
|
1065
|
+
// would wake the dev server until the next one.
|
|
1066
|
+
if (!(await this.#startIdleWatcher())) {
|
|
1067
|
+
this.#armIdleSuspend();
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
this.#suspended = true;
|
|
1071
|
+
this.#stopBuilder();
|
|
1072
|
+
this.logger.info(
|
|
1073
|
+
`[idle-suspend] no build activity for ${Math.round(idleMs / 1000)}s; released the builder — the next edit or route request brings it back`,
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
get #restartPending(): boolean {
|
|
1077
|
+
return !!(
|
|
1078
|
+
this.#pendingRecycle ||
|
|
1079
|
+
this.#restartTimer ||
|
|
1080
|
+
this.#backendRecoveryTimer ||
|
|
1081
|
+
this.#builderRecoveryTimer ||
|
|
1082
|
+
this.#rssRecycleReason
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
async #startIdleWatcher(): Promise<boolean> {
|
|
1086
|
+
try {
|
|
1087
|
+
const roots = await new WatchRootResolver(this.app).resolve();
|
|
1088
|
+
const watcher = new HmrWatcher({
|
|
1089
|
+
roots,
|
|
1090
|
+
logger: this.logger,
|
|
1091
|
+
onBatch: (batch) => {
|
|
1092
|
+
this.#recordSuspendedChange(batch);
|
|
1093
|
+
void this.#wakeFromIdle(`${batch.files.length} file(s) changed`);
|
|
1094
|
+
},
|
|
1095
|
+
});
|
|
1096
|
+
// Awaited so the mtime baseline exists before the first edit: `#recordSuspendedChange` replays this
|
|
1097
|
+
// batch's file list to the woken builder, and a file missing from it is never rebuilt at all.
|
|
1098
|
+
await watcher.start();
|
|
1099
|
+
this.#idleWatcher = watcher;
|
|
1100
|
+
return true;
|
|
1101
|
+
} catch (err) {
|
|
1102
|
+
// Better to keep paying for the builder than to suspend into a dev server that cannot notice edits.
|
|
1103
|
+
this.logger.warn(
|
|
1104
|
+
`[idle-suspend] could not install the idle watcher; staying awake: ${err instanceof Error ? err.message : String(err)}`,
|
|
1105
|
+
);
|
|
1106
|
+
this.#stopIdleWatcher();
|
|
1107
|
+
return false;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
#stopIdleWatcher(): void {
|
|
1111
|
+
this.#idleWatcher?.stop();
|
|
1112
|
+
this.#idleWatcher = null;
|
|
1113
|
+
}
|
|
1114
|
+
#recordSuspendedChange(batch: ChangeBatch): void {
|
|
1115
|
+
const current = this.#suspendedChanges;
|
|
1116
|
+
if (!current) {
|
|
1117
|
+
this.#suspendedChanges = { files: [...batch.files], kinds: new Set(batch.kinds) };
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
this.#suspendedChanges = {
|
|
1121
|
+
files: [...new Set([...current.files, ...batch.files])],
|
|
1122
|
+
kinds: new Set([...current.kinds, ...batch.kinds]),
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Bring build capacity back. Reuses the paths an ordinary change would take, so a change made while
|
|
1127
|
+
* suspended lands the same way it would have while awake.
|
|
1128
|
+
*/
|
|
1129
|
+
async #wakeFromIdle(reason: string): Promise<void> {
|
|
1130
|
+
if (!this.#suspended || this.#waking) return;
|
|
1131
|
+
this.#waking = true;
|
|
1132
|
+
this.#cancelIdleSuspend();
|
|
1133
|
+
this.#stopIdleWatcher();
|
|
1134
|
+
const batch = this.#suspendedChanges;
|
|
1135
|
+
this.#suspendedChanges = null;
|
|
1136
|
+
const startedAtMono = performance.now();
|
|
1137
|
+
this.logger.info(`[idle-suspend] waking (${reason})`);
|
|
1138
|
+
try {
|
|
1139
|
+
await this.#applyIdleWake(batch);
|
|
1140
|
+
this.logger.info(`[idle-suspend] awake in ${Math.round(performance.now() - startedAtMono)}ms`);
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
// Never leave the dev server without a builder: fall back to the ordinary recovery loop, which
|
|
1143
|
+
// keeps retrying, rather than sitting suspended with no watcher.
|
|
1144
|
+
this.logger.error(
|
|
1145
|
+
`[idle-suspend] wake failed; recovering the builder: ${err instanceof Error ? err.message : String(err)}`,
|
|
1146
|
+
);
|
|
1147
|
+
this.#scheduleBuilderRecovery({ files: batch?.files ?? [] });
|
|
1148
|
+
} finally {
|
|
1149
|
+
this.#suspended = false;
|
|
1150
|
+
this.#waking = false;
|
|
1151
|
+
this.#wokeAtMono = performance.now();
|
|
1152
|
+
this.#flushPendingBuilderMessages();
|
|
1153
|
+
this.#armIdleSuspend();
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
async #applyIdleWake(batch: ChangeBatch | null): Promise<void> {
|
|
1157
|
+
const files = batch?.files ?? [];
|
|
1158
|
+
if (shouldRefreshConfigOnIdleWake(batch)) {
|
|
1159
|
+
this.logger.verbose("[idle-suspend] config changed while suspended; restarting the dev host");
|
|
1160
|
+
await this.#recycleDevChildren(
|
|
1161
|
+
{ type: "invalidate", kinds: [...(batch?.kinds ?? [])], files },
|
|
1162
|
+
{
|
|
1163
|
+
refreshConfig: true,
|
|
1164
|
+
},
|
|
1165
|
+
);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
// Refresh before deciding: a file created while suspended is not in the graph yet.
|
|
1169
|
+
if (files.length > 0) await this.#backendGraph.refresh();
|
|
1170
|
+
await this.#startBuilder({ announceBootState: true });
|
|
1171
|
+
const backendFiles = files.filter((file) => this.#isBackendFile(file));
|
|
1172
|
+
if (backendFiles.length === 0) return;
|
|
1173
|
+
this.logger.verbose(`[idle-suspend] ${backendFiles.length} backend file(s) changed while suspended`);
|
|
1174
|
+
this.#scheduleBackendRestart({ files: backendFiles, roles: [] });
|
|
1175
|
+
}
|
|
1176
|
+
#flushPendingBuilderMessages(): void {
|
|
1177
|
+
const pending = this.#pendingBuilderMessages.splice(0);
|
|
1178
|
+
if (pending.length === 0) return;
|
|
1179
|
+
this.logger.verbose(`[idle-suspend] replaying ${pending.length} request(s) held during the wake`);
|
|
1180
|
+
for (const message of pending) this.#sendToBuilder(message);
|
|
1181
|
+
}
|
|
1182
|
+
#recycleBuilderForRss(reason: string): void {
|
|
1183
|
+
// A config or runtime-metadata change already replaces the builder along with the backend, and a
|
|
1184
|
+
// pending backend restart is disruption enough on its own; either way, dropping the recycle here
|
|
1185
|
+
// costs nothing — the next build re-reports an over-ceiling rss and arms it again.
|
|
1186
|
+
if (this.#pendingRecycle || this.#restartTimer) {
|
|
1187
|
+
this.logger.verbose(`[builder-recycle] skipped (${reason}); a dev restart is already pending`);
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
if (!this.#builder?.recycle(reason)) return;
|
|
1191
|
+
this.#lastRssRecycleAtMono = performance.now();
|
|
1192
|
+
}
|
|
653
1193
|
async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
|
|
654
1194
|
this.#logDevPlan(message);
|
|
1195
|
+
// More batches are on the way, so push the recycle out until the dev server settles.
|
|
1196
|
+
if (this.#rssRecycleReason) this.#armRssRecycle(this.#rssRecycleReason);
|
|
655
1197
|
// Config changes subsume builder restarts: the dev-host restart recycles builder and backend
|
|
656
1198
|
// AND re-runs the prepare step, so check it first when a batch carries both actions.
|
|
657
1199
|
const wantsDevHostRestart = shouldRestartDevHostByDevPlan(message);
|
|
@@ -811,18 +1353,25 @@ export class AkanAppHost {
|
|
|
811
1353
|
await this.#startBuilder();
|
|
812
1354
|
this.#startBackend({ generation, files: message.files });
|
|
813
1355
|
}
|
|
1356
|
+
/**
|
|
1357
|
+
* `supersede` bypasses the generation check for a builder that just replaced another one. Its
|
|
1358
|
+
* generation counter restarts at 0, so its announcement looks stale to `shouldReplaceLastGoodMessage`
|
|
1359
|
+
* — and leaving the old payload cached would make the next backend restart replay an artifact the
|
|
1360
|
+
* builder that produced it no longer serves.
|
|
1361
|
+
*/
|
|
814
1362
|
#recordLastGood(
|
|
815
1363
|
message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
|
|
1364
|
+
{ supersede = false }: { supersede?: boolean } = {},
|
|
816
1365
|
): void {
|
|
817
1366
|
if (message.type === "pages-updated") {
|
|
818
|
-
if (!shouldReplaceLastGoodMessage(this.#lastGoodFrontend.pages, message)) return;
|
|
1367
|
+
if (!supersede && !shouldReplaceLastGoodMessage(this.#lastGoodFrontend.pages, message)) return;
|
|
819
1368
|
this.#lastGoodFrontend.pages = message;
|
|
820
1369
|
this.logger.verbose(
|
|
821
1370
|
`[last-good] pages generation=${message.data.generation ?? "(unknown)"} buildId=${message.data.buildId}`,
|
|
822
1371
|
);
|
|
823
1372
|
return;
|
|
824
1373
|
}
|
|
825
|
-
if (!shouldReplaceLastGoodMessage(this.#lastGoodFrontend.css, message)) return;
|
|
1374
|
+
if (!supersede && !shouldReplaceLastGoodMessage(this.#lastGoodFrontend.css, message)) return;
|
|
826
1375
|
this.#lastGoodFrontend.css = message;
|
|
827
1376
|
this.logger.verbose(
|
|
828
1377
|
`[last-good] css generation=${message.data.generation ?? "(unknown)"} assets=${Object.keys(message.data.cssAssets).length}`,
|
|
@@ -906,7 +1455,11 @@ export class AkanAppHost {
|
|
|
906
1455
|
#isBackendFile(file: string): boolean {
|
|
907
1456
|
return this.#backendGraph.has(file);
|
|
908
1457
|
}
|
|
909
|
-
async #startBuilder(
|
|
1458
|
+
async #startBuilder({
|
|
1459
|
+
announceBootState = false,
|
|
1460
|
+
}: {
|
|
1461
|
+
announceBootState?: boolean;
|
|
1462
|
+
} = {}): Promise<IncrementalBuilderHost> {
|
|
910
1463
|
const startTime = Date.now();
|
|
911
1464
|
this.app.verbose(`[cli] waiting for builder to complete initial base build…`);
|
|
912
1465
|
let lastError: unknown;
|
|
@@ -915,7 +1468,7 @@ export class AkanAppHost {
|
|
|
915
1468
|
this.#enqueueBuilderMessage(msg);
|
|
916
1469
|
});
|
|
917
1470
|
try {
|
|
918
|
-
await this.#waitForBuilderReady(attempt);
|
|
1471
|
+
await this.#waitForBuilderReady(attempt, { announceBootState });
|
|
919
1472
|
this.app.verbose(`[cli] base build ready in ${Date.now() - startTime}ms — starting backend`);
|
|
920
1473
|
return this.#builder;
|
|
921
1474
|
} catch (err) {
|
|
@@ -927,7 +1480,10 @@ export class AkanAppHost {
|
|
|
927
1480
|
}
|
|
928
1481
|
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
929
1482
|
}
|
|
930
|
-
#waitForBuilderReady(
|
|
1483
|
+
#waitForBuilderReady(
|
|
1484
|
+
attempt: number,
|
|
1485
|
+
{ announceBootState = false }: { announceBootState?: boolean } = {},
|
|
1486
|
+
): Promise<void> {
|
|
931
1487
|
return new Promise<void>((resolve, reject) => {
|
|
932
1488
|
if (!this.#builder) throw new Error("Builder Not Found");
|
|
933
1489
|
let settled = false;
|
|
@@ -941,6 +1497,7 @@ export class AkanAppHost {
|
|
|
941
1497
|
settle(() => reject(new Error("[cli] builder timed out before emitting builder-ready")));
|
|
942
1498
|
}, BUILDER_READY_TIMEOUT_MS);
|
|
943
1499
|
this.#builder.start({
|
|
1500
|
+
announceBootState,
|
|
944
1501
|
onExit: () => {
|
|
945
1502
|
settle(() => reject(new Error(`[cli] builder exited before emitting builder-ready (attempt ${attempt})`)));
|
|
946
1503
|
},
|
|
@@ -955,6 +1512,14 @@ export class AkanAppHost {
|
|
|
955
1512
|
});
|
|
956
1513
|
}
|
|
957
1514
|
#sendToBuilder(message: BuilderMessage): void {
|
|
1515
|
+
this.#markDevActivity();
|
|
1516
|
+
if (this.#suspended || this.#waking) {
|
|
1517
|
+
// A navigation must not fail just because the sandbox was idle — hold the request and let the
|
|
1518
|
+
// builder the wake brings up answer it.
|
|
1519
|
+
this.#pendingBuilderMessages.push(message);
|
|
1520
|
+
void this.#wakeFromIdle(`${message.type} arrived while suspended`);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
958
1523
|
// The builder skips dev CSR artifacts until a `?csr=true` request needs one. Remember that this
|
|
959
1524
|
// session armed it and pass the flag through `env`, which is re-read on every builder spawn, so a
|
|
960
1525
|
// builder restart re-arms itself instead of silently breaking an in-progress mobile dev session.
|
|
@@ -985,6 +1550,7 @@ export class AkanAppHost {
|
|
|
985
1550
|
this.logger.warn("akanAppHost builder is not running");
|
|
986
1551
|
}
|
|
987
1552
|
#stopBuilder(): void {
|
|
1553
|
+
this.#cancelRssRecycle();
|
|
988
1554
|
if (!this.#builder) return;
|
|
989
1555
|
this.#builder.stop();
|
|
990
1556
|
this.#builder = null;
|