@akanjs/devkit 2.4.1-rc.3 → 2.4.1-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,31 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { BuilderMessage, DevBuildStatus, DevChangeAction } from "akanjs/server";
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { Logger } from "akanjs/common";
6
+ import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangeAction } from "akanjs/server";
7
+ import type { App } from "../commandDecorators";
3
8
  import {
9
+ AkanAppHost,
10
+ BackendImportGraph,
4
11
  backendRestartReasonFromMessage,
5
12
  buildStatusReplaySequence,
6
13
  createBackendBuildStatus,
7
14
  decideBuilderRssRecycle,
15
+ decideBuilderRssSettle,
16
+ decideIdleSuspend,
17
+ hasAnyBuildFailure,
8
18
  hasBuildFailureForGeneration,
9
19
  isLegacyBackendFallbackFile,
10
20
  mergeBackendRestartReasons,
11
21
  mergeInvalidateMessages,
12
22
  normalizeBackendReportedGeneration,
23
+ resolveIdleSuspendMs,
13
24
  shouldAbandonBackendRecovery,
14
25
  shouldAbandonBuilderRssCeiling,
15
26
  shouldMarkBuildPhaseRecovered,
16
27
  shouldQueueBuildStatusReplay,
28
+ shouldRefreshConfigOnIdleWake,
17
29
  shouldRelayRecycledFrontendState,
18
30
  shouldReplaceLastGoodMessage,
19
31
  shouldRestartBackendByDevPlan,
@@ -185,6 +197,111 @@ describe("builder rss recycle", () => {
185
197
  expect(shouldAbandonBuilderRssCeiling(2)).toBe(true);
186
198
  expect(shouldAbandonBuilderRssCeiling(1, 1)).toBe(true);
187
199
  });
200
+
201
+ // Measured on Linux: the builder peaked at 522MiB and settled at 214MiB with no help, so a 400MiB
202
+ // ceiling recycled a process that was already back under it. The armed sample is always the peak.
203
+ describe("settle check before committing", () => {
204
+ test("waits when the builder is only modestly over the ceiling", () => {
205
+ expect(decideBuilderRssSettle({ rssBytes: 522, ceilingBytes: 400 })).toBe("wait-and-recheck");
206
+ expect(decideBuilderRssSettle({ rssBytes: 401, ceilingBytes: 400 })).toBe("wait-and-recheck");
207
+ });
208
+
209
+ // No purge is going to rescue a builder this far over, so waiting only delays the inevitable.
210
+ test("recycles immediately once far enough past the ceiling", () => {
211
+ expect(decideBuilderRssSettle({ rssBytes: 600, ceilingBytes: 400 })).toBe("recycle-now");
212
+ expect(decideBuilderRssSettle({ rssBytes: 900, ceilingBytes: 400 })).toBe("recycle-now");
213
+ expect(decideBuilderRssSettle({ rssBytes: 500, ceilingBytes: 400, hardMultiple: 1.2 })).toBe("recycle-now");
214
+ });
215
+ });
216
+
217
+ describe("readProcessRssBytes", () => {
218
+ test("reads this process's own rss", async () => {
219
+ const rssBytes = await AkanAppHost.readProcessRssBytes(process.pid);
220
+ if (rssBytes === null) throw new Error("expected to read this process's own rss");
221
+ // Loose bounds on purpose: the point is that it read a real number from the OS, not which number.
222
+ expect(rssBytes).toBeGreaterThan(1024 * 1024);
223
+ expect(rssBytes).toBeLessThan(64 * 1024 * 1024 * 1024);
224
+ });
225
+
226
+ // Null rather than 0, because callers must treat an unreadable pid as "no new information" — a 0
227
+ // would read as "settled below the ceiling" and cancel a recycle that should happen.
228
+ test("returns null for a pid that does not exist", async () => {
229
+ expect(await AkanAppHost.readProcessRssBytes(2_147_483_646)).toBeNull();
230
+ });
231
+ });
232
+ });
233
+
234
+ describe("dev idle suspend", () => {
235
+ const decide = (over: Partial<Parameters<typeof decideIdleSuspend>[0]> = {}) =>
236
+ decideIdleSuspend({
237
+ enabled: true,
238
+ suspended: false,
239
+ builderReady: true,
240
+ backendReady: true,
241
+ buildFailed: false,
242
+ restartPending: false,
243
+ msSinceWake: null,
244
+ ...over,
245
+ });
246
+
247
+ test("suspends an idle dev server whose builder and backend are both up", () => {
248
+ expect(decide()).toBe("suspend");
249
+ });
250
+
251
+ test("keeps build capacity while anything is still in motion", () => {
252
+ expect(decide({ enabled: false })).toBe("disabled");
253
+ expect(decide({ suspended: true })).toBe("already-suspended");
254
+ expect(decide({ builderReady: false })).toBe("builder-not-ready");
255
+ expect(decide({ backendReady: false })).toBe("backend-not-ready");
256
+ expect(decide({ restartPending: true })).toBe("restart-pending");
257
+ });
258
+
259
+ // A wake would boot straight back into the same compile error, and the developer is mid-fix anyway.
260
+ test("never suspends on a red build", () => {
261
+ expect(decide({ buildFailed: true })).toBe("build-failed");
262
+ });
263
+
264
+ test("enforces a minimum uptime after a wake so it cannot flap", () => {
265
+ expect(decide({ msSinceWake: 5_000 })).toBe("too-soon");
266
+ expect(decide({ msSinceWake: 31_000 })).toBe("suspend");
267
+ expect(decide({ msSinceWake: 5_000, minUptimeMs: 1_000 })).toBe("suspend");
268
+ });
269
+
270
+ test("defaults to on and treats any non-positive value as off", () => {
271
+ expect(resolveIdleSuspendMs(undefined)).toBe(300_000);
272
+ expect(resolveIdleSuspendMs("")).toBe(300_000);
273
+ expect(resolveIdleSuspendMs("0")).toBeNull();
274
+ expect(resolveIdleSuspendMs("-1")).toBeNull();
275
+ expect(resolveIdleSuspendMs("not-a-number")).toBeNull();
276
+ expect(resolveIdleSuspendMs("1500")).toBe(1_500);
277
+ });
278
+
279
+ test("blocks a suspend on a failure in any phase, not just the newest generation", () => {
280
+ const status = (phase: BuildPhase, ok: boolean): DevBuildStatus => ({ generation: 1, phase, ok, files: [] });
281
+ expect(hasAnyBuildFailure(new Map())).toBe(false);
282
+ expect(hasAnyBuildFailure(new Map([["scan", status("scan", true)]]))).toBe(false);
283
+ expect(
284
+ hasAnyBuildFailure(
285
+ new Map([
286
+ ["scan", status("scan", true)],
287
+ ["pages", status("pages", false)],
288
+ ]),
289
+ ),
290
+ ).toBe(true);
291
+ });
292
+
293
+ test("routes a config change made while suspended through the dev host restart", () => {
294
+ expect(shouldRefreshConfigOnIdleWake(null)).toBe(false);
295
+ expect(shouldRefreshConfigOnIdleWake({ files: ["/repo/apps/demo/ui/A.tsx"], kinds: new Set(["code"]) })).toBe(
296
+ false,
297
+ );
298
+ expect(
299
+ shouldRefreshConfigOnIdleWake({
300
+ files: ["/repo/apps/demo/akan.config.ts"],
301
+ kinds: new Set(["config", "code"]),
302
+ }),
303
+ ).toBe(true);
304
+ });
188
305
  });
