@ultimat3/cli 27.10.3 → 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 +28 -28
- package/src/verify-deadline.ts +50 -11
- package/src/verify-stalled.ts +43 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "27.10.
|
|
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.
|
|
43
|
-
"@ultimat3/admin": "27.10.
|
|
44
|
-
"@ultimat3/ai": "27.10.
|
|
45
|
-
"@ultimat3/auth": "27.10.
|
|
46
|
-
"@ultimat3/cache": "27.10.
|
|
47
|
-
"@ultimat3/core": "27.10.
|
|
48
|
-
"@ultimat3/db": "27.10.
|
|
49
|
-
"@ultimat3/entity": "27.10.
|
|
50
|
-
"@ultimat3/flags": "27.10.
|
|
51
|
-
"@ultimat3/http": "27.10.
|
|
52
|
-
"@ultimat3/i18n": "27.10.
|
|
53
|
-
"@ultimat3/jobs": "27.10.
|
|
54
|
-
"@ultimat3/mail": "27.10.
|
|
55
|
-
"@ultimat3/manifest": "27.10.
|
|
56
|
-
"@ultimat3/mcp": "27.10.
|
|
57
|
-
"@ultimat3/money": "27.10.
|
|
58
|
-
"@ultimat3/notify": "27.10.
|
|
59
|
-
"@ultimat3/policy": "27.10.
|
|
60
|
-
"@ultimat3/pwa": "27.10.
|
|
61
|
-
"@ultimat3/query": "27.10.
|
|
62
|
-
"@ultimat3/realtime": "27.10.
|
|
63
|
-
"@ultimat3/render": "27.10.
|
|
64
|
-
"@ultimat3/schema": "27.10.
|
|
65
|
-
"@ultimat3/seo": "27.10.
|
|
66
|
-
"@ultimat3/storage": "27.10.
|
|
67
|
-
"@ultimat3/testing": "27.10.
|
|
68
|
-
"@ultimat3/time": "27.10.
|
|
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
|
}
|
package/src/verify-deadline.ts
CHANGED
|
@@ -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>,
|
|
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
|
-
|
|
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
|
|
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
|
|
245
|
+
return raced === undefined || raced.timedOut
|
|
246
|
+
? { command, runningMs }
|
|
247
|
+
: { command, output: raced.value, runningMs };
|
|
209
248
|
}),
|
|
210
249
|
);
|
|
211
250
|
}
|
package/src/verify-stalled.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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,
|