leerness 1.36.299 → 1.36.301
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/CHANGELOG.md +56 -0
- package/CONTRIBUTING.md +2 -0
- package/README.md +4 -4
- package/bin/leerness.js +17 -10
- package/docs/runtime-compatibility-api.md +8 -4
- package/lib/runtime-layout.js +7 -3
- package/lib/workspace-dir.js +134 -35
- package/package.json +9 -5
- package/scripts/ci-shard-probe.js +343 -0
- package/scripts/ci-shard-weights.json +590 -0
- package/scripts/ci-shard.js +394 -0
- package/scripts/e2e-child-diagnostics-probe.js +5 -3
- package/scripts/e2e-concurrency.js +24 -10
- package/scripts/e2e.js +843 -822
- package/scripts/lazy-auto-track-probe.js +87 -0
- package/scripts/lock-probe.js +117 -10
- package/scripts/race-overlap-probe.js +148 -0
- package/scripts/race-overlap.js +28 -0
- package/scripts/release-runtime-probe.js +9 -6
- package/scripts/workspace-migration-heartbeat-probe.js +334 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// T-0359: split CI work across parallel jobs. Until 1.36.300 each Windows gate job ran e2e.js (167-226 min) and the
|
|
4
|
+
// command-surface leaves (43-52 min) one after the other, and the Windows fast job ran its leaves for 79 min, so a
|
|
5
|
+
// release waited about five hours for CI (run 38193803239). A shard runs part of that work. Shards 1..k of one job
|
|
6
|
+
// together run every unit exactly once, because every shard computes the same assignment from the same inputs:
|
|
7
|
+
// the units in source order, weighted by measured seconds (scripts/ci-shard-weights.json), placed largest first on
|
|
8
|
+
// the least-loaded shard, ties to the earlier unit and the lower shard.
|
|
9
|
+
//
|
|
10
|
+
// node ./scripts/ci-shard.js npm <script> <i>/<k> run the leaves of an npm script that shard i of k owns
|
|
11
|
+
// node ./scripts/ci-shard.js plan npm <script> <k> print the assignment
|
|
12
|
+
// node ./scripts/ci-shard.js plan e2e <k> print the assignment of the e2e.js cases
|
|
13
|
+
// node ./scripts/ci-shard.js weights e2e|npm <times.json>... replace weights with measured seconds, written by
|
|
14
|
+
// LEERNESS_E2E_TIMES=<file> (e2e.js) or LEERNESS_CI_SHARD_TIMES=<file>
|
|
15
|
+
//
|
|
16
|
+
// A leaf is `node <file> [args]`, or `npm run <name>`, which is expanded. Any other shape is refused rather than
|
|
17
|
+
// guessed: running it differently from npm would test something else. Leaves run with node directly, in the
|
|
18
|
+
// package root, in their order in the script. A shard runs all of its leaves and fails if any failed, so one CI
|
|
19
|
+
// run reports every failing leaf of the shard (a plain `&&` chain stops at the first).
|
|
20
|
+
// e2e.js uses e2eShard() below: each top-level case is `if (e2eShard.take('<key>')) {`, and finish() checks that the
|
|
21
|
+
// file has no unguarded top-level block and that every guard was reached exactly once.
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const cp = require('child_process');
|
|
25
|
+
|
|
26
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
27
|
+
const WEIGHTS_FILE = path.join(__dirname, 'ci-shard-weights.json');
|
|
28
|
+
|
|
29
|
+
function parseShard(text) {
|
|
30
|
+
const m = /^([1-9]\d{0,2})\/([1-9]\d{0,2})$/.exec(String(text == null ? '' : text).trim());
|
|
31
|
+
if (!m || Number(m[1]) > Number(m[2])) throw new Error(`shard must be i/k with 1 <= i <= k, got "${text}"`);
|
|
32
|
+
return { index: Number(m[1]), count: Number(m[2]) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// units: [{ key, weight }] in source order. Returns the shard (1..count) of each unit and each shard's load.
|
|
36
|
+
function assign(units, count) {
|
|
37
|
+
for (const u of units) {
|
|
38
|
+
if (!(typeof u.weight === 'number' && Number.isFinite(u.weight) && u.weight >= 0)) throw new Error(`bad weight for ${u.key}: ${u.weight}`);
|
|
39
|
+
}
|
|
40
|
+
const order = units.map((u, i) => i).sort((a, b) => (units[b].weight - units[a].weight) || (a - b));
|
|
41
|
+
const load = new Array(count).fill(0);
|
|
42
|
+
const shardOf = new Array(units.length);
|
|
43
|
+
for (const i of order) {
|
|
44
|
+
let best = 0;
|
|
45
|
+
for (let s = 1; s < count; s++) if (load[s] < load[best]) best = s;
|
|
46
|
+
shardOf[i] = best + 1;
|
|
47
|
+
load[best] += units[i].weight;
|
|
48
|
+
}
|
|
49
|
+
return { shardOf, load };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function loadWeights(file = WEIGHTS_FILE) {
|
|
53
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
54
|
+
for (const kind of ['e2e', 'npm']) {
|
|
55
|
+
const table = data[kind];
|
|
56
|
+
if (!table || typeof table !== 'object' || Array.isArray(table)) throw new Error(`${path.basename(file)}: "${kind}" table missing`);
|
|
57
|
+
for (const [key, value] of Object.entries(table)) {
|
|
58
|
+
if (!(typeof value === 'number' && Number.isFinite(value) && value >= 0)) throw new Error(`${path.basename(file)}: bad ${kind} weight for ${key}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return data;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A unit without a measurement weighs the median of its table, so a new case or leaf lands like an ordinary one.
|
|
65
|
+
function weigh(keys, table) {
|
|
66
|
+
const values = Object.values(table).sort((a, b) => a - b);
|
|
67
|
+
const median = values.length ? values[Math.floor(values.length / 2)] : 1;
|
|
68
|
+
const plain = key => key.replace(/ #\d+$/, '');
|
|
69
|
+
return keys.map(key => ({ key, weight: Object.prototype.hasOwnProperty.call(table, key) ? table[key]
|
|
70
|
+
: Object.prototype.hasOwnProperty.call(table, plain(key)) ? table[plain(key)] : median }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Measured seconds (the LEERNESS_E2E_TIMES / LEERNESS_CI_SHARD_TIMES files) replace those units' weights; the
|
|
74
|
+
// table is kept sorted so a refresh diffs cleanly. A repeated leaf (`<text> #2`) is stored under its text.
|
|
75
|
+
function withMeasured(data, kind, measured) {
|
|
76
|
+
const table = { ...data[kind] };
|
|
77
|
+
let updated = 0;
|
|
78
|
+
for (const times of measured) {
|
|
79
|
+
for (const [key, seconds] of Object.entries(times)) {
|
|
80
|
+
if (!(typeof seconds === 'number' && Number.isFinite(seconds) && seconds >= 0)) throw new Error(`bad seconds for ${key}: ${seconds}`);
|
|
81
|
+
table[key.replace(/ #\d+$/, '')] = Math.round(seconds * 10) / 10; updated++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const sorted = Object.fromEntries(Object.entries(table).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
85
|
+
return { data: { ...data, [kind]: sorted }, updated };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function uniqueKeys(texts) {
|
|
89
|
+
const seen = new Map();
|
|
90
|
+
return texts.map(text => { const n = (seen.get(text) || 0) + 1; seen.set(text, n); return n === 1 ? text : `${text} #${n}`; });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- npm script leaves ----
|
|
94
|
+
const LEAF_TOKEN = /^[\w./:=@+-]+$/;
|
|
95
|
+
|
|
96
|
+
function npmLeaves(scripts, name, stack = []) {
|
|
97
|
+
if (!Object.prototype.hasOwnProperty.call(scripts, name)) throw new Error(`npm script not found: ${name}`);
|
|
98
|
+
if (stack.includes(name)) throw new Error(`npm script cycle: ${[...stack, name].join(' -> ')}`);
|
|
99
|
+
// `npm run <name>` also runs pre<name> and post<name> (review round 1). This runner does not run lifecycle
|
|
100
|
+
// scripts, so a script that has one is refused instead of losing it.
|
|
101
|
+
for (const hook of [`pre${name}`, `post${name}`]) {
|
|
102
|
+
if (Object.prototype.hasOwnProperty.call(scripts, hook)) {
|
|
103
|
+
throw new Error(`unsupported npm script ${name}: npm would also run its lifecycle script ${hook}, which ci-shard.js does not run`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const part of String(scripts[name]).split('&&').map(p => p.trim())) {
|
|
108
|
+
const nested = /^npm run ([\w:.-]+)$/.exec(part);
|
|
109
|
+
if (nested) { out.push(...npmLeaves(scripts, nested[1], [...stack, name])); continue; }
|
|
110
|
+
const argv = part.split(/\s+/);
|
|
111
|
+
if (argv[0] !== 'node' || argv.length < 2 || !argv.every(t => LEAF_TOKEN.test(t)) || argv[1].startsWith('-')) {
|
|
112
|
+
throw new Error(`unsupported step in npm script ${name}: "${part}" (expected node <file> [args] or npm run <name>)`);
|
|
113
|
+
}
|
|
114
|
+
out.push({ text: argv.join(' '), file: argv[1], args: argv.slice(2), from: name });
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readScripts() {
|
|
120
|
+
return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8').replace(/^\uFEFF/, '')).scripts || {};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function planNpm(scripts, name, count, weights) {
|
|
124
|
+
const leaves = npmLeaves(scripts, name);
|
|
125
|
+
const keys = uniqueKeys(leaves.map(l => l.text));
|
|
126
|
+
const units = weigh(keys, weights.npm);
|
|
127
|
+
const { shardOf, load } = assign(units, count);
|
|
128
|
+
return { leaves: leaves.map((l, i) => ({ ...l, key: keys[i], weight: units[i].weight, shard: shardOf[i] })), load };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function runNpm(name, spec, opts = {}) {
|
|
132
|
+
const root = opts.root || ROOT;
|
|
133
|
+
const env = opts.env || process.env;
|
|
134
|
+
const scripts = opts.scripts || readScripts();
|
|
135
|
+
const weights = opts.weights || loadWeights();
|
|
136
|
+
const { leaves } = planNpm(scripts, name, spec.count, weights);
|
|
137
|
+
const mine = leaves.filter(l => l.shard === spec.index);
|
|
138
|
+
const log = opts.log || (line => process.stdout.write(line + '\n'));
|
|
139
|
+
const grouped = env.GITHUB_ACTIONS === 'true';
|
|
140
|
+
log(`ci-shard: ${name} shard ${spec.index}/${spec.count} runs ${mine.length} of ${leaves.length} leaves`);
|
|
141
|
+
const results = [];
|
|
142
|
+
for (const leaf of mine) {
|
|
143
|
+
if (grouped) log(`::group::${leaf.key}`);
|
|
144
|
+
const started = Date.now();
|
|
145
|
+
const r = cp.spawnSync(process.execPath, [path.resolve(root, leaf.file), ...leaf.args], { cwd: root, stdio: opts.stdio || 'inherit', env });
|
|
146
|
+
const seconds = (Date.now() - started) / 1000;
|
|
147
|
+
const ok = r.status === 0 && !r.signal && !r.error;
|
|
148
|
+
results.push({ key: leaf.key, ok, seconds, status: r.status, signal: r.signal || null, error: r.error ? String(r.error.code || r.error.message) : null });
|
|
149
|
+
if (grouped) log('::endgroup::');
|
|
150
|
+
log(`ci-shard: ${ok ? 'ok ' : 'FAIL'} ${seconds.toFixed(1).padStart(7)} s ${leaf.key}${ok ? '' : ` (exit ${r.status}${r.signal ? `, signal ${r.signal}` : ''}${r.error ? `, ${r.error.code || r.error.message}` : ''})`}`);
|
|
151
|
+
}
|
|
152
|
+
const failed = results.filter(r => !r.ok);
|
|
153
|
+
const total = results.reduce((s, r) => s + r.seconds, 0);
|
|
154
|
+
log(`ci-shard: ${name} shard ${spec.index}/${spec.count}: ${results.length - failed.length}/${results.length} leaves passed · ${total.toFixed(0)} s`);
|
|
155
|
+
for (const f of failed) log(`ci-shard: failed: ${f.key}`);
|
|
156
|
+
if (env.LEERNESS_CI_SHARD_TIMES) {
|
|
157
|
+
fs.writeFileSync(env.LEERNESS_CI_SHARD_TIMES, JSON.stringify(Object.fromEntries(results.map(r => [r.key, Math.round(r.seconds * 10) / 10])), null, 1) + '\n');
|
|
158
|
+
}
|
|
159
|
+
return { ran: results.length, of: leaves.length, failed: failed.map(f => f.key), results };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- e2e.js cases ----
|
|
163
|
+
// A case is a top-level `if (e2eShard.take('<key>')) {` block. Its text, for the check below, runs to the next guard:
|
|
164
|
+
// a column-0 `}` can sit inside a template literal, and a short text could hide a mention.
|
|
165
|
+
// The helper is created by one line naming the file's shared top-level state (the fixture project and its helpers,
|
|
166
|
+
// whose state one case leaves for the next). Every case whose text mentions one of those names runs in the same
|
|
167
|
+
// shard as the others, in source order; the other cases are placed one by one.
|
|
168
|
+
const GUARD = /^if \(e2eShard\.take\('([^'\\\n]+)'\)\) \{$/;
|
|
169
|
+
const CREATE = /^const e2eShard = require\('\.\/ci-shard'\)\.e2eShard\(__filename, \[((?:'[\w$]+'(?:, )?)*)\]\);$/;
|
|
170
|
+
const SHARED_GROUP = '(cases using the shared fixture)';
|
|
171
|
+
// A case's block ends at its first column-0 `}`. The cases start after the line `// e2e-shard: cases start here`;
|
|
172
|
+
// from there, top-level code outside a case only prepares what later cases use (smoke `run()` calls, fixture files,
|
|
173
|
+
// environment switches). A block, a control-flow block, an immediately invoked function or a case counter there is
|
|
174
|
+
// a case without a guard: review round 1 found a block opened by `{ // comment`, round 2 an `if (false) {` in place
|
|
175
|
+
// of the first guard (the boundary was the first guard then). Whatever wraps a case, its `total++` stays outside
|
|
176
|
+
// a guard and is reported. The preamble above the line may use any statement except a bare block. The run-wide
|
|
177
|
+
// section, from a column-0 `setImmediate(` to its column-0 `});` (a comment may follow), ends the file; each of
|
|
178
|
+
// its own counters carries `// e2e-shard: run-wide check`.
|
|
179
|
+
const CASES_START = /^\/\/ e2e-shard: cases start here\b/;
|
|
180
|
+
const RUN_WIDE_CHECK = /\/\/ e2e-shard: run-wide check\s*$/;
|
|
181
|
+
const CASE_COUNTER = /(^|[^\w$.])total\s*(?:\+\+|\+=)/;
|
|
182
|
+
const OUTSIDE_A_CASE = [
|
|
183
|
+
[/^\s*\{/, 'a block'],
|
|
184
|
+
[/^\s*(?:if|else|for|while|do|try|switch)\b.*\{\s*(?:\/\/.*)?$/, 'a control-flow block'],
|
|
185
|
+
[/^\s*[(!;+~-]\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[\w$]+\s*=>)/, 'an immediately invoked function'],
|
|
186
|
+
[CASE_COUNTER, 'a case counter'],
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
function e2eCases(source) {
|
|
190
|
+
const lines = String(source).replace(/\r\n/g, '\n').split('\n');
|
|
191
|
+
const keys = [], problems = [], at = new Map(), text = new Map();
|
|
192
|
+
let shared = null, current = null, inCase = false, casesStarted = false, runWide = false, runWideEnded = false;
|
|
193
|
+
lines.forEach((line, i) => {
|
|
194
|
+
const c = CREATE.exec(line);
|
|
195
|
+
if (c) {
|
|
196
|
+
if (shared) problems.push(`line ${i + 1}: a second e2eShard(...) line`);
|
|
197
|
+
shared = [...c[1].matchAll(/'([\w$]+)'/g)].map(m => m[1]);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const m = GUARD.exec(line);
|
|
201
|
+
if (m) {
|
|
202
|
+
if (!casesStarted) problems.push(`line ${i + 1}: case "${m[1]}" before the line "// e2e-shard: cases start here"`);
|
|
203
|
+
if (runWide) problems.push(`line ${i + 1}: case "${m[1]}" in or after the run-wide section`);
|
|
204
|
+
if (inCase) problems.push(`line ${i + 1}: case "${m[1]}" starts inside case "${current}"`);
|
|
205
|
+
if (at.has(m[1])) { problems.push(`key "${m[1]}" repeats (lines ${at.get(m[1])} and ${i + 1})`); current = null; }
|
|
206
|
+
else { at.set(m[1], i + 1); keys.push(m[1]); current = m[1]; text.set(current, []); }
|
|
207
|
+
inCase = true;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (current) text.get(current).push(line);
|
|
211
|
+
if (/e2eShard\.take\(/.test(line) && !/^\s*\/\//.test(line)) {
|
|
212
|
+
problems.push(`line ${i + 1}: e2eShard.take outside the guard form "if (e2eShard.take('<key>')) {"`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (inCase) { if (/^\}/.test(line)) inCase = false; return; }
|
|
216
|
+
if (CASES_START.test(line)) {
|
|
217
|
+
if (casesStarted) problems.push(`line ${i + 1}: a second "cases start here" line`);
|
|
218
|
+
casesStarted = true;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (runWideEnded) {
|
|
222
|
+
if (line.trim() && !/^\s*\/\//.test(line)) problems.push(`line ${i + 1}: code after the run-wide section`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (runWide) {
|
|
226
|
+
// Review round 3: `}); // end` left the section open, so a case after it was exempt.
|
|
227
|
+
if (/^\}\)\s*;?\s*(?:\/\/.*|\/\*.*\*\/\s*)?$/.test(line)) { runWideEnded = true; return; }
|
|
228
|
+
// The section's own checks say so; any other counter there is a case without a guard.
|
|
229
|
+
if (CASE_COUNTER.test(line) && !RUN_WIDE_CHECK.test(line)) {
|
|
230
|
+
problems.push(`line ${i + 1}: a case counter in the run-wide section without "// e2e-shard: run-wide check"`);
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (casesStarted && /^setImmediate\(/.test(line)) { runWide = true; return; }
|
|
235
|
+
if (/^\s*\/\//.test(line)) return;
|
|
236
|
+
for (const [re, what] of casesStarted ? OUTSIDE_A_CASE : OUTSIDE_A_CASE.slice(0, 1)) {
|
|
237
|
+
if (re.test(line)) { problems.push(`line ${i + 1}: ${what} outside a case, without an e2eShard.take guard`); break; }
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
if (!casesStarted) problems.push('no line "// e2e-shard: cases start here"');
|
|
241
|
+
if (runWide && !runWideEnded) problems.push('the run-wide section (column-0 "setImmediate(") never closes with a column-0 "});"');
|
|
242
|
+
if (!shared) { problems.push("no line \"const e2eShard = require('./ci-shard').e2eShard(__filename, [<shared names>]);\""); shared = []; }
|
|
243
|
+
const mentions = shared.map(n => new RegExp(`(^|[^\\w$.])${n.replace(/\$/g, '\\$')}([^\\w$]|$)`, 'm'));
|
|
244
|
+
const usesShared = new Set(keys.filter(k => { const t = text.get(k).join('\n'); return mentions.some(re => re.test(t)); }));
|
|
245
|
+
return { keys, problems, shared, usesShared };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function planE2e(source, count, weights) {
|
|
249
|
+
const { keys, problems, shared, usesShared } = e2eCases(source);
|
|
250
|
+
const weighed = weigh(keys, weights.e2e);
|
|
251
|
+
// One unit for the cases using shared state, at the position of the first of them; one unit per other case.
|
|
252
|
+
const units = [], unitOf = new Map();
|
|
253
|
+
weighed.forEach(u => {
|
|
254
|
+
if (!usesShared.has(u.key)) { unitOf.set(u.key, units.length); units.push({ key: u.key, weight: u.weight }); return; }
|
|
255
|
+
if (!unitOf.has(SHARED_GROUP)) { unitOf.set(SHARED_GROUP, units.length); units.push({ key: SHARED_GROUP, weight: 0 }); }
|
|
256
|
+
units[unitOf.get(SHARED_GROUP)].weight += u.weight;
|
|
257
|
+
unitOf.set(u.key, unitOf.get(SHARED_GROUP));
|
|
258
|
+
});
|
|
259
|
+
const { shardOf, load } = assign(units, count);
|
|
260
|
+
return { cases: weighed.map(u => ({ key: u.key, weight: u.weight, shared: usesShared.has(u.key), shard: shardOf[unitOf.get(u.key)] })),
|
|
261
|
+
load, problems, shared };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function e2eShard(file, shared, env = process.env, opts = {}) {
|
|
265
|
+
const raw = env.LEERNESS_E2E_SHARD;
|
|
266
|
+
const timesOut = env.LEERNESS_E2E_TIMES || null;
|
|
267
|
+
// Children see the environment of an unsharded run: a test that starts this file again runs all of it.
|
|
268
|
+
delete env.LEERNESS_E2E_SHARD; delete env.LEERNESS_E2E_TIMES;
|
|
269
|
+
const spec = raw === undefined || raw === '' ? null : parseShard(raw);
|
|
270
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
271
|
+
const parsed = e2eCases(source);
|
|
272
|
+
const { keys, problems } = parsed;
|
|
273
|
+
if (JSON.stringify(shared) !== JSON.stringify(parsed.shared)) problems.push(`shared names passed (${JSON.stringify(shared)}) differ from the file's e2eShard line (${JSON.stringify(parsed.shared)})`);
|
|
274
|
+
const known = new Set(keys);
|
|
275
|
+
let mine = null;
|
|
276
|
+
if (spec) {
|
|
277
|
+
const { cases } = planE2e(source, spec.count, opts.weights || loadWeights());
|
|
278
|
+
mine = new Set(cases.filter(c => c.shard === spec.index).map(c => c.key));
|
|
279
|
+
}
|
|
280
|
+
const reached = new Map(), times = {};
|
|
281
|
+
let current = null, since = 0;
|
|
282
|
+
const stop = () => { if (current !== null) times[current] = Math.round((times[current] || 0) * 10 + (Date.now() - since) / 100) / 10; current = null; };
|
|
283
|
+
return {
|
|
284
|
+
spec,
|
|
285
|
+
take(key) {
|
|
286
|
+
stop();
|
|
287
|
+
reached.set(key, (reached.get(key) || 0) + 1);
|
|
288
|
+
// A key the parser did not see (a guard in another form) runs in every shard: finish() fails the run, but no
|
|
289
|
+
// shard drops the case.
|
|
290
|
+
const run = !mine || mine.has(key) || !known.has(key);
|
|
291
|
+
if (run) { current = key; since = Date.now(); }
|
|
292
|
+
return run;
|
|
293
|
+
},
|
|
294
|
+
finish() {
|
|
295
|
+
stop();
|
|
296
|
+
const missing = keys.filter(k => !reached.has(k));
|
|
297
|
+
const repeated = [...reached].filter(([, n]) => n > 1).map(([k]) => k);
|
|
298
|
+
const unknown = [...reached.keys()].filter(k => !known.has(k));
|
|
299
|
+
if (timesOut) { try { fs.writeFileSync(timesOut, JSON.stringify(times, null, 1) + '\n'); } catch (e) { problems.push(`times file not written: ${e.message}`); } }
|
|
300
|
+
const ok = !problems.length && !missing.length && !repeated.length && !unknown.length;
|
|
301
|
+
return { ok, spec, ran: mine ? keys.filter(k => mine.has(k)).length + unknown.length : keys.length, total: keys.length, problems, missing, repeated, unknown };
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- .github/workflows/ci.yml shard lists ----
|
|
307
|
+
// The jobs of a workflow written the way ci.yml is: two-space job keys under `jobs:`, matrix entries as one-line flow
|
|
308
|
+
// mappings (`- { os: windows-latest, node: 18, shard: 1, shards: 4 }`), steps with `run:` and `env:` lines.
|
|
309
|
+
// Returns { <job>: { entries: [{ key: value }], runs: [command], lines: [line] } }.
|
|
310
|
+
function workflowJobs(text) {
|
|
311
|
+
const lines = String(text).replace(/\r\n/g, '\n').split('\n');
|
|
312
|
+
const jobs = {};
|
|
313
|
+
let job = null, inJobs = false;
|
|
314
|
+
for (const line of lines) {
|
|
315
|
+
if (/^\S/.test(line)) { inJobs = line === 'jobs:'; job = null; continue; }
|
|
316
|
+
if (!inJobs) continue;
|
|
317
|
+
const head = /^ {2}([\w-]+):\s*$/.exec(line);
|
|
318
|
+
if (head) { job = jobs[head[1]] = { entries: [], runs: [], lines: [] }; continue; }
|
|
319
|
+
if (!job) continue;
|
|
320
|
+
job.lines.push(line);
|
|
321
|
+
const entry = /^\s+- \{ (.+) \}\s*$/.exec(line);
|
|
322
|
+
if (entry) {
|
|
323
|
+
job.entries.push(Object.fromEntries(entry[1].split(/,\s*/).map(pair => {
|
|
324
|
+
const at = pair.indexOf(':');
|
|
325
|
+
return at < 0 ? [pair.trim(), ''] : [pair.slice(0, at).trim(), pair.slice(at + 1).trim()];
|
|
326
|
+
})));
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const run = /^\s+(?:- )?run:\s*(.+?)\s*$/.exec(line);
|
|
330
|
+
if (run) job.runs.push(run[1]);
|
|
331
|
+
}
|
|
332
|
+
return jobs;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// The configurations of a job's entries (an entry without shard/shards), and for a sharded job the configurations
|
|
336
|
+
// whose entries are not exactly shards 1..k of one k.
|
|
337
|
+
function shardLists(entries) {
|
|
338
|
+
const groups = new Map();
|
|
339
|
+
for (const e of entries) {
|
|
340
|
+
const { shard, shards, ...config } = e;
|
|
341
|
+
const id = Object.entries(config).sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, v]) => `${k}=${v}`).join(' ');
|
|
342
|
+
if (!groups.has(id)) groups.set(id, []);
|
|
343
|
+
groups.get(id).push({ shard, shards });
|
|
344
|
+
}
|
|
345
|
+
const problems = [];
|
|
346
|
+
for (const [id, list] of groups) {
|
|
347
|
+
const k = Number(list[0].shards);
|
|
348
|
+
const got = list.map(x => Number(x.shard)).sort((a, b) => a - b);
|
|
349
|
+
const sameK = list.every(x => x.shards === list[0].shards);
|
|
350
|
+
if (!sameK || !Number.isInteger(k) || k < 1 || got.length !== k || got.some((s, i) => s !== i + 1)) {
|
|
351
|
+
problems.push(`${id}: ${list.map(x => `${x.shard}/${x.shards}`).join(', ')}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return { configs: [...groups.keys()], problems };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function main(argv) {
|
|
358
|
+
const [cmd, ...rest] = argv;
|
|
359
|
+
if (cmd === 'npm' && rest.length === 2) {
|
|
360
|
+
const r = runNpm(rest[0], parseShard(rest[1]));
|
|
361
|
+
return r.failed.length ? 1 : 0;
|
|
362
|
+
}
|
|
363
|
+
if (cmd === 'plan' && ((rest[0] === 'npm' && rest.length === 3) || (rest[0] === 'e2e' && rest.length === 2))) {
|
|
364
|
+
const count = parseShard(`1/${rest[rest.length - 1]}`).count;
|
|
365
|
+
const weights = loadWeights();
|
|
366
|
+
const plan = rest[0] === 'npm' ? planNpm(readScripts(), rest[1], count, weights)
|
|
367
|
+
: planE2e(fs.readFileSync(path.join(__dirname, 'e2e.js'), 'utf8'), count, weights);
|
|
368
|
+
const units = plan.leaves || plan.cases;
|
|
369
|
+
for (let s = 1; s <= count; s++) {
|
|
370
|
+
const own = units.filter(u => u.shard === s);
|
|
371
|
+
console.log(`shard ${s}/${count}: ${own.length} units, ${(plan.load[s - 1] / 60).toFixed(1)} min measured`);
|
|
372
|
+
for (const u of own) console.log(` ${u.weight.toFixed(1).padStart(8)} s ${u.shared ? '[shared] ' : ''}${u.key}`);
|
|
373
|
+
}
|
|
374
|
+
if (plan.shared) console.log(`shared names: ${plan.shared.join(', ')}; cases using them: ${plan.cases.filter(c => c.shared).length}`);
|
|
375
|
+
for (const p of plan.problems || []) console.log(`problem: ${p}`);
|
|
376
|
+
return plan.problems && plan.problems.length ? 1 : 0;
|
|
377
|
+
}
|
|
378
|
+
if (cmd === 'weights' && ['e2e', 'npm'].includes(rest[0]) && rest.length >= 2) {
|
|
379
|
+
const measured = rest.slice(1).map(file => JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
380
|
+
const { data, updated } = withMeasured(loadWeights(), rest[0], measured);
|
|
381
|
+
fs.writeFileSync(WEIGHTS_FILE, JSON.stringify(data, null, 1) + '\n');
|
|
382
|
+
console.log(`ci-shard: ${updated} ${rest[0]} weights updated in ${path.relative(ROOT, WEIGHTS_FILE)}`);
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
console.error('usage: ci-shard.js npm <script> <i>/<k> | plan npm <script> <k> | plan e2e <k> | weights e2e|npm <times.json>...');
|
|
386
|
+
return 2;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
module.exports = { parseShard, assign, loadWeights, weigh, withMeasured, uniqueKeys, npmLeaves, planNpm, runNpm, e2eCases, planE2e, e2eShard,
|
|
390
|
+
workflowJobs, shardLists, WEIGHTS_FILE };
|
|
391
|
+
|
|
392
|
+
if (require.main === module) {
|
|
393
|
+
try { process.exitCode = main(process.argv.slice(2)); } catch (e) { console.error(`ci-shard: ${e.message}`); process.exitCode = 2; }
|
|
394
|
+
}
|
|
@@ -34,9 +34,10 @@ const unique = marker => {
|
|
|
34
34
|
const extract = (start, end, precedingCounter = false) => {
|
|
35
35
|
let a = unique(start); const b = unique(end);
|
|
36
36
|
if (precedingCounter) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
a
|
|
37
|
+
// T-0359: a case opens with its shard guard, and the counter is the guard's first line.
|
|
38
|
+
const guard = source.lastIndexOf("if (e2eShard.take('", a);
|
|
39
|
+
assert(guard >= 0 && /^if \(e2eShard\.take\('[^'\\\n]+'\)\) \{\n total\+\+;\n\s*$/.test(source.slice(guard, a)), 'handoff counter boundary drift');
|
|
40
|
+
a = guard;
|
|
40
41
|
}
|
|
41
42
|
assert(b > a, 'source markers reversed');
|
|
42
43
|
const block = source.slice(a, b);
|
|
@@ -100,6 +101,7 @@ try {
|
|
|
100
101
|
const execute = (name, failingResult) => {
|
|
101
102
|
const lines = [], calls = [];
|
|
102
103
|
const context = { fs, path, os: { ...os, tmpdir: () => arena }, process: { execPath: process.execPath, env: { ...process.env } }, CLI, SELFTEST_SPAWN_MS, performance, childDiagnostics, total: 0, failed: 0,
|
|
104
|
+
e2eShard: { take: () => true },
|
|
103
105
|
console: { log: (...args) => lines.push(args.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')) },
|
|
104
106
|
cp: { spawnSync: (executable, args, options) => {
|
|
105
107
|
assert.equal(executable, process.execPath); assert.equal(args[0], CLI);
|
|
@@ -9,6 +9,7 @@ const cp = require('child_process');
|
|
|
9
9
|
|
|
10
10
|
const CLI = path.resolve(process.argv[2] || path.join(__dirname, '..', 'bin', 'leerness.js'));
|
|
11
11
|
const PRELOAD = path.join(__dirname, 'lock-probe.js');
|
|
12
|
+
const { overlapOf } = require('./race-overlap');
|
|
12
13
|
const ENV = {
|
|
13
14
|
...process.env,
|
|
14
15
|
LEERNESS_OFFLINE: '1',
|
|
@@ -37,16 +38,22 @@ function init(d) {
|
|
|
37
38
|
// is observed rather than inferred. T-0314: inferring it from `<target>.tmp-*` names stopped working when
|
|
38
39
|
// 1.36.190 published memory outputs through `.<target>.publish-*` + link; 8 of 11 races then never raced.
|
|
39
40
|
const readyFileOf = d => path.join(d, '.stall-ready.json');
|
|
41
|
+
// T-0346: the second command writes this file after it read the target, or tried its lock or a lock the first
|
|
42
|
+
// command held when it stalled; the first command's stall ends only after it exists (see scripts/lock-probe.js).
|
|
43
|
+
// A fixed 1.2 s stall could end before the second command had started (a Node start alone can take that long on
|
|
44
|
+
// Windows), and the race then passed without overlapping the publication.
|
|
45
|
+
const reachFileOf = d => path.join(d, '.stall-reached.json');
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const env =
|
|
46
|
-
? { ...ENV, LOCKPROBE_STALL_TARGET:
|
|
47
|
-
: ENV;
|
|
47
|
+
// role 'first' stalls the publication of `target` until the second command reaches it; role 'second' reports
|
|
48
|
+
// reaching it. Both run with the lock-probe preload.
|
|
49
|
+
function spawn(d, args, role, target) {
|
|
50
|
+
const nodeArgs = ['--require', PRELOAD, CLI, ...args, '--path', d];
|
|
51
|
+
const env = role === 'first'
|
|
52
|
+
? { ...ENV, LOCKPROBE_STALL_TARGET: target, LOCKPROBE_STALL_MS: String(STALL_MS), LOCKPROBE_READY_FILE: readyFileOf(d), LOCKPROBE_WAIT_FILE: reachFileOf(d) }
|
|
53
|
+
: { ...ENV, LOCKPROBE_REACH_TARGET: target, LOCKPROBE_REACH_FILE: reachFileOf(d), LOCKPROBE_REACH_LOCKS_FILE: readyFileOf(d) };
|
|
48
54
|
return cp.spawn(process.execPath, nodeArgs, { cwd: d, env, stdio: 'ignore' });
|
|
49
55
|
}
|
|
56
|
+
const readJson = file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
50
57
|
|
|
51
58
|
function waitForStall(d, target) {
|
|
52
59
|
// T-0343: an absolute target (a file outside .leerness) is acknowledged by its full path, a relative one by its
|
|
@@ -66,15 +73,22 @@ async function race(name, config) {
|
|
|
66
73
|
if (config.seed) config.seed(d);
|
|
67
74
|
// T-0343: a target outside .leerness is given as a function of the project directory (its absolute path).
|
|
68
75
|
const stallTarget = typeof config.stallTarget === 'function' ? config.stallTarget(d) : config.stallTarget;
|
|
69
|
-
const first = spawn(d, config.first, stallTarget);
|
|
76
|
+
const first = spawn(d, config.first, 'first', stallTarget);
|
|
70
77
|
const stalled = waitForStall(d, stallTarget);
|
|
71
78
|
if (config.beforeSecond) config.beforeSecond(d);
|
|
72
|
-
const second = spawn(d, typeof config.second === 'function' ? config.second(d) : config.second);
|
|
79
|
+
const second = spawn(d, typeof config.second === 'function' ? config.second(d) : config.second, 'second', stallTarget);
|
|
73
80
|
const exits = await Promise.all([wait(first), wait(second)]);
|
|
81
|
+
// T-0346: the race counts only when the second command overlapped the first one's stalled section (see
|
|
82
|
+
// scripts/race-overlap.js): a contended lock attempt, or, for a command that tried none of those locks, a read
|
|
83
|
+
// of the stalled file that finished before the first command resumed.
|
|
84
|
+
const ready = readJson(readyFileOf(d)) || {}, reach = readJson(reachFileOf(d));
|
|
85
|
+
const { overlapped, why } = overlapOf({ stalled, ready, reach });
|
|
74
86
|
const inspected = config.inspect(d);
|
|
75
87
|
// T-0343: `secondExit` names the second command's expected exit when it is not 0 (lazy detect reports issues with 1).
|
|
76
88
|
const secondExit = config.secondExit === undefined ? 0 : config.secondExit;
|
|
77
|
-
return { name, stalled,
|
|
89
|
+
return { name, stalled, overlapped, why, read: reach && reach.read && reach.read.how, lock: reach && reach.lock && reach.lock.result,
|
|
90
|
+
waitedMs: ready.waitedMs, exits, ...inspected,
|
|
91
|
+
ok: overlapped && exits[0] === 0 && exits[1] === secondExit && inspected.preserved === true };
|
|
78
92
|
} catch (e) {
|
|
79
93
|
return { name, error: String(e && e.message || e), ok: false };
|
|
80
94
|
} finally {
|