@ultimat3/cli 22.12.0 → 22.14.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.
@@ -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
+ }
@@ -58,8 +58,10 @@ export interface DevRouteTable {
58
58
  readonly theme: ThemeBoot;
59
59
  /** The app's own error pages' inline styles, admitted the same way. */
60
60
  readonly errorStyles: readonly string[];
61
- /** Where the app's MCP endpoint was mounted, or `undefined`. */
61
+ /** Where the app's default MCP endpoint was mounted, or `null`. */
62
62
  readonly mcpPath: string | null;
63
+ /** Every mounted MCP endpoint's path, default first — one per population the app serves. */
64
+ readonly mcpPaths: readonly string[];
63
65
  }
64
66
 
65
67
  export async function devRouteTable(input: DevRouteTableInput): Promise<DevRouteTable> {
@@ -156,5 +158,5 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
156
158
  }),
157
159
  ];
158
160
 
159
- return { routes, theme, errorStyles, mcpPath: mcpMount.path };
161
+ return { routes, theme, errorStyles, mcpPath: mcpMount.path, mcpPaths: mcpMount.paths };
160
162
  }
@@ -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 restarting = false;
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
- restarting = true;
197
+ leaving ??= 'restart';
198
+ watched?.stopping(DEV_RESTART_EXIT_CODE);
171
199
  void drain('restart');
172
200
  },
173
201
  },
174
202
  exit: () => {
175
- if (restarting) process.exit(DEV_RESTART_EXIT_CODE);
203
+ if (leaving === 'restart') process.exit(DEV_RESTART_EXIT_CODE);
204
+ if (leaving === 'root') process.exit(1);
176
205
  },
177
206
  };
178
207
  }
@@ -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';