@ultimat3/cli 22.12.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/budgets.ts +35 -2
- package/src/dev-child-watch.ts +144 -0
- package/src/dev-live-fixture.ts +122 -0
- package/src/dev-supervisor.ts +32 -3
- package/src/error-codes.ts +2 -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 +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/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)) {
|
|
@@ -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-supervisor.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { relative } from 'node:path';
|
|
|
10
10
|
import { drain } from '@ultimat3/core';
|
|
11
11
|
import type { StalePin } from './app-reload-graph';
|
|
12
12
|
import { devSpec } from './cmd-dev-spec';
|
|
13
|
+
import type { DevChildGone } from './dev-child-watch';
|
|
14
|
+
import { DEV_SUPERVISOR_PID_ENV, startDevChildWatch, supervisorPid } from './dev-child-watch';
|
|
13
15
|
import { devPortFor } from './dev-port';
|
|
14
16
|
import { msg } from './messages';
|
|
15
17
|
import type { Finding } from './output';
|
|
@@ -96,10 +98,16 @@ const spawnInherited = (
|
|
|
96
98
|
* SIGINT and SIGTERM are forwarded — a terminal's Ctrl-C reaches the child on its own too, and its
|
|
97
99
|
* drain is idempotent — and once one arrived no child is started again: a Ctrl-C during a restart
|
|
98
100
|
* is a stop, never a respawn.
|
|
101
|
+
*
|
|
102
|
+
* Every other way out stops the child too: an uncaught error or a `process.exit` here reaches the
|
|
103
|
+
* `exit` listener, which SIGTERMs a child still running. The one way no listener sees is a SIGKILL
|
|
104
|
+
* of this process — the child's own watch (`dev-child-watch.ts`) answers that, from its side, off
|
|
105
|
+
* the pid this loop hands it.
|
|
99
106
|
*/
|
|
100
107
|
export async function superviseDev(input: SuperviseDevInput): Promise<number> {
|
|
101
108
|
const spawn = input.spawn ?? spawnInherited;
|
|
102
109
|
let child: DevChild | undefined;
|
|
110
|
+
let running = false;
|
|
103
111
|
let stopping = false;
|
|
104
112
|
const forward = (signal: 'SIGINT' | 'SIGTERM') => (): void => {
|
|
105
113
|
stopping = true;
|
|
@@ -107,20 +115,28 @@ export async function superviseDev(input: SuperviseDevInput): Promise<number> {
|
|
|
107
115
|
};
|
|
108
116
|
const onInt = forward('SIGINT');
|
|
109
117
|
const onTerm = forward('SIGTERM');
|
|
118
|
+
const onExit = (): void => {
|
|
119
|
+
if (running) child?.kill('SIGTERM');
|
|
120
|
+
};
|
|
110
121
|
process.on('SIGINT', onInt);
|
|
111
122
|
process.on('SIGTERM', onTerm);
|
|
123
|
+
process.on('exit', onExit);
|
|
112
124
|
try {
|
|
113
125
|
for (;;) {
|
|
114
126
|
child = spawn([process.execPath, input.bin, ...input.argv], {
|
|
115
127
|
...input.env,
|
|
116
128
|
[DEV_CHILD_ENV]: '1',
|
|
129
|
+
[DEV_SUPERVISOR_PID_ENV]: String(process.pid),
|
|
117
130
|
});
|
|
131
|
+
running = true;
|
|
118
132
|
const code = await child.exited;
|
|
133
|
+
running = false;
|
|
119
134
|
if (code !== DEV_RESTART_EXIT_CODE || stopping) return code;
|
|
120
135
|
}
|
|
121
136
|
} finally {
|
|
122
137
|
process.off('SIGINT', onInt);
|
|
123
138
|
process.off('SIGTERM', onTerm);
|
|
139
|
+
process.off('exit', onExit);
|
|
124
140
|
}
|
|
125
141
|
}
|
|
126
142
|
|
|
@@ -156,23 +172,36 @@ export interface ChildRestart {
|
|
|
156
172
|
* Only a supervised child restarts: it says why on stderr (fd 1 may be `--json`'s one document),
|
|
157
173
|
* starts core's drain — the hold then releases the lock, the port and the embedded database — and
|
|
158
174
|
* exits `DEV_RESTART_EXIT_CODE` once released. Anything else keeps the finding on `/_x`.
|
|
175
|
+
*
|
|
176
|
+
* A child its supervisor named (`DEV_SUPERVISOR_PID_ENV`) also watches for that supervisor's death
|
|
177
|
+
* and its app root's, and stops on either — exit 1 for a root that is gone, which the supervisor,
|
|
178
|
+
* if it is still there, answers by stopping rather than respawning into nothing.
|
|
159
179
|
*/
|
|
160
180
|
export function childRestart(
|
|
161
181
|
root: string,
|
|
162
182
|
env: Readonly<Record<string, string | undefined>>,
|
|
183
|
+
watch: typeof startDevChildWatch = startDevChildWatch,
|
|
163
184
|
): ChildRestart {
|
|
164
185
|
if (env[DEV_CHILD_ENV] !== '1') return { options: {}, exit: () => undefined };
|
|
165
|
-
let
|
|
186
|
+
let leaving: 'restart' | DevChildGone | undefined;
|
|
187
|
+
const watched =
|
|
188
|
+
supervisorPid(env) === undefined
|
|
189
|
+
? undefined
|
|
190
|
+
: watch(root, env, (why) => {
|
|
191
|
+
leaving ??= why;
|
|
192
|
+
});
|
|
166
193
|
return {
|
|
167
194
|
options: {
|
|
168
195
|
onRestart: (pins) => {
|
|
169
196
|
process.stderr.write(`${msg('cli.dev.restart', { reason: restartReason(root, pins) })}\n`);
|
|
170
|
-
|
|
197
|
+
leaving ??= 'restart';
|
|
198
|
+
watched?.stopping(DEV_RESTART_EXIT_CODE);
|
|
171
199
|
void drain('restart');
|
|
172
200
|
},
|
|
173
201
|
},
|
|
174
202
|
exit: () => {
|
|
175
|
-
if (
|
|
203
|
+
if (leaving === 'restart') process.exit(DEV_RESTART_EXIT_CODE);
|
|
204
|
+
if (leaving === 'root') process.exit(1);
|
|
176
205
|
},
|
|
177
206
|
};
|
|
178
207
|
}
|
package/src/error-codes.ts
CHANGED
|
@@ -204,6 +204,7 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
204
204
|
'X_VERIFY_MERGE_INPUT',
|
|
205
205
|
'X_SITEMAP_EXTRA_INVALID',
|
|
206
206
|
'X_DEV_RESTART_REQUIRED',
|
|
207
|
+
'X_DEV_ROOT_GONE',
|
|
207
208
|
] as const;
|
|
208
209
|
|
|
209
210
|
/**
|
|
@@ -358,6 +359,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
358
359
|
X_SITEMAP_EXTRA_INVALID: 'a seo.sitemap.extra path names no public page',
|
|
359
360
|
X_DEV_RESTART_REQUIRED:
|
|
360
361
|
'a save reached a module that defines a primitive; only a new process serves it',
|
|
362
|
+
X_DEV_ROOT_GONE: 'the app root x dev serves was deleted or moved',
|
|
361
363
|
};
|
|
362
364
|
|
|
363
365
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
package/src/index.ts
CHANGED
|
@@ -107,7 +107,7 @@ export { ICON_BASE_PATH, ICON_SOURCE } from './icon-assets';
|
|
|
107
107
|
// `@ultimat3/testing` takes this function as its `build` parameter (issue #260). `discoverIslands`,
|
|
108
108
|
// `islandBundle`, `writeIslands`, `ISLAND_BASE_PATH` and `ISLAND_GLOB` stay internal: they are
|
|
109
109
|
// `x build`'s and `x dev`'s wiring, and every name here is a semver promise forever.
|
|
110
|
-
export type { IslandChunk } from './island-bundle';
|
|
110
|
+
export type { IslandChunk, SharedChunk } from './island-bundle';
|
|
111
111
|
export { buildIslands } from './island-bundle';
|
|
112
112
|
export { JOB_STATES } from './jobs-report';
|
|
113
113
|
export { renderJobTable } from './jobs-table';
|