@akanjs/devkit 2.4.1-rc.3 → 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 +199 -2
- package/akanApp/akanApp.host.ts +399 -9
- 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/builderReply.test.ts +73 -0
- package/incrementalBuilder/builderReply.ts +30 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +88 -3
- package/incrementalBuilder/incrementalBuilder.host.ts +50 -3
- package/incrementalBuilder/incrementalBuilder.proc.ts +25 -12
- package/integration/devStability.integration.test.ts +245 -98
- package/integration/devStabilityHarness.test.ts +111 -0
- package/integration/devStabilityHarness.ts +528 -39
- package/package.json +2 -2
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { Logger } from "akanjs/common";
|
|
3
4
|
import type {
|
|
4
5
|
BuilderMessage,
|
|
5
6
|
BuilderMetrics,
|
|
6
7
|
BuildPhase,
|
|
8
|
+
ChangeBatch,
|
|
7
9
|
DevBuildStatus,
|
|
8
10
|
DevChangePlan,
|
|
9
11
|
DevChangeRole,
|
|
@@ -11,6 +13,10 @@ import type {
|
|
|
11
13
|
import type { App } from "../commandDecorators";
|
|
12
14
|
import { createTunnel } from "../createTunnel";
|
|
13
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";
|
|
14
20
|
import { IncrementalBuilderHost } from "../incrementalBuilder";
|
|
15
21
|
|
|
16
22
|
const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr"]);
|
|
@@ -29,6 +35,20 @@ const BUILDER_START_MAX_ATTEMPTS = 3;
|
|
|
29
35
|
const BUILDER_RSS_RECYCLE_QUIET_MS = 750;
|
|
30
36
|
const BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS = 30_000;
|
|
31
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;
|
|
32
52
|
// The builder is the file watcher: while it is down no edit can trigger a retry, so unlike the
|
|
33
53
|
// backend the recovery loop never gives up — it only backs off.
|
|
34
54
|
const BUILDER_RECOVERY_BASE_DELAY_MS = 2_000;
|
|
@@ -220,6 +240,90 @@ export const decideBuilderRssRecycle = ({
|
|
|
220
240
|
return "recycle";
|
|
221
241
|
};
|
|
222
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
|
+
|
|
223
327
|
/**
|
|
224
328
|
* A builder whose fresh boot already exceeds the ceiling reports `too-soon` after every recycle and
|
|
225
329
|
* would be replaced forever without ever getting under it. After this many recycles bought no relief
|
|
@@ -288,7 +392,7 @@ export const buildStatusReplaySequence = (
|
|
|
288
392
|
latestByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
|
|
289
393
|
): DevBuildStatus[] => [...pendingReplay, ...latestByPhase.values()];
|
|
290
394
|
|
|
291
|
-
class BackendImportGraph {
|
|
395
|
+
export class BackendImportGraph {
|
|
292
396
|
readonly #app: App;
|
|
293
397
|
readonly #logger: Logger;
|
|
294
398
|
readonly #tsTranspiler = new Bun.Transpiler({ loader: "ts" });
|
|
@@ -296,6 +400,15 @@ class BackendImportGraph {
|
|
|
296
400
|
readonly #jsTranspiler = new Bun.Transpiler({ loader: "js" });
|
|
297
401
|
readonly #jsxTranspiler = new Bun.Transpiler({ loader: "jsx" });
|
|
298
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[] }>();
|
|
299
412
|
#ready = false;
|
|
300
413
|
#lastRefreshSucceeded = false;
|
|
301
414
|
|
|
@@ -344,11 +457,10 @@ class BackendImportGraph {
|
|
|
344
457
|
const current = path.resolve(queue.pop() as string);
|
|
345
458
|
if (files.has(current)) continue;
|
|
346
459
|
if (!this.#isWorkspaceSource(current, workspaceRoot)) continue;
|
|
347
|
-
|
|
460
|
+
const imports = await this.#importsOf(current);
|
|
461
|
+
if (!imports) continue;
|
|
348
462
|
|
|
349
463
|
files.add(current);
|
|
350
|
-
const source = await Bun.file(current).text();
|
|
351
|
-
const imports = this.#scanImports(current, source);
|
|
352
464
|
const importerDir = path.dirname(current);
|
|
353
465
|
for (const imp of imports) {
|
|
354
466
|
if (!GRAPH_IMPORT_KINDS.has(imp.kind) || !imp.path || NON_SOURCE_EXT_RE.test(imp.path)) continue;
|
|
@@ -357,9 +469,23 @@ class BackendImportGraph {
|
|
|
357
469
|
queue.push(resolved);
|
|
358
470
|
}
|
|
359
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);
|
|
360
474
|
return files;
|
|
361
475
|
}
|
|
362
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
|
+
|
|
363
489
|
async #entrypoints(): Promise<string[]> {
|
|
364
490
|
const roots = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
|
|
365
491
|
const existing: string[] = [];
|
|
@@ -422,11 +548,22 @@ export class AkanAppHost {
|
|
|
422
548
|
#rssRecycleReason: string | null = null;
|
|
423
549
|
#lastRssRecycleAtMono: number | null = null;
|
|
424
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;
|
|
425
554
|
#rssCeilingAbandoned = false;
|
|
426
555
|
#buildStatusByPhase = new Map<BuildPhase, DevBuildStatus>();
|
|
427
556
|
#pendingBuildStatusReplay: DevBuildStatus[] = [];
|
|
428
557
|
#builderMessageQueue: Promise<void> = Promise.resolve();
|
|
429
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[] = [];
|
|
430
567
|
constructor(
|
|
431
568
|
private readonly app: App,
|
|
432
569
|
{ env, withInk = false }: { env: Record<string, string>; withInk?: boolean },
|
|
@@ -445,9 +582,12 @@ export class AkanAppHost {
|
|
|
445
582
|
]);
|
|
446
583
|
Object.assign(this.env, { REDIS_HOST: redisHost });
|
|
447
584
|
this.#startBackend();
|
|
585
|
+
this.#armIdleSuspend();
|
|
448
586
|
return this;
|
|
449
587
|
}
|
|
450
588
|
async stop() {
|
|
589
|
+
this.#cancelIdleSuspend();
|
|
590
|
+
this.#stopIdleWatcher();
|
|
451
591
|
if (this.#restartTimer) {
|
|
452
592
|
clearTimeout(this.#restartTimer);
|
|
453
593
|
this.#restartTimer = null;
|
|
@@ -715,6 +855,7 @@ export class AkanAppHost {
|
|
|
715
855
|
});
|
|
716
856
|
}
|
|
717
857
|
async #handleBuilderMessage(message: BuilderMessage) {
|
|
858
|
+
this.#markDevActivity();
|
|
718
859
|
if (message.type === "build-status") {
|
|
719
860
|
this.#recordBuildStatus(message.data);
|
|
720
861
|
this.#sendOrQueueBuildStatus(message.data);
|
|
@@ -784,27 +925,260 @@ export class AkanAppHost {
|
|
|
784
925
|
}
|
|
785
926
|
this.#armRssRecycle(
|
|
786
927
|
`rss=${asMib(metrics.rssBytes)}MiB>=${asMib(ceilingBytes ?? 0)}MiB after ${metrics.workCount} build(s)`,
|
|
928
|
+
{ rssBytes: metrics.rssBytes, ceilingBytes: ceilingBytes ?? 0 },
|
|
787
929
|
);
|
|
788
930
|
}
|
|
789
931
|
/** Waits for the builder to go quiet, so a recycle never lands in the middle of a burst of saves. */
|
|
790
|
-
#armRssRecycle(reason: string): void {
|
|
932
|
+
#armRssRecycle(reason: string, over?: { rssBytes: number; ceilingBytes: number }): void {
|
|
791
933
|
if (this.#rssRecycleReason !== reason)
|
|
792
934
|
this.logger.verbose(`[builder-recycle] armed (${reason}); replacing the builder once it stays quiet`);
|
|
793
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;
|
|
794
939
|
if (this.#rssRecycleTimer) clearTimeout(this.#rssRecycleTimer);
|
|
795
940
|
this.#rssRecycleTimer = setTimeout(() => {
|
|
796
941
|
this.#rssRecycleTimer = null;
|
|
797
942
|
const pendingReason = this.#rssRecycleReason;
|
|
943
|
+
const pendingOver = this.#rssRecycleOver;
|
|
798
944
|
this.#rssRecycleReason = null;
|
|
799
|
-
|
|
945
|
+
this.#rssRecycleOver = null;
|
|
946
|
+
if (!pendingReason) return;
|
|
947
|
+
if (!pendingOver) {
|
|
948
|
+
this.#recycleBuilderForRss(pendingReason);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
void this.#recycleBuilderForRssWhenStillOver(pendingReason, pendingOver);
|
|
800
952
|
}, BUILDER_RSS_RECYCLE_QUIET_MS);
|
|
801
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
|
+
}
|
|
802
1014
|
#cancelRssRecycle(): void {
|
|
803
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;
|
|
804
1019
|
if (!this.#rssRecycleTimer) return;
|
|
805
1020
|
clearTimeout(this.#rssRecycleTimer);
|
|
806
1021
|
this.#rssRecycleTimer = null;
|
|
807
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
|
+
}
|
|
808
1182
|
#recycleBuilderForRss(reason: string): void {
|
|
809
1183
|
// A config or runtime-metadata change already replaces the builder along with the backend, and a
|
|
810
1184
|
// pending backend restart is disruption enough on its own; either way, dropping the recycle here
|
|
@@ -1081,7 +1455,11 @@ export class AkanAppHost {
|
|
|
1081
1455
|
#isBackendFile(file: string): boolean {
|
|
1082
1456
|
return this.#backendGraph.has(file);
|
|
1083
1457
|
}
|
|
1084
|
-
async #startBuilder(
|
|
1458
|
+
async #startBuilder({
|
|
1459
|
+
announceBootState = false,
|
|
1460
|
+
}: {
|
|
1461
|
+
announceBootState?: boolean;
|
|
1462
|
+
} = {}): Promise<IncrementalBuilderHost> {
|
|
1085
1463
|
const startTime = Date.now();
|
|
1086
1464
|
this.app.verbose(`[cli] waiting for builder to complete initial base build…`);
|
|
1087
1465
|
let lastError: unknown;
|
|
@@ -1090,7 +1468,7 @@ export class AkanAppHost {
|
|
|
1090
1468
|
this.#enqueueBuilderMessage(msg);
|
|
1091
1469
|
});
|
|
1092
1470
|
try {
|
|
1093
|
-
await this.#waitForBuilderReady(attempt);
|
|
1471
|
+
await this.#waitForBuilderReady(attempt, { announceBootState });
|
|
1094
1472
|
this.app.verbose(`[cli] base build ready in ${Date.now() - startTime}ms — starting backend`);
|
|
1095
1473
|
return this.#builder;
|
|
1096
1474
|
} catch (err) {
|
|
@@ -1102,7 +1480,10 @@ export class AkanAppHost {
|
|
|
1102
1480
|
}
|
|
1103
1481
|
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1104
1482
|
}
|
|
1105
|
-
#waitForBuilderReady(
|
|
1483
|
+
#waitForBuilderReady(
|
|
1484
|
+
attempt: number,
|
|
1485
|
+
{ announceBootState = false }: { announceBootState?: boolean } = {},
|
|
1486
|
+
): Promise<void> {
|
|
1106
1487
|
return new Promise<void>((resolve, reject) => {
|
|
1107
1488
|
if (!this.#builder) throw new Error("Builder Not Found");
|
|
1108
1489
|
let settled = false;
|
|
@@ -1116,6 +1497,7 @@ export class AkanAppHost {
|
|
|
1116
1497
|
settle(() => reject(new Error("[cli] builder timed out before emitting builder-ready")));
|
|
1117
1498
|
}, BUILDER_READY_TIMEOUT_MS);
|
|
1118
1499
|
this.#builder.start({
|
|
1500
|
+
announceBootState,
|
|
1119
1501
|
onExit: () => {
|
|
1120
1502
|
settle(() => reject(new Error(`[cli] builder exited before emitting builder-ready (attempt ${attempt})`)));
|
|
1121
1503
|
},
|
|
@@ -1130,6 +1512,14 @@ export class AkanAppHost {
|
|
|
1130
1512
|
});
|
|
1131
1513
|
}
|
|
1132
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
|
+
}
|
|
1133
1523
|
// The builder skips dev CSR artifacts until a `?csr=true` request needs one. Remember that this
|
|
1134
1524
|
// session armed it and pass the flag through `env`, which is re-read on every builder spawn, so a
|
|
1135
1525
|
// builder restart re-arms itself instead of silently breaking an in-progress mobile dev session.
|
package/executors.test.ts
CHANGED
|
@@ -271,6 +271,66 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
271
271
|
expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
|
|
272
272
|
});
|
|
273
273
|
|
|
274
|
+
describe("getDevPort", () => {
|
|
275
|
+
const makeWorkspaceWithApps = async (names: string[]) => {
|
|
276
|
+
const root = await makeTempRoot();
|
|
277
|
+
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
278
|
+
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
279
|
+
process.env.AKAN_PUBLIC_ENV = "local";
|
|
280
|
+
process.env.PORT_OFFSET = "0";
|
|
281
|
+
await writeJson(path.join(root, "package.json"), rootPackageJson());
|
|
282
|
+
for (const name of names) {
|
|
283
|
+
await mkdir(path.join(root, "apps", name), { recursive: true });
|
|
284
|
+
await writeFile(path.join(root, "apps", name, "akan.config.ts"), "export default {};\n");
|
|
285
|
+
}
|
|
286
|
+
// `AppExecutor.from` memoises by name, so each test needs names no other test has used.
|
|
287
|
+
return new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
test("derives the port from the app's position in the sorted apps listing", async () => {
|
|
291
|
+
const workspace = await makeWorkspaceWithApps(["port-a", "port-b"]);
|
|
292
|
+
|
|
293
|
+
expect(await AppExecutor.from(workspace, "port-a").getDevPort()).toBe(8282);
|
|
294
|
+
expect(await AppExecutor.from(workspace, "port-b").getDevPort()).toBe(8283);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("moves when another app appears before it, which is why pinning exists", async () => {
|
|
298
|
+
const workspace = await makeWorkspaceWithApps(["drift-b"]);
|
|
299
|
+
const app = AppExecutor.from(workspace, "drift-b");
|
|
300
|
+
expect(await app.getDevPort()).toBe(8282);
|
|
301
|
+
|
|
302
|
+
// Sorts ahead of `drift-b`, so the same app now answers with a different port — and a dev host
|
|
303
|
+
// recomputes this on every restart.
|
|
304
|
+
await mkdir(path.join(workspace.workspaceRoot, "apps/drift-a"), { recursive: true });
|
|
305
|
+
await writeFile(path.join(workspace.workspaceRoot, "apps/drift-a/akan.config.ts"), "export default {};\n");
|
|
306
|
+
|
|
307
|
+
expect(await app.getDevPort()).toBe(8283);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("AKAN_DEV_PORT pins it, and survives an app appearing before it", async () => {
|
|
311
|
+
const workspace = await makeWorkspaceWithApps(["pin-b"]);
|
|
312
|
+
const app = AppExecutor.from(workspace, "pin-b");
|
|
313
|
+
process.env.AKAN_DEV_PORT = "12345";
|
|
314
|
+
|
|
315
|
+
expect(await app.getDevPort()).toBe(12345);
|
|
316
|
+
|
|
317
|
+
await mkdir(path.join(workspace.workspaceRoot, "apps/pin-a"), { recursive: true });
|
|
318
|
+
await writeFile(path.join(workspace.workspaceRoot, "apps/pin-a/akan.config.ts"), "export default {};\n");
|
|
319
|
+
|
|
320
|
+
expect(await app.getDevPort()).toBe(12345);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("ignores an unusable AKAN_DEV_PORT rather than binding a nonsense port", async () => {
|
|
324
|
+
const workspace = await makeWorkspaceWithApps(["bad-a"]);
|
|
325
|
+
const app = AppExecutor.from(workspace, "bad-a");
|
|
326
|
+
|
|
327
|
+
for (const value of ["0", "-1", "nope", "", "70000", "8282.5"]) {
|
|
328
|
+
process.env.AKAN_DEV_PORT = value;
|
|
329
|
+
expect(await app.getDevPort()).toBe(8282);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
274
334
|
test("accepts metadata route exports during page key discovery", async () => {
|
|
275
335
|
const root = await makeTempRoot();
|
|
276
336
|
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
package/executors.ts
CHANGED
|
@@ -1256,7 +1256,18 @@ export class AppExecutor extends SysExecutor {
|
|
|
1256
1256
|
getEnv() {
|
|
1257
1257
|
return WorkspaceExecutor.getBaseDevEnv().env;
|
|
1258
1258
|
}
|
|
1259
|
+
/**
|
|
1260
|
+
* This app's dev port, derived from its position in the sorted `apps/` listing so several apps can run
|
|
1261
|
+
* at once without colliding.
|
|
1262
|
+
*
|
|
1263
|
+
* `AKAN_DEV_PORT` pins it instead, because the derived value *moves*: the index shifts whenever any other
|
|
1264
|
+
* app directory appears or disappears, and a dev host recomputes this on every restart. So a session that
|
|
1265
|
+
* adds an app relands its dev server on a different port, and anything that reserved a port relative to
|
|
1266
|
+
* the old one is left pointing at nothing.
|
|
1267
|
+
*/
|
|
1259
1268
|
async getDevPort() {
|
|
1269
|
+
const pinned = Number(process.env.AKAN_DEV_PORT);
|
|
1270
|
+
if (Number.isInteger(pinned) && pinned > 0 && pinned <= 65_535) return pinned;
|
|
1260
1271
|
const basePort = 8282;
|
|
1261
1272
|
const appNames = (await this.workspace.getApps()).sort((a, b) => a.localeCompare(b));
|
|
1262
1273
|
const appIndex = Math.max(appNames.indexOf(this.name), 0);
|