brittle-jobs 0.2.1 → 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 +13 -5
- package/bin.js +56 -14
- 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,12 +26,14 @@ 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
|
```
|
|
@@ -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,22 +28,21 @@ 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)
|
|
@@ -56,6 +57,15 @@ if (!opts.runtime) {
|
|
|
56
57
|
} catch {}
|
|
57
58
|
}
|
|
58
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
|
+
|
|
59
69
|
let files = [
|
|
60
70
|
...new Set(
|
|
61
71
|
fs
|
|
@@ -63,17 +73,26 @@ let files = [
|
|
|
63
73
|
.filter((f) => !/^all\./.test(path.basename(f)) && fs.statSync(f).isFile())
|
|
64
74
|
)
|
|
65
75
|
]
|
|
66
|
-
.map((f) => ({ f,
|
|
67
|
-
.sort((a, b) => b.
|
|
68
|
-
.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))
|
|
69
78
|
|
|
70
79
|
if (opts.shard) {
|
|
71
80
|
const [i, n] = opts.shard.split('/').map(Number)
|
|
72
81
|
if (!(i >= 1 && i <= n)) die(`bad --shard ${opts.shard}`)
|
|
73
|
-
|
|
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
|
+
})
|
|
74
88
|
}
|
|
89
|
+
files = files.map((x) => x.f)
|
|
75
90
|
if (files.length === 0) die('no test files matched')
|
|
76
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
|
+
|
|
77
96
|
// ponytail: tmp root keyed by cwd, two concurrent runs in one cwd wipe each other. Key by pid if that happens.
|
|
78
97
|
const cwdHash = crypto.createHash('sha1').update(process.cwd()).digest('hex').slice(0, 8)
|
|
79
98
|
const tmpRoot = path.join(fs.realpathSync(os.tmpdir()), `brittle-jobs-${cwdHash}`)
|
|
@@ -181,9 +200,14 @@ function runOnce(file, slot) {
|
|
|
181
200
|
touch()
|
|
182
201
|
|
|
183
202
|
let exit = null
|
|
203
|
+
let done = false
|
|
204
|
+
let closeTimer
|
|
184
205
|
const finish = () => {
|
|
206
|
+
if (done) return
|
|
207
|
+
done = true
|
|
185
208
|
clearTimeout(hardTimer)
|
|
186
209
|
clearTimeout(idleTimer)
|
|
210
|
+
clearTimeout(closeTimer)
|
|
187
211
|
active.delete(pid)
|
|
188
212
|
fs.rmSync(tmp, { recursive: true, force: true })
|
|
189
213
|
for (const w of waiters.splice(0)) w()
|
|
@@ -205,6 +229,11 @@ function runOnce(file, slot) {
|
|
|
205
229
|
child.on('exit', (code, signal) => {
|
|
206
230
|
exit = { code, signal }
|
|
207
231
|
killGroup(pid, 'SIGKILL')
|
|
232
|
+
closeTimer = setTimeout(() => {
|
|
233
|
+
child.stdout.destroy()
|
|
234
|
+
child.stderr.destroy()
|
|
235
|
+
finish()
|
|
236
|
+
}, GRACE_MS)
|
|
208
237
|
})
|
|
209
238
|
child.on('close', () => exit && !exit.err && finish())
|
|
210
239
|
})
|
|
@@ -235,7 +264,10 @@ function report(file, res) {
|
|
|
235
264
|
if (gha) console.log(`::error file=${file}::${res.status}${reason}\n::group::${file} output`)
|
|
236
265
|
console.log(res.output.trim())
|
|
237
266
|
if (gha) console.log('::endgroup::')
|
|
238
|
-
if (opts.bail)
|
|
267
|
+
if (opts.bail) {
|
|
268
|
+
stop = true
|
|
269
|
+
for (const pid of active) killGroup(pid, 'SIGKILL')
|
|
270
|
+
}
|
|
239
271
|
}
|
|
240
272
|
|
|
241
273
|
const started = Date.now()
|
|
@@ -245,17 +277,27 @@ console.log(
|
|
|
245
277
|
|
|
246
278
|
let next = 0
|
|
247
279
|
await Promise.all(
|
|
248
|
-
Array.from({ length: Math.min(jobs,
|
|
249
|
-
while (!stop && next <
|
|
250
|
-
const file =
|
|
280
|
+
Array.from({ length: Math.min(jobs, pool.length) }, async (_, i) => {
|
|
281
|
+
while (!stop && next < pool.length) {
|
|
282
|
+
const file = pool[next++]
|
|
251
283
|
const res = await runFile(file, i + 1)
|
|
252
|
-
if (res) report(file, res)
|
|
284
|
+
if (res && !stop) report(file, res)
|
|
253
285
|
}
|
|
254
286
|
})
|
|
255
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
|
+
}
|
|
256
293
|
|
|
257
294
|
fs.rmSync(tmpRoot, { recursive: true, force: true })
|
|
258
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
|
+
|
|
259
301
|
const count = (s) => results.filter((r) => r.status === s).length
|
|
260
302
|
const bad = results.filter((r) => r.status === 'FAIL' || r.status === 'HUNG')
|
|
261
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
|
+
}
|