@ultimat3/cli 22.11.0 → 22.13.0
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/package.json +28 -28
- package/src/app-reload-graph.ts +51 -9
- package/src/bin.ts +14 -0
- package/src/budgets.ts +35 -2
- package/src/cmd-dev.ts +31 -9
- package/src/dev-child-watch.ts +144 -0
- package/src/dev-live-fixture.ts +122 -0
- package/src/dev-port.ts +1 -1
- package/src/dev-supervisor.ts +207 -0
- package/src/error-codes.ts +5 -0
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +234 -170
- package/src/island-identity.ts +158 -0
- package/src/island-link.ts +138 -0
- package/src/island-realtime.ts +28 -11
- package/src/island-routes.ts +3 -1
- package/src/island-store.ts +65 -8
- package/src/mcp-errors.ts +3 -0
- package/src/messages.ts +1 -0
- package/src/prerender.ts +6 -1
- package/src/sw-precache-plan.ts +6 -0
- package/src/worker-bundle.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.13.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,33 +38,33 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@babel/core": "^8.0.1",
|
|
41
|
-
"@ultimat3/action": "22.
|
|
42
|
-
"@ultimat3/admin": "22.
|
|
43
|
-
"@ultimat3/ai": "22.
|
|
44
|
-
"@ultimat3/auth": "22.
|
|
45
|
-
"@ultimat3/cache": "22.
|
|
46
|
-
"@ultimat3/core": "22.
|
|
47
|
-
"@ultimat3/db": "22.
|
|
48
|
-
"@ultimat3/entity": "22.
|
|
49
|
-
"@ultimat3/flags": "22.
|
|
50
|
-
"@ultimat3/http": "22.
|
|
51
|
-
"@ultimat3/i18n": "22.
|
|
52
|
-
"@ultimat3/jobs": "22.
|
|
53
|
-
"@ultimat3/mail": "22.
|
|
54
|
-
"@ultimat3/manifest": "22.
|
|
55
|
-
"@ultimat3/mcp": "22.
|
|
56
|
-
"@ultimat3/money": "22.
|
|
57
|
-
"@ultimat3/notify": "22.
|
|
58
|
-
"@ultimat3/policy": "22.
|
|
59
|
-
"@ultimat3/pwa": "22.
|
|
60
|
-
"@ultimat3/query": "22.
|
|
61
|
-
"@ultimat3/realtime": "22.
|
|
62
|
-
"@ultimat3/render": "22.
|
|
63
|
-
"@ultimat3/schema": "22.
|
|
64
|
-
"@ultimat3/seo": "22.
|
|
65
|
-
"@ultimat3/storage": "22.
|
|
66
|
-
"@ultimat3/testing": "22.
|
|
67
|
-
"@ultimat3/time": "22.
|
|
41
|
+
"@ultimat3/action": "22.13.0",
|
|
42
|
+
"@ultimat3/admin": "22.13.0",
|
|
43
|
+
"@ultimat3/ai": "22.13.0",
|
|
44
|
+
"@ultimat3/auth": "22.13.0",
|
|
45
|
+
"@ultimat3/cache": "22.13.0",
|
|
46
|
+
"@ultimat3/core": "22.13.0",
|
|
47
|
+
"@ultimat3/db": "22.13.0",
|
|
48
|
+
"@ultimat3/entity": "22.13.0",
|
|
49
|
+
"@ultimat3/flags": "22.13.0",
|
|
50
|
+
"@ultimat3/http": "22.13.0",
|
|
51
|
+
"@ultimat3/i18n": "22.13.0",
|
|
52
|
+
"@ultimat3/jobs": "22.13.0",
|
|
53
|
+
"@ultimat3/mail": "22.13.0",
|
|
54
|
+
"@ultimat3/manifest": "22.13.0",
|
|
55
|
+
"@ultimat3/mcp": "22.13.0",
|
|
56
|
+
"@ultimat3/money": "22.13.0",
|
|
57
|
+
"@ultimat3/notify": "22.13.0",
|
|
58
|
+
"@ultimat3/policy": "22.13.0",
|
|
59
|
+
"@ultimat3/pwa": "22.13.0",
|
|
60
|
+
"@ultimat3/query": "22.13.0",
|
|
61
|
+
"@ultimat3/realtime": "22.13.0",
|
|
62
|
+
"@ultimat3/render": "22.13.0",
|
|
63
|
+
"@ultimat3/schema": "22.13.0",
|
|
64
|
+
"@ultimat3/seo": "22.13.0",
|
|
65
|
+
"@ultimat3/storage": "22.13.0",
|
|
66
|
+
"@ultimat3/testing": "22.13.0",
|
|
67
|
+
"@ultimat3/time": "22.13.0",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/app-reload-graph.ts
CHANGED
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
// A module that DEFINES a primitive is PINNED and never leaves the registry: its exports are held
|
|
17
17
|
// by every importer and by a registry that refuses a second definition of one name (an entity is
|
|
18
18
|
// `X_ENTITY_DUPLICATE`; a second action instance is a handler nobody routes to). The closure stops
|
|
19
|
-
// at a pinned module
|
|
19
|
+
// at a pinned module — and RECORDS it (`takeStalePins`): a pinned module above a change still holds
|
|
20
|
+
// the old instance of what changed (a slice's service under its query, an admin page's view under
|
|
21
|
+
// `defineAdmin`), so only a new process serves the save. `x dev` restarts on it (`dev-supervisor.ts`).
|
|
20
22
|
|
|
21
23
|
// why: Bun ships no path API; import specifiers resolve against the importing file's directory.
|
|
22
24
|
import { dirname, resolve } from 'node:path';
|
|
@@ -37,6 +39,27 @@ const nodes = new Map<string, Node>();
|
|
|
37
39
|
/** Reverse edges: file → the files that import it. */
|
|
38
40
|
const importers = new Map<string, Set<string>>();
|
|
39
41
|
const pinned = new Set<string>();
|
|
42
|
+
|
|
43
|
+
/** A pinned module that still holds the old instance of `changed` — or IS `changed`. */
|
|
44
|
+
export interface StalePin {
|
|
45
|
+
/** The saved file, absolute. */
|
|
46
|
+
readonly changed: string;
|
|
47
|
+
/** The module that defines a primitive and was not re-evaluated, absolute. */
|
|
48
|
+
readonly pinned: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What the last rescans' closures stopped at, until `takeStalePins` reads it. */
|
|
52
|
+
let stale: StalePin[] = [];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every pinned module a rescan since the last call could not refresh, sorted by pinned path, and
|
|
56
|
+
* forgets them. Empty: everything the saves reached was re-evaluated and the process serves it.
|
|
57
|
+
*/
|
|
58
|
+
export function takeStalePins(): readonly StalePin[] {
|
|
59
|
+
const taken = stale;
|
|
60
|
+
stale = [];
|
|
61
|
+
return taken;
|
|
62
|
+
}
|
|
40
63
|
/**
|
|
41
64
|
* Edges cost a transpile scan and a resolve per import (~300 ms over notificado.co's 1200 modules),
|
|
42
65
|
* so only the one process that rescans pays it: `x dev` turns this on before its first scan. Off,
|
|
@@ -54,6 +77,7 @@ export function resetReloadGraph(): void {
|
|
|
54
77
|
nodes.clear();
|
|
55
78
|
importers.clear();
|
|
56
79
|
pinned.clear();
|
|
80
|
+
stale = [];
|
|
57
81
|
}
|
|
58
82
|
|
|
59
83
|
/** Marks a module that defines a primitive: never evicted, and the closure stops at it. */
|
|
@@ -185,18 +209,36 @@ async function changedFiles(root: string): Promise<string[]> {
|
|
|
185
209
|
return changed;
|
|
186
210
|
}
|
|
187
211
|
|
|
188
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* `changed` and everything above it, stopping at — and excluding — every pinned module. Each
|
|
214
|
+
* pinned module a walk stopped at is recorded against the change that reached it, once.
|
|
215
|
+
*/
|
|
189
216
|
function closure(changed: readonly string[]): Set<string> {
|
|
190
217
|
const dirty = new Set<string>();
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
218
|
+
const reached = new Map<string, string>();
|
|
219
|
+
for (const origin of changed) {
|
|
220
|
+
if (pinned.has(origin)) {
|
|
221
|
+
if (!reached.has(origin)) reached.set(origin, origin);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const seen = new Set<string>();
|
|
225
|
+
const stack = [origin];
|
|
226
|
+
while (stack.length > 0) {
|
|
227
|
+
const path = stack.pop();
|
|
228
|
+
if (path === undefined || seen.has(path)) continue;
|
|
229
|
+
seen.add(path);
|
|
230
|
+
dirty.add(path);
|
|
231
|
+
for (const importer of importers.get(path) ?? []) {
|
|
232
|
+
if (!pinned.has(importer)) stack.push(importer);
|
|
233
|
+
else if (!reached.has(importer)) reached.set(importer, origin);
|
|
234
|
+
}
|
|
198
235
|
}
|
|
199
236
|
}
|
|
237
|
+
const held = new Set(stale.map((entry) => entry.pinned));
|
|
238
|
+
const found = [...reached]
|
|
239
|
+
.filter(([module]) => !held.has(module))
|
|
240
|
+
.map(([module, origin]) => ({ changed: origin, pinned: module }));
|
|
241
|
+
stale = [...stale, ...found].sort((a, b) => (a.pinned < b.pinned ? -1 : 1));
|
|
200
242
|
return dirty;
|
|
201
243
|
}
|
|
202
244
|
|
package/src/bin.ts
CHANGED
|
@@ -33,6 +33,20 @@ if (local !== undefined) {
|
|
|
33
33
|
process.exit(await child.exited);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// `x dev` supervises a child that serves (`dev-supervisor.ts`): a save that reaches a module which
|
|
37
|
+
// defines a primitive is a restart, since no re-import in one process can serve it. After the
|
|
38
|
+
// handoff above, so the supervisor and its children are the app's own CLI.
|
|
39
|
+
// Imported only for `dev`: every other command's process must not load, or register, anything more.
|
|
40
|
+
if (Bun.argv[2] === 'dev') {
|
|
41
|
+
const { devSupervision, freeDevPort, superviseDev } = await import('./dev-supervisor');
|
|
42
|
+
const supervision = devSupervision(Bun.argv.slice(2), Bun.env, freeDevPort);
|
|
43
|
+
if (supervision !== undefined) {
|
|
44
|
+
process.exit(
|
|
45
|
+
await superviseDev({ bin: import.meta.path, argv: supervision.argv, env: Bun.env }),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
const code = await dispatch({
|
|
37
51
|
argv: Bun.argv.slice(2),
|
|
38
52
|
cwd: process.cwd(),
|
package/src/budgets.ts
CHANGED
|
@@ -60,8 +60,9 @@ export interface BuildStats {
|
|
|
60
60
|
* rule stops being read as a measurement. `.x/` survives across framework upgrades, and
|
|
61
61
|
* `examples/dummy` was charged 250 B for `/x-sw-register.js` by a file written before
|
|
62
62
|
* `FRAMEWORK_SCRIPTS` exempted it. `2`: that exemption and the page-boot decision (ledger #28).
|
|
63
|
+
* `3`: an island entry's imported chunks are charged, each once per document (shared chunks).
|
|
63
64
|
*/
|
|
64
|
-
export const BUILD_STATS_RULES =
|
|
65
|
+
export const BUILD_STATS_RULES = 3;
|
|
65
66
|
|
|
66
67
|
const chainOf = (stats: RouteStats): string =>
|
|
67
68
|
stats.heaviestChain === undefined ? 'unknown import chain' : stats.heaviestChain.join(' -> ');
|
|
@@ -252,6 +253,31 @@ const carriesJson = (attrs: string): boolean => {
|
|
|
252
253
|
*/
|
|
253
254
|
const ENTRY_ATTR = /\sdata-x-entry="(?<url>[^"]*)"/g;
|
|
254
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Static imports and `import()` both. A chunk reached by `import()` was INLINED into its island
|
|
258
|
+
* before islands were split, so charging it keeps a route's number what it was; and it is fetched
|
|
259
|
+
* the moment the island needs it, which is before the page does what it was loaded for.
|
|
260
|
+
*/
|
|
261
|
+
const LOADS: ReadonlySet<string> = new Set(['import-statement', 'dynamic-import']);
|
|
262
|
+
|
|
263
|
+
/** Same-origin paths `code`, served at `url`, makes the browser load. Cross-origin and bare are not this build's. */
|
|
264
|
+
function loadedBy(url: string, code: string): readonly string[] {
|
|
265
|
+
let imports: readonly { readonly path: string; readonly kind: string }[];
|
|
266
|
+
try {
|
|
267
|
+
imports = new Bun.Transpiler({ loader: 'js' }).scanImports(code);
|
|
268
|
+
} catch {
|
|
269
|
+
// A file that does not parse as JavaScript imports nothing this gate can name; its own bytes
|
|
270
|
+
// are already charged.
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
const base = new URL(url, 'https://artifact.invalid');
|
|
274
|
+
return imports
|
|
275
|
+
.filter((one) => LOADS.has(one.kind) && /^\.{0,2}\//.test(one.path))
|
|
276
|
+
.map((one) => new URL(one.path, base))
|
|
277
|
+
.filter((target) => target.origin === base.origin)
|
|
278
|
+
.map((target) => target.pathname);
|
|
279
|
+
}
|
|
280
|
+
|
|
255
281
|
/** One executable module the document names, and what it weighs on disk. */
|
|
256
282
|
export interface MeasuredEntry {
|
|
257
283
|
readonly url: string;
|
|
@@ -337,7 +363,8 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
337
363
|
if (!url.startsWith('/') || fetched.has(url)) return;
|
|
338
364
|
fetched.add(url);
|
|
339
365
|
const file = Bun.file(join(out, url.slice(1)));
|
|
340
|
-
const
|
|
366
|
+
const exists = await file.exists();
|
|
367
|
+
const bytes = exists ? file.size : 0;
|
|
341
368
|
// Counted and set aside, not skipped: the bytes are real and a reader is owed the number.
|
|
342
369
|
// Kept out of `entries` as well as out of `jsBytes`, because `entries` is what a finding reads
|
|
343
370
|
// to name the heaviest import — and on a fresh scaffold every route's `heaviestChain` was
|
|
@@ -348,6 +375,12 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
348
375
|
}
|
|
349
376
|
entries.push({ url, bytes });
|
|
350
377
|
jsBytes += bytes;
|
|
378
|
+
// And every file THAT one loads: an island entry imports its shared chunks (`island-link.ts`),
|
|
379
|
+
// and each is a fetch the browser makes before the island is whole. Through `weigh`, so a chunk
|
|
380
|
+
// two islands share is charged once and a cycle ends at the first repeat.
|
|
381
|
+
if (exists) {
|
|
382
|
+
for (const next of loadedBy(url, await file.text())) await weigh(next);
|
|
383
|
+
}
|
|
351
384
|
};
|
|
352
385
|
|
|
353
386
|
for (const match of html.matchAll(SCRIPT_TAG)) {
|
package/src/cmd-dev.ts
CHANGED
|
@@ -16,7 +16,8 @@ import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
|
16
16
|
import { createIsrController } from '@ultimat3/render/server';
|
|
17
17
|
import { loadSignInPath } from './app-auth';
|
|
18
18
|
import { appManifest } from './app-manifest';
|
|
19
|
-
import {
|
|
19
|
+
import type { StalePin } from './app-reload-graph';
|
|
20
|
+
import { enableReloadTracking, takeStalePins } from './app-reload-graph';
|
|
20
21
|
import { requireAppRoot } from './app-root';
|
|
21
22
|
import { loadAppRuntime } from './app-runtime';
|
|
22
23
|
import { devSpec } from './cmd-dev-spec';
|
|
@@ -29,6 +30,7 @@ import { createStatementLedger } from './dev-n-plus-one';
|
|
|
29
30
|
import { devPortFor } from './dev-port';
|
|
30
31
|
import { coalesceReloads } from './dev-reload';
|
|
31
32
|
import { devRouteTable } from './dev-route-table';
|
|
33
|
+
import { childRestart, restartFinding } from './dev-supervisor';
|
|
32
34
|
import { createTraceRecorder } from './dev-traces';
|
|
33
35
|
import { watchTree } from './dev-watch-tree';
|
|
34
36
|
import { holdUntilShutdown } from './hold';
|
|
@@ -108,6 +110,12 @@ export interface StartDevOptions {
|
|
|
108
110
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
109
111
|
readonly roles?: readonly Role[];
|
|
110
112
|
readonly onReload?: (file: string, durationMs: number) => void;
|
|
113
|
+
/**
|
|
114
|
+
* A save reached a module that defines a primitive, so this process cannot serve it
|
|
115
|
+
* (`takeStalePins`). Given, it is called INSTEAD of `onReload` — `x dev`'s child drains and exits
|
|
116
|
+
* for its supervisor to boot a fresh one. Absent, the save is `X_DEV_RESTART_REQUIRED` on `/_x`.
|
|
117
|
+
*/
|
|
118
|
+
readonly onRestart?: (pins: readonly StalePin[]) => void;
|
|
111
119
|
}
|
|
112
120
|
|
|
113
121
|
/**
|
|
@@ -294,8 +302,14 @@ async function bootDev(
|
|
|
294
302
|
state.islands = islands;
|
|
295
303
|
for (const path of isr.store().paths()) isr.store().delete(path);
|
|
296
304
|
state.reloads += 1;
|
|
297
|
-
|
|
298
|
-
|
|
305
|
+
// Read on EVERY rebuild, so a pin one save reached is never reported against the next save.
|
|
306
|
+
const pins = takeStalePins();
|
|
307
|
+
state.reloadFinding =
|
|
308
|
+
pins.length > 0 && options.onRestart === undefined
|
|
309
|
+
? restartFinding(options.root, pins)
|
|
310
|
+
: undefined;
|
|
311
|
+
if (pins.length > 0 && options.onRestart !== undefined) options.onRestart(pins);
|
|
312
|
+
else options.onReload?.(file, Math.round(performance.now() - started));
|
|
299
313
|
},
|
|
300
314
|
// Same rule as a module that will not import: a save the manifest cannot be rebuilt from is
|
|
301
315
|
// a finding on `/_x`, never an unhandled rejection that takes the dev server down.
|
|
@@ -375,6 +389,7 @@ export const devCommand: CliCommand = {
|
|
|
375
389
|
// The directory is CLAIMED from here down, so a boot that throws has to give it back — the
|
|
376
390
|
// `releaseBoot` shape, with one acquisition. Without it the first failed `x dev` in a shell
|
|
377
391
|
// refuses every later one with a pid that is no longer running.
|
|
392
|
+
const restart = childRestart(root, ctx.env);
|
|
378
393
|
const server = await startDev({
|
|
379
394
|
root,
|
|
380
395
|
port,
|
|
@@ -384,6 +399,8 @@ export const devCommand: CliCommand = {
|
|
|
384
399
|
if (!ctx.args.json)
|
|
385
400
|
process.stdout.write(`${msg('cli.dev.hmr', { file, ms: durationMs })}\n`);
|
|
386
401
|
},
|
|
402
|
+
// Supervised, a save this process cannot serve is a drain and an exit for a fresh child.
|
|
403
|
+
...restart.options,
|
|
387
404
|
}).catch((error: unknown) => {
|
|
388
405
|
release();
|
|
389
406
|
throw error;
|
|
@@ -461,12 +478,17 @@ export const devCommand: CliCommand = {
|
|
|
461
478
|
// directory locked by a process that no longer exists.
|
|
462
479
|
return {
|
|
463
480
|
...result,
|
|
464
|
-
hold: holdUntilShutdown(
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
481
|
+
hold: holdUntilShutdown(
|
|
482
|
+
'dev',
|
|
483
|
+
async () => {
|
|
484
|
+
// The lock first: a stop() that throws must not leave a file claiming this pid still owns
|
|
485
|
+
// the directory, because the next boot would then refuse for a process that is gone.
|
|
486
|
+
clearLock(services.stateDir);
|
|
487
|
+
await server.stop();
|
|
488
|
+
},
|
|
489
|
+
// Released — the port, the lock and the embedded database are free for the next child.
|
|
490
|
+
{ exit: restart.exit },
|
|
491
|
+
),
|
|
470
492
|
};
|
|
471
493
|
},
|
|
472
494
|
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// A supervised `x dev` child that outlives what it serves stops itself. Two facts nothing else in
|
|
2
|
+
// the child watched: its SUPERVISOR is gone (SIGKILLed, so it forwarded nothing — the child was
|
|
3
|
+
// reparented to init and served forever), and its APP ROOT is gone (the jobs worker, the outbox
|
|
4
|
+
// relay and the scheduler then poll an embedded database whose directory no longer exists, at
|
|
5
|
+
// 100% CPU, measured at ~50 MB/s of heap growth). Found on a laptop, 2026-09-29: two children of
|
|
6
|
+
// `cmd-dev-restart.live.test.ts`, six hours old, ppid 1, cwd deleted, one at 7 GB.
|
|
7
|
+
//
|
|
8
|
+
// And a stop that does not finish is finished for it. A drain is bounded by core's deadline, but a
|
|
9
|
+
// loop starved by a spin like the one above reaches its timers late or never, so every stop this
|
|
10
|
+
// child begins — a signal, the watch below, a restart — carries a hard exit past that bound.
|
|
11
|
+
|
|
12
|
+
import { existsSync } from 'node:fs'; // why: one synchronous stat per tick; Bun has no sync exists.
|
|
13
|
+
import { drain, drainDeadlineMs, ERROR_DOCS_URL, finiteCount, logger } from '@ultimat3/core';
|
|
14
|
+
import { MIN_RELEASE_MS } from './hold';
|
|
15
|
+
|
|
16
|
+
/** Set on the child by the supervisor: its own pid, so a child started after it died still knows. */
|
|
17
|
+
export const DEV_SUPERVISOR_PID_ENV = 'ULTIMATE_DEV_SUPERVISOR_PID';
|
|
18
|
+
|
|
19
|
+
/** How often the child asks. A second: a dead supervisor's child is gone about a second later. */
|
|
20
|
+
export const DEV_CHILD_WATCH_MS = 1_000;
|
|
21
|
+
|
|
22
|
+
/** Past the drain's own deadline and `hold.ts`'s release floor, a stop is not going to finish. */
|
|
23
|
+
export const devHardExitMs = (): number => drainDeadlineMs() + MIN_RELEASE_MS + 5_000;
|
|
24
|
+
|
|
25
|
+
export type DevChildGone = 'supervisor' | 'root';
|
|
26
|
+
|
|
27
|
+
export interface DevChildWatchInput {
|
|
28
|
+
readonly root: string;
|
|
29
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
30
|
+
/** Called once, when the child decides to leave. */
|
|
31
|
+
readonly leave: (why: DevChildGone) => void;
|
|
32
|
+
/** Test seams: the defaults read the process and the disk. */
|
|
33
|
+
readonly parentPid?: () => number;
|
|
34
|
+
readonly rootExists?: (root: string) => boolean;
|
|
35
|
+
readonly intervalMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The supervisor's pid, or `undefined` when this process was not started by one. */
|
|
39
|
+
export function supervisorPid(
|
|
40
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
41
|
+
): number | undefined {
|
|
42
|
+
const said = Number(env[DEV_SUPERVISOR_PID_ENV]);
|
|
43
|
+
return Number.isInteger(said) && said > 0 ? said : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Polls both facts until one fails, then calls `leave` once and stops. Answers the stop.
|
|
48
|
+
* `process.ppid` rather than a pipe from the supervisor: stdin is the terminal's (`inherit`), and
|
|
49
|
+
* a supervisor that died before the first tick is still caught — its pid is never ours again.
|
|
50
|
+
*/
|
|
51
|
+
export function watchDevChild(input: DevChildWatchInput): () => void {
|
|
52
|
+
const expected = supervisorPid(input.env);
|
|
53
|
+
const parent = input.parentPid ?? (() => process.ppid);
|
|
54
|
+
const exists = input.rootExists ?? existsSync;
|
|
55
|
+
let left = false;
|
|
56
|
+
const timer = setInterval(
|
|
57
|
+
() => {
|
|
58
|
+
if (left) return;
|
|
59
|
+
const why: DevChildGone | undefined =
|
|
60
|
+
expected !== undefined && parent() !== expected
|
|
61
|
+
? 'supervisor'
|
|
62
|
+
: exists(input.root)
|
|
63
|
+
? undefined
|
|
64
|
+
: 'root';
|
|
65
|
+
if (why === undefined) return;
|
|
66
|
+
left = true;
|
|
67
|
+
clearInterval(timer);
|
|
68
|
+
input.leave(why);
|
|
69
|
+
},
|
|
70
|
+
finiteCount('watchDevChild', 'intervalMs', input.intervalMs ?? DEV_CHILD_WATCH_MS, 1),
|
|
71
|
+
);
|
|
72
|
+
// Never the reason the process is alive — the hold is, and a watch outliving it would be a leak.
|
|
73
|
+
timer.unref();
|
|
74
|
+
return () => {
|
|
75
|
+
left = true;
|
|
76
|
+
clearInterval(timer);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The line the child leaves on stderr (fd 1 may be `--json`'s one document). */
|
|
81
|
+
export function reportGone(root: string, why: DevChildGone): void {
|
|
82
|
+
if (why === 'root') {
|
|
83
|
+
logger.error('X_DEV_ROOT_GONE', {
|
|
84
|
+
code: 'X_DEV_ROOT_GONE',
|
|
85
|
+
cause: `${root}, the app root this x dev serves, no longer exists — nothing is left to serve, so it stops instead of polling a database whose directory is gone`,
|
|
86
|
+
fix: 'x dev # from the app root, once it exists again (restored, re-cloned or moved back)',
|
|
87
|
+
docs: ERROR_DOCS_URL,
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
logger.warn('dev.supervisor.gone', {
|
|
92
|
+
cause:
|
|
93
|
+
'the x dev supervisor that started this child exited without stopping it (a SIGKILL cannot be forwarded), so the child stops itself',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One hard exit, armed by the first stop and never disarmed: it is NOT unref'd, because it is the
|
|
99
|
+
* one timer that must still fire once everything else is done or stuck. `exit` is the test seam.
|
|
100
|
+
*/
|
|
101
|
+
export function hardExit(
|
|
102
|
+
exit: (code: number) => void,
|
|
103
|
+
afterMs: () => number = devHardExitMs,
|
|
104
|
+
): (code: number) => void {
|
|
105
|
+
let armed = false;
|
|
106
|
+
return (code: number): void => {
|
|
107
|
+
if (armed) return;
|
|
108
|
+
armed = true;
|
|
109
|
+
const ms = afterMs();
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
logger.warn('dev.stop.forced', {
|
|
112
|
+
cause: `x dev was still stopping ${String(ms)}ms after it began, so it exits without finishing`,
|
|
113
|
+
});
|
|
114
|
+
exit(code);
|
|
115
|
+
}, ms);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The child's watch, wired to core's drain: the supervisor's death or the root's, and SIGINT or
|
|
121
|
+
* SIGTERM, each begin a stop that ends within `devHardExitMs` however the drain fares. `onGone`
|
|
122
|
+
* marks why, so the hold's exit can answer a code.
|
|
123
|
+
*/
|
|
124
|
+
export function startDevChildWatch(
|
|
125
|
+
root: string,
|
|
126
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
127
|
+
onGone: (why: DevChildGone) => void,
|
|
128
|
+
): { readonly stopping: (code: number) => void } {
|
|
129
|
+
const stopping = hardExit((code) => process.exit(code));
|
|
130
|
+
// Beside core's own handlers, never instead of them: the drain is theirs, the deadline is this.
|
|
131
|
+
process.on('SIGINT', () => stopping(130));
|
|
132
|
+
process.on('SIGTERM', () => stopping(143));
|
|
133
|
+
watchDevChild({
|
|
134
|
+
root,
|
|
135
|
+
env,
|
|
136
|
+
leave: (why) => {
|
|
137
|
+
reportGone(root, why);
|
|
138
|
+
onGone(why);
|
|
139
|
+
stopping(why === 'root' ? 1 : 0);
|
|
140
|
+
void drain(why === 'root' ? 'dev-root-gone' : 'dev-supervisor-gone');
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
return { stopping };
|
|
144
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The `x dev` live tests' process discipline, in one place: spawn the supervisor, read its two
|
|
2
|
+
// streams, and — on every path, a failing one included — reap every process it started and prove
|
|
3
|
+
// none is left. Two children of `cmd-dev-restart.live.test.ts` ran for six hours with ppid 1 and a
|
|
4
|
+
// deleted cwd because the test's `finally` SIGKILLed the supervisor, which cannot forward a signal
|
|
5
|
+
// it never gets, and nothing then looked for what it had started (2026-09-29).
|
|
6
|
+
|
|
7
|
+
import { readdirSync, readlinkSync } from 'node:fs'; // why: /proc is read synchronously; Bun has no readlink.
|
|
8
|
+
|
|
9
|
+
/** One pump per stream — reading a stream twice closes its reader under the first `for await`. */
|
|
10
|
+
export function pump(stream: ReadableStream<Uint8Array>): { seen: () => string } {
|
|
11
|
+
const decoder = new TextDecoder();
|
|
12
|
+
let seen = '';
|
|
13
|
+
void (async () => {
|
|
14
|
+
for await (const chunk of stream) seen += decoder.decode(chunk, { stream: true });
|
|
15
|
+
})();
|
|
16
|
+
return { seen: () => seen };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Poll the buffer until `marker` shows up. The caller's own timeout is the deadline. */
|
|
20
|
+
export async function waitFor(output: { seen: () => string }, marker: string): Promise<string> {
|
|
21
|
+
for (;;) {
|
|
22
|
+
const seen = output.seen();
|
|
23
|
+
if (seen.includes(marker)) return seen;
|
|
24
|
+
await Bun.sleep(25);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether `pid` is still a process — signal 0 delivers nothing and only asks. */
|
|
29
|
+
export function alive(pid: number): boolean {
|
|
30
|
+
try {
|
|
31
|
+
process.kill(pid, 0);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Every descendant of `pid`, depth first — asked while `pid` lives, or its children are init's. */
|
|
39
|
+
export function descendantsOf(pid: number): readonly number[] {
|
|
40
|
+
const out = Bun.spawnSync(['pgrep', '-P', String(pid)]).stdout.toString();
|
|
41
|
+
const children = out
|
|
42
|
+
.split('\n')
|
|
43
|
+
.map((line) => Number(line.trim()))
|
|
44
|
+
.filter((child) => Number.isInteger(child) && child > 0);
|
|
45
|
+
return children.flatMap((child) => [child, ...descendantsOf(child)]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every process whose cwd is `dir` — a deleted one included, which Linux spells `<dir> (deleted)`.
|
|
50
|
+
* The one question that finds an orphan after its parent is gone, when no process tree reaches it.
|
|
51
|
+
* Empty where there is no `/proc` (macOS): the descendant walk is then the whole answer.
|
|
52
|
+
*/
|
|
53
|
+
export function processesIn(dir: string): readonly number[] {
|
|
54
|
+
let pids: readonly string[];
|
|
55
|
+
try {
|
|
56
|
+
pids = readdirSync('/proc').filter((name) => /^\d+$/.test(name));
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
const found: number[] = [];
|
|
61
|
+
for (const pid of pids) {
|
|
62
|
+
try {
|
|
63
|
+
const cwd = readlinkSync(`/proc/${pid}/cwd`);
|
|
64
|
+
if (cwd === dir || cwd === `${dir} (deleted)`) found.push(Number(pid));
|
|
65
|
+
} catch {
|
|
66
|
+
// Gone between the listing and the read, or not ours to read.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return found;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether anything still answers on `port` at the loopback address `x dev` binds. */
|
|
73
|
+
export async function listening(port: number): Promise<boolean> {
|
|
74
|
+
try {
|
|
75
|
+
const socket = await Bun.connect({
|
|
76
|
+
hostname: '127.0.0.1',
|
|
77
|
+
port,
|
|
78
|
+
socket: { data: () => undefined },
|
|
79
|
+
});
|
|
80
|
+
socket.end();
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* SIGKILL `pids`, then wait until each is gone. A test's last resort, run in `finally` — by then
|
|
89
|
+
* every graceful stop the test meant to prove has had its chance. `performance.now()`, never
|
|
90
|
+
* `Date.now()`: the test preload freezes the wall clock, and a deadline read off it never arrives.
|
|
91
|
+
*/
|
|
92
|
+
export async function reap(pids: readonly number[], timeoutMs = 10_000): Promise<void> {
|
|
93
|
+
for (const pid of pids) {
|
|
94
|
+
try {
|
|
95
|
+
process.kill(pid, 'SIGKILL');
|
|
96
|
+
} catch {
|
|
97
|
+
// Already gone.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const until = performance.now() + timeoutMs;
|
|
101
|
+
while (pids.some(alive) && performance.now() < until) await Bun.sleep(25);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** What was left: the pids still in `dir` and whether `port` still answers. Both empty is clean. */
|
|
105
|
+
export async function leftovers(
|
|
106
|
+
dir: string,
|
|
107
|
+
port: number | undefined,
|
|
108
|
+
): Promise<{ readonly pids: readonly number[]; readonly port: boolean }> {
|
|
109
|
+
return {
|
|
110
|
+
pids: processesIn(dir),
|
|
111
|
+
port: port === undefined ? false : await listening(port),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The net under a TIMED-OUT test: bun abandons its body without running `finally`, so an
|
|
117
|
+
* `afterEach` reaps whatever still runs in the fixture directory — the only handle left on an
|
|
118
|
+
* orphan whose parent is gone.
|
|
119
|
+
*/
|
|
120
|
+
export async function reapIn(dir: string): Promise<void> {
|
|
121
|
+
await reap(processesIn(dir));
|
|
122
|
+
}
|
package/src/dev-port.ts
CHANGED