brittle-jobs 0.2.0 → 0.2.2
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/README.md +15 -7
- package/bin.js +69 -23
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,7 +4,8 @@ Run [brittle](https://github.com/holepunchto/brittle) test files concurrently, o
|
|
|
4
4
|
|
|
5
5
|
- A hung file costs one file, not the run: idle and hard timeouts kill the whole process group, grandchildren included.
|
|
6
6
|
- Every file gets its own `TMPDIR`, wiped by the runner on exit or kill, so leaked tmp data cannot starve the disk.
|
|
7
|
-
- Pool size
|
|
7
|
+
- Pool size defaults to the core count. Oversubscribing made a CPU-bound suite slower, so raise `-j` only for
|
|
8
|
+
wait-bound suites.
|
|
8
9
|
|
|
9
10
|
## Installation
|
|
10
11
|
|
|
@@ -25,18 +26,20 @@ brittle-jobs test/*.js --runtime node
|
|
|
25
26
|
```
|
|
26
27
|
brittle-jobs [globs|files...] [flags]
|
|
27
28
|
|
|
28
|
-
-j, --jobs N concurrent processes (default BRITTLE_JOBS or
|
|
29
|
+
-j, --jobs N concurrent processes (default BRITTLE_JOBS or cores)
|
|
29
30
|
-t, --timeout MS hard cap per file (default 300000)
|
|
30
31
|
--idle-timeout MS kill file after this long without output (default 120000)
|
|
31
32
|
--retries N rerun failed files, pass on retry = FLAKY (default 0)
|
|
32
33
|
--min-free-mb N wait for free disk on tmp root before spawning (default 1024)
|
|
33
34
|
--shard i/n run the i-th of n slices
|
|
35
|
+
--solo GLOB run matching files alone, after the rest (repeatable)
|
|
36
|
+
--timings FILE json of file durations: orders and balances shards by it, updated after the run
|
|
34
37
|
--runtime CMD child command (default bare)
|
|
35
38
|
-b, --bail stop on first failure
|
|
36
39
|
```
|
|
37
40
|
|
|
38
|
-
Each file runs as `<runtime> <file>`; a brittle test file runs itself. `bare`
|
|
39
|
-
`bare-runtime
|
|
41
|
+
Each file runs as `<runtime> <file>`; a brittle test file runs itself. `bare` is spawned through the project's own
|
|
42
|
+
`bare-runtime/spawn`, then from `PATH`. Pin `bare-runtime` exactly: that API is not covered by semver.
|
|
40
43
|
|
|
41
44
|
Output is one line per file as it finishes, the full output of any file that fails, the ten slowest files, and a
|
|
42
45
|
summary. Exit code is 1 when any file fails or hangs.
|
|
@@ -56,12 +59,17 @@ A `HUNG` report includes the process tree of the file at the moment it was kille
|
|
|
56
59
|
- `TMPDIR`, `TMP`, `TEMP` point at a per-file directory under `os.tmpdir()/brittle-jobs-<cwd hash>`. It is removed
|
|
57
60
|
when the file ends, however it ends. A stale root from a killed runner is removed at startup.
|
|
58
61
|
- `BRITTLE_JOBS_WORKER_ID` and `JEST_WORKER_ID` hold the pool slot (`1..jobs`), for namespacing ports or databases.
|
|
59
|
-
- Files start
|
|
62
|
+
- Files start heaviest first. `--shard i/n` assigns each file, in that order, to the lightest shard so far.
|
|
63
|
+
- Weight is the file size, or its duration with `--timings`. A file missing from the timings gets the median.
|
|
64
|
+
- Every shard must read the same timings file, or shards drop and repeat files. Commit it; do not restore it from
|
|
65
|
+
a per-job cache. Each run merges the durations of passing files back into it.
|
|
66
|
+
- `--solo` files run one at a time once the pool is empty, for files that starve each other under concurrency.
|
|
60
67
|
|
|
61
68
|
### Limits
|
|
62
69
|
|
|
63
70
|
- POSIX only: process groups are not available on Windows.
|
|
64
|
-
- A grandchild that calls `setsid` escapes the group kill.
|
|
71
|
+
- A grandchild that calls `setsid` escapes the group kill. If it holds the file's stdout, the runner stops waiting
|
|
72
|
+
2s after the file exits.
|
|
65
73
|
- Two concurrent runs from the same working directory share a tmp root and wipe each other.
|
|
66
74
|
|
|
67
75
|
## Migrating from 0.1
|
|
@@ -70,7 +78,7 @@ A `HUNG` report includes the process tree of the file at the moment it was kille
|
|
|
70
78
|
- Default runtime is `bare`. `--bare` is accepted and ignored; use `--runtime node` for Node.
|
|
71
79
|
- Generated `all.*` runner files are skipped, not expanded. Pass the test files or a glob.
|
|
72
80
|
- `--timeout` is now a per-file cap enforced by the runner, not brittle's per-test timeout.
|
|
73
|
-
- No timing cache; files are ordered by size.
|
|
81
|
+
- No implicit timing cache; files are ordered by size unless `--timings` is passed.
|
|
74
82
|
|
|
75
83
|
## License
|
|
76
84
|
|
package/bin.js
CHANGED
|
@@ -16,6 +16,8 @@ const { values: opts, positionals } = parseArgs({
|
|
|
16
16
|
retries: { type: 'string', default: '0' },
|
|
17
17
|
'min-free-mb': { type: 'string', default: '1024' },
|
|
18
18
|
shard: { type: 'string' },
|
|
19
|
+
solo: { type: 'string', multiple: true },
|
|
20
|
+
timings: { type: 'string' },
|
|
19
21
|
runtime: { type: 'string' },
|
|
20
22
|
bail: { type: 'boolean', short: 'b', default: false },
|
|
21
23
|
bare: { type: 'boolean', default: false },
|
|
@@ -26,37 +28,44 @@ const { values: opts, positionals } = parseArgs({
|
|
|
26
28
|
if (opts.help) {
|
|
27
29
|
console.log(`brittle-jobs [globs|files...] [flags]
|
|
28
30
|
|
|
29
|
-
-j, --jobs N concurrent processes (default BRITTLE_JOBS or
|
|
31
|
+
-j, --jobs N concurrent processes (default BRITTLE_JOBS or cores)
|
|
30
32
|
-t, --timeout MS hard cap per file (default 300000)
|
|
31
33
|
--idle-timeout MS kill file after this long without output (default 120000)
|
|
32
34
|
--retries N rerun failed files, pass on retry = FLAKY (default 0)
|
|
33
35
|
--min-free-mb N wait for free disk on tmp root before spawning (default 1024)
|
|
34
36
|
--shard i/n run the i-th of n slices
|
|
37
|
+
--solo GLOB run matching files alone, after the rest (repeatable)
|
|
38
|
+
--timings FILE json of file durations: orders and balances shards by it, updated after the run
|
|
35
39
|
--runtime CMD child command (default bare)
|
|
36
40
|
-b, --bail stop on first failure`)
|
|
37
41
|
process.exit(0)
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
const GRACE_MS = 2000
|
|
41
|
-
const jobs = Math.max(
|
|
42
|
-
1,
|
|
43
|
-
Number(opts.jobs || process.env.BRITTLE_JOBS) || 2 * os.availableParallelism()
|
|
44
|
-
)
|
|
45
|
+
const jobs = Math.max(1, Number(opts.jobs || process.env.BRITTLE_JOBS) || os.availableParallelism())
|
|
45
46
|
const timeoutMs = Number(opts.timeout)
|
|
46
47
|
const idleMs = Number(opts['idle-timeout'])
|
|
47
48
|
const retries = Number(opts.retries)
|
|
48
49
|
const minFree = Number(opts['min-free-mb']) * 1024 * 1024
|
|
49
50
|
const gha = !!process.env.GITHUB_ACTIONS
|
|
50
|
-
const [runtime, ...runtimeArgs] = (opts.runtime ||
|
|
51
|
+
const [runtime, ...runtimeArgs] = (opts.runtime || 'bare').split(' ')
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
let spawnBare = null
|
|
54
|
+
if (!opts.runtime) {
|
|
53
55
|
try {
|
|
54
|
-
|
|
55
|
-
} catch {
|
|
56
|
-
return 'bare'
|
|
57
|
-
}
|
|
56
|
+
spawnBare = createRequire(path.join(process.cwd(), 'package.json'))('bare-runtime/spawn')
|
|
57
|
+
} catch {}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
let timings = {}
|
|
61
|
+
if (opts.timings) {
|
|
62
|
+
try {
|
|
63
|
+
timings = JSON.parse(fs.readFileSync(opts.timings, 'utf8'))
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
const known = Object.values(timings).sort((a, b) => a - b)
|
|
67
|
+
const median = known[known.length >> 1]
|
|
68
|
+
|
|
60
69
|
let files = [
|
|
61
70
|
...new Set(
|
|
62
71
|
fs
|
|
@@ -64,17 +73,26 @@ let files = [
|
|
|
64
73
|
.filter((f) => !/^all\./.test(path.basename(f)) && fs.statSync(f).isFile())
|
|
65
74
|
)
|
|
66
75
|
]
|
|
67
|
-
.map((f) => ({ f,
|
|
68
|
-
.sort((a, b) => b.
|
|
69
|
-
.map((x) => x.f)
|
|
76
|
+
.map((f) => ({ f, w: timings[f] ?? median ?? fs.statSync(f).size }))
|
|
77
|
+
.sort((a, b) => b.w - a.w || a.f.localeCompare(b.f))
|
|
70
78
|
|
|
71
79
|
if (opts.shard) {
|
|
72
80
|
const [i, n] = opts.shard.split('/').map(Number)
|
|
73
81
|
if (!(i >= 1 && i <= n)) die(`bad --shard ${opts.shard}`)
|
|
74
|
-
|
|
82
|
+
const load = Array(n).fill(0)
|
|
83
|
+
files = files.filter((x) => {
|
|
84
|
+
const k = load.indexOf(Math.min(...load))
|
|
85
|
+
load[k] += x.w || 1
|
|
86
|
+
return k === i - 1
|
|
87
|
+
})
|
|
75
88
|
}
|
|
89
|
+
files = files.map((x) => x.f)
|
|
76
90
|
if (files.length === 0) die('no test files matched')
|
|
77
91
|
|
|
92
|
+
const soloSet = new Set(opts.solo ? fs.globSync(opts.solo) : [])
|
|
93
|
+
const pool = files.filter((f) => !soloSet.has(f))
|
|
94
|
+
const solo = files.filter((f) => soloSet.has(f))
|
|
95
|
+
|
|
78
96
|
// ponytail: tmp root keyed by cwd, two concurrent runs in one cwd wipe each other. Key by pid if that happens.
|
|
79
97
|
const cwdHash = crypto.createHash('sha1').update(process.cwd()).digest('hex').slice(0, 8)
|
|
80
98
|
const tmpRoot = path.join(fs.realpathSync(os.tmpdir()), `brittle-jobs-${cwdHash}`)
|
|
@@ -133,7 +151,7 @@ function runOnce(file, slot) {
|
|
|
133
151
|
return new Promise((resolve) => {
|
|
134
152
|
const tmp = fs.mkdtempSync(path.join(tmpRoot, 'bb-'))
|
|
135
153
|
const started = Date.now()
|
|
136
|
-
const
|
|
154
|
+
const spawnOpts = {
|
|
137
155
|
detached: true,
|
|
138
156
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
139
157
|
env: {
|
|
@@ -144,7 +162,10 @@ function runOnce(file, slot) {
|
|
|
144
162
|
BRITTLE_JOBS_WORKER_ID: String(slot),
|
|
145
163
|
JEST_WORKER_ID: String(slot)
|
|
146
164
|
}
|
|
147
|
-
}
|
|
165
|
+
}
|
|
166
|
+
const child = spawnBare
|
|
167
|
+
? spawnBare('bare', { ...spawnOpts, args: [file] })
|
|
168
|
+
: spawn(runtime, [...runtimeArgs, file], spawnOpts)
|
|
148
169
|
const pid = child.pid
|
|
149
170
|
if (pid) active.add(pid)
|
|
150
171
|
|
|
@@ -179,9 +200,14 @@ function runOnce(file, slot) {
|
|
|
179
200
|
touch()
|
|
180
201
|
|
|
181
202
|
let exit = null
|
|
203
|
+
let done = false
|
|
204
|
+
let closeTimer
|
|
182
205
|
const finish = () => {
|
|
206
|
+
if (done) return
|
|
207
|
+
done = true
|
|
183
208
|
clearTimeout(hardTimer)
|
|
184
209
|
clearTimeout(idleTimer)
|
|
210
|
+
clearTimeout(closeTimer)
|
|
185
211
|
active.delete(pid)
|
|
186
212
|
fs.rmSync(tmp, { recursive: true, force: true })
|
|
187
213
|
for (const w of waiters.splice(0)) w()
|
|
@@ -203,6 +229,11 @@ function runOnce(file, slot) {
|
|
|
203
229
|
child.on('exit', (code, signal) => {
|
|
204
230
|
exit = { code, signal }
|
|
205
231
|
killGroup(pid, 'SIGKILL')
|
|
232
|
+
closeTimer = setTimeout(() => {
|
|
233
|
+
child.stdout.destroy()
|
|
234
|
+
child.stderr.destroy()
|
|
235
|
+
finish()
|
|
236
|
+
}, GRACE_MS)
|
|
206
237
|
})
|
|
207
238
|
child.on('close', () => exit && !exit.err && finish())
|
|
208
239
|
})
|
|
@@ -233,25 +264,40 @@ function report(file, res) {
|
|
|
233
264
|
if (gha) console.log(`::error file=${file}::${res.status}${reason}\n::group::${file} output`)
|
|
234
265
|
console.log(res.output.trim())
|
|
235
266
|
if (gha) console.log('::endgroup::')
|
|
236
|
-
if (opts.bail)
|
|
267
|
+
if (opts.bail) {
|
|
268
|
+
stop = true
|
|
269
|
+
for (const pid of active) killGroup(pid, 'SIGKILL')
|
|
270
|
+
}
|
|
237
271
|
}
|
|
238
272
|
|
|
239
273
|
const started = Date.now()
|
|
240
|
-
console.log(
|
|
274
|
+
console.log(
|
|
275
|
+
`brittle-jobs: ${files.length} files, ${jobs} jobs, runtime ${spawnBare ? 'bare-runtime' : runtime}`
|
|
276
|
+
)
|
|
241
277
|
|
|
242
278
|
let next = 0
|
|
243
279
|
await Promise.all(
|
|
244
|
-
Array.from({ length: Math.min(jobs,
|
|
245
|
-
while (!stop && next <
|
|
246
|
-
const file =
|
|
280
|
+
Array.from({ length: Math.min(jobs, pool.length) }, async (_, i) => {
|
|
281
|
+
while (!stop && next < pool.length) {
|
|
282
|
+
const file = pool[next++]
|
|
247
283
|
const res = await runFile(file, i + 1)
|
|
248
|
-
if (res) report(file, res)
|
|
284
|
+
if (res && !stop) report(file, res)
|
|
249
285
|
}
|
|
250
286
|
})
|
|
251
287
|
)
|
|
288
|
+
for (const file of solo) {
|
|
289
|
+
if (stop) break
|
|
290
|
+
const res = await runFile(file, 1)
|
|
291
|
+
if (res) report(file, res)
|
|
292
|
+
}
|
|
252
293
|
|
|
253
294
|
fs.rmSync(tmpRoot, { recursive: true, force: true })
|
|
254
295
|
|
|
296
|
+
if (opts.timings) {
|
|
297
|
+
for (const r of results) if (r.status === 'PASS') timings[r.file] = Math.round(r.secs * 1000)
|
|
298
|
+
fs.writeFileSync(opts.timings, JSON.stringify(timings, Object.keys(timings).sort(), 2) + '\n')
|
|
299
|
+
}
|
|
300
|
+
|
|
255
301
|
const count = (s) => results.filter((r) => r.status === s).length
|
|
256
302
|
const bad = results.filter((r) => r.status === 'FAIL' || r.status === 'HUNG')
|
|
257
303
|
console.log(`\nslowest:`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brittle-jobs",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Run brittle test files concurrently, one isolated process per file, with hang detection and tmp cleanup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,4 +27,4 @@
|
|
|
27
27
|
"url": "git+https://github.com/matheus1lva/brittle-jobs.git"
|
|
28
28
|
},
|
|
29
29
|
"license": "MIT"
|
|
30
|
-
}
|
|
30
|
+
}
|