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.
Files changed (3) hide show
  1. package/README.md +15 -7
  2. package/bin.js +69 -23
  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,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 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
  ```
37
40
 
38
- Each file runs as `<runtime> <file>`; a brittle test file runs itself. `bare` resolves from the project's own
39
- `bare-runtime` install, then from `PATH`.
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 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,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 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)
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 || defaultRuntime()).split(' ')
51
+ const [runtime, ...runtimeArgs] = (opts.runtime || 'bare').split(' ')
51
52
 
52
- function defaultRuntime() {
53
+ let spawnBare = null
54
+ if (!opts.runtime) {
53
55
  try {
54
- return createRequire(path.join(process.cwd(), 'package.json'))('bare-runtime')()
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, size: fs.statSync(f).size }))
68
- .sort((a, b) => b.size - a.size || a.f.localeCompare(b.f))
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
- 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
+ })
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 child = spawn(runtime, [...runtimeArgs, file], {
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) stop = true
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(`brittle-jobs: ${files.length} files, ${jobs} jobs, runtime ${runtime}`)
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, files.length) }, async (_, i) => {
245
- while (!stop && next < files.length) {
246
- 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++]
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.0",
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
+ }