@akanjs/devkit 3.0.0-alpha.88 → 3.0.0-alpha.89

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.
@@ -77,6 +77,33 @@ which is the one case where replacing it again cannot help; it says that too, an
77
77
  | `AKAN_MEMORY_LOG_INTERVAL_MS` | `60000` | How often that report is written. |
78
78
  | `AKAN_MEMORY_GC_ON_REPORT` | off | `=1` forces a GC before each report, so the number is retained memory rather than garbage. Costs a full GC per report. |
79
79
 
80
+ ## Several apps at once
81
+
82
+ `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, right after both apps
84
+ finished booting:
85
+
86
+ | process | akan | minimal |
87
+ |---|---|---|
88
+ | dev host | 101MB | 94MB |
89
+ | incremental builder | 596MB | 532MB |
90
+ | backend | 35MB | 38MB |
91
+ | RSC worker | 190MB | 81MB |
92
+
93
+ Plus ~51MB for the supervisor itself: **~1.7GB for two apps at their peak.** The builders are almost all of
94
+ that, and they are also the part that goes away — `AKAN_DEV_IDLE_SUSPEND_MS` releases each one independently, so
95
+ a session where you are editing one app settles to roughly one builder plus ~190MB per idle app. **Multi-app is
96
+ sized against idle suspend being on**; setting it to `0` keeps every builder resident for the whole session.
97
+
98
+ Two things bound the peak rather than the floor:
99
+
100
+ - **`--concurrency` (default 1).** Apps boot in waves, and the next wave starts only once the previous one
101
+ reports ready. A cold boot build is the builder's peak, so booting `n` apps at once means `n` overlapping
102
+ peaks — which is what OOM-kills a container that would have been fine with them staggered.
103
+ - **`AKAN_MEMORY_LIMIT` is per process, not per session.** Each dev host derives its builder and RSC-worker
104
+ ceilings from it independently, so a limit sized for one app does not become a budget for four. Divide it
105
+ yourself, or leave it unset on a laptop.
106
+
80
107
  ## Sizing a small sandbox
81
108
 
82
109
  A worked example, for a 1.2GB container:
@@ -7,7 +7,7 @@ import { WorkspaceExecutor } from "../executors";
7
7
  // tailwind stack, which is exactly what a suspended dev host must not be holding.
8
8
  import { HmrWatcher } from "../frontendBuild/hmrWatcher";
9
9
  import { WatchRootResolver } from "../frontendBuild/watchRootResolver";
10
- import { IncrementalBuilderHost } from "../incrementalBuilder";
10
+ import { type DevStdioMode, IncrementalBuilderHost } from "../incrementalBuilder";
11
11
  import { BuilderRequestRouter } from "../incrementalBuilder/builderRequestRouter";
12
12
  import { BackendImportGraph } from "./BackendImportGraph";
13
13
  import {
@@ -17,9 +17,12 @@ import {
17
17
  backendRestartReasonFromMessage,
18
18
  buildStatusReplaySequence,
19
19
  createBackendBuildStatus,
20
+ type DevHostEvent,
21
+ type DevHostState,
20
22
  decideBuilderRssRecycle,
21
23
  decideBuilderRssSettle,
22
24
  decideIdleSuspend,
25
+ devHostStateOf,
23
26
  filesChangedSince,
24
27
  hasAnyBuildFailure,
25
28
  hasBuildFailureForGeneration,
@@ -89,9 +92,11 @@ interface LastGoodFrontendState {
89
92
 
90
93
  export class AkanAppHost {
91
94
  logger = new Logger("AkanAppHost");
92
- readonly withInk: boolean;
95
+ readonly stdio: DevStdioMode;
93
96
  readonly env: Record<string, string>;
94
- #backend: Bun.Subprocess<"ignore", "inherit", "inherit"> | null = null;
97
+ readonly #onDevEvent: ((event: DevHostEvent) => void) | null;
98
+ #lastDevState: DevHostState | null = null;
99
+ #backend: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe"> | null = null;
95
100
  #builder: IncrementalBuilderHost | null = null;
96
101
  #backendReady = false;
97
102
  #plannedBackendStops = new WeakSet<Bun.Subprocess<"ignore", "inherit", "inherit">>();
@@ -134,12 +139,22 @@ export class AkanAppHost {
134
139
  readonly #builderRequests = new BuilderRequestRouter();
135
140
  constructor(
136
141
  private readonly app: App,
137
- { env, withInk = false }: { env: Record<string, string>; withInk?: boolean },
142
+ {
143
+ env,
144
+ stdio = "inherit",
145
+ onDevEvent,
146
+ }: { env: Record<string, string>; stdio?: DevStdioMode; onDevEvent?: (event: DevHostEvent) => void },
138
147
  ) {
139
148
  this.env = env;
140
- this.withInk = withInk;
149
+ this.stdio = stdio;
150
+ this.#onDevEvent = onDevEvent ?? null;
141
151
  this.#backendGraph = new BackendImportGraph(app, this.logger);
142
152
  }
153
+ #emitDevEvent(state: DevHostState, detail?: string) {
154
+ if (!this.#onDevEvent || state === this.#lastDevState) return;
155
+ this.#lastDevState = state;
156
+ this.#onDevEvent({ app: this.app.name, state, ...(detail ? { detail } : {}) });
157
+ }
143
158
  async start() {
144
159
  if (this.#backend) await this.#stopBackend();
145
160
  if (this.#builder) this.#stopBuilder();
@@ -195,7 +210,7 @@ export class AkanAppHost {
195
210
  this.#backendStderrTail = [];
196
211
  const backend = Bun.spawn(["bun", `apps/${this.app.name}/main.ts`], {
197
212
  cwd: this.app.workspace.workspaceRoot,
198
- stdio: this.withInk ? ["ignore", "pipe", "pipe"] : ["inherit", "inherit", "inherit"],
213
+ stdio: this.stdio === "pipe" ? ["ignore", "pipe", "pipe"] : ["inherit", "inherit", "inherit"],
199
214
  env: this.env,
200
215
  ipc: (msg: BuilderMessage) => {
201
216
  if (!msg || typeof msg !== "object") return;
@@ -235,9 +250,9 @@ export class AkanAppHost {
235
250
  });
236
251
  this.#backend = backend;
237
252
  this.logger.verbose(`backend spawned pid=${backend.pid}`);
238
- if (this.withInk) {
239
- // Ink mode pipes backend stdio to keep the TUI clean; drain the pipes and surface
240
- // them through the logger so runtime errors are not silently swallowed.
253
+ if (this.stdio === "pipe") {
254
+ // A piped backend writes to nobody unless the pipes are drained; surface them through the logger
255
+ // so a runtime error is not silently swallowed, and so the tail kept for a boot failure still fills.
241
256
  void this.#forwardBackendStream(backend.stderr as unknown as ReadableStream<Uint8Array> | undefined, "stderr");
242
257
  void this.#forwardBackendStream(backend.stdout as unknown as ReadableStream<Uint8Array> | undefined, "stdout");
243
258
  }
@@ -250,19 +265,23 @@ export class AkanAppHost {
250
265
  this.#backendStderrTail.splice(0, this.#backendStderrTail.length - BACKEND_STDERR_TAIL_LIMIT);
251
266
  }
252
267
  }
268
+ /**
269
+ * A piped child is written through verbatim rather than re-logged: a level floor here would swallow
270
+ * what the runtime printed, and re-rendering would double the timestamp the child already wrote. So
271
+ * `"pipe"` looks exactly like `"inherit"` to whoever owns this process's stdout — the only difference
272
+ * is `#backendStderrTail`, which is what the crash-loop diagnostic reads.
273
+ */
253
274
  async #forwardBackendStream(stream: ReadableStream<Uint8Array> | undefined | null, kind: "stdout" | "stderr") {
