@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.
- package/DEV_RUNTIME_KNOBS.md +27 -0
- package/akanApp/akanApp.host.ts +49 -17
- package/akanApp/devHostPolicy.ts +30 -0
- package/akanConfig/akanConfig.ts +11 -0
- package/akanConfig/types.ts +1 -0
- package/appSelectionMemory.ts +28 -0
- package/codegenLock.test.ts +137 -0
- package/codegenLock.ts +136 -0
- package/commandDecorators/argMeta.ts +9 -1
- package/commandDecorators/command.ts +64 -1
- package/commandDecorators/helpFormatter.ts +4 -0
- package/executors.ts +18 -11
- package/fileSys.ts +19 -1
- package/frontendBuild/autoImportSync.ts +3 -2
- package/frontendBuild/devGeneratedIndexSync.ts +3 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +50 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +9 -2
- package/index.ts +0 -2
- package/package.json +2 -4
- package/ui/MultiScrollList.tsx +0 -242
- package/ui/ScrollList.tsx +0 -105
- package/ui/index.ts +0 -2
- package/useStdoutDimensions.ts +0 -20
package/DEV_RUNTIME_KNOBS.md
CHANGED
|
@@ -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:
|
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -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
|
|
95
|
+
readonly stdio: DevStdioMode;
|
|
93
96
|
readonly env: Record<string, string>;
|
|
94
|
-
#
|
|
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
|
-
{
|
|
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.
|
|
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.
|
|
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.
|
|
239
|
-
//
|
|
240
|
-
//
|
|
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
|
|
280
|
+
if (!text) continue;
|
|
260
281
|
if (kind === "stderr") {
|
|
261
282
|
this.#recordBackendStderr(text);
|
|
262
|
-
|
|
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(
|
|
1225
|
-
this
|
|
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`);
|
package/akanApp/devHostPolicy.ts
CHANGED
|
@@ -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[];
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -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}
|
package/akanConfig/types.ts
CHANGED
|
@@ -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
|
|