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.
Files changed (3) hide show
  1. package/README.md +13 -5
  2. package/bin.js +56 -14
  3. 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 is not tied to core count. Test files are mostly wait-bound, so the default oversubscribes.
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 2 x cores)
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 largest first. `--shard i/n` takes every n-th file of that order, so heavy files spread across shards.
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 2 x cores)
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, size: fs.statSync(f).size }))
67
- .sort((a, b) => b.size - a.size || a.f.localeCompare(b.f))
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
- files = files.filter((_, k) => k % n === i - 1)
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) stop = true
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, files.length) }, async (_, i) => {
249
- while (!stop && next < files.length) {
250
- const file = files[next++]
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.1",
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
+ }