@akanjs/devkit 2.4.1-rc.6 → 2.4.1
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/CHANGELOG.md +30 -0
- package/DEV_RUNTIME_KNOBS.md +97 -0
- package/README.md +6 -0
- package/akanApp/akanApp.host.test.ts +75 -6
- package/akanApp/akanApp.host.ts +303 -25
- package/commandDecorators/command.ts +16 -1
- package/frontendBuild/clientEntryDiscovery.ts +91 -53
- package/frontendBuild/cssCandidateCache.ts +109 -0
- package/frontendBuild/cssCompiler.ts +70 -18
- package/frontendBuild/frontendBuild.test.ts +3 -0
- package/frontendBuild/hmrWatcher.ts +6 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +51 -2
- package/frontendBuild/sourceMtimeIndex.ts +66 -5
- package/incrementalBuilder/buildBatchRunner.ts +10 -1
- package/incrementalBuilder/builderChannel.test.ts +16 -7
- package/incrementalBuilder/builderRequestRouter.test.ts +89 -0
- package/incrementalBuilder/builderRequestRouter.ts +66 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +28 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +24 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +14 -13
- package/integration/devResourceProbe.ts +319 -0
- package/integration/devStability.integration.test.ts +153 -4
- package/integration/devStabilityHarness.ts +30 -5
- package/package.json +2 -2
- package/transforms/barrelImportsPlugin.ts +20 -13
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export type DevResourceRole = "host" | "builder" | "batch" | "gateway" | "replica" | "rsc" | "other";
|
|
5
|
+
|
|
6
|
+
export interface DevResourceProc {
|
|
7
|
+
pid: number;
|
|
8
|
+
ppid: number;
|
|
9
|
+
rssMb: number;
|
|
10
|
+
cpuSec: number;
|
|
11
|
+
role: DevResourceRole;
|
|
12
|
+
command: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DevResourceProbeOptions {
|
|
16
|
+
appName: string;
|
|
17
|
+
/** Must be the workspace root: package resolution has to match the real dev processes. */
|
|
18
|
+
workspaceRoot: string;
|
|
19
|
+
/** A tenant runs `node_modules/@akanjs/cli`; this repo runs its own `dist` build. */
|
|
20
|
+
cliEntry: string;
|
|
21
|
+
port: number;
|
|
22
|
+
edits: number;
|
|
23
|
+
idleSeconds: number;
|
|
24
|
+
/** `0` leaves idle suspend at its default; any other value also arms the suspend phase. */
|
|
25
|
+
suspendSeconds: number;
|
|
26
|
+
/** App-relative file to append a comment to, once per edit. */
|
|
27
|
+
editPath: string;
|
|
28
|
+
logPath: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Process-tree RSS over boot, browse, edits and idle-suspend — probe #1 of
|
|
33
|
+
* `04-measurement-harness.md`, and the source of every idle/warm/suspended number in the
|
|
34
|
+
* `optimize-resource` docs.
|
|
35
|
+
*
|
|
36
|
+
* bun pkgs/@akanjs/devkit/integration/devResourceProbe.ts <app> [--idle=120] [--edits=3] [--suspend=60]
|
|
37
|
+
*
|
|
38
|
+
* Run it from the workspace root. Two things it exists to get right, both learned the hard way:
|
|
39
|
+
* it walks the process tree to a **fixpoint** (the tree is seven levels deep in places, so a
|
|
40
|
+
* fixed-depth walk silently misses the workers), and it **restores the edited file** in a `finally`
|
|
41
|
+
* so a killed probe cannot leave a comment in the tree.
|
|
42
|
+
*
|
|
43
|
+
* On macOS, a plateau that drops with no activity is the OS trimming idle pages, not convergence —
|
|
44
|
+
* report both numbers rather than the lower one.
|
|
45
|
+
*/
|
|
46
|
+
export class DevResourceProbe {
|
|
47
|
+
static readonly #columns: DevResourceRole[] = ["host", "builder", "batch", "gateway", "replica", "rsc"];
|
|
48
|
+
|
|
49
|
+
static parseArgs(argv: string[]): DevResourceProbeOptions {
|
|
50
|
+
const args = new Map(
|
|
51
|
+
argv.slice(1).map((arg) => {
|
|
52
|
+
const [key, value] = arg.replace(/^--/, "").split("=");
|
|
53
|
+
return [key ?? "", value ?? "1"];
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
const appName = argv[0] ?? "akan";
|
|
57
|
+
const num = (key: string, fallback: number) => Number(args.get(key) ?? fallback);
|
|
58
|
+
return {
|
|
59
|
+
appName,
|
|
60
|
+
workspaceRoot: args.get("root") ?? process.cwd(),
|
|
61
|
+
cliEntry: args.get("cli") ?? "dist/pkgs/@akanjs/cli/index.js",
|
|
62
|
+
port: num("port", 8482),
|
|
63
|
+
edits: num("edits", 3),
|
|
64
|
+
idleSeconds: num("idle", 120),
|
|
65
|
+
suspendSeconds: num("suspend", 0),
|
|
66
|
+
editPath: args.get("edit") ?? "page/(home)/_index.tsx",
|
|
67
|
+
logPath: args.get("log") ?? path.join(os.tmpdir(), `akan-dev-resource-${appName}.log`),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
readonly #options: DevResourceProbeOptions;
|
|
72
|
+
#hostPid = 0;
|
|
73
|
+
|
|
74
|
+
constructor(options: DevResourceProbeOptions) {
|
|
75
|
+
this.#options = options;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async run(): Promise<void> {
|
|
79
|
+
const { appName, workspaceRoot, cliEntry, port, logPath, suspendSeconds } = this.#options;
|
|
80
|
+
await Bun.write(logPath, "");
|
|
81
|
+
const host = Bun.spawn(["bun", cliEntry, "start", appName], {
|
|
82
|
+
cwd: workspaceRoot,
|
|
83
|
+
env: {
|
|
84
|
+
...process.env,
|
|
85
|
+
AKAN_PUBLIC_LOG_LEVEL: "verbose",
|
|
86
|
+
// Pinned: the derived port moves with the `apps/` listing, so a probe cannot predict it.
|
|
87
|
+
AKAN_DEV_PORT: String(port),
|
|
88
|
+
...(suspendSeconds ? { AKAN_DEV_IDLE_SUSPEND_MS: String(suspendSeconds * 1_000) } : {}),
|
|
89
|
+
},
|
|
90
|
+
stdout: Bun.file(logPath),
|
|
91
|
+
stderr: Bun.file(logPath),
|
|
92
|
+
});
|
|
93
|
+
this.#hostPid = host.pid;
|
|
94
|
+
console.info(`[probe] app=${appName} hostPid=${host.pid} port=${port} log=${logPath}`);
|
|
95
|
+
|
|
96
|
+
// Captured before the try so a crashed probe cannot leave a probe comment in the user's tree.
|
|
97
|
+
const target = path.join(workspaceRoot, "apps", appName, this.#options.editPath);
|
|
98
|
+
const original = await Bun.file(target)
|
|
99
|
+
.text()
|
|
100
|
+
.catch(() => null);
|
|
101
|
+
try {
|
|
102
|
+
await this.#measure(target, original);
|
|
103
|
+
} finally {
|
|
104
|
+
if (
|
|
105
|
+
original !== null &&
|
|
106
|
+
(await Bun.file(target)
|
|
107
|
+
.text()
|
|
108
|
+
.catch(() => "")) !== original
|
|
109
|
+
)
|
|
110
|
+
await Bun.write(target, original);
|
|
111
|
+
await this.#cleanup(host);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async #measure(target: string, original: string | null): Promise<void> {
|
|
116
|
+
const { appName, edits, idleSeconds, suspendSeconds } = this.#options;
|
|
117
|
+
const booted = await this.#waitForLog(/backend ready pid=\d+|gateway is running on port/, 240_000);
|
|
118
|
+
console.info(`[probe] boot log seen=${booted}`);
|
|
119
|
+
|
|
120
|
+
const header = ["host", "bldr", "batch", "gway", "repl", "rsc"].map((h) => h.padStart(5)).join(" ");
|
|
121
|
+
console.info(`\n${"sample".padEnd(22)} ${header}`);
|
|
122
|
+
const started = Date.now();
|
|
123
|
+
let idleTotal = 0;
|
|
124
|
+
while ((Date.now() - started) / 1_000 < idleSeconds) {
|
|
125
|
+
await Bun.sleep(5_000);
|
|
126
|
+
idleTotal = this.#row(`idle+${Math.round((Date.now() - started) / 1_000)}s`, await this.#sampleTree());
|
|
127
|
+
}
|
|
128
|
+
console.info(`[probe] IDLE BASELINE ${idleTotal.toFixed(0)}MB`);
|
|
129
|
+
await this.#reportMetrics("idle");
|
|
130
|
+
|
|
131
|
+
// 4.2: every idle number understates, because route modules are evaluated on first request.
|
|
132
|
+
const routes = await this.#staticRoutes();
|
|
133
|
+
const first = await this.#browse(routes);
|
|
134
|
+
console.info(`[probe] browsed ${routes.length} static route(s): ok=${first.ok} failed=${first.failed}`);
|
|
135
|
+
this.#row("browse-all-once", await this.#sampleTree());
|
|
136
|
+
const hot = routes.slice(0, Math.max(1, Math.ceil(routes.length / 4)));
|
|
137
|
+
for (let pass = 1; pass <= 3; pass++) await this.#browse(hot);
|
|
138
|
+
const warm = this.#row("browse-hot-x3", await this.#sampleTree());
|
|
139
|
+
console.info(`[probe] WARM TOTAL ${warm.toFixed(0)}MB (idle was ${idleTotal.toFixed(0)}MB)`);
|
|
140
|
+
await this.#reportMetrics("warm");
|
|
141
|
+
|
|
142
|
+
if (original === null) console.info(`[probe] no edit target at ${target}; skipping edits`);
|
|
143
|
+
else {
|
|
144
|
+
for (let edit = 1; edit <= edits; edit++) {
|
|
145
|
+
await Bun.write(target, `${original}\n// probe-edit-${edit}\n`);
|
|
146
|
+
await Bun.sleep(25_000);
|
|
147
|
+
this.#row(`edit${edit}`, await this.#sampleTree());
|
|
148
|
+
}
|
|
149
|
+
await Bun.write(target, original);
|
|
150
|
+
await Bun.sleep(10_000);
|
|
151
|
+
this.#row("restored", await this.#sampleTree());
|
|
152
|
+
}
|
|
153
|
+
await this.#reportMetrics("after-edits");
|
|
154
|
+
|
|
155
|
+
if (!suspendSeconds) return;
|
|
156
|
+
const suspended = await this.#waitForLog(/\[idle-suspend\].*released the builder/, (suspendSeconds + 60) * 1_000);
|
|
157
|
+
console.info(`[probe] idle-suspend seen=${suspended} (app=${appName})`);
|
|
158
|
+
await Bun.sleep(5_000);
|
|
159
|
+
console.info(`[probe] IDLE-SUSPENDED ${this.#row("idle-suspended", await this.#sampleTree()).toFixed(0)}MB`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#roleOf(command: string): DevResourceRole {
|
|
163
|
+
if (/cli\/index\.js\s+start\s/.test(command)) return "host";
|
|
164
|
+
if (/incrementalBuilder\.proc/.test(command)) return "builder";
|
|
165
|
+
if (/buildBatch\.proc/.test(command)) return "batch";
|
|
166
|
+
if (/rscWorker|react-server/.test(command)) return "rsc";
|
|
167
|
+
if (new RegExp(`apps/${this.#options.appName}/main\\.ts`).test(command)) return "gateway";
|
|
168
|
+
if (/server\.ts|import\(/.test(command)) return "replica";
|
|
169
|
+
return "other";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** One `ps`, then walk from the host pid to a fixpoint — the tree is seven levels deep in places. */
|
|
173
|
+
async #sampleTree(): Promise<DevResourceProc[]> {
|
|
174
|
+
const proc = Bun.spawn(["ps", "-eo", "pid=,ppid=,rss=,time=,command="], { stdout: "pipe" });
|
|
175
|
+
const text = await new Response(proc.stdout).text();
|
|
176
|
+
await proc.exited;
|
|
177
|
+
const all = text
|
|
178
|
+
.split("\n")
|
|
179
|
+
.map((line) => line.trim())
|
|
180
|
+
.filter(Boolean)
|
|
181
|
+
.map((line) => {
|
|
182
|
+
const match = /^(\d+)\s+(\d+)\s+(\d+)\s+([\d:.]+)\s+(.*)$/.exec(line);
|
|
183
|
+
if (!match) return null;
|
|
184
|
+
const [, pid, ppid, rss, time, command] = match;
|
|
185
|
+
const parts = (time ?? "0:0").split(":");
|
|
186
|
+
const cpuSec =
|
|
187
|
+
parts.length === 3
|
|
188
|
+
? Number(parts[0]) * 3600 + Number(parts[1]) * 60 + Number(parts[2])
|
|
189
|
+
: Number(parts[0] ?? 0) * 60 + Number(parts[1] ?? 0);
|
|
190
|
+
return {
|
|
191
|
+
pid: Number(pid),
|
|
192
|
+
ppid: Number(ppid),
|
|
193
|
+
rssMb: Number(rss) / 1024,
|
|
194
|
+
cpuSec,
|
|
195
|
+
role: this.#roleOf(command ?? ""),
|
|
196
|
+
command: command ?? "",
|
|
197
|
+
} satisfies DevResourceProc;
|
|
198
|
+
})
|
|
199
|
+
.filter((proc): proc is DevResourceProc => proc !== null);
|
|
200
|
+
const kept = new Map<number, DevResourceProc>();
|
|
201
|
+
const root = all.find((proc) => proc.pid === this.#hostPid);
|
|
202
|
+
if (root) kept.set(this.#hostPid, root);
|
|
203
|
+
for (let pass = 0; pass < 12; pass++) {
|
|
204
|
+
const before = kept.size;
|
|
205
|
+
for (const proc of all) if (kept.has(proc.ppid) && !kept.has(proc.pid)) kept.set(proc.pid, proc);
|
|
206
|
+
if (kept.size === before) break;
|
|
207
|
+
}
|
|
208
|
+
return [...kept.values()].filter((proc) => proc.role !== "other" || proc.pid === this.#hostPid);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#row(label: string, procs: DevResourceProc[]): number {
|
|
212
|
+
const byRole = new Map<DevResourceRole, number>();
|
|
213
|
+
for (const proc of procs) byRole.set(proc.role, (byRole.get(proc.role) ?? 0) + proc.rssMb);
|
|
214
|
+
const total = procs.reduce((sum, proc) => sum + proc.rssMb, 0);
|
|
215
|
+
const cpu = procs.reduce((sum, proc) => sum + proc.cpuSec, 0);
|
|
216
|
+
const fmt = (value: number) => value.toFixed(0).padStart(5);
|
|
217
|
+
const cells = DevResourceProbe.#columns.map((role) => (byRole.has(role) ? fmt(byRole.get(role) ?? 0) : " —"));
|
|
218
|
+
console.info(
|
|
219
|
+
`${label.padEnd(22)} ${cells.join(" ")} | total ${fmt(total)}MB cpu ${cpu.toFixed(0)}s n=${procs.length}`,
|
|
220
|
+
);
|
|
221
|
+
return total;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Static route urls from the page tree: `(group)` segments are stripped, and a route with a
|
|
226
|
+
* `[dynamic]` segment is skipped because it needs a real id to render.
|
|
227
|
+
*/
|
|
228
|
+
async #staticRoutes(): Promise<string[]> {
|
|
229
|
+
const glob = new Bun.Glob("**/_index.tsx");
|
|
230
|
+
const cwd = path.join(this.#options.workspaceRoot, "apps", this.#options.appName, "page");
|
|
231
|
+
const urls = new Set<string>();
|
|
232
|
+
for await (const file of glob.scan({ cwd })) {
|
|
233
|
+
if (/\[[^\]]+\]/.test(file)) continue;
|
|
234
|
+
const segments = file
|
|
235
|
+
.replace(/\/?_index\.tsx$/, "")
|
|
236
|
+
.split("/")
|
|
237
|
+
.filter((segment) => segment && !/^\(.*\)$/.test(segment));
|
|
238
|
+
urls.add(`/${segments.join("/")}`);
|
|
239
|
+
}
|
|
240
|
+
return [...urls].sort();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async #browse(urls: string[]): Promise<{ ok: number; failed: number }> {
|
|
244
|
+
let ok = 0;
|
|
245
|
+
let failed = 0;
|
|
246
|
+
for (const url of urls) {
|
|
247
|
+
try {
|
|
248
|
+
const res = await fetch(`http://localhost:${this.#options.port}${url}`, {
|
|
249
|
+
signal: AbortSignal.timeout(60_000),
|
|
250
|
+
});
|
|
251
|
+
await res.text();
|
|
252
|
+
if (res.ok) ok++;
|
|
253
|
+
else failed++;
|
|
254
|
+
} catch {
|
|
255
|
+
failed++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { ok, failed };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Only the counters 4.2 asks for; the raw payload is hundreds of fields. */
|
|
262
|
+
async #reportMetrics(label: string): Promise<void> {
|
|
263
|
+
const child = await (async () => {
|
|
264
|
+
try {
|
|
265
|
+
const res = await fetch(`http://localhost:${this.#options.port}/_akan/app/metrics`, {
|
|
266
|
+
signal: AbortSignal.timeout(5_000),
|
|
267
|
+
});
|
|
268
|
+
if (!res.ok) return null;
|
|
269
|
+
const body = (await res.json()) as { children?: { metrics?: { [key: string]: number } }[] };
|
|
270
|
+
return body.children?.[0]?.metrics ?? null;
|
|
271
|
+
} catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
})();
|
|
275
|
+
if (!child) {
|
|
276
|
+
console.info(`[metrics ${label}] unavailable`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const keys = [
|
|
280
|
+
"rscRenderCount",
|
|
281
|
+
"rscRouteModuleCount",
|
|
282
|
+
"rscLoadedRouteModuleCount",
|
|
283
|
+
"ssrChunkRegistrySize",
|
|
284
|
+
"ssrChunkLoadCount",
|
|
285
|
+
"rscWorkerRecycleCount",
|
|
286
|
+
"httpFullSsrCount",
|
|
287
|
+
];
|
|
288
|
+
const rssMb = (Number(child.rssBytes ?? 0) / 1024 / 1024).toFixed(0);
|
|
289
|
+
const parts = keys.map((key) => `${key}=${child[key] ?? "?"}`);
|
|
290
|
+
console.info(`[metrics ${label}] rsc rss=${rssMb}MB ${parts.join(" ")}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async #waitForLog(pattern: RegExp, timeoutMs: number): Promise<boolean> {
|
|
294
|
+
const deadline = Date.now() + timeoutMs;
|
|
295
|
+
while (Date.now() < deadline) {
|
|
296
|
+
const text = await Bun.file(this.#options.logPath)
|
|
297
|
+
.text()
|
|
298
|
+
.catch(() => "");
|
|
299
|
+
if (pattern.test(text)) return true;
|
|
300
|
+
await Bun.sleep(500);
|
|
301
|
+
}
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async #cleanup(host: { kill: (signal: NodeJS.Signals) => void }): Promise<void> {
|
|
306
|
+
for (const proc of (await this.#sampleTree()).reverse()) {
|
|
307
|
+
try {
|
|
308
|
+
process.kill(proc.pid, "SIGKILL");
|
|
309
|
+
} catch {}
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
host.kill("SIGKILL");
|
|
313
|
+
} catch {}
|
|
314
|
+
await Bun.sleep(1_000);
|
|
315
|
+
console.info(`[probe] survivors after kill: ${(await this.#sampleTree()).length}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (import.meta.main) await new DevResourceProbe(DevResourceProbe.parseArgs(process.argv.slice(2))).run();
|
|
@@ -513,6 +513,13 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
|
|
|
513
513
|
describe("dev resource budgets", () => {
|
|
514
514
|
const BOOT_MS = 150_000;
|
|
515
515
|
const WAIT_MS = 90_000;
|
|
516
|
+
/**
|
|
517
|
+
* Measured on this fixture at **260-516ms** per save. Set ~6× above that, like every budget here: it is
|
|
518
|
+
* looking for a path that got slower by a factor — a boot build that stopped being avoidable, a worker
|
|
519
|
+
* spawn that stopped being warm — not for jitter on a busy laptop. `apps/akan`, ~25× this fixture's
|
|
520
|
+
* pages bundle, took ~1.5s per save when the plan started.
|
|
521
|
+
*/
|
|
522
|
+
const SAVE_LATENCY_BUDGET_MS = 3_000;
|
|
516
523
|
const budgetTest = (name: string, fn: () => Promise<void>): void => {
|
|
517
524
|
if (integrationEnabled) test(name, fn, 300_000);
|
|
518
525
|
else test.skip(name, fn);
|
|
@@ -669,15 +676,116 @@ describe("dev resource budgets", () => {
|
|
|
669
676
|
);
|
|
670
677
|
await harness.waitForHttpText("marker-after-recycle", WAIT_MS);
|
|
671
678
|
|
|
672
|
-
//
|
|
673
|
-
//
|
|
679
|
+
// This fixture's builder boots well under the ceiling and blows past it on its first build, which
|
|
680
|
+
// is the shape of every app the ceiling is derived for — a 1.2GB sandbox gives the builder ~420MB
|
|
681
|
+
// and a single route build costs ~247MB. So the host says the ceiling is tight...
|
|
674
682
|
for (let i = 1; i <= 3; i++) {
|
|
675
683
|
const { mark } = await harness.editUntilSeen(host, (attempt) =>
|
|
676
684
|
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-settled-${i}-${attempt}`),
|
|
677
685
|
);
|
|
678
686
|
await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
|
|
679
687
|
}
|
|
680
|
-
await host.waitForLogSince(start, /ceiling
|
|
688
|
+
await host.waitForLogSince(start, /ceiling costs about one boot build per interval/, WAIT_MS);
|
|
689
|
+
// ...and goes on enforcing it. This used to disable the ceiling for the rest of the session on the
|
|
690
|
+
// same evidence, which on a small sandbox means nothing bounds the builder from the first page load
|
|
691
|
+
// onwards. Recycling is throttled to one per interval; that throttle is the answer to the cost, not
|
|
692
|
+
// dropping the bound.
|
|
693
|
+
expect(host.logs.join("").slice(start)).not.toMatch(/no longer enforcing it this session/);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// The guard the memory work did not have. Every budget above asserts RSS; none of them asserts that a
|
|
697
|
+
// page still renders while the machinery those budgets require is mid-swap — and that gap is where two
|
|
698
|
+
// shipped defects lived (`local/optimize-resource/19-shutdown-and-restart-races.md`).
|
|
699
|
+
budgetTest("serves a page requested while the builder is being replaced", async () => {
|
|
700
|
+
const harness = await createHarness();
|
|
701
|
+
// Same unmeetable ceiling as the guard above, for the same reason: a builder that no longer grows
|
|
702
|
+
// into a ceiling has to start under one for the recycle path to run end to end.
|
|
703
|
+
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
|
|
704
|
+
const port = await harness.resolvePort();
|
|
705
|
+
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
706
|
+
|
|
707
|
+
const start = host.markLog();
|
|
708
|
+
// One save does both halves of the setup: it drops the route's client entries, so the next request
|
|
709
|
+
// has to ask the builder again rather than being served from cache, and it makes the builder report
|
|
710
|
+
// an rss over the ceiling, which is what arms the recycle.
|
|
711
|
+
const { mark } = await harness.editUntilSeen(host, (attempt) =>
|
|
712
|
+
harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-through-recycle-${attempt}`),
|
|
713
|
+
);
|
|
714
|
+
await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS);
|
|
715
|
+
|
|
716
|
+
// A builder that has been asked to drain is still alive and refuses everything new, so this request
|
|
717
|
+
// has to be held for the replacement exactly like one that arrives after the process is gone. Timing
|
|
718
|
+
// decides which of the two windows it actually lands in — the drain of an idle builder is short, and
|
|
719
|
+
// the log is polled — so what is asserted is the outcome both windows must produce. The drain itself
|
|
720
|
+
// is pinned deterministically one layer down, in `incrementalBuilder.host.test.ts`.
|
|
721
|
+
await host.waitForLogSince(start, /recycling builder pid=\d+/, WAIT_MS);
|
|
722
|
+
// Not awaited here: a held request only returns once the replacement is up, and waiting for it would
|
|
723
|
+
// put every assertion below on the far side of the window they are about.
|
|
724
|
+
const drainRequest = fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(WAIT_MS) }).then(
|
|
725
|
+
async (response) => ({ status: response.status, html: await response.text() }),
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
// Wait until the replacement exists but is still doing its boot build. From here it cannot answer
|
|
729
|
+
// anything for seconds — which is exactly the window a developer's reload lands in, and the point of
|
|
730
|
+
// anchoring on the spawn rather than on the exit: the gap after it is wide and known, not a race.
|
|
731
|
+
await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
|
|
732
|
+
await host.waitForLogSince(start, /builder spawned pid=\d+ .*restart=1/, WAIT_MS);
|
|
733
|
+
|
|
734
|
+
const holdMark = host.markLog();
|
|
735
|
+
const startedAtMono = performance.now();
|
|
736
|
+
// The second route, never requested before, so this one cannot be answered from the route cache the
|
|
737
|
+
// request above just populated — it has to reach a builder that is not there yet.
|
|
738
|
+
const res = await fetch(`http://127.0.0.1:${port}/second`, { signal: AbortSignal.timeout(WAIT_MS) });
|
|
739
|
+
const html = await res.text();
|
|
740
|
+
const heldMs = Math.round(performance.now() - startedAtMono);
|
|
741
|
+
|
|
742
|
+
// What the developer sees, asserted first because it is the whole point: with the hold removed this
|
|
743
|
+
// same request is answered **500** by the dev error page, and nothing retries on its own — the tab
|
|
744
|
+
// stays broken until it is reloaded by hand. Measured both ways before this guard was committed.
|
|
745
|
+
expect(res.status).toBe(200);
|
|
746
|
+
expect(html).toContain("marker-through-recycle");
|
|
747
|
+
expect(html).not.toContain("reload after the builder is ready");
|
|
748
|
+
// And this is what stops the test passing having tested nothing: a request that arrives after the
|
|
749
|
+
// replacement is already ready never reaches the path under test, and everything above holds anyway.
|
|
750
|
+
expect(host.logs.join("").slice(holdMark)).toMatch(/holding build-route until the builder is ready/);
|
|
751
|
+
|
|
752
|
+
// The one fired at the drain, whichever side of the exit it landed on.
|
|
753
|
+
const drained = await drainRequest;
|
|
754
|
+
expect(drained.status).toBe(200);
|
|
755
|
+
expect(drained.html).toContain("marker-through-recycle");
|
|
756
|
+
console.info(`[restart-guard] page held ${heldMs}ms across the recycle, then rendered`);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// The other half of a resource budget, and the half this plan spent: every megabyte above was bought
|
|
760
|
+
// with latency — a worker process spawned per save, a boot build per recycle and per wake, a hold
|
|
761
|
+
// window in front of requests that land in one. Nothing measured what that cost, so a change that
|
|
762
|
+
// traded another second per save for another 50MB would have passed every guard in this block.
|
|
763
|
+
budgetTest("keeps a save's round trip inside its budget", async () => {
|
|
764
|
+
const harness = await createHarness();
|
|
765
|
+
const host = await harness.startHost({ timeoutMs: BOOT_MS });
|
|
766
|
+
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
767
|
+
|
|
768
|
+
const samples: number[] = [];
|
|
769
|
+
for (let i = 1; i <= 3; i++) {
|
|
770
|
+
let savedAtMono = 0;
|
|
771
|
+
// Timed from inside the mutate callback so a retried save — Bun drops watcher events, which is why
|
|
772
|
+
// `editUntilSeen` exists — is measured from the attempt that actually landed, not from the first.
|
|
773
|
+
const { mark } = await harness.editUntilSeen(host, async (attempt) => {
|
|
774
|
+
await harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-latency-${i}-${attempt}`);
|
|
775
|
+
savedAtMono = performance.now();
|
|
776
|
+
});
|
|
777
|
+
// The moment the running dev server is serving the new code, which is everything the developer is
|
|
778
|
+
// waiting for. Deliberately not the browser's refresh message: that one is only published when a
|
|
779
|
+
// client is connected, so measuring it would measure a WebSocket probe's reconnects as well.
|
|
780
|
+
await host.waitForLogSince(mark, /\[hmr\] backend apply/, WAIT_MS);
|
|
781
|
+
samples.push(performance.now() - savedAtMono);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const rounded = samples.map((ms) => Math.round(ms));
|
|
785
|
+
console.info(`[latency-guard] save -> new code live ${rounded.join("ms, ")}ms`);
|
|
786
|
+
// Per save, not summed: the interesting regression is one save getting slower, and the median would
|
|
787
|
+
// hide a first save that pays for something the rest do not.
|
|
788
|
+
expect(Math.max(...rounded)).toBeLessThan(SAVE_LATENCY_BUDGET_MS);
|
|
681
789
|
});
|
|
682
790
|
|
|
683
791
|
budgetTest("suspends the builder when the dev server goes idle and wakes it on the next edit", async () => {
|
|
@@ -739,8 +847,49 @@ describe("dev resource budgets", () => {
|
|
|
739
847
|
.catch(() => 0);
|
|
740
848
|
|
|
741
849
|
await host.waitForLogSince(mark, /\[idle-suspend\] waking \(build-csr arrived while suspended\)/, WAIT_MS);
|
|
742
|
-
|
|
850
|
+
// `[builder]`, not `[idle-suspend]`: the same queue now also holds requests across a builder restart,
|
|
851
|
+
// so the replay says what it did rather than which of the two reasons put the request there. The wake
|
|
852
|
+
// line above is what distinguishes them, and it is asserted first for that reason.
|
|
853
|
+
await host.waitForLogSince(mark, /\[builder\] replaying 1 request\(s\) held while the builder was away/, WAIT_MS);
|
|
743
854
|
expect(status).toBe(200);
|
|
744
855
|
expect(await DevStabilityHarness.builderProcess(host.proc.pid)).not.toBeNull();
|
|
745
856
|
});
|
|
857
|
+
|
|
858
|
+
// The gap on the other side of the same window: requests held across it are answered, but for a while
|
|
859
|
+
// nothing was *watching* across it. The suspend stops its watcher before the replacement builder has
|
|
860
|
+
// primed its index, and the replacement primes from the disk it finds — so a save in between is
|
|
861
|
+
// baseline to it, reported by nobody, and the backend goes on running the code it replaced. Phase 2
|
|
862
|
+
// made this window routine by recycling the builder on every rss ceiling crossing.
|
|
863
|
+
budgetTest("restarts the backend for a save that lands while the builder is away", async () => {
|
|
864
|
+
const harness = await createHarness();
|
|
865
|
+
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_DEV_IDLE_SUSPEND_MS: "3000" } });
|
|
866
|
+
const start = host.markLog();
|
|
867
|
+
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
868
|
+
await host.waitForLogSince(start, /\[idle-suspend\] .*released the builder/, WAIT_MS);
|
|
869
|
+
|
|
870
|
+
const mark = host.markLog();
|
|
871
|
+
const port = await harness.resolvePort();
|
|
872
|
+
// This request is the clock: it wakes the dev server and is answered only once the replacement
|
|
873
|
+
// builder is ready, so a write issued while it is in flight lands squarely inside the gap. Its own
|
|
874
|
+
// status is not asserted here — the write below restarts the backend, which drops the connection.
|
|
875
|
+
const request = fetch(`http://127.0.0.1:${port}/__csr`).catch(() => null);
|
|
876
|
+
// A service the backend imports, rather than this fixture's `srvkit/backendMarker.ts`: that marker is
|
|
877
|
+
// orphaned once `akan start` regenerates `server.ts`, so it is not in the backend graph at all and an
|
|
878
|
+
// edit to it restarts the backend by path role instead.
|
|
879
|
+
await harness.writeFile(
|
|
880
|
+
"lib/_fixture/fixture.service.ts",
|
|
881
|
+
`import { serve } from "akanjs/service";
|
|
882
|
+
|
|
883
|
+
export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
|
|
884
|
+
// touched while the builder was away
|
|
885
|
+
`,
|
|
886
|
+
);
|
|
887
|
+
await request;
|
|
888
|
+
|
|
889
|
+
await host.waitForLogSince(mark, /\[builder-gap\] 1 backend file\(s\) changed while the builder was away/, WAIT_MS);
|
|
890
|
+
await host.waitForLogSince(mark, /\[backend-reload\]/, WAIT_MS);
|
|
891
|
+
// And back to a working dev server, rather than one stuck restarting.
|
|
892
|
+
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/, WAIT_MS);
|
|
893
|
+
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
894
|
+
});
|
|
746
895
|
});
|
|
@@ -188,6 +188,23 @@ export default function Page() {
|
|
|
188
188
|
</main>
|
|
189
189
|
);
|
|
190
190
|
}
|
|
191
|
+
`,
|
|
192
|
+
),
|
|
193
|
+
// A second route exists so a test can ask for a page the backend has *never* built, at a moment of
|
|
194
|
+
// its choosing. Route clients are built on demand and cached, so on a one-route fixture the first
|
|
195
|
+
// request of a test is the only one that reaches the builder at all.
|
|
196
|
+
this.writeFile(
|
|
197
|
+
"page/second/_index.tsx",
|
|
198
|
+
`import { ClientMarker } from "../../ui/ClientMarker";
|
|
199
|
+
|
|
200
|
+
export default function Page() {
|
|
201
|
+
return (
|
|
202
|
+
<main>
|
|
203
|
+
<h1>Second route</h1>
|
|
204
|
+
<ClientMarker />
|
|
205
|
+
</main>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
191
208
|
`,
|
|
192
209
|
),
|
|
193
210
|
this.writeFile(
|
|
@@ -968,11 +985,19 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
968
985
|
static readonly #psTimeoutMs = 5_000;
|
|
969
986
|
|
|
970
987
|
static async #psRows(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }> | null> {
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
988
|
+
let proc: Bun.Subprocess<"ignore", "pipe", "ignore">;
|
|
989
|
+
try {
|
|
990
|
+
proc = Bun.spawn(["ps", "-eo", "pid,ppid,rss,command"], {
|
|
991
|
+
stdout: "pipe",
|
|
992
|
+
stderr: "ignore",
|
|
993
|
+
stdin: "ignore",
|
|
994
|
+
});
|
|
995
|
+
} catch (error) {
|
|
996
|
+
// No `ps` at all — a slim container image such as `oven/bun` ships without procps. Same answer as
|
|
997
|
+
// a timeout, and for the same reason: this is "could not look", not "nothing is running".
|
|
998
|
+
console.warn(`[harness] could not run ps: ${error instanceof Error ? error.message : String(error)}`);
|
|
999
|
+
return null;
|
|
1000
|
+
}
|
|
976
1001
|
const output = await Promise.race([
|
|
977
1002
|
new Response(proc.stdout).text().catch(() => ""),
|
|
978
1003
|
wait(DevStabilityHarness.#psTimeoutMs).then(() => null),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "2.4.1
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"@langchain/openai": "^1.4.6",
|
|
45
45
|
"@tailwindcss/node": "^4.3.0",
|
|
46
46
|
"@trapezedev/project": "^7.1.4",
|
|
47
|
-
"akanjs": "2.4.1
|
|
47
|
+
"akanjs": "2.4.1",
|
|
48
48
|
"chalk": "^5.6.2",
|
|
49
49
|
"commander": "^14.0.3",
|
|
50
50
|
"daisyui": "5.5.23",
|
|
@@ -64,18 +64,10 @@ export const createBarrelImportsPlugin = async (
|
|
|
64
64
|
const hasMacroAttr = MACRO_ATTR_RE.test(source);
|
|
65
65
|
|
|
66
66
|
if (!hasMacroAttr && barrels.length > 0) {
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
maybe = true;
|
|
72
|
-
break;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
if (maybe) {
|
|
76
|
-
const rewritten = await rewriteBarrelImports(source, barrels, analyzer);
|
|
77
|
-
if (rewritten !== null) source = rewritten;
|
|
78
|
-
}
|
|
67
|
+
// The pre-check that used to live here — "does this file mention a barrel at all" — now
|
|
68
|
+
// lives inside `rewriteBarrelImports`, so every caller gets it rather than just this one.
|
|
69
|
+
const rewritten = await rewriteBarrelImports(source, barrels, analyzer);
|
|
70
|
+
if (rewritten !== null) source = rewritten;
|
|
79
71
|
}
|
|
80
72
|
|
|
81
73
|
if (pipeAfter) {
|
|
@@ -309,6 +301,12 @@ export const rewriteBarrelImports = async (
|
|
|
309
301
|
barrels: string[],
|
|
310
302
|
analyzer: BarrelAnalyzer,
|
|
311
303
|
): Promise<string | null> => {
|
|
304
|
+
// Establish there is something to rewrite before the TypeScript parser is involved. This runs on
|
|
305
|
+
// every source file of every dev rebuild, and parsing was by far the most expensive thing in one:
|
|
306
|
+
// measured across 1189 files here, 299ms and 161MB of RSS, of which **63% of files import no barrel
|
|
307
|
+
// at all**. A static import cannot name a specifier without that specifier appearing literally in the
|
|
308
|
+
// text, so a substring test is a sound filter and costs 4ms for the whole corpus.
|
|
309
|
+
if (!barrels.some((barrel) => source.includes(barrel))) return null;
|
|
312
310
|
const statements = findImportStatements(source);
|
|
313
311
|
if (statements.length === 0) return null;
|
|
314
312
|
|
|
@@ -340,7 +338,16 @@ interface ImportStatement {
|
|
|
340
338
|
|
|
341
339
|
const findImportStatements = (source: string): ImportStatement[] => {
|
|
342
340
|
const statements: ImportStatement[] = [];
|
|
343
|
-
|
|
341
|
+
// `setParentNodes: false`: nothing below reads `node.parent`, and every position comes from
|
|
342
|
+
// `getStart(sourceFile)`, which takes the file explicitly. Building the parent links cost 132ms and
|
|
343
|
+
// 143MB of RSS across 1189 files for no reader.
|
|
344
|
+
const sourceFile = ts.createSourceFile(
|
|
345
|
+
"barrel-imports.tsx",
|
|
346
|
+
source,
|
|
347
|
+
ts.ScriptTarget.Latest,
|
|
348
|
+
false,
|
|
349
|
+
ts.ScriptKind.TSX,
|
|
350
|
+
);
|
|
344
351
|
for (const statement of sourceFile.statements) {
|
|
345
352
|
if (!ts.isImportDeclaration(statement)) continue;
|
|
346
353
|
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
|