254
275
  if (!stream) return;
255
276
  const decoder = new TextDecoder();
256
277
  try {
257
278
  for await (const chunk of stream) {
258
279
  const text = decoder.decode(chunk, { stream: true });
259
- if (!text.trim()) continue;
280
+ if (!text) continue;
260
281
  if (kind === "stderr") {
261
282
  this.#recordBackendStderr(text);
262
- this.logger.warn(`[backend] ${text.trimEnd()}`);
263
- } else {
264
- this.logger.verbose(`[backend] ${text.trimEnd()}`);
265
- }
283
+ process.stderr.write(text);
284
+ } else process.stdout.write(text);
266
285
  }
267
286
  } catch {
268
287
  // The stream closes when the backend exits; nothing further to surface here.
@@ -315,6 +334,7 @@ export class AkanAppHost {
315
334
  const prev = this.#backendLifecycleState;
316
335
  this.#backendLifecycleState = next;
317
336
  this.logger.verbose(`[backend-lifecycle] ${prev} -> ${next}${detail ? ` ${detail}` : ""}`);
337
+ this.#emitDevEvent(devHostStateOf(next, this.#backendGaveUp), detail);
318
338
  }
319
339
  #sendToBackend(message: BuilderMessage) {
320
340
  if (!this.#backend || !this.#backendReady) {
@@ -695,6 +715,7 @@ export class AkanAppHost {
695
715
  return;
696
716
  }
697
717
  this.#suspended = true;
718
+ this.#emitDevEvent("suspended", `idle ${Math.round(idleMs / 1000)}s`);
698
719
  this.#stopBuilder();
699
720
  this.#openBuilderGap("idle suspend");
700
721
  this.logger.info(
@@ -779,6 +800,9 @@ export class AkanAppHost {
779
800
  this.#wokeAtMono = performance.now();
780
801
  this.#flushPendingBuilderMessages();
781
802
  this.#armIdleSuspend();
803
+ // A wake often leaves the backend's own state untouched, so nothing else would report that the
804
+ // app came back and a supervisor would show it suspended for the rest of the session.
805
+ this.#emitDevEvent(devHostStateOf(this.#backendLifecycleState, this.#backendGaveUp));
782
806
  }
783
807
  }
784
808
  async #applyIdleWake(batch: ChangeBatch | null): Promise<void> {
@@ -1221,9 +1245,17 @@ export class AkanAppHost {
1221
1245
  this.app.verbose(`[cli] waiting for builder to complete initial base build…`);
1222
1246
  let lastError: unknown;
1223
1247
  for (let attempt = 1; attempt <= BUILDER_START_MAX_ATTEMPTS; attempt++) {
1224
- this.#builder = await IncrementalBuilderHost.create(this.app, this.env, (msg) => {
1225
- this.#enqueueBuilderMessage(msg);
1226
- });
1248
+ this.#builder = await IncrementalBuilderHost.create(
1249
+ this.app,
1250
+ this.env,
1251
+ (msg) => {
1252
+ this.#enqueueBuilderMessage(msg);
1253
+ },
1254
+ {
1255
+ stdio: this.stdio,
1256
+ onOutput: (kind, text) => void (kind === "stderr" ? process.stderr : process.stdout).write(text),
1257
+ },
1258
+ );
1227
1259
  try {
1228
1260
  await this.#waitForBuilderReady(attempt, { announceBootState });
1229
1261
  this.app.verbose(`[cli] base build ready in ${Date.now() - startTime}ms — starting backend`);
@@ -69,6 +69,36 @@ export const shouldRestartDevHostByDevPlan = (message: Extract<BuilderMessage, {
69
69
 
70
70
  export type BackendLifecycleState = "starting" | "ready" | "restart-pending" | "stopping" | "recovering" | "stopped";
71
71
 
72
+ /**
73
+ * What a dev host tells a supervising process about itself. Reported rather than scraped: the parent
74
+ * gets one pipe of interleaved child output, in which "this app is up" is indistinguishable from a line
75
+ * that merely mentions it.
76
+ */
77
+ export type DevHostState = "starting" | "ready" | "restarting" | "recovering" | "suspended" | "failed" | "stopped";
78
+
79
+ export interface DevHostEvent {
80
+ app: string;
81
+ state: DevHostState;
82
+ detail?: string;
83
+ }
84
+
85
+ const devHostStateByBackendState = {
86
+ starting: "starting",
87
+ ready: "ready",
88
+ "restart-pending": "restarting",
89
+ recovering: "recovering",
90
+ stopping: "stopped",
91
+ stopped: "stopped",
92
+ } as const satisfies { [key in BackendLifecycleState]: DevHostState };
93
+
94
+ /**
95
+ * The backend's own lifecycle collapsed to what a supervisor can act on. `stopped` is the one state
96
+ * that is ambiguous on its own — a planned shutdown and a crash loop that ran out of retries both land
97
+ * there — so the give-up flag is what separates "gone" from "broken".
98
+ */
99
+ export const devHostStateOf = (state: BackendLifecycleState, gaveUp: boolean): DevHostState =>
100
+ state === "stopped" && gaveUp ? "failed" : devHostStateByBackendState[state];
101
+
72
102
  export interface BackendRestartReason {
73
103
  generation?: number;
74
104
  files: string[];
@@ -2,12 +2,14 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import type { AkanPlugin } from "akanjs";
5
+ import { normalizeRoutePrefix } from "akanjs/base";
5
6
  import { type AkanI18nConfig, resolveAkanI18nConfig } from "akanjs/common";
6
7
  import type { AkanImageConfig } from "akanjs/server";
7
8
  import type { App, Lib } from "../commandDecorators";
8
9
  import { LibExecutor, WorkspaceExecutor } from "../executors";
9
10
  import type { BaseDevEnv, PackageJson } from "../types";
10
11
  import {
12
+ type AkanApiConfig,
11
13
  type AkanAssetsConfig,
12
14
  type AkanMobileConfig,
13
15
  type AkanMobileTargetConfig,
@@ -216,6 +218,7 @@ export class AkanAppConfig implements AppConfigResult {
216
218
  optimizeImports: string[];
217
219
  images: AkanImageConfig;
218
220
  i18n: AkanI18nConfig;
221
+ api: AkanApiConfig;
219
222
  publicEnv: string[];
220
223
  mobile: AkanMobileConfig;
221
224
  /** True only when the app's akan.config.ts explicitly declares a `mobile` section (vs. the synthesized default). */
@@ -260,6 +263,12 @@ export class AkanAppConfig implements AppConfigResult {
260
263
  this.i18n = resolveAkanI18nConfig(config?.i18n);
261
264
  process.env.AKAN_PUBLIC_DEFAULT_LOCALE = this.i18n.defaultLocale;
262
265
  process.env.AKAN_PUBLIC_LOCALES = this.i18n.locales.join(",");
266
+ this.api = {
267
+ prefix: normalizeRoutePrefix(config?.api?.prefix) ?? "/api",
268
+ websocketPrefix: normalizeRoutePrefix(config?.api?.websocketPrefix) ?? "/ws",
269
+ };
270
+ process.env.AKAN_PUBLIC_API_PREFIX = this.api.prefix;
271
+ process.env.AKAN_PUBLIC_WS_PREFIX = this.api.websocketPrefix;
263
272
  this.publicEnv = (config?.publicEnv as string[] | undefined) ?? ([] as string[]);
264
273
  this.secrets = (config?.secrets as string[] | undefined) ?? ([] as string[]);
265
274
  this.assets = {
@@ -446,6 +455,8 @@ ENV AKAN_PUBLIC_ENV=${this.baseDevEnv.env}
446
455
  ${this.basePaths.size ? `ENV AKAN_PUBLIC_BASE_PATHS=${[...this.basePaths].join(",")}` : ""}
447
456
  ENV AKAN_PUBLIC_DEFAULT_LOCALE=${this.i18n.defaultLocale}
448
457
  ENV AKAN_PUBLIC_LOCALES=${this.i18n.locales.join(",")}
458
+ ENV AKAN_PUBLIC_API_PREFIX=${this.api.prefix}
459
+ ENV AKAN_PUBLIC_WS_PREFIX=${this.api.websocketPrefix}
449
460
  ENV AKAN_PUBLIC_OPERATION_MODE=cloud
450
461
  ENV AKAN_LOG_TO_FILE=0
451
462
  ${webEnvLines}
@@ -1,4 +1,5 @@
1
1
  export type {
2
+ AkanApiConfig,
2
3
  AkanAssetsConfig,
3
4
  AkanConfigFile,
4
5
  AkanExecutor,
@@ -0,0 +1,28 @@
1
+ import path from "node:path";
2
+ import { FileSys } from "./fileSys";
3
+
4
+ interface StoredSelection {
5
+ apps: string[];
6
+ }
7
+
8
+ /**
9
+ * The apps the last interactive pick chose, so `akan start` + Enter repeats a multi-app session instead
10
+ * of re-ticking it. Advisory only: a missing, unreadable or stale file just means nothing is pre-ticked.
11
+ */
12
+ export class AppSelectionMemory {
13
+ static pathIn(workspaceRoot: string) {
14
+ return path.join(workspaceRoot, "local", ".akan", "lastStart.json");
15
+ }
16
+ static async read(workspaceRoot: string): Promise<string[]> {
17
+ const stored = await Bun.file(AppSelectionMemory.pathIn(workspaceRoot))
18
+ .json()
19
+ .catch(() => null);
20
+ const apps = (stored as StoredSelection | null)?.apps;
21
+ return Array.isArray(apps) ? apps.filter((name): name is string => typeof name === "string") : [];
22
+ }
23
+ static async write(workspaceRoot: string, apps: string[]) {
24
+ await FileSys.writeJson(AppSelectionMemory.pathIn(workspaceRoot), { apps } satisfies StoredSelection).catch(
25
+ () => undefined,
26
+ );
27
+ }
28
+ }
@@ -0,0 +1,137 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { CodegenLock } from "./codegenLock";
6
+
7
+ const roots: string[] = [];
8
+ const makeRoot = async () => {
9
+ const root = await mkdtemp(path.join(tmpdir(), "akan-codegen-lock-"));
10
+ roots.push(root);
11
+ return root;
12
+ };
13
+ const seedHolder = async (root: string, holder: unknown) => {
14
+ const lockPath = CodegenLock.pathIn(root);
15
+ await Bun.write(lockPath, typeof holder === "string" ? holder : JSON.stringify(holder));
16
+ return lockPath;
17
+ };
18
+ /** A pid that cannot be alive: `kill(0)` on it is ESRCH on every platform this runs on. */
19
+ const deadPid = 0x7ffffff;
20
+
21
+ afterEach(async () => {
22
+ for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
23
+ });
24
+
25
+ describe("CodegenLock", () => {
26
+ test("serializes concurrent callers in the same process", async () => {
27
+ const root = await makeRoot();
28
+ const order: string[] = [];
29
+ const body = async (name: string) => {
30
+ order.push(`${name}:in`);
31
+ await Bun.sleep(20);
32
+ order.push(`${name}:out`);
33
+ };
34
+ await Promise.all([
35
+ CodegenLock.run(root, "a", () => body("a")),
36
+ CodegenLock.run(root, "b", () => body("b")),
37
+ CodegenLock.run(root, "c", () => body("c")),
38
+ ]);
39
+ for (const name of ["a", "b", "c"]) {
40
+ const enter = order.indexOf(`${name}:in`);
41
+ const leave = order.indexOf(`${name}:out`);
42
+ expect(leave).toBe(enter + 1);
43
+ }
44
+ });
45
+
46
+ test("releases the lock file even when the body throws", async () => {
47
+ const root = await makeRoot();
48
+ const lockPath = CodegenLock.pathIn(root);
49
+ await expect(
50
+ CodegenLock.run(root, "boom", async () => {
51
+ expect(await Bun.file(lockPath).exists()).toBe(true);
52
+ throw new Error("boom");
53
+ }),
54
+ ).rejects.toThrow("boom");
55
+ expect(await Bun.file(lockPath).exists()).toBe(false);
56
+ });
57
+
58
+ test("writes a holder naming this process", async () => {
59
+ const root = await makeRoot();
60
+ const lockPath = CodegenLock.pathIn(root);
61
+ const holder = await CodegenLock.run(root, "scan:minimal", async () => await readFile(lockPath, "utf8"));
62
+ expect(JSON.parse(holder)).toMatchObject({ pid: process.pid, label: "scan:minimal" });
63
+ });
64
+
65
+ test("reclaims a lock whose holder is gone", async () => {
66
+ const root = await makeRoot();
67
+ const lockPath = await seedHolder(root, { pid: deadPid, at: Date.now(), label: "crashed" });
68
+ const started = Date.now();
69
+ const holder = await CodegenLock.run(root, "next", async () => await readFile(lockPath, "utf8"));
70
+ expect(JSON.parse(holder).pid).toBe(process.pid);
71
+ expect(Date.now() - started).toBeLessThan(CodegenLock.waitTimeoutMs);
72
+ });
73
+
74
+ test("respects a live holder until the wait expires, then proceeds without the lock", async () => {
75
+ const root = await makeRoot();
76
+ const lockPath = await seedHolder(root, { pid: process.pid, at: Date.now(), label: "other-session" });
77
+ const waitTimeoutMs = CodegenLock.waitTimeoutMs;
78
+ Object.defineProperty(CodegenLock, "waitTimeoutMs", { value: 150, configurable: true });
79
+ try {
80
+ let ran = false;
81
+ await CodegenLock.run(root, "blocked", async () => {
82
+ ran = true;
83
+ // The foreign holder is left in place: nothing may delete a lock it does not hold.
84
+ expect(JSON.parse(await readFile(lockPath, "utf8")).label).toBe("other-session");
85
+ });
86
+ expect(ran).toBe(true);
87
+ expect(await Bun.file(lockPath).exists()).toBe(true);
88
+ } finally {
89
+ Object.defineProperty(CodegenLock, "waitTimeoutMs", { value: waitTimeoutMs, configurable: true });
90
+ }
91
+ });
92
+
93
+ test("keeps a young unreadable lock but reclaims a stale one", async () => {
94
+ const young = await makeRoot();
95
+ await seedHolder(young, "");
96
+ const waitTimeoutMs = CodegenLock.waitTimeoutMs;
97
+ Object.defineProperty(CodegenLock, "waitTimeoutMs", { value: 150, configurable: true });
98
+ try {
99
+ await CodegenLock.run(young, "young", async () => undefined);
100
+ expect(await Bun.file(CodegenLock.pathIn(young)).exists()).toBe(true);
101
+ } finally {
102
+ Object.defineProperty(CodegenLock, "waitTimeoutMs", { value: waitTimeoutMs, configurable: true });
103
+ }
104
+
105
+ const stale = await makeRoot();
106
+ const stalePath = await seedHolder(stale, "");
107
+ const aged = new Date(Date.now() - CodegenLock.unknownHolderStaleMs - 1_000);
108
+ await utimes(stalePath, aged, aged);
109
+ const holder = await CodegenLock.run(stale, "stale", async () => await readFile(stalePath, "utf8"));
110
+ expect(JSON.parse(holder).pid).toBe(process.pid);
111
+ });
112
+
113
+ test("blocks a second process for as long as it holds the lock", async () => {
114
+ const root = await makeRoot();
115
+ const lockPath = CodegenLock.pathIn(root);
116
+ const script = path.join(root, "holder.ts");
117
+ await writeFile(
118
+ script,
119
+ `import { CodegenLock } from ${JSON.stringify(path.resolve(import.meta.dir, "codegenLock.ts"))};
120
+ await CodegenLock.run(${JSON.stringify(root)}, "child", async () => {
121
+ process.stdout.write("held\\n");
122
+ await Bun.sleep(400);
123
+ });
124
+ `,
125
+ );
126
+ const child = Bun.spawn(["bun", script], { stdio: ["ignore", "pipe", "inherit"] });
127
+ const reader = child.stdout.getReader();
128
+ await reader.read();
129
+ reader.releaseLock();
130
+
131
+ const started = Date.now();
132
+ await CodegenLock.run(root, "parent", async () => undefined);
133
+ expect(Date.now() - started).toBeGreaterThan(100);
134
+ await child.exited;
135
+ expect(await stat(lockPath).catch(() => null)).toBeNull();
136
+ });
137
+ });
package/codegenLock.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { mkdir, open, readFile, rm, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Logger } from "akanjs/common";
4
+
5
+ interface LockHolder {
6
+ pid: number;
7
+ at: number;
8
+ label: string;
9
+ }
10
+
11
+ /**
12
+ * A workspace-wide mutex over the generated source files every dev server in the workspace rewrites.
13
+ *
14
+ * `WatchRootResolver` narrows the `apps/` container to one app but keeps `libs/` whole on purpose, so
15
+ * with two dev servers up a save under `libs/` reaches both builders and both regenerate the same
16
+ * barrel. Whichever watcher is mid-scan then reads a half-written file back as a user edit, which is a
17
+ * rebuild per rewrite. `scanSync` writes the same files at boot for every mounting app.
18
+ *
19
+ * A wait that expires proceeds *without* the lock rather than failing: this sits on the dev server's
20
+ * hot path, and stalling the file watcher is worse than the torn read `FileSys.writeTextAtomic` already
21
+ * prevents on its own.
22
+ */
23
+ export class CodegenLock {
24
+ static readonly fileName = "codegen.lock";
25
+ static readonly waitTimeoutMs = 10_000;
26
+ /**
27
+ * How long an unreadable lock file is respected. It covers the window between the exclusive create
28
+ * and the holder write, where the file exists but names no pid yet — a young one is somebody else
29
+ * mid-acquire, not a corpse.
30
+ */
31
+ static readonly unknownHolderStaleMs = 60_000;
32
+ static readonly #pollMs = 25;
33
+ static readonly #logger = new Logger("CodegenLock");
34
+ /** Serializes callers inside this process, which one `O_EXCL` file cannot tell apart. */
35
+ static #queue: Promise<void> = Promise.resolve();
36
+
37
+ static pathIn(workspaceRoot: string) {
38
+ return path.join(workspaceRoot, "local", ".akan", CodegenLock.fileName);
39
+ }
40
+
41
+ static async run<T>(workspaceRoot: string, label: string, fn: () => Promise<T>): Promise<T> {
42
+ const ahead = CodegenLock.#queue;
43
+ let done!: () => void;
44
+ CodegenLock.#queue = new Promise<void>((resolve) => {
45
+ done = resolve;
46
+ });
47
+ try {
48
+ await CodegenLock.#waitForQueue(ahead, label);
49
+ return await CodegenLock.#withFileLock(workspaceRoot, label, fn);
50
+ } finally {
51
+ done();
52
+ }
53
+ }
54
+
55
+ static async #waitForQueue(ahead: Promise<void>, label: string) {
56
+ let timer: ReturnType<typeof setTimeout> | null = null;
57
+ const expired = new Promise<"expired">((resolve) => {
58
+ timer = setTimeout(() => resolve("expired"), CodegenLock.waitTimeoutMs);
59
+ });
60
+ try {
61
+ if ((await Promise.race([ahead.then(() => "done" as const), expired])) === "expired")
62
+ CodegenLock.#logger.warn(
63
+ `codegen lock queued past ${CodegenLock.waitTimeoutMs}ms in this process; continuing without waiting (${label})`,
64
+ );
65
+ } finally {
66
+ if (timer) clearTimeout(timer);
67
+ }
68
+ }
69
+
70
+ static async #withFileLock<T>(workspaceRoot: string, label: string, fn: () => Promise<T>): Promise<T> {
71
+ const lockPath = CodegenLock.pathIn(workspaceRoot);
72
+ await mkdir(path.dirname(lockPath), { recursive: true }).catch(() => undefined);
73
+ const held = await CodegenLock.#acquire(lockPath, label);
74
+ try {
75
+ return await fn();
76
+ } finally {
77
+ if (held) await rm(lockPath, { force: true }).catch(() => undefined);
78
+ }
79
+ }
80
+
81
+ static async #acquire(lockPath: string, label: string): Promise<boolean> {
82
+ const deadline = Date.now() + CodegenLock.waitTimeoutMs;
83
+ for (;;) {
84
+ const handle = await open(lockPath, "wx").catch(() => null);
85
+ if (handle) {
86
+ await handle
87
+ .writeFile(JSON.stringify({ pid: process.pid, at: Date.now(), label } satisfies LockHolder))
88
+ .catch(() => undefined);
89
+ await handle.close().catch(() => undefined);
90
+ return true;
91
+ }
92
+ if (await CodegenLock.#reclaimIfAbandoned(lockPath)) continue;
93
+ if (Date.now() >= deadline) {
94
+ CodegenLock.#logger.warn(
95
+ `codegen lock at ${lockPath} held past ${CodegenLock.waitTimeoutMs}ms; continuing without it (${label})`,
96
+ );
97
+ return false;
98
+ }
99
+ await Bun.sleep(CodegenLock.#pollMs);
100
+ }
101
+ }
102
+
103
+ /** A live holder is never reclaimed — the wait timeout is what bounds a pathologically slow one. */
104
+ static async #reclaimIfAbandoned(lockPath: string): Promise<boolean> {
105
+ const info = await stat(lockPath).catch(() => null);
106
+ if (!info) return true;
107
+ const holder = CodegenLock.#parseHolder(await readFile(lockPath, "utf8").catch(() => ""));
108
+ if (holder) {
109
+ if (CodegenLock.#isAlive(holder.pid)) return false;
110
+ } else if (Date.now() - info.mtimeMs < CodegenLock.unknownHolderStaleMs) return false;
111
+ await rm(lockPath, { force: true }).catch(() => undefined);
112
+ return true;
113
+ }
114
+
115
+ static #parseHolder(raw: string): LockHolder | null {
116
+ try {
117
+ const parsed = JSON.parse(raw) as Partial<LockHolder>;
118
+ if (typeof parsed.pid !== "number" || typeof parsed.at !== "number") return null;
119
+ return { pid: parsed.pid, at: parsed.at, label: typeof parsed.label === "string" ? parsed.label : "" };
120
+ } catch {
121
+ // A truncated holder file names no pid, so it is aged by mtime instead.
122
+ return null;
123
+ }
124
+ }
125
+
126
+ static #isAlive(pid: number): boolean {
127
+ if (!Number.isInteger(pid) || pid <= 0) return false;
128
+ try {
129
+ process.kill(pid, 0);
130
+ return true;
131
+ } catch (error) {
132
+ // EPERM is a pid that exists under another user, which still holds the lock.
133
+ return (error as NodeJS.ErrnoException).code === "EPERM";
134
+ }
135
+ }
136
+ }
@@ -12,7 +12,7 @@ import { COMMAND_META, type CommandCls } from "./targetMeta";
12
12
  export const argTypes = ["Argument", "Option"] as const;
13
13
  export type ArgType = (typeof argTypes)[number];
14
14
 
15
- export const internalArgTypes = ["Workspace", "App", "Lib", "Sys", "Pkg", "Module", "Exec"] as const;
15
+ export const internalArgTypes = ["Workspace", "App", "Apps", "Lib", "Sys", "Pkg", "Module", "Exec"] as const;
16
16
  export type InternalArgType = (typeof internalArgTypes)[number];
17
17
 
18
18
  export type PrimitiveArgType = StringConstructor | NumberConstructor | BooleanConstructor;
@@ -83,6 +83,14 @@ export const normalizePrimitiveArgType = (type: PrimitiveArgType): NormalizedPri
83
83
  export const App = createInternalArgToken<AppExecutor, "App">("App");
84
84
  export type App = AppExecutor;
85
85
 
86
+ /**
87
+ * One or more apps, from a variadic positional (`akan start a b`, `akan start a,b`, `akan start all`)
88
+ * or a checkbox when none is named. Reach for it only where running several is meaningful — every other
89
+ * command takes `App`, whose single-select is unchanged.
90
+ */
91
+ export const Apps = createInternalArgToken<AppExecutor[], "Apps">("Apps");
92
+ export type Apps = AppExecutor[];
93
+
86
94
  export const Lib = createInternalArgToken<LibExecutor, "Lib">("Lib");
87
95
  export type Lib = LibExecutor;
88
96