@akanjs/devkit 3.0.0-beta.25 → 3.0.0-beta.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEV_RUNTIME_KNOBS.md +9 -6
- package/akanApp/akanApp.host.ts +79 -22
- package/akanApp/devHostPolicy.test.ts +56 -11
- package/akanApp/devHostPolicy.ts +10 -10
- package/frontendBuild/clientEntryDiscovery.test.ts +75 -0
- package/frontendBuild/clientEntryDiscovery.ts +112 -70
- package/frontendBuild/csrDevModuleCompiler.test.ts +89 -0
- package/frontendBuild/csrDevModuleCompiler.ts +6 -2
- package/frontendBuild/csrDevPatcher.test.ts +234 -2
- package/frontendBuild/csrDevPatcher.ts +184 -10
- package/frontendBuild/csrDevPaths.ts +31 -11
- package/frontendBuild/csrDevResolver.test.ts +76 -2
- package/frontendBuild/csrDevResolver.ts +59 -10
- package/frontendBuild/csrDevTypes.ts +2 -0
- package/frontendBuild/devRegistryBundler.ts +18 -3
- package/frontendBuild/ssrDevBundler.ts +1 -7
- package/incrementalBuilder/incrementalBuilder.host.test.ts +34 -0
- package/incrementalBuilder/incrementalBuilder.host.ts +18 -3
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +62 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +58 -24
- package/integration/devStability.integration.test.ts +9 -2
- package/package.json +2 -2
package/DEV_RUNTIME_KNOBS.md
CHANGED
|
@@ -37,8 +37,10 @@ this repo's own `apps/akan`). None of those has a ceiling, and none is subtracte
|
|
|
37
37
|
|
|
38
38
|
In practice this holds because the two ceilings are rarely at their limit simultaneously and the build
|
|
39
39
|
worker exits. But if you are sizing a container to a hard number, size it against the *sum of observed
|
|
40
|
-
peaks*, not against these fractions. Measured
|
|
41
|
-
~
|
|
40
|
+
peaks*, not against these fractions. Measured on `apps/minimal` (macOS, 2026-09-29), for reference: the
|
|
41
|
+
builder ~120MB at rest after boot, ~540MB right after its first route build (the bundler hands most of it
|
|
42
|
+
back after ~20s idle), ~250MB while it patches saves into the SSR registry; the RSC worker ~75MB after boot
|
|
43
|
+
and ~110MB after route builds; the build worker ~900MB for the SSR registry's boot build.
|
|
42
44
|
|
|
43
45
|
## Recycling behaviour
|
|
44
46
|
|
|
@@ -133,13 +135,14 @@ AKAN_MEMORY_LIMIT=1200mb # builder gets ~420MB, rsc worker ~660MB
|
|
|
133
135
|
AKAN_DEV_IDLE_SUSPEND_MS=300000 # release the builder after 5 idle minutes
|
|
134
136
|
```
|
|
135
137
|
|
|
136
|
-
Two things to expect at that size. The builder crosses 420MB during ordinary work —
|
|
137
|
-
|
|
138
|
+
Two things to expect at that size. The builder crosses 420MB during ordinary work — its first route
|
|
139
|
+
build alone takes it to ~540MB — so it is replaced roughly once per 30s while you keep building, each
|
|
138
140
|
replacement costing a boot build that requests wait through rather than fail. And the build worker's
|
|
139
141
|
peak is not covered by any of these ceilings; if the kernel OOM-kills it, the dev server survives with a
|
|
140
142
|
red build for that generation and the log names the signal. On a boot whose `.akan` was cleared, the SSR
|
|
141
|
-
registry's first build is such a worker
|
|
142
|
-
same whole build, so a container that cannot fit it once
|
|
143
|
+
registry's first build is such a worker, peaking near 900MB on `apps/minimal`: a killed one leaves no
|
|
144
|
+
registry, and every later save retries that same whole build, so a container that cannot fit it once
|
|
145
|
+
cannot fit it at all.
|
|
143
146
|
|
|
144
147
|
Raising `AKAN_BUILDER_MAX_RSS_MB` above the derived share trades memory for fewer boot builds. Setting
|
|
145
148
|
it to `0` trades the bound away entirely, which on a container this size means the kernel decides
|
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { Logger } from "akanjs/common";
|
|
2
3
|
import type { BuilderMessage, BuilderMetrics, BuildPhase, ChangeBatch, DevBuildStatus } from "akanjs/server";
|
|
3
4
|
import type { App } from "../commandDecorators";
|
|
@@ -93,6 +94,8 @@ export class AkanAppHost {
|
|
|
93
94
|
readonly #bootLatch = new DevBootLatch(() => this.#onDevEvent?.({ app: this.app.name, booted: true }));
|
|
94
95
|
#backend: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe"> | null = null;
|
|
95
96
|
#builder: IncrementalBuilderHost | null = null;
|
|
97
|
+
//? Outlives the builder host, which idle wake and recovery replace; a config or metadata restart clears it.
|
|
98
|
+
#patcherOff = false;
|
|
96
99
|
#backendReady = false;
|
|
97
100
|
#plannedBackendStops = new WeakSet<Bun.Subprocess<"ignore", "inherit", "inherit">>();
|
|
98
101
|
#restartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -101,7 +104,16 @@ export class AkanAppHost {
|
|
|
101
104
|
#backendGaveUp = false;
|
|
102
105
|
#backendLifecycleState: BackendLifecycleState = "stopped";
|
|
103
106
|
#pendingRestartReason: BackendRestartReason | null = null;
|
|
104
|
-
#pendingRecycle: {
|
|
107
|
+
#pendingRecycle: {
|
|
108
|
+
message: Extract<BuilderMessage, { type: "invalidate" }>;
|
|
109
|
+
refreshConfig: boolean;
|
|
110
|
+
failed?: boolean;
|
|
111
|
+
failedGeneration?: number;
|
|
112
|
+
} | null = null;
|
|
113
|
+
//? A restart that failed after its change applied (the builder or backend did not come up): the builder's return is
|
|
114
|
+
//? its recovery, reported past the failure's generation so the scan overlay clears.
|
|
115
|
+
#restartFailedAfterApply: number | null = null;
|
|
116
|
+
#stopping = false;
|
|
105
117
|
#builderRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
106
118
|
#builderRecoveryAttempts = 0;
|
|
107
119
|
#backendStartStatus: { generation?: number; files: string[] } | null = null;
|
|
@@ -163,6 +175,9 @@ export class AkanAppHost {
|
|
|
163
175
|
return this;
|
|
164
176
|
}
|
|
165
177
|
async stop() {
|
|
178
|
+
//? Nothing reapplies once it stops: a pages ok received meanwhile would bring children back after it.
|
|
179
|
+
this.#stopping = true;
|
|
180
|
+
this.#pendingRecycle = null;
|
|
166
181
|
this.#cancelIdleSuspend();
|
|
167
182
|
this.#stopIdleWatcher();
|
|
168
183
|
this.#clearRestartTimers();
|
|
@@ -430,12 +445,14 @@ export class AkanAppHost {
|
|
|
430
445
|
});
|
|
431
446
|
}
|
|
432
447
|
async #handleBuilderMessage(message: BuilderMessage) {
|
|
448
|
+
if (this.#stopping) return;
|
|
433
449
|
this.#markDevActivity();
|
|
434
450
|
this.#trackBuilderGeneration(message);
|
|
435
451
|
if (message.type === "build-status") {
|
|
436
452
|
this.#recordBuildStatus(message.data);
|
|
437
453
|
this.#sendOrQueueBuildStatus(message.data);
|
|
438
454
|
this.#reviveBackendAfterGreenBuild(message.data);
|
|
455
|
+
await this.#resumeFailedRecycle(message.data);
|
|
439
456
|
return;
|
|
440
457
|
}
|
|
441
458
|
if (message.type === "builder-metrics") {
|
|
@@ -778,12 +795,8 @@ export class AkanAppHost {
|
|
|
778
795
|
this.logger.verbose("[idle-suspend] config changed while suspended; restarting the dev host");
|
|
779
796
|
// Replaces the backend too, and a baseline kept past its own gap costs a restart at the next one.
|
|
780
797
|
this.#discardBuilderGap("config change replaces the backend anyway");
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
{
|
|
784
|
-
refreshConfig: true,
|
|
785
|
-
},
|
|
786
|
-
);
|
|
798
|
+
//? Through the same path as a save's: a failure is reported and stays pending, as one would be awake.
|
|
799
|
+
await this.#applyRecycle({ type: "invalidate", kinds: [...(batch?.kinds ?? [])], files }, true);
|
|
787
800
|
return;
|
|
788
801
|
}
|
|
789
802
|
// Refresh before deciding: a file created while suspended is not in the graph yet.
|
|
@@ -889,7 +902,8 @@ export class AkanAppHost {
|
|
|
889
902
|
const wantsDevHostRestart = shouldRestartDevHostByDevPlan(message);
|
|
890
903
|
const pending = this.#pendingRecycle;
|
|
891
904
|
// A deferred recycle resumes on the next code batch; a css-only batch cannot heal a compile error.
|
|
892
|
-
|
|
905
|
+
const resumes = !!pending && message.kinds.includes("code") && this.#touchesFailedRecycle(message.files);
|
|
906
|
+
if (wantsDevHostRestart || shouldRestartBuilderByDevPlan(message) || resumes) {
|
|
893
907
|
const refreshConfig = wantsDevHostRestart || (pending?.refreshConfig ?? false);
|
|
894
908
|
const merged = pending ? mergeInvalidateMessages(pending.message, message) : message;
|
|
895
909
|
const generation = message.devPlan?.generation ?? message.generation;
|
|
@@ -897,14 +911,7 @@ export class AkanAppHost {
|
|
|
897
911
|
this.#deferRecycle(merged, { refreshConfig, generation });
|
|
898
912
|
return;
|
|
899
913
|
}
|
|
900
|
-
this.#
|
|
901
|
-
try {
|
|
902
|
-
if (refreshConfig) await this.#restartDevHost(merged);
|
|
903
|
-
else await this.#restartDevChildren(merged);
|
|
904
|
-
} catch (err) {
|
|
905
|
-
this.#recordDevHostRestartFailure(merged, err, refreshConfig ? "Config" : "Runtime metadata");
|
|
906
|
-
this.#resurrectDevChildren(merged);
|
|
907
|
-
}
|
|
914
|
+
await this.#applyRecycle(merged, refreshConfig);
|
|
908
915
|
return;
|
|
909
916
|
}
|
|
910
917
|
if (await this.#shouldRestartBackend(message)) {
|
|
@@ -913,6 +920,38 @@ export class AkanAppHost {
|
|
|
913
920
|
}
|
|
914
921
|
this.#sendToBackend(message);
|
|
915
922
|
}
|
|
923
|
+
async #applyRecycle(message: Extract<BuilderMessage, { type: "invalidate" }>, refreshConfig: boolean): Promise<void> {
|
|
924
|
+
if (this.#stopping) return;
|
|
925
|
+
this.#pendingRecycle = null;
|
|
926
|
+
const progress = { applied: !refreshConfig };
|
|
927
|
+
try {
|
|
928
|
+
if (refreshConfig) await this.#restartDevHost(message, progress);
|
|
929
|
+
else await this.#restartDevChildren(message);
|
|
930
|
+
} catch (err) {
|
|
931
|
+
const kind = refreshConfig ? "Config" : "Runtime metadata";
|
|
932
|
+
const generation = this.#recordDevHostRestartFailure(message, err, kind, { applied: progress.applied });
|
|
933
|
+
//? Kept only when the change itself did not apply: a builder that booted degraded (a broken akan.config.ts)
|
|
934
|
+
//? takes the fixing save itself and sends no invalidate, so without this the old config would keep running.
|
|
935
|
+
if (progress.applied) this.#restartFailedAfterApply = generation;
|
|
936
|
+
else this.#pendingRecycle = { message, refreshConfig, failed: true, failedGeneration: generation };
|
|
937
|
+
this.#resurrectDevChildren(message);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
//? The first green pages build after a failed restart that touches its change is the fix: the change applies then. A
|
|
941
|
+
//? cause no save fixes (an env value) waits for the next change of those files, not for every green save.
|
|
942
|
+
async #resumeFailedRecycle(status: DevBuildStatus): Promise<void> {
|
|
943
|
+
const pending = this.#pendingRecycle;
|
|
944
|
+
if (!pending?.failed || !status.ok || status.phase !== "pages" || !this.#touchesFailedRecycle(status.files)) return;
|
|
945
|
+
//? Newer than the failure: the batch that carried the change reports its own pages ok while the restart runs.
|
|
946
|
+
if (status.generation <= (pending.failedGeneration ?? -1)) return;
|
|
947
|
+
await this.#applyRecycle(pending.message, pending.refreshConfig);
|
|
948
|
+
}
|
|
949
|
+
#touchesFailedRecycle(files: string[]): boolean {
|
|
950
|
+
const pending = this.#pendingRecycle;
|
|
951
|
+
if (!pending?.failed) return true;
|
|
952
|
+
const changed = new Set(pending.message.files);
|
|
953
|
+
return files.some((file) => changed.has(file));
|
|
954
|
+
}
|
|
916
955
|
#deferRecycle(
|
|
917
956
|
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
918
957
|
{ refreshConfig, generation }: { refreshConfig: boolean; generation?: number },
|
|
@@ -963,8 +1002,13 @@ export class AkanAppHost {
|
|
|
963
1002
|
this.#builderRecoveryAttempts = 0;
|
|
964
1003
|
this.logger.info("[builder-recovery] builder recovered");
|
|
965
1004
|
void this.#restartBackendForGapChanges();
|
|
1005
|
+
const failedAfterApply = this.#restartFailedAfterApply;
|
|
1006
|
+
this.#restartFailedAfterApply = null;
|
|
966
1007
|
const status: DevBuildStatus = {
|
|
967
|
-
generation:
|
|
1008
|
+
generation:
|
|
1009
|
+
failedAfterApply !== null
|
|
1010
|
+
? this.#nextBackendBuildStatusGeneration(failedAfterApply + 1)
|
|
1011
|
+
: (reason.generation ?? this.#nextBackendBuildStatusGeneration()),
|
|
968
1012
|
phase: "scan",
|
|
969
1013
|
ok: true,
|
|
970
1014
|
files: reason.files,
|
|
@@ -991,16 +1035,19 @@ export class AkanAppHost {
|
|
|
991
1035
|
}
|
|
992
1036
|
// The config is re-imported with a cache-busting query, but modules it imports stay cached: a change inside an
|
|
993
1037
|
// imported plugin file still needs a manual `akan start` restart.
|
|
994
|
-
async #restartDevHost(
|
|
1038
|
+
async #restartDevHost(
|
|
1039
|
+
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
1040
|
+
progress?: { applied: boolean },
|
|
1041
|
+
): Promise<void> {
|
|
995
1042
|
const generation = message.devPlan?.generation ?? message.generation;
|
|
996
1043
|
this.logger.warn(
|
|
997
1044
|
`[dev-host] config change detected; restarting dev host generation=${generation ?? "(unknown)"} files=${message.files.length}`,
|
|
998
1045
|
);
|
|
999
|
-
await this.#recycleDevChildren(message, { refreshConfig: true });
|
|
1046
|
+
await this.#recycleDevChildren(message, { refreshConfig: true, progress });
|
|
1000
1047
|
}
|
|
1001
1048
|
async #recycleDevChildren(
|
|
1002
1049
|
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
1003
|
-
{ refreshConfig = false }: { refreshConfig?: boolean } = {},
|
|
1050
|
+
{ refreshConfig = false, progress }: { refreshConfig?: boolean; progress?: { applied: boolean } } = {},
|
|
1004
1051
|
): Promise<void> {
|
|
1005
1052
|
const generation = message.devPlan?.generation ?? message.generation;
|
|
1006
1053
|
this.#clearRestartTimers();
|
|
@@ -1011,11 +1058,13 @@ export class AkanAppHost {
|
|
|
1011
1058
|
this.#pendingBuildStatusReplay = [];
|
|
1012
1059
|
await this.#stopBackend();
|
|
1013
1060
|
this.#stopBuilder();
|
|
1061
|
+
this.#patcherOff = false;
|
|
1014
1062
|
if (refreshConfig) {
|
|
1015
1063
|
await this.app.getConfig({ refresh: true });
|
|
1016
1064
|
// Merge, not replace: `start()` added values prepare does not produce (REDIS_HOST from the tunnel).
|
|
1017
1065
|
const { env } = await this.app.prepareCommand("start");
|
|
1018
1066
|
Object.assign(this.env, env);
|
|
1067
|
+
if (progress) progress.applied = true;
|
|
1019
1068
|
}
|
|
1020
1069
|
await this.#backendGraph.refresh();
|
|
1021
1070
|
await this.#startBuilder();
|
|
@@ -1044,19 +1093,25 @@ export class AkanAppHost {
|
|
|
1044
1093
|
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
1045
1094
|
err: unknown,
|
|
1046
1095
|
kind: "Config" | "Runtime metadata",
|
|
1047
|
-
|
|
1096
|
+
{ applied }: { applied: boolean },
|
|
1097
|
+
): number {
|
|
1048
1098
|
const generation = message.devPlan?.generation ?? message.generation ?? this.#nextBackendBuildStatusGeneration();
|
|
1049
1099
|
const detail = err instanceof Error ? err.message : String(err);
|
|
1050
1100
|
this.logger.warn(`[dev-host] ${kind.toLowerCase()} restart failed generation=${generation}: ${detail}`);
|
|
1101
|
+
const names = message.files.slice(0, 3).map((file) => path.basename(file));
|
|
1102
|
+
const saved = names.length > 0 ? names.join(", ") : "the changed file";
|
|
1051
1103
|
const status: DevBuildStatus = {
|
|
1052
1104
|
generation,
|
|
1053
1105
|
phase: "scan",
|
|
1054
1106
|
ok: false,
|
|
1055
1107
|
files: message.files,
|
|
1056
|
-
message:
|
|
1108
|
+
message: applied
|
|
1109
|
+
? `${kind} change applied, but the dev server failed to restart: ${detail}. It is recovering on its own.`
|
|
1110
|
+
: `${kind} change failed to apply: ${detail}. The dev server keeps running on the previous ${kind.toLowerCase()}; once it is fixed, save ${saved} again or restart the dev server to apply it.`,
|
|
1057
1111
|
};
|
|
1058
1112
|
this.#recordBuildStatus(status);
|
|
1059
1113
|
this.#sendOrQueueBuildStatus(status);
|
|
1114
|
+
return generation;
|
|
1060
1115
|
}
|
|
1061
1116
|
#recordBuildStatus(status: DevBuildStatus): void {
|
|
1062
1117
|
const recovered = shouldMarkBuildPhaseRecovered(this.#buildStatusByPhase, status);
|
|
@@ -1139,6 +1194,7 @@ export class AkanAppHost {
|
|
|
1139
1194
|
{
|
|
1140
1195
|
stdio: this.stdio,
|
|
1141
1196
|
onOutput: (kind, text) => void (kind === "stderr" ? process.stderr : process.stdout).write(text),
|
|
1197
|
+
patcherOff: this.#patcherOff,
|
|
1142
1198
|
},
|
|
1143
1199
|
);
|
|
1144
1200
|
try {
|
|
@@ -1226,6 +1282,7 @@ export class AkanAppHost {
|
|
|
1226
1282
|
#stopBuilder(): void {
|
|
1227
1283
|
this.#cancelRssRecycle();
|
|
1228
1284
|
if (!this.#builder) return;
|
|
1285
|
+
if (this.#builder.patcherOff) this.#patcherOff = true;
|
|
1229
1286
|
this.#builder.stop();
|
|
1230
1287
|
this.#builder = null;
|
|
1231
1288
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangeAction } from "akanjs/server";
|
|
3
|
+
//? The source itself, not the package: the published akanjs exports no such subpath, and tests are not bundled.
|
|
4
|
+
import { DevBuildRecovery } from "../../../akanjs/server/artifact/devBuildRecovery";
|
|
3
5
|
import {
|
|
4
6
|
backendRestartReasonFromMessage,
|
|
5
7
|
buildStatusReplaySequence,
|
|
@@ -282,8 +284,9 @@ describe("dev idle suspend", () => {
|
|
|
282
284
|
expect(resolveIdleSuspendMs("1500")).toBe(1_500);
|
|
283
285
|
});
|
|
284
286
|
|
|
285
|
-
test("blocks a suspend on a failure in any phase, not just the newest generation", () => {
|
|
287
|
+
test("blocks a suspend on a failure in any phase but a route's, not just the newest generation", () => {
|
|
286
288
|
expect(hasAnyBuildFailure(new Map())).toBe(false);
|
|
289
|
+
expect(hasAnyBuildFailure(new Map([["route", status("route", 1, false)]]))).toBe(false);
|
|
287
290
|
expect(hasAnyBuildFailure(new Map([["scan", status("scan", 1, true)]]))).toBe(false);
|
|
288
291
|
expect(
|
|
289
292
|
hasAnyBuildFailure(
|
|
@@ -368,16 +371,53 @@ describe("build status helpers", () => {
|
|
|
368
371
|
expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("backend", 7, true))).toBe(true);
|
|
369
372
|
});
|
|
370
373
|
|
|
371
|
-
test("
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
expect(
|
|
377
|
-
expect(
|
|
378
|
-
expect(
|
|
379
|
-
expect(
|
|
380
|
-
expect(
|
|
374
|
+
test("the same route built again recovers at the failure's generation or later, and another route's ok never does", () => {
|
|
375
|
+
const routeA = (ok: boolean, generation = 4) => ({ ...status("route", generation, ok), scope: "/:lang/a" });
|
|
376
|
+
const routeB = (ok: boolean, generation = 4) => ({ ...status("route", generation, ok), scope: "/:lang/b" });
|
|
377
|
+
const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["route", routeA(false)]]);
|
|
378
|
+
expect(shouldMarkBuildPhaseRecovered(previousByPhase, routeA(true))).toBe(true);
|
|
379
|
+
expect(shouldKeepBuildFailure(previousByPhase, routeA(true))).toBe(false);
|
|
380
|
+
expect(shouldMarkBuildPhaseRecovered(previousByPhase, routeB(true))).toBe(false);
|
|
381
|
+
expect(shouldKeepBuildFailure(previousByPhase, routeB(true))).toBe(true);
|
|
382
|
+
expect(shouldMarkBuildPhaseRecovered(previousByPhase, routeB(true, 5))).toBe(false);
|
|
383
|
+
expect(shouldMarkBuildPhaseRecovered(previousByPhase, routeA(true, 5))).toBe(true);
|
|
384
|
+
expect(shouldKeepBuildFailure(previousByPhase, routeA(true, 3))).toBe(true);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("judges recovery as the backend does (akanjs DevBuildRecovery), case for case", () => {
|
|
388
|
+
const at = (phase: BuildPhase, generation: number, ok: boolean, scope?: string): DevBuildStatus => ({
|
|
389
|
+
...status(phase, generation, ok),
|
|
390
|
+
...(scope ? { scope } : {}),
|
|
391
|
+
});
|
|
392
|
+
const cases: [DevBuildStatus | undefined, DevBuildStatus][] = [];
|
|
393
|
+
for (const phase of ["backend", "route", "scan", "pages", "ssr"] as BuildPhase[])
|
|
394
|
+
for (const [previousOk, ok] of [
|
|
395
|
+
[false, true],
|
|
396
|
+
[true, true],
|
|
397
|
+
[false, false],
|
|
398
|
+
])
|
|
399
|
+
for (const generation of [3, 4, 5])
|
|
400
|
+
for (const [previousScope, scope] of [
|
|
401
|
+
[undefined, undefined],
|
|
402
|
+
["/a", "/a"],
|
|
403
|
+
["/a", "/b"],
|
|
404
|
+
])
|
|
405
|
+
cases.push([at(phase, 4, previousOk, previousScope), at(phase, generation, ok, scope)]);
|
|
406
|
+
cases.push([undefined, at("pages", 4, true)]);
|
|
407
|
+
for (const [previous, next] of cases) {
|
|
408
|
+
const previousByPhase = new Map<BuildPhase, DevBuildStatus>(previous ? [[previous.phase, previous]] : []);
|
|
409
|
+
expect([previous, next, shouldMarkBuildPhaseRecovered(previousByPhase, next)]).toEqual([
|
|
410
|
+
previous,
|
|
411
|
+
next,
|
|
412
|
+
DevBuildRecovery.recovers(previous, next),
|
|
413
|
+
]);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("a builder that came back clears no config change still on hold, or one that failed to apply", () => {
|
|
418
|
+
const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["scan", status("scan", 42, false)]]);
|
|
419
|
+
expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("scan", 42, true))).toBe(false);
|
|
420
|
+
expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("scan", 43, true))).toBe(true);
|
|
381
421
|
});
|
|
382
422
|
|
|
383
423
|
test("creates backend build-status payloads for lifecycle test hooks", () => {
|
|
@@ -465,6 +505,11 @@ describe("hasBuildFailureForGeneration", () => {
|
|
|
465
505
|
expect(hasBuildFailureForGeneration(statusByPhase, 4)).toBe(false);
|
|
466
506
|
});
|
|
467
507
|
|
|
508
|
+
test("leaves a route's failure out: nothing a replacement builder boots builds a route", () => {
|
|
509
|
+
const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["route", status("route", 42, false)]]);
|
|
510
|
+
expect(hasBuildFailureForGeneration(statusByPhase, 42)).toBe(false);
|
|
511
|
+
});
|
|
512
|
+
|
|
468
513
|
test("treats unknown generations and green boards as healthy", () => {
|
|
469
514
|
const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["csr", status("csr", 3, false)]]);
|
|
470
515
|
expect(hasBuildFailureForGeneration(statusByPhase, undefined)).toBe(false);
|
package/akanApp/devHostPolicy.ts
CHANGED
|
@@ -120,20 +120,18 @@ export const isLegacyBackendFallbackFile = (file: string, workspaceRoot: string)
|
|
|
120
120
|
);
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
phase === "backend" || phase === "scan" || phase === "route";
|
|
125
|
-
|
|
126
|
-
//? A newer generation, as the backend and the tabs judge it: an ok of the failure's own generation (a route build's
|
|
127
|
-
//? registry check that had nothing to add) says nothing about the failure. The phases whose retry reports the
|
|
128
|
-
//? failure's generation again (a backend or builder that came back, a route built again) recover on it.
|
|
123
|
+
//? The backend's rule (akanjs `DevBuildRecovery`), copied: no exported akanjs subpath reaches it from the CLI bundle.
|
|
129
124
|
export const shouldMarkBuildPhaseRecovered = (
|
|
130
125
|
previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
|
|
131
126
|
status: DevBuildStatus,
|
|
132
127
|
): boolean => {
|
|
133
128
|
const previous = previousByPhase.get(status.phase);
|
|
134
|
-
if (!previous ||
|
|
129
|
+
if (!previous || previous.ok || !status.ok) return false;
|
|
135
130
|
const generation = generationValue(status.generation);
|
|
136
|
-
|
|
131
|
+
if (status.phase === "route" && status.scope !== undefined && previous.scope !== undefined)
|
|
132
|
+
return status.scope === previous.scope && generation >= previous.generation;
|
|
133
|
+
if (generation !== previous.generation) return generation > previous.generation;
|
|
134
|
+
return status.phase === "backend";
|
|
137
135
|
};
|
|
138
136
|
|
|
139
137
|
/** An ok that recovers nothing leaves the failure standing, for hello and for whatever waits on a clean build. */
|
|
@@ -205,7 +203,8 @@ export const hasBuildFailureForGeneration = (
|
|
|
205
203
|
): boolean => {
|
|
206
204
|
if (typeof generation !== "number") return false;
|
|
207
205
|
for (const status of statusByPhase.values()) {
|
|
208
|
-
|
|
206
|
+
//? Not a route's: a replacement builder or a restarted dev host boots without building one, so it cannot hit it.
|
|
207
|
+
if (!status.ok && status.phase !== "route" && status.generation === generation) return true;
|
|
209
208
|
}
|
|
210
209
|
return false;
|
|
211
210
|
};
|
|
@@ -293,8 +292,9 @@ export const resolveIdleSuspendMs = (raw: string | undefined): number | null =>
|
|
|
293
292
|
return Math.round(parsed);
|
|
294
293
|
};
|
|
295
294
|
|
|
295
|
+
//? Not a route's: a broken route builds again on its next request, which wakes a suspended builder anyway.
|
|
296
296
|
export const hasAnyBuildFailure = (statusByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>): boolean =>
|
|
297
|
-
[...statusByPhase.values()].some((status) => !status.ok);
|
|
297
|
+
[...statusByPhase.values()].some((status) => !status.ok && status.phase !== "route");
|
|
298
298
|
|
|
299
299
|
export const shouldRefreshConfigOnIdleWake = (batch: ChangeBatch | null): boolean =>
|
|
300
300
|
!!batch && batch.kinds.has("config");
|
|
@@ -34,4 +34,79 @@ describe("GraphClientEntryDiscovery", () => {
|
|
|
34
34
|
expect(await discovery.discover([file("S.tsx")])).toEqual([file("C.tsx")]);
|
|
35
35
|
}
|
|
36
36
|
});
|
|
37
|
+
|
|
38
|
+
const tempDir = async () => {
|
|
39
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "akan-discovery-"));
|
|
40
|
+
dirs.push(dir);
|
|
41
|
+
return (name: string) => path.join(dir, name);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
test("an entry moved into a folder, or to another extension, is found once the move is invalidated", async () => {
|
|
45
|
+
const file = await tempDir();
|
|
46
|
+
await Bun.write(file("S.tsx"), 'import "./ui/Chart";\nimport "./ui/Card";\n');
|
|
47
|
+
await Bun.write(file("ui/Chart.tsx"), '"use client";\nexport const Chart = () => null;\n');
|
|
48
|
+
await Bun.write(file("ui/Card.tsx"), '"use client";\nexport const Card = () => null;\n');
|
|
49
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
|
|
50
|
+
expect(await discovery.discover([file("S.tsx")])).toEqual([file("ui/Card.tsx"), file("ui/Chart.tsx")]);
|
|
51
|
+
|
|
52
|
+
await rm(file("ui/Chart.tsx"));
|
|
53
|
+
await Bun.write(file("ui/Chart/index.tsx"), '"use client";\nexport const Chart = () => null;\n');
|
|
54
|
+
await rm(file("ui/Card.tsx"));
|
|
55
|
+
await Bun.write(file("ui/Card.ts"), '"use client";\nexport const Card = () => null;\n');
|
|
56
|
+
discovery.invalidate([file("ui/Chart.tsx"), file("ui/Chart/index.tsx"), file("ui/Card.tsx"), file("ui/Card.ts")]);
|
|
57
|
+
expect(await discovery.discover([file("S.tsx")])).toEqual([file("ui/Card.ts"), file("ui/Chart/index.tsx")]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a resolution still in flight when its target appears is not what the next walk reads", async () => {
|
|
61
|
+
const file = await tempDir();
|
|
62
|
+
await Bun.write(file("S.tsx"), 'import "./B";\n');
|
|
63
|
+
await Bun.write(file("B.tsx"), 'import "@demo/C";\nexport const b = 1;\n');
|
|
64
|
+
const gate = Promise.withResolvers<void>();
|
|
65
|
+
const seen = Promise.withResolvers<void>();
|
|
66
|
+
let calls = 0;
|
|
67
|
+
const resolvePackage = async () => {
|
|
68
|
+
calls += 1;
|
|
69
|
+
const exists = await Bun.file(file("C.tsx")).exists();
|
|
70
|
+
if (calls === 1) {
|
|
71
|
+
seen.resolve();
|
|
72
|
+
await gate.promise;
|
|
73
|
+
}
|
|
74
|
+
return exists ? { entryFile: file("C.tsx") } : null;
|
|
75
|
+
};
|
|
76
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, resolvePackage as never);
|
|
77
|
+
const walk = discovery.discover([file("S.tsx")]);
|
|
78
|
+
await seen.promise;
|
|
79
|
+
await Bun.write(file("C.tsx"), '"use client";\nexport const C = () => null;\n');
|
|
80
|
+
discovery.invalidate([file("C.tsx")]);
|
|
81
|
+
gate.resolve();
|
|
82
|
+
await walk;
|
|
83
|
+
expect(await discovery.discover([file("S.tsx")])).toEqual([file("C.tsx")]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a dense import cycle is walked once per file, not once per path", async () => {
|
|
87
|
+
const file = await tempDir();
|
|
88
|
+
const names = Array.from({ length: 12 }, (_, idx) => `M${idx}.tsx`);
|
|
89
|
+
for (const name of names)
|
|
90
|
+
await Bun.write(file(name), names.map((other) => `import "./${other.replace(".tsx", "")}";\n`).join(""));
|
|
91
|
+
await Bun.write(file("C.tsx"), '"use client";\nexport const C = () => null;\n');
|
|
92
|
+
await Bun.write(
|
|
93
|
+
file("M11.tsx"),
|
|
94
|
+
`${names.map((other) => `import "./${other.replace(".tsx", "")}";\n`).join("")}import "./C";\n`,
|
|
95
|
+
);
|
|
96
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
|
|
97
|
+
const started = performance.now();
|
|
98
|
+
expect(await discovery.discover([file("M0.tsx")])).toEqual([file("C.tsx")]);
|
|
99
|
+
expect(performance.now() - started).toBeLessThan(2_000);
|
|
100
|
+
expect(await discovery.discover([file("M5.tsx")])).toEqual([file("C.tsx")]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a file walked inside an import cycle keeps no partial result for a walk that starts at it", async () => {
|
|
104
|
+
const file = await tempDir();
|
|
105
|
+
await Bun.write(file("A.tsx"), 'import "./B";\nimport "./C";\n');
|
|
106
|
+
await Bun.write(file("B.tsx"), 'import "./A";\n');
|
|
107
|
+
await Bun.write(file("C.tsx"), '"use client";\nexport const C = () => null;\n');
|
|
108
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
|
|
109
|
+
expect(await discovery.discover([file("A.tsx")])).toEqual([file("C.tsx")]);
|
|
110
|
+
expect(await discovery.discover([file("B.tsx")])).toEqual([file("C.tsx")]);
|
|
111
|
+
});
|
|
37
112
|
});
|