@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "22.11.0",
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.11.0",
42
- "@ultimat3/admin": "22.11.0",
43
- "@ultimat3/ai": "22.11.0",
44
- "@ultimat3/auth": "22.11.0",
45
- "@ultimat3/cache": "22.11.0",
46
- "@ultimat3/core": "22.11.0",
47
- "@ultimat3/db": "22.11.0",
48
- "@ultimat3/entity": "22.11.0",
49
- "@ultimat3/flags": "22.11.0",
50
- "@ultimat3/http": "22.11.0",
51
- "@ultimat3/i18n": "22.11.0",
52
- "@ultimat3/jobs": "22.11.0",
53
- "@ultimat3/mail": "22.11.0",
54
- "@ultimat3/manifest": "22.11.0",
55
- "@ultimat3/mcp": "22.11.0",
56
- "@ultimat3/money": "22.11.0",
57
- "@ultimat3/notify": "22.11.0",
58
- "@ultimat3/policy": "22.11.0",
59
- "@ultimat3/pwa": "22.11.0",
60
- "@ultimat3/query": "22.11.0",
61
- "@ultimat3/realtime": "22.11.0",
62
- "@ultimat3/render": "22.11.0",
63
- "@ultimat3/schema": "22.11.0",
64
- "@ultimat3/seo": "22.11.0",
65
- "@ultimat3/storage": "22.11.0",
66
- "@ultimat3/testing": "22.11.0",
67
- "@ultimat3/time": "22.11.0",
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
  }
@@ -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, and an edit to one still needs a restart — exactly what it needed before.
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
- /** `changed` and everything above it, stopping at — and excluding — every pinned module. */
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 stack = changed.filter((path) => !pinned.has(path));
192
- while (stack.length > 0) {
193
- const path = stack.pop();
194
- if (path === undefined || dirty.has(path)) continue;
195
- dirty.add(path);
196
- for (const importer of importers.get(path) ?? []) {
197
- if (!pinned.has(importer)) stack.push(importer);
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 = 2;
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 bytes = (await file.exists()) ? file.size : 0;
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 { enableReloadTracking } from './app-reload-graph';
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
- state.reloadFinding = undefined;
298
- options.onReload?.(file, Math.round(performance.now() - started));
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('dev', async () => {
465
- // The lock first: a stop() that throws must not leave a file claiming this pid still owns
466
- // the directory, because the next boot would then refuse for a process that is gone.
467
- clearLock(services.stateDir);
468
- await server.stop();
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
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { PORT_RANGE, readIntFlag } from './flag-number';
6
6
  import type { ParsedArgs } from './parse';
7
- import { portFromEnv } from './serve';
7
+ import { portFromEnv } from './serve-env';
8
8
 
9
9
  export const DEFAULT_DEV_PORT = 3000;
10
10