@akanjs/devkit 3.0.0-beta.24 → 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 +44 -26
- package/akanApp/akanApp.host.ts +113 -23
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +71 -1
- package/akanApp/devHostPolicy.ts +29 -4
- package/cloud/globalConfig.ts +3 -17
- package/csrE2e/csrE2eHarness.fixture.ts +66 -11
- 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 +112 -0
- package/frontendBuild/clientEntryDiscovery.ts +117 -70
- package/frontendBuild/csrArtifactBuilder.ts +3 -6
- package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
- package/frontendBuild/csrDevArtifactWriter.ts +158 -24
- package/frontendBuild/csrDevBundler.ts +44 -205
- package/frontendBuild/csrDevModuleCompiler.test.ts +89 -0
- package/frontendBuild/csrDevModuleCompiler.ts +56 -10
- package/frontendBuild/csrDevPatcher.test.ts +572 -0
- package/frontendBuild/csrDevPatcher.ts +548 -0
- package/frontendBuild/csrDevPaths.ts +81 -1
- package/frontendBuild/csrDevResolver.test.ts +239 -0
- package/frontendBuild/csrDevResolver.ts +119 -18
- package/frontendBuild/csrDevTypes.ts +16 -1
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCompiler.ts +2 -4
- package/frontendBuild/devRegistryBundler.ts +192 -0
- package/frontendBuild/hmrWatcher.test.ts +51 -0
- package/frontendBuild/hmrWatcher.ts +17 -3
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/pagesBundleBuilder.ts +24 -1
- package/frontendBuild/pagesEntrySourceGenerator.ts +9 -2
- 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 +85 -0
- package/incrementalBuilder/buildBatch.proc.ts +94 -16
- 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 +35 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +50 -6
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +190 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +560 -60
- package/integration/devStability.integration.test.ts +25 -7
- package/package.json +2 -2
- 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
|
|
@@ -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
|
|
|
@@ -65,10 +67,17 @@ which is the one case where replacing it again cannot help; it says that too, an
|
|
|
65
67
|
|
|
66
68
|
| variable | default | what it does |
|
|
67
69
|
|---|---|---|
|
|
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. |
|
|
70
|
+
| `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. |
|
|
71
|
+
| `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. |
|
|
72
|
+
| `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). |
|
|
73
|
+
| `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
74
|
| `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
75
|
| `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
76
|
|
|
77
|
+
Two more are read by the dev server for its end-to-end tests only; never set them. `AKAN_DEV_SSR_ARM_DELAY_MS`
|
|
78
|
+
holds the SSR registry's boot build back, and `AKAN_CSR_DEV_APP_WRITE_DELAY_MS` holds a registry's `app.js`
|
|
79
|
+
back after a patch is announced.
|
|
80
|
+
|
|
72
81
|
## Observability
|
|
73
82
|
|
|
74
83
|
| variable | default | what it does |
|
|
@@ -80,31 +89,36 @@ which is the one case where replacing it again cannot help; it says that too, an
|
|
|
80
89
|
## Several apps at once
|
|
81
90
|
|
|
82
91
|
`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
|
|
92
|
+
of apps** — there is no shared builder and no shared RSC worker. Measured on this repo 8 seconds after both apps
|
|
84
93
|
finished booting:
|
|
85
94
|
|
|
86
95
|
| process | akan | minimal |
|
|
87
96
|
|---|---|---|
|
|
88
|
-
| dev host |
|
|
89
|
-
| incremental builder |
|
|
90
|
-
| backend |
|
|
91
|
-
| RSC worker |
|
|
92
|
-
|
|
93
|
-
Plus ~
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
| dev host | 279MB | 196MB |
|
|
98
|
+
| incremental builder | 182MB | 114MB |
|
|
99
|
+
| backend (gateway and replica) | 123MB | 130MB |
|
|
100
|
+
| RSC worker | 162MB | 75MB |
|
|
101
|
+
|
|
102
|
+
Plus ~75MB for the supervisor itself: **~1.3GB for two apps at rest.** The peak is the boot, not the rest: the
|
|
103
|
+
build workers — the base build, then the SSR registry's — take a whole akan dev host to ~1.9GB and minimal's to
|
|
104
|
+
~1.4GB, and booted one after the other the two peaked at 2.2GB. The builders are also the part that goes away —
|
|
105
|
+
`AKAN_DEV_IDLE_SUSPEND_MS` releases each one independently, so a session where you are editing one app keeps one
|
|
106
|
+
builder, and every idle app keeps only its dev host, backend and RSC worker. **Multi-app is sized against idle
|
|
107
|
+
suspend being on**; setting it to `0` keeps every builder resident for the whole session.
|
|
97
108
|
|
|
98
109
|
Two things bound the peak rather than the floor:
|
|
99
110
|
|
|
100
|
-
- **`--concurrency` (default: what the machine allows).** Apps boot in waves
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
- **`--concurrency` (default: what the machine allows).** Apps boot in waves. The next wave starts once each
|
|
112
|
+
app of the previous one serves and its builder's boot builds have settled — the SSR registry's, and CSR's when
|
|
113
|
+
`AKAN_DEV_CSR_REBUILD=1` arms it — since those workers are the largest processes of a boot. An app that never
|
|
114
|
+
reports them starts the next wave 30 seconds after it serves. It shows as ready, and `--open` opens it, as
|
|
115
|
+
soon as it serves. Booting `n` apps at once means `n` overlapping peaks — which is what OOM-kills a container
|
|
116
|
+
that would have been fine with them staggered. Unset, the wave is
|
|
117
|
+
`min(apps, half the memory budget / 1.8GB per app, cores / 4)`, never below one, and the session prints
|
|
118
|
+
which — so a laptop boots its apps together and a small container boots them one at a time. The 1.8GB is
|
|
119
|
+
apps/akan's boot peak across all its processes (measured 1.73–1.9GB). The memory budget is the smaller of
|
|
120
|
+
the host's RAM and `AKAN_MEMORY_LIMIT` / the cgroup limit; `os.freemem()` is not consulted, because it counts
|
|
121
|
+
free pages rather than reclaimable ones and reports ~0.3GB on an idle 48GB laptop.
|
|
108
122
|
- **`AKAN_MEMORY_LIMIT` is per process, not per session.** Each dev host derives its builder and RSC-worker
|
|
109
123
|
ceilings from it independently, so a limit sized for one app does not become a budget for four. Divide it
|
|
110
124
|
yourself, or leave it unset on a laptop. The one place it is read as a session ceiling is the boot wave
|
|
@@ -113,18 +127,22 @@ Two things bound the peak rather than the floor:
|
|
|
113
127
|
|
|
114
128
|
## Sizing a small sandbox
|
|
115
129
|
|
|
116
|
-
A worked example, for a 1.2GB container
|
|
130
|
+
A worked example, for a 1.2GB container running an app the size of `apps/minimal` (apps/akan's boot alone
|
|
131
|
+
peaks near 1.8GB):
|
|
117
132
|
|
|
118
133
|
```bash
|
|
119
134
|
AKAN_MEMORY_LIMIT=1200mb # builder gets ~420MB, rsc worker ~660MB
|
|
120
135
|
AKAN_DEV_IDLE_SUSPEND_MS=300000 # release the builder after 5 idle minutes
|
|
121
136
|
```
|
|
122
137
|
|
|
123
|
-
Two things to expect at that size. The builder crosses 420MB during ordinary work —
|
|
124
|
-
|
|
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
|
|
125
140
|
replacement costing a boot build that requests wait through rather than fail. And the build worker's
|
|
126
141
|
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.
|
|
142
|
+
red build for that generation and the log names the signal. On a boot whose `.akan` was cleared, the SSR
|
|
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.
|
|
128
146
|
|
|
129
147
|
Raising `AKAN_BUILDER_MAX_RSS_MB` above the derived share trades memory for fewer boot builds. Setting
|
|
130
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";
|
|
@@ -9,6 +10,7 @@ import { WatchRootResolver } from "../frontendBuild/watchRootResolver";
|
|
|
9
10
|
import { type DevStdioMode, IncrementalBuilderHost } from "../incrementalBuilder";
|
|
10
11
|
import { BuilderRequestRouter } from "../incrementalBuilder/builderRequestRouter";
|
|
11
12
|
import { BackendImportGraph } from "./BackendImportGraph";
|
|
13
|
+
import { DevBootLatch } from "./devBootLatch";
|
|
12
14
|
import {
|
|
13
15
|
type BackendLifecycleState,
|
|
14
16
|
type BackendRestartReason,
|
|
@@ -34,6 +36,7 @@ import {
|
|
|
34
36
|
type SourceFingerprints,
|
|
35
37
|
shouldAbandonBackendRecovery,
|
|
36
38
|
shouldHoldForReturningBuilder,
|
|
39
|
+
shouldKeepBuildFailure,
|
|
37
40
|
shouldMarkBuildPhaseRecovered,
|
|
38
41
|
shouldQueueBuildStatusReplay,
|
|
39
42
|
shouldRefreshConfigOnIdleWake,
|
|
@@ -88,8 +91,11 @@ export class AkanAppHost {
|
|
|
88
91
|
readonly env: Record<string, string>;
|
|
89
92
|
readonly #onDevEvent: ((event: DevHostEvent) => void) | null;
|
|
90
93
|
#lastDevState: DevHostState | null = null;
|
|
94
|
+
readonly #bootLatch = new DevBootLatch(() => this.#onDevEvent?.({ app: this.app.name, booted: true }));
|
|
91
95
|
#backend: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe"> | null = null;
|
|
92
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;
|
|
93
99
|
#backendReady = false;
|
|
94
100
|
#plannedBackendStops = new WeakSet<Bun.Subprocess<"ignore", "inherit", "inherit">>();
|
|
95
101
|
#restartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -98,7 +104,16 @@ export class AkanAppHost {
|
|
|
98
104
|
#backendGaveUp = false;
|
|
99
105
|
#backendLifecycleState: BackendLifecycleState = "stopped";
|
|
100
106
|
#pendingRestartReason: BackendRestartReason | null = null;
|
|
101
|
-
#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;
|
|
102
117
|
#builderRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
103
118
|
#builderRecoveryAttempts = 0;
|
|
104
119
|
#backendStartStatus: { generation?: number; files: string[] } | null = null;
|
|
@@ -126,6 +141,7 @@ export class AkanAppHost {
|
|
|
126
141
|
#builderGapStamp: Promise<SourceFingerprints | null> | null = null;
|
|
127
142
|
#pendingBuilderMessages: BuilderMessage[] = [];
|
|
128
143
|
readonly #builderRequests = new BuilderRequestRouter();
|
|
144
|
+
#builderGeneration = 0;
|
|
129
145
|
constructor(
|
|
130
146
|
private readonly app: App,
|
|
131
147
|
{
|
|
@@ -143,6 +159,7 @@ export class AkanAppHost {
|
|
|
143
159
|
if (!this.#onDevEvent || state === this.#lastDevState) return;
|
|
144
160
|
this.#lastDevState = state;
|
|
145
161
|
this.#onDevEvent({ app: this.app.name, state, ...(detail ? { detail } : {}) });
|
|
162
|
+
if (state === "ready") this.#bootLatch.ready();
|
|
146
163
|
}
|
|
147
164
|
async start() {
|
|
148
165
|
if (this.#backend) await this.#stopBackend();
|
|
@@ -158,6 +175,9 @@ export class AkanAppHost {
|
|
|
158
175
|
return this;
|
|
159
176
|
}
|
|
160
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;
|
|
161
181
|
this.#cancelIdleSuspend();
|
|
162
182
|
this.#stopIdleWatcher();
|
|
163
183
|
this.#clearRestartTimers();
|
|
@@ -202,6 +222,7 @@ export class AkanAppHost {
|
|
|
202
222
|
this.#setBackendLifecycleState("ready", `pid=${msg.pid}`);
|
|
203
223
|
this.#recordBackendReadyStatus();
|
|
204
224
|
this.logger.verbose(`backend ready pid=${msg.pid}`);
|
|
225
|
+
this.#forgetRouteBuildStatus();
|
|
205
226
|
this.#replayBuilderState();
|
|
206
227
|
return;
|
|
207
228
|
}
|
|
@@ -424,17 +445,25 @@ export class AkanAppHost {
|
|
|
424
445
|
});
|
|
425
446
|
}
|
|
426
447
|
async #handleBuilderMessage(message: BuilderMessage) {
|
|
448
|
+
if (this.#stopping) return;
|
|
427
449
|
this.#markDevActivity();
|
|
450
|
+
this.#trackBuilderGeneration(message);
|
|
428
451
|
if (message.type === "build-status") {
|
|
429
452
|
this.#recordBuildStatus(message.data);
|
|
430
453
|
this.#sendOrQueueBuildStatus(message.data);
|
|
431
454
|
this.#reviveBackendAfterGreenBuild(message.data);
|
|
455
|
+
await this.#resumeFailedRecycle(message.data);
|
|
432
456
|
return;
|
|
433
457
|
}
|
|
434
458
|
if (message.type === "builder-metrics") {
|
|
435
459
|
this.#handleBuilderMetrics(message.data);
|
|
436
460
|
return;
|
|
437
461
|
}
|
|
462
|
+
if (message.type === "boot-armed") {
|
|
463
|
+
this.logger.verbose("[builder] boot builds settled");
|
|
464
|
+
this.#bootLatch.armed();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
438
467
|
if (message.type === "pages-updated" || message.type === "css-updated") {
|
|
439
468
|
const recycled = message.data.reason === "builder-recycle";
|
|
440
469
|
if (recycled && !this.#shouldRelayRecycledState(message)) return;
|
|
@@ -455,6 +484,20 @@ export class AkanAppHost {
|
|
|
455
484
|
}
|
|
456
485
|
this.#sendToBackend(message);
|
|
457
486
|
}
|
|
487
|
+
// In `env`, which every builder spawn re-reads: a replacement (recycled, crashed, restarted) continues from here.
|
|
488
|
+
#trackBuilderGeneration(message: BuilderMessage): void {
|
|
489
|
+
const generation = AkanAppHost.#builderGenerationOf(message);
|
|
490
|
+
if (generation === undefined || generation <= this.#builderGeneration) return;
|
|
491
|
+
this.#builderGeneration = generation;
|
|
492
|
+
Object.assign(this.env, { AKAN_BUILDER_INITIAL_GENERATION: String(generation) });
|
|
493
|
+
}
|
|
494
|
+
// Not `csr-updated` / `ssr-updated`: their generation is the registry's.
|
|
495
|
+
static #builderGenerationOf(message: BuilderMessage): number | undefined {
|
|
496
|
+
if (message.type === "invalidate") return message.generation;
|
|
497
|
+
if (message.type === "build-status" || message.type === "builder-metrics") return message.data.generation;
|
|
498
|
+
if (message.type === "pages-updated" || message.type === "css-updated") return message.data.generation;
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
458
501
|
// The backend reads `base-artifact.json` once, so a recycled builder re-announces it; unchanged hashes are dropped.
|
|
459
502
|
#shouldRelayRecycledState(
|
|
460
503
|
message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
|
|
@@ -752,12 +795,8 @@ export class AkanAppHost {
|
|
|
752
795
|
this.logger.verbose("[idle-suspend] config changed while suspended; restarting the dev host");
|
|
753
796
|
// Replaces the backend too, and a baseline kept past its own gap costs a restart at the next one.
|
|
754
797
|
this.#discardBuilderGap("config change replaces the backend anyway");
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
{
|
|
758
|
-
refreshConfig: true,
|
|
759
|
-
},
|
|
760
|
-
);
|
|
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);
|
|
761
800
|
return;
|
|
762
801
|
}
|
|
763
802
|
// Refresh before deciding: a file created while suspended is not in the graph yet.
|
|
@@ -863,7 +902,8 @@ export class AkanAppHost {
|
|
|
863
902
|
const wantsDevHostRestart = shouldRestartDevHostByDevPlan(message);
|
|
864
903
|
const pending = this.#pendingRecycle;
|
|
865
904
|
// A deferred recycle resumes on the next code batch; a css-only batch cannot heal a compile error.
|
|
866
|
-
|
|
905
|
+
const resumes = !!pending && message.kinds.includes("code") && this.#touchesFailedRecycle(message.files);
|
|
906
|
+
if (wantsDevHostRestart || shouldRestartBuilderByDevPlan(message) || resumes) {
|
|
867
907
|
const refreshConfig = wantsDevHostRestart || (pending?.refreshConfig ?? false);
|
|
868
908
|
const merged = pending ? mergeInvalidateMessages(pending.message, message) : message;
|
|
869
909
|
const generation = message.devPlan?.generation ?? message.generation;
|
|
@@ -871,14 +911,7 @@ export class AkanAppHost {
|
|
|
871
911
|
this.#deferRecycle(merged, { refreshConfig, generation });
|
|
872
912
|
return;
|
|
873
913
|
}
|
|
874
|
-
this.#
|
|
875
|
-
try {
|
|
876
|
-
if (refreshConfig) await this.#restartDevHost(merged);
|
|
877
|
-
else await this.#restartDevChildren(merged);
|
|
878
|
-
} catch (err) {
|
|
879
|
-
this.#recordDevHostRestartFailure(merged, err, refreshConfig ? "Config" : "Runtime metadata");
|
|
880
|
-
this.#resurrectDevChildren(merged);
|
|
881
|
-
}
|
|
914
|
+
await this.#applyRecycle(merged, refreshConfig);
|
|
882
915
|
return;
|
|
883
916
|
}
|
|
884
917
|
if (await this.#shouldRestartBackend(message)) {
|
|
@@ -887,6 +920,38 @@ export class AkanAppHost {
|
|
|
887
920
|
}
|
|
888
921
|
this.#sendToBackend(message);
|
|
889
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
|
+
}
|
|
890
955
|
#deferRecycle(
|
|
891
956
|
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
892
957
|
{ refreshConfig, generation }: { refreshConfig: boolean; generation?: number },
|
|
@@ -937,8 +1002,13 @@ export class AkanAppHost {
|
|
|
937
1002
|
this.#builderRecoveryAttempts = 0;
|
|
938
1003
|
this.logger.info("[builder-recovery] builder recovered");
|
|
939
1004
|
void this.#restartBackendForGapChanges();
|
|
1005
|
+
const failedAfterApply = this.#restartFailedAfterApply;
|
|
1006
|
+
this.#restartFailedAfterApply = null;
|
|
940
1007
|
const status: DevBuildStatus = {
|
|
941
|
-
generation:
|
|
1008
|
+
generation:
|
|
1009
|
+
failedAfterApply !== null
|
|
1010
|
+
? this.#nextBackendBuildStatusGeneration(failedAfterApply + 1)
|
|
1011
|
+
: (reason.generation ?? this.#nextBackendBuildStatusGeneration()),
|
|
942
1012
|
phase: "scan",
|
|
943
1013
|
ok: true,
|
|
944
1014
|
files: reason.files,
|
|
@@ -965,16 +1035,19 @@ export class AkanAppHost {
|
|
|
965
1035
|
}
|
|
966
1036
|
// The config is re-imported with a cache-busting query, but modules it imports stay cached: a change inside an
|
|
967
1037
|
// imported plugin file still needs a manual `akan start` restart.
|
|
968
|
-
async #restartDevHost(
|
|
1038
|
+
async #restartDevHost(
|
|
1039
|
+
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
1040
|
+
progress?: { applied: boolean },
|
|
1041
|
+
): Promise<void> {
|
|
969
1042
|
const generation = message.devPlan?.generation ?? message.generation;
|
|
970
1043
|
this.logger.warn(
|
|
971
1044
|
`[dev-host] config change detected; restarting dev host generation=${generation ?? "(unknown)"} files=${message.files.length}`,
|
|
972
1045
|
);
|
|
973
|
-
await this.#recycleDevChildren(message, { refreshConfig: true });
|
|
1046
|
+
await this.#recycleDevChildren(message, { refreshConfig: true, progress });
|
|
974
1047
|
}
|
|
975
1048
|
async #recycleDevChildren(
|
|
976
1049
|
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
977
|
-
{ refreshConfig = false }: { refreshConfig?: boolean } = {},
|
|
1050
|
+
{ refreshConfig = false, progress }: { refreshConfig?: boolean; progress?: { applied: boolean } } = {},
|
|
978
1051
|
): Promise<void> {
|
|
979
1052
|
const generation = message.devPlan?.generation ?? message.generation;
|
|
980
1053
|
this.#clearRestartTimers();
|
|
@@ -985,17 +1058,19 @@ export class AkanAppHost {
|
|
|
985
1058
|
this.#pendingBuildStatusReplay = [];
|
|
986
1059
|
await this.#stopBackend();
|
|
987
1060
|
this.#stopBuilder();
|
|
1061
|
+
this.#patcherOff = false;
|
|
988
1062
|
if (refreshConfig) {
|
|
989
1063
|
await this.app.getConfig({ refresh: true });
|
|
990
1064
|
// Merge, not replace: `start()` added values prepare does not produce (REDIS_HOST from the tunnel).
|
|
991
1065
|
const { env } = await this.app.prepareCommand("start");
|
|
992
1066
|
Object.assign(this.env, env);
|
|
1067
|
+
if (progress) progress.applied = true;
|
|
993
1068
|
}
|
|
994
1069
|
await this.#backendGraph.refresh();
|
|
995
1070
|
await this.#startBuilder();
|
|
996
1071
|
this.#startBackend({ generation, files: message.files });
|
|
997
1072
|
}
|
|
998
|
-
// `supersede`: a
|
|
1073
|
+
// `supersede`: a recycled builder's re-announcement is what is on disk now, whatever generation it carries.
|
|
999
1074
|
#recordLastGood(
|
|
1000
1075
|
message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
|
|
1001
1076
|
{ supersede = false }: { supersede?: boolean } = {},
|
|
@@ -1018,22 +1093,29 @@ export class AkanAppHost {
|
|
|
1018
1093
|
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
1019
1094
|
err: unknown,
|
|
1020
1095
|
kind: "Config" | "Runtime metadata",
|
|
1021
|
-
|
|
1096
|
+
{ applied }: { applied: boolean },
|
|
1097
|
+
): number {
|
|
1022
1098
|
const generation = message.devPlan?.generation ?? message.generation ?? this.#nextBackendBuildStatusGeneration();
|
|
1023
1099
|
const detail = err instanceof Error ? err.message : String(err);
|
|
1024
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";
|
|
1025
1103
|
const status: DevBuildStatus = {
|
|
1026
1104
|
generation,
|
|
1027
1105
|
phase: "scan",
|
|
1028
1106
|
ok: false,
|
|
1029
1107
|
files: message.files,
|
|
1030
|
-
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.`,
|
|
1031
1111
|
};
|
|
1032
1112
|
this.#recordBuildStatus(status);
|
|
1033
1113
|
this.#sendOrQueueBuildStatus(status);
|
|
1114
|
+
return generation;
|
|
1034
1115
|
}
|
|
1035
1116
|
#recordBuildStatus(status: DevBuildStatus): void {
|
|
1036
1117
|
const recovered = shouldMarkBuildPhaseRecovered(this.#buildStatusByPhase, status);
|
|
1118
|
+
if (shouldKeepBuildFailure(this.#buildStatusByPhase, status)) return;
|
|
1037
1119
|
this.#buildStatusByPhase.set(status.phase, status);
|
|
1038
1120
|
const label = `[build-status] generation=${status.generation} phase=${status.phase} ok=${status.ok} files=${status.files.length}`;
|
|
1039
1121
|
if (status.ok) this.logger.verbose(`${label}${recovered ? " recovered=1" : ""}`);
|
|
@@ -1049,6 +1131,12 @@ export class AkanAppHost {
|
|
|
1049
1131
|
}
|
|
1050
1132
|
this.#sendToBackend({ type: "build-status", data: status });
|
|
1051
1133
|
}
|
|
1134
|
+
//? A route status belongs to the route cache of the backend that asked for the build: a new backend builds its routes
|
|
1135
|
+
//? again, and reports each one itself.
|
|
1136
|
+
#forgetRouteBuildStatus(): void {
|
|
1137
|
+
this.#buildStatusByPhase.delete("route");
|
|
1138
|
+
this.#pendingBuildStatusReplay = this.#pendingBuildStatusReplay.filter((status) => status.phase !== "route");
|
|
1139
|
+
}
|
|
1052
1140
|
#replayBuilderState(): void {
|
|
1053
1141
|
if (!this.#backendReady) return;
|
|
1054
1142
|
if (this.#lastGoodFrontend.css) this.#sendToBackend(this.#lastGoodFrontend.css);
|
|
@@ -1106,6 +1194,7 @@ export class AkanAppHost {
|
|
|
1106
1194
|
{
|
|
1107
1195
|
stdio: this.stdio,
|
|
1108
1196
|
onOutput: (kind, text) => void (kind === "stderr" ? process.stderr : process.stdout).write(text),
|
|
1197
|
+
patcherOff: this.#patcherOff,
|
|
1109
1198
|
},
|
|
1110
1199
|
);
|
|
1111
1200
|
try {
|
|
@@ -1193,6 +1282,7 @@ export class AkanAppHost {
|
|
|
1193
1282
|
#stopBuilder(): void {
|
|
1194
1283
|
this.#cancelRssRecycle();
|
|
1195
1284
|
if (!this.#builder) return;
|
|
1285
|
+
if (this.#builder.patcherOff) this.#patcherOff = true;
|
|
1196
1286
|
this.#builder.stop();
|
|
1197
1287
|
this.#builder = null;
|
|
1198
1288
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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,
|
|
@@ -17,6 +19,7 @@ import {
|
|
|
17
19
|
resolveIdleSuspendMs,
|
|
18
20
|
shouldAbandonBackendRecovery,
|
|
19
21
|
shouldHoldForReturningBuilder,
|
|
22
|
+
shouldKeepBuildFailure,
|
|
20
23
|
shouldMarkBuildPhaseRecovered,
|
|
21
24
|
shouldQueueBuildStatusReplay,
|
|
22
25
|
shouldRefreshConfigOnIdleWake,
|
|
@@ -281,8 +284,9 @@ describe("dev idle suspend", () => {
|
|
|
281
284
|
expect(resolveIdleSuspendMs("1500")).toBe(1_500);
|
|
282
285
|
});
|
|
283
286
|
|
|
284
|
-
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", () => {
|
|
285
288
|
expect(hasAnyBuildFailure(new Map())).toBe(false);
|
|
289
|
+
expect(hasAnyBuildFailure(new Map([["route", status("route", 1, false)]]))).toBe(false);
|
|
286
290
|
expect(hasAnyBuildFailure(new Map([["scan", status("scan", 1, true)]]))).toBe(false);
|
|
287
291
|
expect(
|
|
288
292
|
hasAnyBuildFailure(
|
|
@@ -355,6 +359,67 @@ describe("build status helpers", () => {
|
|
|
355
359
|
expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("pages", 12, true))).toBe(true);
|
|
356
360
|
});
|
|
357
361
|
|
|
362
|
+
test("an ok of the failure's own generation neither recovers nor replaces it, except a backend's", () => {
|
|
363
|
+
const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
|
|
364
|
+
["ssr", status("ssr", 7, false)],
|
|
365
|
+
["backend", status("backend", 7, false)],
|
|
366
|
+
]);
|
|
367
|
+
expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("ssr", 7, true))).toBe(false);
|
|
368
|
+
expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 7, true))).toBe(true);
|
|
369
|
+
expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 8, true))).toBe(false);
|
|
370
|
+
expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 7, false))).toBe(false);
|
|
371
|
+
expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("backend", 7, true))).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
|
|
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);
|
|
421
|
+
});
|
|
422
|
+
|
|
358
423
|
test("creates backend build-status payloads for lifecycle test hooks", () => {
|
|
359
424
|
expect(
|
|
360
425
|
createBackendBuildStatus({
|
|
@@ -440,6 +505,11 @@ describe("hasBuildFailureForGeneration", () => {
|
|
|
440
505
|
expect(hasBuildFailureForGeneration(statusByPhase, 4)).toBe(false);
|
|
441
506
|
});
|
|
442
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
|
+
|
|
443
513
|
test("treats unknown generations and green boards as healthy", () => {
|
|
444
514
|
const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["csr", status("csr", 3, false)]]);
|
|
445
515
|
expect(hasBuildFailureForGeneration(statusByPhase, undefined)).toBe(false);
|