@ultimat3/cli 27.10.2 → 27.10.4

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": "27.10.2",
3
+ "version": "27.10.4",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,33 +39,33 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@babel/core": "^8.0.1",
42
- "@ultimat3/action": "27.10.2",
43
- "@ultimat3/admin": "27.10.2",
44
- "@ultimat3/ai": "27.10.2",
45
- "@ultimat3/auth": "27.10.2",
46
- "@ultimat3/cache": "27.10.2",
47
- "@ultimat3/core": "27.10.2",
48
- "@ultimat3/db": "27.10.2",
49
- "@ultimat3/entity": "27.10.2",
50
- "@ultimat3/flags": "27.10.2",
51
- "@ultimat3/http": "27.10.2",
52
- "@ultimat3/i18n": "27.10.2",
53
- "@ultimat3/jobs": "27.10.2",
54
- "@ultimat3/mail": "27.10.2",
55
- "@ultimat3/manifest": "27.10.2",
56
- "@ultimat3/mcp": "27.10.2",
57
- "@ultimat3/money": "27.10.2",
58
- "@ultimat3/notify": "27.10.2",
59
- "@ultimat3/policy": "27.10.2",
60
- "@ultimat3/pwa": "27.10.2",
61
- "@ultimat3/query": "27.10.2",
62
- "@ultimat3/realtime": "27.10.2",
63
- "@ultimat3/render": "27.10.2",
64
- "@ultimat3/schema": "27.10.2",
65
- "@ultimat3/seo": "27.10.2",
66
- "@ultimat3/storage": "27.10.2",
67
- "@ultimat3/testing": "27.10.2",
68
- "@ultimat3/time": "27.10.2",
42
+ "@ultimat3/action": "27.10.4",
43
+ "@ultimat3/admin": "27.10.4",
44
+ "@ultimat3/ai": "27.10.4",
45
+ "@ultimat3/auth": "27.10.4",
46
+ "@ultimat3/cache": "27.10.4",
47
+ "@ultimat3/core": "27.10.4",
48
+ "@ultimat3/db": "27.10.4",
49
+ "@ultimat3/entity": "27.10.4",
50
+ "@ultimat3/flags": "27.10.4",
51
+ "@ultimat3/http": "27.10.4",
52
+ "@ultimat3/i18n": "27.10.4",
53
+ "@ultimat3/jobs": "27.10.4",
54
+ "@ultimat3/mail": "27.10.4",
55
+ "@ultimat3/manifest": "27.10.4",
56
+ "@ultimat3/mcp": "27.10.4",
57
+ "@ultimat3/money": "27.10.4",
58
+ "@ultimat3/notify": "27.10.4",
59
+ "@ultimat3/policy": "27.10.4",
60
+ "@ultimat3/pwa": "27.10.4",
61
+ "@ultimat3/query": "27.10.4",
62
+ "@ultimat3/realtime": "27.10.4",
63
+ "@ultimat3/render": "27.10.4",
64
+ "@ultimat3/schema": "27.10.4",
65
+ "@ultimat3/seo": "27.10.4",
66
+ "@ultimat3/storage": "27.10.4",
67
+ "@ultimat3/testing": "27.10.4",
68
+ "@ultimat3/time": "27.10.4",
69
69
  "babel-preset-solid": "^1.9.15"
70
70
  }
71
71
  }
@@ -87,12 +87,28 @@ export interface InFlightRun {
87
87
  readonly command: readonly string[];
88
88
  /** Everything it had printed before the kill; absent when it did not settle after it. */
89
89
  readonly output?: string;
90
+ /** How long it had been running when the deadline passed. */
91
+ readonly runningMs?: number;
90
92
  }
91
93
 
94
+ /**
95
+ * The runs a step had already seen settle when its deadline passed. A batched step (a coverage
96
+ * run's slices) that finished ninety of them did not hang — it outgrew its deadline — and a
97
+ * finding that cannot tell the two apart sends its reader hunting a stuck file that is not there.
98
+ * A `type`: it rides in `Finding.meta`, which an interface is not assignable to.
99
+ */
100
+ export type StepProgress = {
101
+ readonly runs: number;
102
+ /** The longest any of them took, as its runner reported it. */
103
+ readonly slowestMs: number;
104
+ };
105
+
92
106
  /** What a step was doing when its deadline passed — `verify-stalled.ts` turns it into the finding. */