189
306
 
190
307
  describe("recycled builder state announcements", () => {
@@ -283,6 +400,86 @@ describe("build status helpers", () => {
283
400
  });
284
401
  });
285
402
 
403
+ describe("BackendImportGraph", () => {
404
+ const tempRoots: string[] = [];
405
+
406
+ const makeGraph = async (files: Record<string, string>) => {
407
+ // Realpath, not the mkdtemp path: `Bun.resolveSync` returns real paths, and on macOS `/var/folders`
408
+ // is a symlink, so an unresolved root makes every resolved import look like it escapes the workspace.
409
+ const workspaceRoot = await realpath(await mkdtemp(path.join(os.tmpdir(), "akan-devkit-graph-")));
410
+ tempRoots.push(workspaceRoot);
411
+ const cwdPath = path.join(workspaceRoot, "apps/demo");
412
+ for (const [rel, source] of Object.entries(files)) {
413
+ const filePath = path.join(cwdPath, rel);
414
+ await mkdir(path.dirname(filePath), { recursive: true });
415
+ await writeFile(filePath, source);
416
+ }
417
+ const app = { cwdPath, workspace: { workspaceRoot } } as unknown as App;
418
+ return { graph: new BackendImportGraph(app, new Logger("test")), cwdPath };
419
+ };
420
+
421
+ afterEach(async () => {
422
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
423
+ });
424
+
425
+ test("walks the backend entrypoints' import graph", async () => {
426
+ const { graph, cwdPath } = await makeGraph({
427
+ "main.ts": 'import "./server";\n',
428
+ "server.ts": 'import { handler } from "./lib/handler";\nexport default handler;\n',
429
+ "lib/handler.ts": "export const handler = () => null;\n",
430
+ "lib/unreachable.ts": "export const nope = 1;\n",
431
+ });
432
+
433
+ expect(await graph.refresh()).toBe(true);
434
+ expect(graph.has(path.join(cwdPath, "lib/handler.ts"))).toBe(true);
435
+ expect(graph.has(path.join(cwdPath, "lib/unreachable.ts"))).toBe(false);
436
+ });
437
+
438
+ test("picks up an import added to an already-scanned file", async () => {
439
+ const { graph, cwdPath } = await makeGraph({
440
+ "main.ts": 'import "./server";\n',
441
+ "server.ts": "export default 1;\n",
442
+ "lib/added.ts": "export const added = 1;\n",
443
+ });
444
+ await graph.refresh();
445
+ expect(graph.has(path.join(cwdPath, "lib/added.ts"))).toBe(false);
446
+
447
+ // The scan cache is keyed on (mtimeMs, size), so the rewrite must invalidate it.
448
+ await writeFile(path.join(cwdPath, "server.ts"), 'import "./lib/added";\nexport default 1;\n');
449
+
450
+ await graph.refresh();
451
+ expect(graph.has(path.join(cwdPath, "lib/added.ts"))).toBe(true);
452
+ });
453
+
454
+ test("drops a file that left the graph", async () => {
455
+ const { graph, cwdPath } = await makeGraph({
456
+ "main.ts": 'import "./server";\n',
457
+ "server.ts": 'import "./lib/leaving";\nexport default 1;\n',
458
+ "lib/leaving.ts": "export const leaving = 1;\n",
459
+ });
460
+ await graph.refresh();
461
+ expect(graph.has(path.join(cwdPath, "lib/leaving.ts"))).toBe(true);
462
+
463
+ await writeFile(path.join(cwdPath, "server.ts"), "export default 1;\n");
464
+ await graph.refresh();
465
+ expect(graph.has(path.join(cwdPath, "lib/leaving.ts"))).toBe(false);
466
+ });
467
+
468
+ test("keeps the previous graph when a refresh finds no entrypoints", async () => {
469
+ const { graph, cwdPath } = await makeGraph({
470
+ "main.ts": 'import "./lib/kept";\n',
471
+ "lib/kept.ts": "export const kept = 1;\n",
472
+ });
473
+ await graph.refresh();
474
+ expect(graph.ready).toBe(true);
475
+
476
+ await rm(path.join(cwdPath, "main.ts"));
477
+ await graph.refresh();
478
+ // An empty scan is not a failure, so the graph legitimately empties out.
479
+ expect(graph.has(path.join(cwdPath, "lib/kept.ts"))).toBe(false);
480
+ });
481
+ });
482
+
286
483
  describe("legacy backend graph fallback", () => {
287
484
  const root = "/repo";
288
485