@akanjs/devkit 3.0.0-beta.23 → 3.0.0-beta.25
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 +37 -22
- package/akanApp/akanApp.host.ts +34 -1
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +25 -0
- package/akanApp/devHostPolicy.ts +27 -2
- package/cloud/globalConfig.ts +3 -2
- package/csrE2e/csrE2eHarness.fixture.ts +455 -0
- package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
- package/csrE2e/csrPageStack.e2e.test.ts +187 -0
- package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
- package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
- package/fileSys.ts +16 -1
- package/frontendBuild/buildRouteClient.test.ts +61 -0
- package/frontendBuild/bundleDefine.test.ts +12 -0
- package/frontendBuild/bundleDefine.ts +5 -1
- package/frontendBuild/clientEntryDiscovery.test.ts +37 -0
- package/frontendBuild/clientEntryDiscovery.ts +10 -5
- package/frontendBuild/csrArtifactBuilder.ts +3 -6
- package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
- package/frontendBuild/csrDevArtifactWriter.ts +303 -0
- package/frontendBuild/csrDevBundler.test.ts +66 -0
- package/frontendBuild/csrDevBundler.ts +95 -0
- package/frontendBuild/csrDevModuleCompiler.ts +304 -0
- package/frontendBuild/csrDevPatcher.test.ts +340 -0
- package/frontendBuild/csrDevPatcher.ts +374 -0
- package/frontendBuild/csrDevPaths.ts +124 -0
- package/frontendBuild/csrDevResolver.test.ts +165 -0
- package/frontendBuild/csrDevResolver.ts +234 -0
- package/frontendBuild/csrDevTypes.ts +59 -0
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCompiler.ts +2 -4
- package/frontendBuild/devRegistryBundler.ts +177 -0
- package/frontendBuild/hmrWatcher.test.ts +51 -0
- package/frontendBuild/hmrWatcher.ts +17 -3
- package/frontendBuild/index.ts +4 -0
- package/frontendBuild/pagesBundleBuilder.ts +24 -1
- package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
- package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
- package/frontendBuild/registryMetadataFingerprint.ts +28 -0
- package/frontendBuild/routeClientBuilder.ts +79 -18
- package/frontendBuild/serverGraphFile.test.ts +77 -0
- package/frontendBuild/serverGraphFile.ts +93 -0
- package/frontendBuild/ssrDevBundler.ts +91 -0
- package/incrementalBuilder/buildBatch.proc.ts +119 -6
- package/incrementalBuilder/buildBatchProtocol.ts +6 -3
- package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
- package/incrementalBuilder/buildBatchRunner.ts +17 -8
- package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
- package/incrementalBuilder/builderWorkQueue.ts +126 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +1 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +33 -3
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +129 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +518 -52
- package/integration/devStability.integration.test.ts +18 -7
- package/package.json +3 -3
- package/transforms/rscUseClientTransform.ts +7 -0
- package/transforms/transforms.test.ts +13 -0
- package/transforms/useClientBundlePlugin.ts +4 -1
package/DEV_RUNTIME_KNOBS.md
CHANGED
|
@@ -22,7 +22,7 @@ crosses its ceiling is replaced when it is next idle — never mid-work.
|
|
|
22
22
|
|
|
23
23
|
| process | explicit (MiB) | explicit (bytes) | share of the limit | fallback with no limit |
|
|
24
24
|
|---|---|---|---|---|
|
|
25
|
-
| incremental builder | `AKAN_BUILDER_MAX_RSS_MB` | `AKAN_BUILDER_MAX_RSS` | **0.35** |
|
|
25
|
+
| incremental builder | `AKAN_BUILDER_MAX_RSS_MB` | `AKAN_BUILDER_MAX_RSS` | **0.35** | 2048MB (dev) |
|
|
26
26
|
| RSC worker | `AKAN_RSC_WORKER_MAX_RSS_MB` | `AKAN_RSC_WORKER_MAX_RSS` | **0.55** | 768MB (dev), unbounded (production) |
|
|
27
27
|
|
|
28
28
|
`AKAN_BUILDER_MAX_RSS_MB=0` leaves the builder unbounded, which is the escape hatch for an app whose
|
|
@@ -65,10 +65,17 @@ which is the one case where replacing it again cannot help; it says that too, an
|
|
|
65
65
|
|
|
66
66
|
| variable | default | what it does |
|
|
67
67
|
|---|---|---|
|
|
68
|
-
| `AKAN_DEV_CSR_REBUILD` | off | Rebuild the CSR artifact on every save. Armed automatically by the first `/__csr` or `?csr=true` request, which is what a mobile WebView session does — set it explicitly only to have it from boot. |
|
|
68
|
+
| `AKAN_DEV_CSR_REBUILD` | off | Rebuild the CSR artifact on every save. Armed automatically by the first `/__csr` or `?csr=true` request, which is what a mobile WebView session does — set it explicitly only to have it from boot. Set at boot, its CSR build runs beside the SSR registry's: on apps/akan the builder and its workers peak at about 2.7GB instead of 1.3GB, and the next boot wave waits for both builds. |
|
|
69
|
+
| `AKAN_DEV_CSR` | `registry` | How the dev CSR page is built. `registry` serves it as a module registry that patches the modules a save changed; `artifact` brings back the single-file bundle, which reloads on every save. |
|
|
70
|
+
| `AKAN_DEV_CSR_PATCHER` | on | `off` sends every save of a dev module registry — the CSR page's, and the one SSR pages load their client code from (`.akan/artifact/ssr-dev`) — to a disposable build worker instead of patching it in the resident builder. The patcher keeps each registry in memory (on minimal the CSR one adds about 170MB to the builder's median RSS and 210MB to its peak, flat across saves) and hands whole-app builds to a worker: a first build, a new npm module, a bare import no file of its package resolved before, a config change, a signal or dictionary save (their metadata is inlined at build time), and a registry whose last whole build was cut short. A misspelled import fails in the builder, like a missing file. A builder that dies mid-patch turns it off until the dev server next replaces its builder and backend together (a config, signal or dictionary change). |
|
|
71
|
+
| `AKAN_DEV_WATCH_DEBOUNCE_MS` | `30` | How long the file watcher collects changes before it hands a batch over. Every save waits this long before anything builds. A second write inside the window joins the same batch; one after it starts another batch, which reads the file's latest content, so a format-on-save costs a second build rather than a wrong one. |
|
|
69
72
|
| `AKAN_BUILDER_RPC_TIMEOUT_MS` | `120000` | How long the backend waits for a builder answer. Generous on purpose: a cold CSR build of every page legitimately takes tens of seconds. |
|
|
70
73
|
| `AKAN_SERVER_PAGES_SPLITTING` | off | Emit the server pages bundle as chunks instead of one file. Experimental — the memory/latency trade has not been measured on a real app. |
|
|
71
74
|
|
|
75
|
+
Two more are read by the dev server for its end-to-end tests only; never set them. `AKAN_DEV_SSR_ARM_DELAY_MS`
|
|
76
|
+
holds the SSR registry's boot build back, and `AKAN_CSR_DEV_APP_WRITE_DELAY_MS` holds a registry's `app.js`
|
|
77
|
+
back after a patch is announced.
|
|
78
|
+
|
|
72
79
|
## Observability
|
|
73
80
|
|
|
74
81
|
| variable | default | what it does |
|
|
@@ -80,31 +87,36 @@ which is the one case where replacing it again cannot help; it says that too, an
|
|
|
80
87
|
## Several apps at once
|
|
81
88
|
|
|
82
89
|
`akan start a,b` runs one dev host per app under a supervisor, so **every number above multiplies by the number
|
|
83
|
-
of apps** — there is no shared builder and no shared RSC worker. Measured on this repo
|
|
90
|
+
of apps** — there is no shared builder and no shared RSC worker. Measured on this repo 8 seconds after both apps
|
|
84
91
|
finished booting:
|
|
85
92
|
|
|
86
93
|
| process | akan | minimal |
|
|
87
94
|
|---|---|---|
|
|
88
|
-
| dev host |
|
|
89
|
-
| incremental builder |
|
|
90
|
-
| backend |
|
|
91
|
-
| RSC worker |
|
|
92
|
-
|
|
93
|
-
Plus ~
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
| dev host | 279MB | 196MB |
|
|
96
|
+
| incremental builder | 182MB | 114MB |
|
|
97
|
+
| backend (gateway and replica) | 123MB | 130MB |
|
|
98
|
+
| RSC worker | 162MB | 75MB |
|
|
99
|
+
|
|
100
|
+
Plus ~75MB for the supervisor itself: **~1.3GB for two apps at rest.** The peak is the boot, not the rest: the
|
|
101
|
+
build workers — the base build, then the SSR registry's — take a whole akan dev host to ~1.9GB and minimal's to
|
|
102
|
+
~1.4GB, and booted one after the other the two peaked at 2.2GB. The builders are also the part that goes away —
|
|
103
|
+
`AKAN_DEV_IDLE_SUSPEND_MS` releases each one independently, so a session where you are editing one app keeps one
|
|
104
|
+
builder, and every idle app keeps only its dev host, backend and RSC worker. **Multi-app is sized against idle
|
|
105
|
+
suspend being on**; setting it to `0` keeps every builder resident for the whole session.
|
|
97
106
|
|
|
98
107
|
Two things bound the peak rather than the floor:
|
|
99
108
|
|
|
100
|
-
- **`--concurrency` (default: what the machine allows).** Apps boot in waves
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
109
|
+
- **`--concurrency` (default: what the machine allows).** Apps boot in waves. The next wave starts once each
|
|
110
|
+
app of the previous one serves and its builder's boot builds have settled — the SSR registry's, and CSR's when
|
|
111
|
+
`AKAN_DEV_CSR_REBUILD=1` arms it — since those workers are the largest processes of a boot. An app that never
|
|
112
|
+
reports them starts the next wave 30 seconds after it serves. It shows as ready, and `--open` opens it, as
|
|
113
|
+
soon as it serves. Booting `n` apps at once means `n` overlapping peaks — which is what OOM-kills a container
|
|
114
|
+
that would have been fine with them staggered. Unset, the wave is
|
|
115
|
+
`min(apps, half the memory budget / 1.8GB per app, cores / 4)`, never below one, and the session prints
|
|
116
|
+
which — so a laptop boots its apps together and a small container boots them one at a time. The 1.8GB is
|
|
117
|
+
apps/akan's boot peak across all its processes (measured 1.73–1.9GB). The memory budget is the smaller of
|
|
118
|
+
the host's RAM and `AKAN_MEMORY_LIMIT` / the cgroup limit; `os.freemem()` is not consulted, because it counts
|
|
119
|
+
free pages rather than reclaimable ones and reports ~0.3GB on an idle 48GB laptop.
|
|
108
120
|
- **`AKAN_MEMORY_LIMIT` is per process, not per session.** Each dev host derives its builder and RSC-worker
|
|
109
121
|
ceilings from it independently, so a limit sized for one app does not become a budget for four. Divide it
|
|
110
122
|
yourself, or leave it unset on a laptop. The one place it is read as a session ceiling is the boot wave
|
|
@@ -113,7 +125,8 @@ Two things bound the peak rather than the floor:
|
|
|
113
125
|
|
|
114
126
|
## Sizing a small sandbox
|
|
115
127
|
|
|
116
|
-
A worked example, for a 1.2GB container
|
|
128
|
+
A worked example, for a 1.2GB container running an app the size of `apps/minimal` (apps/akan's boot alone
|
|
129
|
+
peaks near 1.8GB):
|
|
117
130
|
|
|
118
131
|
```bash
|
|
119
132
|
AKAN_MEMORY_LIMIT=1200mb # builder gets ~420MB, rsc worker ~660MB
|
|
@@ -124,7 +137,9 @@ Two things to expect at that size. The builder crosses 420MB during ordinary wor
|
|
|
124
137
|
costs ~247MB on top of its floor — so it is replaced roughly once per 30s while you keep building, each
|
|
125
138
|
replacement costing a boot build that requests wait through rather than fail. And the build worker's
|
|
126
139
|
peak is not covered by any of these ceilings; if the kernel OOM-kills it, the dev server survives with a
|
|
127
|
-
red build for that generation and the log names the signal.
|
|
140
|
+
red build for that generation and the log names the signal. On a boot whose `.akan` was cleared, the SSR
|
|
141
|
+
registry's first build is such a worker: a killed one leaves no registry, and every later save retries that
|
|
142
|
+
same whole build, so a container that cannot fit it once cannot fit it at all.
|
|
128
143
|
|
|
129
144
|
Raising `AKAN_BUILDER_MAX_RSS_MB` above the derived share trades memory for fewer boot builds. Setting
|
|
130
145
|
it to `0` trades the bound away entirely, which on a container this size means the kernel decides
|
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { WatchRootResolver } from "../frontendBuild/watchRootResolver";
|
|
|
9
9
|
import { type DevStdioMode, IncrementalBuilderHost } from "../incrementalBuilder";
|
|
10
10
|
import { BuilderRequestRouter } from "../incrementalBuilder/builderRequestRouter";
|
|
11
11
|
import { BackendImportGraph } from "./BackendImportGraph";
|
|
12
|
+
import { DevBootLatch } from "./devBootLatch";
|
|
12
13
|
import {
|
|
13
14
|
type BackendLifecycleState,
|
|
14
15
|
type BackendRestartReason,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
type SourceFingerprints,
|
|
35
36
|
shouldAbandonBackendRecovery,
|
|
36
37
|
shouldHoldForReturningBuilder,
|
|
38
|
+
shouldKeepBuildFailure,
|
|
37
39
|
shouldMarkBuildPhaseRecovered,
|
|
38
40
|
shouldQueueBuildStatusReplay,
|
|
39
41
|
shouldRefreshConfigOnIdleWake,
|
|
@@ -88,6 +90,7 @@ export class AkanAppHost {
|
|
|
88
90
|
readonly env: Record<string, string>;
|
|
89
91
|
readonly #onDevEvent: ((event: DevHostEvent) => void) | null;
|
|
90
92
|
#lastDevState: DevHostState | null = null;
|
|
93
|
+
readonly #bootLatch = new DevBootLatch(() => this.#onDevEvent?.({ app: this.app.name, booted: true }));
|
|
91
94
|
#backend: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe"> | null = null;
|
|
92
95
|
#builder: IncrementalBuilderHost | null = null;
|
|
93
96
|
#backendReady = false;
|
|
@@ -126,6 +129,7 @@ export class AkanAppHost {
|
|
|
126
129
|
#builderGapStamp: Promise<SourceFingerprints | null> | null = null;
|
|
127
130
|
#pendingBuilderMessages: BuilderMessage[] = [];
|
|
128
131
|
readonly #builderRequests = new BuilderRequestRouter();
|
|
132
|
+
#builderGeneration = 0;
|
|
129
133
|
constructor(
|
|
130
134
|
private readonly app: App,
|
|
131
135
|
{
|
|
@@ -143,6 +147,7 @@ export class AkanAppHost {
|
|
|
143
147
|
if (!this.#onDevEvent || state === this.#lastDevState) return;
|
|
144
148
|
this.#lastDevState = state;
|
|
145
149
|
this.#onDevEvent({ app: this.app.name, state, ...(detail ? { detail } : {}) });
|
|
150
|
+
if (state === "ready") this.#bootLatch.ready();
|
|
146
151
|
}
|
|
147
152
|
async start() {
|
|
148
153
|
if (this.#backend) await this.#stopBackend();
|
|
@@ -202,6 +207,7 @@ export class AkanAppHost {
|
|
|
202
207
|
this.#setBackendLifecycleState("ready", `pid=${msg.pid}`);
|
|
203
208
|
this.#recordBackendReadyStatus();
|
|
204
209
|
this.logger.verbose(`backend ready pid=${msg.pid}`);
|
|
210
|
+
this.#forgetRouteBuildStatus();
|
|
205
211
|
this.#replayBuilderState();
|
|
206
212
|
return;
|
|
207
213
|
}
|
|
@@ -425,6 +431,7 @@ export class AkanAppHost {
|
|
|
425
431
|
}
|
|
426
432
|
async #handleBuilderMessage(message: BuilderMessage) {
|
|
427
433
|
this.#markDevActivity();
|
|
434
|
+
this.#trackBuilderGeneration(message);
|
|
428
435
|
if (message.type === "build-status") {
|
|
429
436
|
this.#recordBuildStatus(message.data);
|
|
430
437
|
this.#sendOrQueueBuildStatus(message.data);
|
|
@@ -435,6 +442,11 @@ export class AkanAppHost {
|
|
|
435
442
|
this.#handleBuilderMetrics(message.data);
|
|
436
443
|
return;
|
|
437
444
|
}
|
|
445
|
+
if (message.type === "boot-armed") {
|
|
446
|
+
this.logger.verbose("[builder] boot builds settled");
|
|
447
|
+
this.#bootLatch.armed();
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
438
450
|
if (message.type === "pages-updated" || message.type === "css-updated") {
|
|
439
451
|
const recycled = message.data.reason === "builder-recycle";
|
|
440
452
|
if (recycled && !this.#shouldRelayRecycledState(message)) return;
|
|
@@ -455,6 +467,20 @@ export class AkanAppHost {
|
|
|
455
467
|
}
|
|
456
468
|
this.#sendToBackend(message);
|
|
457
469
|
}
|
|
470
|
+
// In `env`, which every builder spawn re-reads: a replacement (recycled, crashed, restarted) continues from here.
|
|
471
|
+
#trackBuilderGeneration(message: BuilderMessage): void {
|
|
472
|
+
const generation = AkanAppHost.#builderGenerationOf(message);
|
|
473
|
+
if (generation === undefined || generation <= this.#builderGeneration) return;
|
|
474
|
+
this.#builderGeneration = generation;
|
|
475
|
+
Object.assign(this.env, { AKAN_BUILDER_INITIAL_GENERATION: String(generation) });
|
|
476
|
+
}
|
|
477
|
+
// Not `csr-updated` / `ssr-updated`: their generation is the registry's.
|
|
478
|
+
static #builderGenerationOf(message: BuilderMessage): number | undefined {
|
|
479
|
+
if (message.type === "invalidate") return message.generation;
|
|
480
|
+
if (message.type === "build-status" || message.type === "builder-metrics") return message.data.generation;
|
|
481
|
+
if (message.type === "pages-updated" || message.type === "css-updated") return message.data.generation;
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
458
484
|
// The backend reads `base-artifact.json` once, so a recycled builder re-announces it; unchanged hashes are dropped.
|
|
459
485
|
#shouldRelayRecycledState(
|
|
460
486
|
message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
|
|
@@ -995,7 +1021,7 @@ export class AkanAppHost {
|
|
|
995
1021
|
await this.#startBuilder();
|
|
996
1022
|
this.#startBackend({ generation, files: message.files });
|
|
997
1023
|
}
|
|
998
|
-
// `supersede`: a
|
|
1024
|
+
// `supersede`: a recycled builder's re-announcement is what is on disk now, whatever generation it carries.
|
|
999
1025
|
#recordLastGood(
|
|
1000
1026
|
message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
|
|
1001
1027
|
{ supersede = false }: { supersede?: boolean } = {},
|
|
@@ -1034,6 +1060,7 @@ export class AkanAppHost {
|
|
|
1034
1060
|
}
|
|
1035
1061
|
#recordBuildStatus(status: DevBuildStatus): void {
|
|
1036
1062
|
const recovered = shouldMarkBuildPhaseRecovered(this.#buildStatusByPhase, status);
|
|
1063
|
+
if (shouldKeepBuildFailure(this.#buildStatusByPhase, status)) return;
|
|
1037
1064
|
this.#buildStatusByPhase.set(status.phase, status);
|
|
1038
1065
|
const label = `[build-status] generation=${status.generation} phase=${status.phase} ok=${status.ok} files=${status.files.length}`;
|
|
1039
1066
|
if (status.ok) this.logger.verbose(`${label}${recovered ? " recovered=1" : ""}`);
|
|
@@ -1049,6 +1076,12 @@ export class AkanAppHost {
|
|
|
1049
1076
|
}
|
|
1050
1077
|
this.#sendToBackend({ type: "build-status", data: status });
|
|
1051
1078
|
}
|
|
1079
|
+
//? A route status belongs to the route cache of the backend that asked for the build: a new backend builds its routes
|
|
1080
|
+
//? again, and reports each one itself.
|
|
1081
|
+
#forgetRouteBuildStatus(): void {
|
|
1082
|
+
this.#buildStatusByPhase.delete("route");
|
|
1083
|
+
this.#pendingBuildStatusReplay = this.#pendingBuildStatusReplay.filter((status) => status.phase !== "route");
|
|
1084
|
+
}
|
|
1052
1085
|
#replayBuilderState(): void {
|
|
1053
1086
|
if (!this.#backendReady) return;
|
|
1054
1087
|
if (this.#lastGoodFrontend.css) this.#sendToBackend(this.#lastGoodFrontend.css);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { DevBootLatch } from "./devBootLatch";
|
|
3
|
+
|
|
4
|
+
const latch = (waitMs = 60_000) => {
|
|
5
|
+
const booted: number[] = [];
|
|
6
|
+
return { booted, latch: new DevBootLatch(() => booted.push(Date.now()), { waitMs }) };
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
describe("DevBootLatch", () => {
|
|
10
|
+
test("opens once the app serves and its boot builds have settled, in either order", () => {
|
|
11
|
+
const readyFirst = latch();
|
|
12
|
+
readyFirst.latch.ready();
|
|
13
|
+
expect(readyFirst.booted).toHaveLength(0);
|
|
14
|
+
readyFirst.latch.armed();
|
|
15
|
+
expect(readyFirst.booted).toHaveLength(1);
|
|
16
|
+
|
|
17
|
+
const armedFirst = latch();
|
|
18
|
+
armedFirst.latch.armed();
|
|
19
|
+
expect(armedFirst.booted).toHaveLength(0);
|
|
20
|
+
armedFirst.latch.ready();
|
|
21
|
+
expect(armedFirst.booted).toHaveLength(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("opens once per session, however often the app restarts or the builder re-arms", () => {
|
|
25
|
+
const { booted, latch: boot } = latch();
|
|
26
|
+
boot.armed();
|
|
27
|
+
boot.ready();
|
|
28
|
+
boot.ready();
|
|
29
|
+
boot.armed();
|
|
30
|
+
expect(booted).toHaveLength(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("opens after its wait past ready when the builder never reports", async () => {
|
|
34
|
+
const { booted, latch: boot } = latch(20);
|
|
35
|
+
boot.ready();
|
|
36
|
+
await Bun.sleep(60);
|
|
37
|
+
expect(booted).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
//* An app's boot is over for the supervisor once it serves and its builder's boot builds have settled (the SSR
|
|
2
|
+
//* registry's, and CSR's when the env arms it): those workers are a boot's largest processes, so the next boot wave
|
|
3
|
+
//* starts here, while the browser opens on the ready that comes first.
|
|
4
|
+
export class DevBootLatch {
|
|
5
|
+
readonly #onBooted: () => void;
|
|
6
|
+
readonly #waitMs: number;
|
|
7
|
+
#ready = false;
|
|
8
|
+
#armed = false;
|
|
9
|
+
#booted = false;
|
|
10
|
+
#timer: ReturnType<typeof setTimeout> | null = null;
|
|
11
|
+
|
|
12
|
+
/** `waitMs` bounds the wait past ready: a builder that never reports (killed mid-boot) must not stall the waves. */
|
|
13
|
+
constructor(onBooted: () => void, { waitMs = 30_000 }: { waitMs?: number } = {}) {
|
|
14
|
+
this.#onBooted = onBooted;
|
|
15
|
+
this.#waitMs = waitMs;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
ready(): void {
|
|
19
|
+
this.#ready = true;
|
|
20
|
+
if (!this.#armed && !this.#timer) {
|
|
21
|
+
this.#timer = setTimeout(() => this.armed(), this.#waitMs);
|
|
22
|
+
this.#timer.unref?.();
|
|
23
|
+
}
|
|
24
|
+
this.#release();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
armed(): void {
|
|
28
|
+
this.#armed = true;
|
|
29
|
+
this.#release();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#release(): void {
|
|
33
|
+
if (this.#booted || !this.#ready || !this.#armed) return;
|
|
34
|
+
this.#booted = true;
|
|
35
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
36
|
+
this.#timer = null;
|
|
37
|
+
this.#onBooted();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
resolveIdleSuspendMs,
|
|
18
18
|
shouldAbandonBackendRecovery,
|
|
19
19
|
shouldHoldForReturningBuilder,
|
|
20
|
+
shouldKeepBuildFailure,
|
|
20
21
|
shouldMarkBuildPhaseRecovered,
|
|
21
22
|
shouldQueueBuildStatusReplay,
|
|
22
23
|
shouldRefreshConfigOnIdleWake,
|
|
@@ -355,6 +356,30 @@ describe("build status helpers", () => {
|
|
|
355
356
|
expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("pages", 12, true))).toBe(true);
|
|
356
357
|
});
|
|
357
358
|
|
|
359
|
+
test("an ok of the failure's own generation neither recovers nor replaces it, except a backend's", () => {
|
|
360
|
+
const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
|
|
361
|
+
["ssr", status("ssr", 7, false)],
|
|
362
|
+
["backend", status("backend", 7, false)],
|
|
363
|
+
]);
|
|
364
|
+
expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("ssr", 7, true))).toBe(false);
|
|
365
|
+
expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 7, true))).toBe(true);
|
|
366
|
+
expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 8, true))).toBe(false);
|
|
367
|
+
expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 7, false))).toBe(false);
|
|
368
|
+
expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("backend", 7, true))).toBe(true);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("a route built again and a builder that came back recover on the failure's own generation", () => {
|
|
372
|
+
const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
|
|
373
|
+
["route", status("route", 4, false)],
|
|
374
|
+
["scan", status("scan", 42, false)],
|
|
375
|
+
]);
|
|
376
|
+
expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("route", 4, true))).toBe(true);
|
|
377
|
+
expect(shouldKeepBuildFailure(previousByPhase, status("route", 4, true))).toBe(false);
|
|
378
|
+
expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("scan", 42, true))).toBe(true);
|
|
379
|
+
expect(shouldKeepBuildFailure(previousByPhase, status("scan", 42, true))).toBe(false);
|
|
380
|
+
expect(shouldKeepBuildFailure(previousByPhase, status("route", 3, true))).toBe(true);
|
|
381
|
+
});
|
|
382
|
+
|
|
358
383
|
test("creates backend build-status payloads for lifecycle test hooks", () => {
|
|
359
384
|
expect(
|
|
360
385
|
createBackendBuildStatus({
|
package/akanApp/devHostPolicy.ts
CHANGED
|
@@ -61,12 +61,20 @@ export type BackendLifecycleState = "starting" | "ready" | "restart-pending" | "
|
|
|
61
61
|
/** Reported, not scraped: in interleaved child output "this app is up" reads like any line mentioning it. */
|
|
62
62
|
export type DevHostState = "starting" | "ready" | "restarting" | "recovering" | "suspended" | "failed" | "stopped";
|
|
63
63
|
|
|
64
|
-
export interface
|
|
64
|
+
export interface DevHostStateEvent {
|
|
65
65
|
app: string;
|
|
66
66
|
state: DevHostState;
|
|
67
67
|
detail?: string;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Sent once: the app serves and its boot builds have settled, which is what the next boot wave waits for. */
|
|
71
|
+
export interface DevHostBootEvent {
|
|
72
|
+
app: string;
|
|
73
|
+
booted: true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type DevHostEvent = DevHostStateEvent | DevHostBootEvent;
|
|
77
|
+
|
|
70
78
|
const devHostStateByBackendState = {
|
|
71
79
|
starting: "starting",
|
|
72
80
|
ready: "ready",
|
|
@@ -112,14 +120,31 @@ export const isLegacyBackendFallbackFile = (file: string, workspaceRoot: string)
|
|
|
112
120
|
);
|
|
113
121
|
};
|
|
114
122
|
|
|
123
|
+
const recoversOnSameGeneration = (phase: BuildPhase): boolean =>
|
|
124
|
+
phase === "backend" || phase === "scan" || phase === "route";
|
|
125
|
+
|
|
126
|
+
//? A newer generation, as the backend and the tabs judge it: an ok of the failure's own generation (a route build's
|
|
127
|
+
//? registry check that had nothing to add) says nothing about the failure. The phases whose retry reports the
|
|
128
|
+
//? failure's generation again (a backend or builder that came back, a route built again) recover on it.
|
|
115
129
|
export const shouldMarkBuildPhaseRecovered = (
|
|
116
130
|
previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
|
|
117
131
|
status: DevBuildStatus,
|
|
118
132
|
): boolean => {
|
|
119
133
|
const previous = previousByPhase.get(status.phase);
|
|
120
|
-
|
|
134
|
+
if (!previous || !status.ok || previous.ok) return false;
|
|
135
|
+
const generation = generationValue(status.generation);
|
|
136
|
+
return recoversOnSameGeneration(status.phase) ? generation >= previous.generation : generation > previous.generation;
|
|
121
137
|
};
|
|
122
138
|
|
|
139
|
+
/** An ok that recovers nothing leaves the failure standing, for hello and for whatever waits on a clean build. */
|
|
140
|
+
export const shouldKeepBuildFailure = (
|
|
141
|
+
previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
|
|
142
|
+
status: DevBuildStatus,
|
|
143
|
+
): boolean =>
|
|
144
|
+
status.ok &&
|
|
145
|
+
previousByPhase.get(status.phase)?.ok === false &&
|
|
146
|
+
!shouldMarkBuildPhaseRecovered(previousByPhase, status);
|
|
147
|
+
|
|
123
148
|
export const createBackendBuildStatus = ({
|
|
124
149
|
generation,
|
|
125
150
|
ok,
|
package/cloud/globalConfig.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import dayjs from "dayjs";
|
|
3
3
|
import { FileSys } from "../fileSys";
|
|
4
4
|
import { ConfigLock } from "./configLock";
|
|
@@ -39,7 +39,8 @@ export class GlobalConfig {
|
|
|
39
39
|
const temp = `${configPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
40
40
|
try {
|
|
41
41
|
await writeFile(temp, JSON.stringify(akanConfig, null, 2), { mode: 0o600 });
|
|
42
|
-
|
|
42
|
+
// Every CLI reads this file outside the lock, which on Windows makes the rename wait for the reader.
|
|
43
|
+
await FileSys.replace(temp, configPath);
|
|
43
44
|
} catch (error) {
|
|
44
45
|
await rm(temp, { force: true });
|
|
45
46
|
throw error;
|