93
107
  export interface StepExpiry {
94
108
  readonly killed: readonly KilledProcess[];
95
109
  readonly inFlight: readonly InFlightRun[];
110
+ /** Absent is none: a step that never started a child, or a fixture that does not say. */
111
+ readonly finished?: StepProgress;
96
112
  }
97
113
 
98
114
  /** A killed child's pipes close with it; this is only the bound on a runner that is not `exec`. */
@@ -138,7 +154,10 @@ export function guardStep(
138
154
  ): StepGuard {
139
155
  let expired = false;
140
156
  // Keyed by the promise itself: two batches of one step may run the very same argv.
141
- const inFlight = new Map<Promise<ExecResult>, readonly string[]>();
157
+ const inFlight = new Map<Promise<ExecResult>, Waiting>();
158
+ // Settled runs, counted only before the deadline: a kill settles the rest, and those are not
159
+ // progress.
160
+ let finished = { runs: 0, slowestMs: 0 };
142
161
  // Every child's pid, recorded the moment it exists and dropped when its run settles (a settled
143
162
  // pid may be reused). Windows reads no process's environment, so this is the step's only handle.
144
163
  const spawned = new Map<number, readonly string[]>();
@@ -169,9 +188,16 @@ export function guardStep(
169
188
  options.onSpawn?.(spawnedPid);
170
189
  },
171
190
  });
