padded_cell 0.2.0-x86_64-linux-gnu → 0.3.0-x86_64-linux-gnu

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d938c3e531357727a22bf6a2759c2f381960729f309ce98c54474087cc5e7599
4
- data.tar.gz: 6f91994847f2b887ddd3523c71e1c197028d529a55a9b2726f8146afcd847cb3
3
+ metadata.gz: 18ddd483c349cd093dafa125ee74d7a340926d64494d71103725320d79e4f491
4
+ data.tar.gz: 25b575be7e64071b58a4fdb0bcd5891973e8423dae49884b8f8e696dfa20b0e2
5
5
  SHA512:
6
- metadata.gz: 703c85c4e53f4c03c6b068e688b459d0b279e3ad80117075e3ded0e342b73383975f4632bbb4694d48d6c8e5e2fe8233b7839efc7df53ee2f2e5ffb904157ab0
7
- data.tar.gz: f9d6f0dbd6e472529eabe4c82bccb669295957fcaad2c7319b2a2d8136267a52986b8e4c1dabf2ed9c71e70d1da1d79839ba9c3abbfdf5bd5c0e561a0b3e02ec
6
+ metadata.gz: b05b36a943bcc0af55de4f9102f8bcf5049151a3140f6a6b0beb91de9fbad0ed32c2296fa99f1be2ce66672b6cf01aa37a8dcd32c9904f9c672f104b9893fda4
7
+ data.tar.gz: bd71550a929918248364af56965200fb895f6fb0e32729cc1838645acfe5b434e0d8a5a8907e158b5692a1659ee28f40a06624e3cae498a28e2b6135f24641e0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 (2026-11-08)
4
+
5
+ - `--budget SECONDS`: a deadline for the whole run, loading included. Once
6
+ it's spent, nothing more is dispatched and running tests are killed. Tests
7
+ it left out fail the run (exit 1). `--timeout` never outlives it.
8
+ - Workers that keep dying (more than 4 per worker, at least 16, in 10s) are
9
+ no longer replaced forever. padded_cell gives up, reports how many tests
10
+ never ran, and exits 1. Uses `supervisor-machines`.
11
+ - A replacement worker that died before its first test crashed the run with
12
+ `Errno::EPIPE` (exit 2). A worker reset mid-frame is now reported as dead.
13
+ - `--asylum` retry seeds keep all 64 bits: they were folded modulo 0xffff,
14
+ so large `--seed`s shared the same few retry seeds.
15
+
3
16
  ## 0.2.0 (2026-10-11)
4
17
 
5
18
  - Built-in coverage: `--coverage[=lines,branches,methods,eval]` (or
data/README.md CHANGED
@@ -69,11 +69,13 @@ $ padded_cell user_test.rb:12-30:41 # tests overlapping 12..30, or spanni
69
69
  $ padded_cell -n /refund/ -e test_slow # Minitest's --include / --exclude
70
70
  $ padded_cell -j 8 --seed 1234 # 8 workers, reproducible order
71
71
  $ padded_cell --timeout 30 # kill and fail anything running 30s
72
+ $ padded_cell --budget 600 # the whole run gets 10 minutes; what's left out fails it
72
73
  $ padded_cell --fail-fast
73
74
  $ padded_cell --asylum # retry failures; report the flaky ones (see below)
74
75
  $ padded_cell --list # what would run, without loading anything
75
76
  $ padded_cell --shard 2/4 # CI: the second quarter of the classes
76
77
  $ padded_cell --junit tmp/junit.xml --json tmp/results.jsonl
78
+ $ PADDED_CELL_JSON=tmp/results.jsonl padded_cell # same, from a CI runner's env
77
79
  $ padded_cell --coverage # line and branch coverage (see Coverage)
78
80
  ```
79
81
 
@@ -110,6 +112,23 @@ cheap, and order-dependent tests stop depending on order.
110
112
  Rails' `parallelize`. A worker that crashes or times out is still reported
111
113
  and replaced.
112
114
 
115
+ Replaced up to a point. If workers keep dying, say a boot that segfaults or an
116
+ `after_fork` hook that calls `exit`, every remaining test would come back as a
117
+ crash report, one fork at a time. So past 4 deaths per worker in 10 seconds
118
+ (16 at least), padded_cell gives up: it lists how many tests never ran and
119
+ exits 1. The decision comes from
120
+ [supervisor_machines](https://github.com/seuros/supervisor_machines), the same
121
+ one-for-one restart intensity OTP uses.
122
+
123
+ ### Budget
124
+
125
+ `--budget SECONDS` caps the whole run, loading included. Once it's spent,
126
+ nothing more is dispatched, and a test still running is killed and fails
127
+ ("run budget of 600.0s spent"). A test's `--timeout` never outlives the
128
+ budget. If the budget left tests out, the run fails and says how many. The
129
+ deadline math comes from
130
+ [deadline_machines](https://github.com/seuros/deadline_machines).
131
+
113
132
  The default is `test`, except when Active Record fixtures are loaded: a fork
114
133
  discards Active Record's connection pool, and every test would reload its
115
134
  fixtures, so those suites default to `worker`. The `Run options:` line shows
@@ -305,7 +324,9 @@ PaddedCell::Scheduler.batches(class_names, durations: seconds, seed: 42, shard:
305
324
  ## Rust
306
325
 
307
326
  The extension lives in `ext/padded_cell_native`: `core` does discovery and
308
- scheduling in plain Rust (no Ruby types), `ffi` binds it to Ruby. Neither is
327
+ scheduling in plain Rust (no Ruby types), `ffi` binds it to Ruby. Worker
328
+ supervision and deadlines come from the published `supervisor-machines` and
329
+ `deadline-machines` crates. Neither is
309
330
  published as a crate.
310
331
 
311
332
  ## Development
@@ -13,18 +13,20 @@ module PaddedCell
13
13
  class Asylum
14
14
  ALONE = 'timing, contention or state left by other tests'
15
15
  RESEEDED = 'randomness or test order'
16
+ SEEDS = 2**64
16
17
 
17
18
  # The seed of retry attempt, derived from the run's seed so a report can
18
- # be replayed. Attempt 1 keeps the run's seed.
19
+ # be replayed. Attempt 1 keeps the run's seed. Wraps within the planner's
20
+ # 64-bit seeds, as --seed accepts them.
19
21
  def self.retry_seed(seed, attempt)
20
- attempt == 1 ? seed : (seed + (attempt * 0x9e37)) % 0xffff
22
+ attempt == 1 ? seed : (seed + (attempt * 0x9e37)) % SEEDS
21
23
  end
22
24
 
23
- def initialize(tests, attempts:, seed:, timeout: nil)
25
+ def initialize(tests, attempts:, seed:, limits: Limits.new)
24
26
  @tests = tests
25
27
  @attempts = attempts
26
28
  @seed = seed
27
- @timeout = timeout
29
+ @limits = limits
28
30
  @success = true
29
31
  end
30
32
 
@@ -58,7 +60,7 @@ module PaddedCell
58
60
  def retry_round(pending, history, attempt)
59
61
  seed = self.class.retry_seed(@seed, attempt)
60
62
  passed = []
61
- pool = Pool.new(@tests, jobs: 1, isolation: :test, seed:, timeout: @timeout)
63
+ pool = Pool.new(@tests, jobs: 1, isolation: :test, seed:, limits: @limits)
62
64
  pool.run([pending.dup]) do |index, result|
63
65
  recovered = result.passed? && !result.skipped?
64
66
  history[index] << attempt_entry(seed, true, recovered ? nil : result)
@@ -9,19 +9,21 @@ module PaddedCell
9
9
  module_function
10
10
 
11
11
  # Runs klass#name in a fork and returns its Minitest::Result, or an error
12
- # result when the cell died or ran past timeout seconds.
12
+ # result when the cell died or ran past its deadline.
13
13
  #
14
- # close: descriptors the cell must not keep open (the worker's socket)
15
- # probe: the worker's Chart::Probe, which gets the cell's coverage
16
- def run(klass, name, seed:, timeout: nil, close: [], probe: nil)
14
+ # limits: the run's Limits (--timeout, --budget); nil: no deadline
15
+ # close: descriptors the cell must not keep open (the worker's socket)
16
+ # probe: the worker's Chart::Probe, which gets the cell's coverage
17
+ def run(klass, name, seed:, limits: nil, close: [], probe: nil)
17
18
  # Binary: the result is Marshal data, and with Encoding.default_internal
18
19
  # set (Rails sets it) a text-mode pipe transcodes what is written to it.
19
20
  reader, writer = IO.pipe
20
21
  [reader, writer].each(&:binmode)
21
22
  started = Minitest.clock_time
23
+ deadline = limits&.for_test
22
24
  pid = fork_cell(klass, name, seed, reader, writer, close, probe)
23
25
  writer.close
24
- delta, result = collect(pid, reader, klass, name, started, timeout)
26
+ delta, result = collect(pid, reader, klass, name, started, deadline) { limits.message(deadline) }
25
27
  delta ? probe.absorb(delta) : probe&.lost
26
28
  result
27
29
  ensure
@@ -59,14 +61,15 @@ module PaddedCell
59
61
  end
60
62
  end
61
63
 
62
- # [coverage delta or nil, result]
63
- def collect(pid, reader, klass, name, started, timeout)
64
- data = read(reader, timeout && (started + timeout))
64
+ # [coverage delta or nil, result]. Yields for the message of a cell
65
+ # killed at its deadline.
66
+ def collect(pid, reader, klass, name, started, deadline)
67
+ data = read(reader, deadline)
65
68
  elapsed = Minitest.clock_time - started
66
69
  if data.nil?
67
70
  Process.kill(:KILL, pid)
68
71
  Process.wait(pid)
69
- return [nil, Results.crashed(klass, name, "timed out after #{timeout}s", time: elapsed)]
72
+ return [nil, Results.crashed(klass, name, yield, time: elapsed)]
70
73
  end
71
74
 
72
75
  _, status = Process.wait2(pid)
@@ -75,11 +78,11 @@ module PaddedCell
75
78
  [nil, Results.crashed(klass, name, Results.describe(status, 'cell'), time: elapsed)]
76
79
  end
77
80
 
78
- # Everything until EOF, or nil when the deadline passes first.
81
+ # Everything until EOF, or nil when the deadline (if any) passes first.
79
82
  def read(io, deadline)
80
83
  data = String.new(encoding: Encoding::BINARY)
81
84
  loop do
82
- remaining = deadline && (deadline - Minitest.clock_time)
85
+ remaining = deadline && Limits.remaining(deadline)
83
86
  return if remaining&.<= 0
84
87
  next unless io.wait_readable(remaining)
85
88
 
@@ -22,7 +22,7 @@ module PaddedCell
22
22
  # Exit status: 0 passed, 1 failed, 2 usage or load error, 3 coverage below
23
23
  # a minimum, 130 interrupted.
24
24
  def start(argv, io: $stdout)
25
- options = { io:, coverage: coverage_criteria(ENV.fetch('PADDED_CELL_COVERAGE', nil)) }
25
+ options = { io:, coverage: coverage_criteria(ENV.fetch('PADDED_CELL_COVERAGE', nil)), **report_paths }
26
26
  parser(options).parse!(argv)
27
27
  return merge(options) if options[:coverage_merge]
28
28
 
@@ -33,6 +33,13 @@ module PaddedCell
33
33
  2
34
34
  end
35
35
 
36
+ # Report paths a CI runner can set without touching the command line;
37
+ # --json and --junit still win.
38
+ def report_paths
39
+ { json: ENV.fetch('PADDED_CELL_JSON', nil), junit: ENV.fetch('PADDED_CELL_JUNIT', nil) }
40
+ .reject { |_, path| path.nil? || path.empty? }
41
+ end
42
+
36
43
  def parser(options)
37
44
  OptionParser.new do |opts|
38
45
  opts.banner = BANNER
@@ -70,6 +77,10 @@ module PaddedCell
70
77
  opts.on('--timeout SECONDS', Float, 'Kill and fail a test running longer') do
71
78
  options[:timeout] = positive(it)
72
79
  end
80
+ opts.on('--budget SECONDS', Float, 'Stop dispatching once the run has taken SECONDS (and kill and ' \
81
+ 'fail what runs past it); tests left out fail the run') do
82
+ options[:budget] = positive(it)
83
+ end
73
84
  opts.on('--fail-fast', 'Stop dispatching after the first failure') { options[:fail_fast] = true }
74
85
  opts.on('--asylum[=N]', Integer, 'Retry failed tests up to N times (default 2) after the run, alone, ' \
75
86
  'each in a fresh cell; report the flaky ones') do
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddedCell
4
+ # What cuts a test short: its --timeout, and what is left of the run's
5
+ # --budget. A test's deadline never outlives the run's. Deadlines are
6
+ # PaddedCellNative::Deadline values in monotonic nanoseconds: one clock for
7
+ # the runner, its workers and their cells.
8
+ class Limits
9
+ NS = 1_000_000_000
10
+
11
+ def self.now = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
12
+
13
+ # Seconds left before deadline (0.0 once passed), nil when never.
14
+ def self.remaining(deadline, now = self.now) = deadline.remaining_ns(now)&./(NS.to_f)
15
+
16
+ attr_reader :timeout, :budget
17
+
18
+ # timeout: seconds per test, or nil
19
+ # budget: seconds for the whole run from started, or nil
20
+ def initialize(timeout: nil, budget: nil, started: Limits.now)
21
+ @timeout = timeout
22
+ @budget = budget
23
+ @run = budget ? PaddedCellNative::Deadline.after(started, nanoseconds(budget)) : PaddedCellNative::Deadline.never
24
+ end
25
+
26
+ # The deadline of a test starting now.
27
+ def for_test(now = Limits.now) = @run.derive(now, @timeout && nanoseconds(@timeout))
28
+
29
+ # True once the run's budget is gone: nothing more gets dispatched.
30
+ def spent?(now = Limits.now) = @run.expired?(now)
31
+
32
+ # Why a test that reached deadline was cut short.
33
+ def message(deadline)
34
+ if @budget && deadline.expires_at == @run.expires_at
35
+ "run budget of #{@budget}s spent"
36
+ else
37
+ "timed out after #{@timeout}s"
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def nanoseconds(seconds) = (seconds * NS).round
44
+ end
45
+ end
@@ -13,20 +13,34 @@ module PaddedCell
13
13
  #
14
14
  # With isolation :test a worker forks a Cell per test; with :worker it runs
15
15
  # tests itself, faster but sharing process state. A worker that dies
16
- # mid-test, or runs past the timeout, gets its test reported as an error and
17
- # is replaced; its replacement continues the class.
16
+ # mid-test, or runs past its deadline (--timeout, --budget), gets its test
17
+ # reported as an error and is replaced; its replacement continues the class.
18
+ #
19
+ # Replaced, up to a point: workers that keep dying (a boot that segfaults,
20
+ # an after_fork hook that exits) would turn every remaining test into a
21
+ # crash report. Past PaddedCellNative::Restarts' limit the pool gives up.
22
+ # Once the run's budget is spent, it dispatches nothing more.
18
23
  class Pool
19
- Slot = Struct.new(:worker, :pid, :socket, :test, :started, :batch)
24
+ Slot = Struct.new(:worker, :pid, :socket, :test, :started, :batch, :deadline)
25
+
26
+ # Tests left undispatched by stop! (--fail-fast, the budget, giving up).
27
+ attr_reader :undispatched
28
+
29
+ # Why the pool gave up on its workers, or nil.
30
+ attr_reader :gave_up
20
31
 
21
32
  # tests: [[klass, method_name], ...], dispatched by index.
33
+ # limits: the run's Limits
22
34
  # coverage: a Chart::Accumulator when measuring coverage
23
- def initialize(tests, jobs:, isolation:, seed:, timeout: nil, coverage: nil)
35
+ def initialize(tests, jobs:, isolation:, seed:, limits: Limits.new, coverage: nil)
24
36
  @tests = tests
25
37
  @jobs = jobs
26
38
  @isolation = isolation
27
39
  @seed = seed
28
- @timeout = timeout
40
+ @limits = limits
29
41
  @coverage = coverage
42
+ @restarts = PaddedCellNative::Restarts.new(jobs)
43
+ @undispatched = 0
30
44
  end
31
45
 
32
46
  # Runs batches of test indices in order (consuming them), yielding
@@ -36,7 +50,7 @@ module PaddedCell
36
50
  @on_result = on_result
37
51
  @sockets = []
38
52
  Hooks.run_before_fork
39
- @slots = Array.new([@jobs, batches.sum(&:size)].min) { spawn(Slot.new(it, nil, nil, nil, nil, [])) }
53
+ @slots = Array.new([@jobs, batches.sum(&:size)].min) { spawn(Slot.new(it, nil, nil, nil, nil, [], nil)) }
40
54
  @slots.each { feed(it) }
41
55
  loop do
42
56
  busy = @slots.select(&:test)
@@ -53,6 +67,7 @@ module PaddedCell
53
67
 
54
68
  # Dispatch nothing more; tests already running still report.
55
69
  def stop!
70
+ @undispatched += @queue.sum(&:size) + @slots.sum { it.batch.size }
56
71
  @queue.clear
57
72
  @slots.each { it.batch.clear }
58
73
  end
@@ -67,7 +82,7 @@ module PaddedCell
67
82
  # A worker holding another worker's socket would keep it from ever
68
83
  # seeing EOF: keep only its own end.
69
84
  [parent, *@sockets].each(&:close)
70
- Worker.new(@tests, isolation: @isolation, seed: @seed, timeout: @timeout, probe: @coverage&.probe)
85
+ Worker.new(@tests, isolation: @isolation, seed: @seed, limits: @limits, probe: @coverage&.probe)
71
86
  .run(slot.worker, child)
72
87
  end
73
88
  child.close
@@ -77,18 +92,25 @@ module PaddedCell
77
92
  end
78
93
 
79
94
  def feed(slot)
95
+ return stop! if @limits.spent?
96
+
80
97
  slot.batch = @queue.shift || steal if slot.batch.empty?
81
98
  slot.test = slot.batch.shift
82
99
  return unless slot.test
83
100
 
84
101
  slot.started = Minitest.clock_time
102
+ slot.deadline = @limits.for_test
85
103
  begin
86
104
  slot.socket.puts(slot.test)
87
105
  rescue Errno::EPIPE, Errno::ECONNRESET
88
- # The worker died between tests: its replacement gets this one.
106
+ # The worker died between tests: its replacement gets this one, unless
107
+ # it dies too. Restarts bounds the retries.
89
108
  reap(slot)
90
109
  @coverage&.lost_worker
91
- spawn(slot).socket.puts(slot.test)
110
+ return abandon(slot) unless @restarts.died(Limits.now)
111
+
112
+ spawn(slot)
113
+ retry
92
114
  end
93
115
  end
94
116
 
@@ -99,17 +121,24 @@ module PaddedCell
99
121
  end
100
122
 
101
123
  def receive(slot)
102
- header = slot.socket.read(4)
124
+ header = read_frame(slot, 4)
103
125
  return died(slot) unless header&.bytesize == 4
104
126
 
105
127
  size = header.unpack1('N')
106
- payload = slot.socket.read(size)
128
+ payload = read_frame(slot, size)
107
129
  return died(slot) unless payload&.bytesize == size
108
130
 
109
131
  report(slot, Results.load(payload, *@tests.fetch(slot.test)))
110
132
  feed(slot)
111
133
  end
112
134
 
135
+ # nil when the worker died: EOF, or a reset when it left data unread.
136
+ def read_frame(slot, size)
137
+ slot.socket.read(size)
138
+ rescue Errno::ECONNRESET
139
+ nil
140
+ end
141
+
113
142
  def report(slot, result)
114
143
  index = slot.test
115
144
  slot.test = nil
@@ -118,25 +147,42 @@ module PaddedCell
118
147
 
119
148
  def died(slot)
120
149
  status = reap(slot)
150
+ give_up unless @restarts.died(Limits.now)
121
151
  replace(slot, Results.describe(status, 'worker'), Minitest.clock_time - slot.started)
122
152
  end
123
153
 
124
- # Cells time out inside their worker; with :worker isolation the runner
125
- # kills the worker itself.
154
+ # Stops replacing workers: whatever was not dispatched is not run.
155
+ def give_up
156
+ window = PaddedCellNative::Restarts.window_ns / Limits::NS
157
+ @gave_up = "workers died more than #{@restarts.limit} times in #{window}s"
158
+ stop!
159
+ end
160
+
161
+ # A test taken for a worker that died before receiving it, and is not
162
+ # replaced.
163
+ def abandon(slot)
164
+ slot.test = nil
165
+ @undispatched += 1
166
+ give_up
167
+ end
168
+
169
+ # Cells meet their deadline inside their worker; with :worker isolation
170
+ # the runner kills the worker itself.
126
171
  def wait_time(busy)
127
- return unless @timeout && @isolation == :worker
172
+ return unless @isolation == :worker
128
173
 
129
- deadline = busy.map { it.started + @timeout }.min
130
- [deadline - Minitest.clock_time, 0].max
174
+ now = Limits.now
175
+ busy.filter_map { Limits.remaining(it.deadline, now) }.min
131
176
  end
132
177
 
133
178
  def expire(busy)
179
+ now = Limits.now
134
180
  busy.each do |slot|
135
- next if Minitest.clock_time - slot.started < @timeout
181
+ next unless slot.deadline.expired?(now)
136
182
 
137
183
  Process.kill(:KILL, slot.pid)
138
184
  reap(slot)
139
- replace(slot, "timed out after #{@timeout}s", @timeout)
185
+ replace(slot, @limits.message(slot.deadline), Minitest.clock_time - slot.started)
140
186
  end
141
187
  end
142
188
 
@@ -145,6 +191,7 @@ module PaddedCell
145
191
  @coverage&.lost_worker
146
192
  klass, name = @tests.fetch(slot.test)
147
193
  report(slot, Results.crashed(klass, name, message, time:))
194
+ stop! if @limits.spent?
148
195
  return if @queue.empty? && @slots.all? { it.batch.empty? }
149
196
 
150
197
  spawn(slot)
@@ -4,6 +4,7 @@ require 'etc'
4
4
  require 'minitest'
5
5
  require_relative '../padded_cell'
6
6
  require_relative 'results'
7
+ require_relative 'limits'
7
8
  require_relative 'cell'
8
9
  require_relative 'worker'
9
10
  require_relative 'pool'
@@ -20,7 +21,7 @@ module PaddedCell
20
21
  class Runner
21
22
  DEFAULTS = {
22
23
  paths: [], jobs: Etc.nprocessors, seed: nil, include: nil, exclude: nil, shard: [0, 1],
23
- isolation: nil, timeout: nil, asylum: nil, fail_fast: false, verbose: false, show_skips: false,
24
+ isolation: nil, timeout: nil, budget: nil, asylum: nil, fail_fast: false, verbose: false, show_skips: false,
24
25
  list: false, junit: nil, json: nil, timings: 'tmp/padded_cell/timings.tsv', coverage: nil,
25
26
  coverage_dir: nil, io: $stdout
26
27
  }.freeze
@@ -35,6 +36,8 @@ module PaddedCell
35
36
  # Exit status: 0 when everything passed, 1 otherwise, 3 when only
36
37
  # coverage is below a minimum, 130 on Ctrl-C.
37
38
  def run
39
+ # The budget covers the whole run, loading included.
40
+ @limits = Limits.new(timeout: options[:timeout], budget: options[:budget])
38
41
  selection = Selection.parse(options[:paths])
39
42
  refs = Discovery.call(selection.files)
40
43
  filter = Filter.new(selection, refs, include: options[:include], exclude: options[:exclude])
@@ -121,7 +124,8 @@ module PaddedCell
121
124
 
122
125
  # Yields each result as it arrives. With --asylum a failure is held back:
123
126
  # only its final result, after the retries, is reported. Returns 1 when a
124
- # worker failed (its cleanup hook), 130 when interrupted, 0 otherwise.
127
+ # worker failed (its cleanup hook) or tests were left out (the budget ran
128
+ # out, workers kept dying), 130 when interrupted, 0 otherwise.
125
129
  def dispatch(tests, batches, reporter, retried)
126
130
  held = {}
127
131
  record = lambda do |index, result|
@@ -130,15 +134,16 @@ module PaddedCell
130
134
  retried << result if result.metadata? && (result.metadata[:flaky] || result.metadata[:asylum])
131
135
  end
132
136
  pool = Pool.new(tests, jobs: options[:jobs], isolation: options[:isolation], seed: options[:seed],
133
- timeout: options[:timeout], coverage: @coverage)
137
+ limits: @limits, coverage: @coverage)
134
138
  pool.run(batches) do |index, result|
135
139
  yield index, result
136
140
  failed = failed?(result)
137
141
  options[:asylum] && failed ? held[index] = result : record.call(index, result)
138
142
  pool.stop! if options[:fail_fast] && failed
139
143
  end
144
+ complete = report_left_out(pool)
140
145
  retries_ok = retry_held(tests, held, record)
141
- pool.success? && retries_ok ? 0 : 1
146
+ pool.success? && retries_ok && complete ? 0 : 1
142
147
  rescue Interrupt
143
148
  warn 'Interrupted. Exiting...'
144
149
  # Failures still waiting for a retry are failures.
@@ -146,11 +151,31 @@ module PaddedCell
146
151
  130
147
152
  end
148
153
 
154
+ # False when tests never ran because the budget ran out or workers kept
155
+ # dying. --fail-fast leaving them out is what it asked for.
156
+ def report_left_out(pool)
157
+ left_out = pool.undispatched
158
+ if pool.gave_up
159
+ warn "padded_cell: #{pool.gave_up}; gave up, #{left_out} tests not run"
160
+ elsif @limits.spent? && left_out.positive?
161
+ warn "padded_cell: run budget of #{@limits.budget}s spent, #{left_out} tests not run"
162
+ else
163
+ return true
164
+ end
165
+ false
166
+ end
167
+
149
168
  # False when a retry's worker failed.
150
169
  def retry_held(tests, held, record)
151
170
  return true if held.empty?
152
171
 
153
- asylum = Asylum.new(tests, attempts: options[:asylum], seed: options[:seed], timeout: options[:timeout])
172
+ # No budget left to retry in: failures stay failures.
173
+ if @limits.spent?
174
+ held.each { |index, result| record.call(index, result) }
175
+ return true
176
+ end
177
+
178
+ asylum = Asylum.new(tests, attempts: options[:asylum], seed: options[:seed], limits: @limits)
154
179
  asylum.call(held) do |index, result|
155
180
  held.delete(index)
156
181
  record.call(index, result)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PaddedCell
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -6,12 +6,12 @@ module PaddedCell
6
6
  # Minitest::Result as a length-prefixed Marshal frame. Exits once the
7
7
  # socket closes.
8
8
  class Worker
9
- # probe: a Chart::Probe when measuring coverage
10
- def initialize(tests, isolation:, seed:, timeout:, probe:)
9
+ # limits: the run's Limits; probe: a Chart::Probe when measuring coverage
10
+ def initialize(tests, isolation:, seed:, limits:, probe:)
11
11
  @tests = tests
12
12
  @isolation = isolation
13
13
  @seed = seed
14
- @timeout = timeout
14
+ @limits = limits
15
15
  @probe = probe
16
16
  end
17
17
 
@@ -54,7 +54,7 @@ module PaddedCell
54
54
  if setup_error
55
55
  Results.crashed(klass, name, "after_fork hook failed: #{setup_error.class}: #{setup_error.message}")
56
56
  elsif @isolation == :test
57
- Cell.run(klass, name, seed: @seed, timeout: @timeout, close: [socket], probe: @probe)
57
+ Cell.run(klass, name, seed: @seed, limits: @limits, close: [socket], probe: @probe)
58
58
  else
59
59
  Cell.execute(klass, name)
60
60
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: padded_cell
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: x86_64-linux-gnu
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -56,6 +56,7 @@ files:
56
56
  - lib/padded_cell/cli.rb
57
57
  - lib/padded_cell/discovery.rb
58
58
  - lib/padded_cell/hooks.rb
59
+ - lib/padded_cell/limits.rb
59
60
  - lib/padded_cell/loader.rb
60
61
  - lib/padded_cell/native.rb
61
62
  - lib/padded_cell/pool.rb