172
- inFlight.set(run, command);
191
+ inFlight.set(run, { command, startedAt: performance.now() });
173
192
  try {
174
- return await run;
193
+ const result = await run;
194
+ if (!expired) {
195
+ finished = {
196
+ runs: finished.runs + 1,
197
+ slowestMs: Math.max(finished.slowestMs, result.durationMs),
198
+ };
199
+ }
200
+ return result;
175
201
  } finally {
176
202
  inFlight.delete(run);
177
203
  if (pid !== undefined) spawned.delete(pid);
@@ -179,11 +205,16 @@ export function guardStep(
179
205
  },
180
206
  async expire(): Promise<StepExpiry> {
181
207
  expired = true;
208
+ const progress = finished.runs === 0 ? {} : { finished };
182
209
  // Before the kill: a killed child settles its runner call, which takes it off the map.
183
- const waiting = [...inFlight];
210
+ const at = performance.now();
211
+ const waiting = [...inFlight].map(
212
+ ([run, { command, startedAt }]) =>
213
+ [run, command, Math.round(at - startedAt)] as const satisfies WaitingRun,
214
+ );
184
215
  if (windows) {
185
216
  const killed = await killTrees(spawned, platform.killTree ?? taskkillTree);
186
- return { killed, inFlight: await settleAll(waiting) };
217
+ return { killed, inFlight: await settleAll(waiting), ...progress };
187
218
  }
188
219
  const workers = await killTagged(tag, undefined, isTestWorker);
189
220
  if (workers.length > 0) {
@@ -193,19 +224,27 @@ export function guardStep(
193
224
  const killed = [...workers, ...rest.filter((one) => workers.every((w) => w.pid !== one.pid))];
194
225
  // After it: the kill closed the child's pipes, so its call now resolves with everything it
195
226
  // had printed — for `bun test`, the crash line of each file a worker was holding.
196
- return { killed, inFlight: await settleAll(waiting) };
227
+ return { killed, inFlight: await settleAll(waiting), ...progress };
197
228
  },
198
229
  };
199
230
  }
200
231
 
232
+ interface Waiting {
233
+ readonly command: readonly string[];
234
+ readonly startedAt: number;
235
+ }
236
+
237
+ /** A run the deadline found in flight: its call, its argv, and how long it had been running. */
238
+ type WaitingRun = readonly [Promise<ExecResult>, readonly string[], number];
239
+
201
240
  /** What each child had printed, once the kill closed its pipes — or just its argv past `SETTLE_MS`. */
202
- function settleAll(
203
- waiting: readonly (readonly [Promise<ExecResult>, readonly string[]])[],
204
- ): Promise<readonly InFlightRun[]> {
241
+ function settleAll(waiting: readonly WaitingRun[]): Promise<readonly InFlightRun[]> {
205
242
  return Promise.all(
206
- waiting.map(async ([run, command]): Promise<InFlightRun> => {
243
+ waiting.map(async ([run, command, runningMs]): Promise<InFlightRun> => {
207
244
  const raced = await raceDeadline(run.then(execOutput), SETTLE_MS).catch(() => undefined);
208
- return raced === undefined || raced.timedOut ? { command } : { command, output: raced.value };
245
+ return raced === undefined || raced.timedOut
246
+ ? { command, runningMs }
247
+ : { command, output: raced.value, runningMs };
209
248
  }),
210
249
  );
211
250
  }
@@ -5,7 +5,7 @@
5
5
  import { ERROR_DOCS_URL } from '@ultimat3/core';
6
6
  import type { Finding } from './output';
7
7
  import { quoteArg } from './shell-quote';
8
- import type { InFlightRun, StepExpiry } from './verify-deadline';
8
+ import type { InFlightRun, StepExpiry, StepProgress } from './verify-deadline';
9
9
  import { STEP_TIMEOUT_FIELD } from './verify-deadline';
10
10
  import type { VerifyStepName } from './verify-step';
11
11
 
@@ -16,12 +16,14 @@ import type { VerifyStepName } from './verify-step';
16
16
  export type StalledRun = {
17
17
  /** The argv without its file list: `bun test --parallel=3`. */
18
18
  readonly command: string;
19
- /** Files the run was handed; 0 for a serial run, which selects by filter. */
19
+ /** Files the run was handed by name; 0 for a serial run that selects by filter. */
20
20
  readonly files: number;
21
21
  /** `--parallel`'s width, and 1 for a serial run. */
22
22
  readonly workers: number;
23
23
  /** The files that were still running, as bun named them. Empty when it named none. */
24
24
  readonly stuck: readonly string[];
25
+ /** How long it had been running when it was stopped. */
26
+ readonly runningMs?: number;
25
27
  };
26
28
 
27
29
  /** `X_VERIFY_STEP_TIMEOUT`'s `meta`. A `type`: an interface is not assignable to `Finding.meta`. */
@@ -30,6 +32,8 @@ export type StepTimeoutMeta = {
30
32
  readonly deadlineMs: number;
31
33
  readonly killed: readonly { readonly pid: number; readonly command: string }[];
32
34
  readonly inFlight: readonly StalledRun[];
35
+ /** The runs that had finished before the deadline — the step's progress. */
36
+ readonly finished: StepProgress;
33
37
  };
34
38
 
35
39
  /**
@@ -59,15 +63,18 @@ export function stalledRun(run: InFlightRun): StalledRun | undefined {
59
63
  const width = rest.find((arg) => arg.startsWith(PARALLEL));
60
64
  const flags = rest.filter((arg) => arg.startsWith('--') && !arg.startsWith('--timings'));
61
65
  const command = ['bun', 'test', ...flags].join(' ');
66
+ const age = run.runningMs === undefined ? {} : { runningMs: run.runningMs };
62
67
  if (width === undefined) {
63
68
  const last = lines.findLastIndex((line) => FILE_LINE.test(line));
64
69
  const started = last < 0 ? undefined : FILE_LINE.exec(lines[last] ?? '')?.[1];
65
70
  const failed = lines.slice(last + 1).some((line) => line.startsWith('(fail)'));
66
71
  return {
67
72
  command,
68
- files: 0,
73
+ // A coverage slice names its files; a serial suite selects by a filter that is not a path.
74
+ files: rest.filter((arg) => TEST_FILE.test(arg)).length,
69
75
  workers: 1,
70
76
  stuck: started === undefined || failed ? [] : [started],
77
+ ...age,
71
78
  };
72
79
  }
73
80
  const files = rest.filter((arg) => !arg.startsWith('--'));
@@ -81,9 +88,32 @@ export function stalledRun(run: InFlightRun): StalledRun | undefined {
81
88
  files: files.length,
82
89
  workers: Math.max(1, Number(width.slice(PARALLEL.length)) || 1),
83
90
  stuck: [...new Set(crashed)],
91
+ ...age,
84
92
  };
85
93
  }
86
94
 
95
+ const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
96
+
97
+ /**
98
+ * A batched step that had finished runs, none of those still in flight older than twice the
99
+ * slowest that finished, was making progress when its deadline passed: it outgrew the deadline
100
+ * at this width, and no file is stuck. Anything older than that is read as the hang it may be.
101
+ */
102
+ const PROGRESS_SLACK = 2;
103
+
104
+ export function outgrewDeadline(runs: readonly StalledRun[], expiry: StepExpiry): boolean {
105
+ const finished = expiry.finished;
106
+ if (finished === undefined || finished.runs === 0) return false;
107
+ if (runs.some((run) => run.stuck.length > 0)) return false;
108
+ const bound = finished.slowestMs * PROGRESS_SLACK;
109
+ return expiry.inFlight.every((run) => run.runningMs !== undefined && run.runningMs <= bound);
110
+ }
111
+
112
+ const seconds = (ms: number): string => String(Math.round(ms / 1000));
113
+
114
+ const oldest = (expiry: StepExpiry): number =>
115
+ Math.max(0, ...expiry.inFlight.map((run) => run.runningMs ?? 0));
116
+
87
117
  /** How many paths a `cause:` spells out — the rest are in `meta`. */
88
118
  const NAMED_IN_CAUSE = 5;
89
119
 
@@ -99,6 +129,10 @@ export function stalledOutput(expiry: StepExpiry): string | undefined {
99
129
  }
100
130
 
101
131
  const inFlightClause = (runs: readonly StalledRun[], expiry: StepExpiry): string => {
132
+ if (outgrewDeadline(runs, expiry)) {
133
+ const finished = expiry.finished as StepProgress;
134
+ return `; it was not hung: ${String(finished.runs)} run(s) it started had finished (the slowest in ${seconds(finished.slowestMs)} s) and the longest still running had been going ${seconds(oldest(expiry))} s — the step needs longer than its deadline at this width`;
135
+ }
102
136
  const files = runs.flatMap((run) => run.stuck);
103
137
  const named = runs.find((run) => run.stuck.length > 0);
104
138
  if (named !== undefined) {
@@ -110,7 +144,7 @@ const inFlightClause = (runs: readonly StalledRun[], expiry: StepExpiry): string
110
144
  if (waiting === undefined) return '';
111
145
  return runs[0] === undefined
112
146
  ? `; it was waiting on: ${waiting.command.slice(0, 4).join(' ')}`
113
- : `; its running "${runs[0].command}" named no file still in flight, so the run itself did not exit`;
147
+ : `; its running "${runs[0].command}" named no file still in flight, so the run itself did not exit${waiting.runningMs === undefined ? '' : ` after ${seconds(waiting.runningMs)} s`}`;
114
148
  };
115
149
 
116
150
  /**
@@ -137,12 +171,15 @@ export const stepTimeoutFinding = (
137
171
  deadlineMs: ms,
138
172
  killed: expiry.killed.map((one) => ({ pid: one.pid, command: one.command })),
139
173
  inFlight: runs,
174
+ finished: expiry.finished ?? { runs: 0, slowestMs: 0 },
140
175
  };
176
+ const wider = `${command} --only ${step} --workers <n> --json`;
141
177
  return {
142
178
  code: 'X_VERIFY_STEP_TIMEOUT',
143
179
  cause: `step "${step}" did not finish within its ${String(ms)} ms deadline, so it was stopped and ${String(expiry.killed.length)} process(es) it had started were killed${inFlightClause(runs, expiry)}`,
144
- fix:
145
- stuck.length > 0
180
+ fix: outgrewDeadline(runs, expiry)
181
+ ? `${wider} # it outgrew its deadline at this width, so run it wider; or raise the deadline in x.verify.json — add "${STEP_TIMEOUT_FIELD}": { "${step}": <milliseconds> } above the ${String(ms)} it was given`
182
+ : stuck.length > 0
146
183
  ? `${alone} # what had not finished, alone; the whole step: ${rerun}; ${longer}`
147
184
  : `${rerun} # reproduce it alone; ${longer}`,
148
185
  docs: ERROR_DOCS_URL,