padded_cell 0.2.0 → 0.3.0
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 +4 -4
- data/CHANGELOG.md +13 -0
- data/Cargo.lock +16 -2
- data/Cargo.toml +2 -0
- data/README.md +22 -1
- data/ext/padded_cell_native/core/Cargo.toml +2 -1
- data/ext/padded_cell_native/core/src/lib.rs +5 -2
- data/ext/padded_cell_native/core/src/restarts/tests.rs +29 -0
- data/ext/padded_cell_native/core/src/restarts.rs +64 -0
- data/ext/padded_cell_native/ffi/Cargo.toml +2 -1
- data/ext/padded_cell_native/ffi/src/lib.rs +2 -0
- data/ext/padded_cell_native/ffi/src/limits.rs +83 -0
- data/lib/padded_cell/asylum.rb +7 -5
- data/lib/padded_cell/cell.rb +14 -11
- data/lib/padded_cell/cli.rb +12 -1
- data/lib/padded_cell/limits.rb +45 -0
- data/lib/padded_cell/pool.rb +65 -18
- data/lib/padded_cell/runner.rb +30 -5
- data/lib/padded_cell/version.rb +1 -1
- data/lib/padded_cell/worker.rb +4 -4
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d2b2f1c6d06b6cf4e72a67cfb6d511cc121fad2794d5fffc84bbf323c8431f1
|
|
4
|
+
data.tar.gz: '08aacbd7f382259c1eee0b45e3508c7dba4522be0c6d57ba4ca13b496ba70a1b'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d473da59b1f2de1ae4cee4d423c8361ffcf42060bd8b4fd264894a82c6d631cdddd8e8ee376716ae0d73db4ea4e3aefbf540fdbd42b20ed996ca024740448aba
|
|
7
|
+
data.tar.gz: 4d7a2de1bad5aaf24df760011df2a9728193586640bb7f7f14a9dcd66d38750082995692736bdfc0a4ccb951fb174384d600ac723414a39e0c479d98203f2515
|
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/Cargo.lock
CHANGED
|
@@ -73,6 +73,12 @@ dependencies = [
|
|
|
73
73
|
"libloading",
|
|
74
74
|
]
|
|
75
75
|
|
|
76
|
+
[[package]]
|
|
77
|
+
name = "deadline-machines"
|
|
78
|
+
version = "0.4.0"
|
|
79
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
80
|
+
checksum = "805cba0e8981fd367f11a17886e0ad53b84365601f5d39d8b66819f9bd0ba463"
|
|
81
|
+
|
|
76
82
|
[[package]]
|
|
77
83
|
name = "either"
|
|
78
84
|
version = "1.19.0"
|
|
@@ -220,17 +226,19 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
|
220
226
|
|
|
221
227
|
[[package]]
|
|
222
228
|
name = "padded_cell"
|
|
223
|
-
version = "0.
|
|
229
|
+
version = "0.3.0"
|
|
224
230
|
dependencies = [
|
|
225
231
|
"ruby-prism",
|
|
232
|
+
"supervisor-machines",
|
|
226
233
|
"tempfile",
|
|
227
234
|
"thiserror",
|
|
228
235
|
]
|
|
229
236
|
|
|
230
237
|
[[package]]
|
|
231
238
|
name = "padded_cell_native"
|
|
232
|
-
version = "0.
|
|
239
|
+
version = "0.3.0"
|
|
233
240
|
dependencies = [
|
|
241
|
+
"deadline-machines",
|
|
234
242
|
"magnus",
|
|
235
243
|
"padded_cell",
|
|
236
244
|
]
|
|
@@ -435,6 +443,12 @@ version = "2.0.1"
|
|
|
435
443
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
436
444
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
|
437
445
|
|
|
446
|
+
[[package]]
|
|
447
|
+
name = "supervisor-machines"
|
|
448
|
+
version = "0.4.0"
|
|
449
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
450
|
+
checksum = "89f7646079e8a1b802737bb514b9c68e8e0743c046c9fb3c570b50e38ba03121"
|
|
451
|
+
|
|
438
452
|
[[package]]
|
|
439
453
|
name = "syn"
|
|
440
454
|
version = "2.0.119"
|
data/Cargo.toml
CHANGED
|
@@ -13,6 +13,8 @@ repository = "https://github.com/seuros/padded_cell"
|
|
|
13
13
|
padded_cell = { path = "ext/padded_cell_native/core" }
|
|
14
14
|
magnus = { version = "0.9.2", default-features = false }
|
|
15
15
|
ruby-prism = "1.9.0"
|
|
16
|
+
deadline_machines = { package = "deadline-machines", version = "0.4.0", default-features = false }
|
|
17
|
+
supervisor_machines = { package = "supervisor-machines", version = "0.4.0", default-features = false }
|
|
16
18
|
tempfile = "3.27.0"
|
|
17
19
|
thiserror = "2.0.21"
|
|
18
20
|
|
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.
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "padded_cell"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Static Minitest discovery and test scheduling for the padded_cell gem"
|
|
5
5
|
publish = false
|
|
6
6
|
edition.workspace = true
|
|
@@ -14,6 +14,7 @@ workspace = true
|
|
|
14
14
|
|
|
15
15
|
[dependencies]
|
|
16
16
|
ruby-prism.workspace = true
|
|
17
|
+
supervisor_machines.workspace = true
|
|
17
18
|
thiserror.workspace = true
|
|
18
19
|
|
|
19
20
|
[dev-dependencies]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
//! Static Minitest discovery
|
|
2
|
-
//!
|
|
1
|
+
//! Static Minitest discovery, deterministic test scheduling and worker
|
|
2
|
+
//! supervision: the engine of the
|
|
3
|
+
//! [padded_cell](https://github.com/seuros/padded_cell) test runner.
|
|
3
4
|
//!
|
|
4
5
|
//! ```
|
|
5
6
|
//! let source = b"class UserTest < Minitest::Test\n def test_valid\n end\nend\n";
|
|
@@ -19,6 +20,7 @@ mod error;
|
|
|
19
20
|
mod hash;
|
|
20
21
|
mod parallel;
|
|
21
22
|
mod plan;
|
|
23
|
+
pub mod restarts;
|
|
22
24
|
mod rng;
|
|
23
25
|
|
|
24
26
|
pub use discover::{
|
|
@@ -27,6 +29,7 @@ pub use discover::{
|
|
|
27
29
|
pub use error::FileError;
|
|
28
30
|
pub use hash::fnv1a64;
|
|
29
31
|
pub use plan::plan;
|
|
32
|
+
pub use restarts::Restarts;
|
|
30
33
|
pub use rng::SplitMix64;
|
|
31
34
|
|
|
32
35
|
#[cfg(doctest)]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
const SECOND: u64 = 1_000_000_000;
|
|
4
|
+
|
|
5
|
+
#[test]
|
|
6
|
+
fn scales_with_jobs_above_a_floor() {
|
|
7
|
+
assert_eq!(Restarts::new(1).limit(), 16);
|
|
8
|
+
assert_eq!(Restarts::new(8).limit(), 32);
|
|
9
|
+
assert_eq!(Restarts::new(100_000).limit(), 1023);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
#[test]
|
|
13
|
+
fn gives_up_past_the_limit_inside_the_window_and_stays_down() {
|
|
14
|
+
let mut restarts = Restarts::new(1);
|
|
15
|
+
for death in 0..16 {
|
|
16
|
+
assert!(restarts.died(death), "death {death}");
|
|
17
|
+
}
|
|
18
|
+
assert!(!restarts.died(16));
|
|
19
|
+
// A supervisor that gave up does not change its mind.
|
|
20
|
+
assert!(!restarts.died(WINDOW_NS * 10));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#[test]
|
|
24
|
+
fn deaths_spread_over_time_never_give_up() {
|
|
25
|
+
let mut restarts = Restarts::new(1);
|
|
26
|
+
for death in 0..1_000 {
|
|
27
|
+
assert!(restarts.died(death * SECOND), "death {death}");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//! When to stop replacing dead workers.
|
|
2
|
+
//!
|
|
3
|
+
//! A worker that dies takes its test with it and gets replaced; a worker
|
|
4
|
+
//! that keeps dying (a boot that segfaults, an `after_fork` hook that exits)
|
|
5
|
+
//! would otherwise turn every remaining test into a crash report, one fork
|
|
6
|
+
//! at a time.
|
|
7
|
+
//!
|
|
8
|
+
//! The decision is [`supervisor_machines`]'s: one-for-one, restart
|
|
9
|
+
//! intensity over a sliding window. Spawning, killing and reaping stay with
|
|
10
|
+
//! the caller.
|
|
11
|
+
|
|
12
|
+
use supervisor_machines::{Decision, Exit, Intensity, Policy, Strategy, Supervisor};
|
|
13
|
+
|
|
14
|
+
#[cfg(test)]
|
|
15
|
+
mod tests;
|
|
16
|
+
|
|
17
|
+
/// Restart timestamps kept: bounds [`Restarts::limit`].
|
|
18
|
+
const HISTORY: usize = 1024;
|
|
19
|
+
|
|
20
|
+
/// Deaths allowed per worker inside one window, before giving up.
|
|
21
|
+
const PER_WORKER: u32 = 4;
|
|
22
|
+
|
|
23
|
+
/// Deaths allowed inside one window whatever the worker count: a few tests
|
|
24
|
+
/// that kill their worker are what cells and workers exist to contain.
|
|
25
|
+
const FLOOR: u32 = 16;
|
|
26
|
+
|
|
27
|
+
/// The sliding window, in nanoseconds.
|
|
28
|
+
pub const WINDOW_NS: u64 = 10_000_000_000;
|
|
29
|
+
|
|
30
|
+
/// Worker deaths seen by one pool.
|
|
31
|
+
#[derive(Debug, Clone)]
|
|
32
|
+
pub struct Restarts {
|
|
33
|
+
supervisor: Supervisor<1, HISTORY>,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
impl Restarts {
|
|
37
|
+
/// For a pool of `jobs` workers: `4 * jobs` deaths per 10 seconds, at
|
|
38
|
+
/// least 16.
|
|
39
|
+
#[must_use]
|
|
40
|
+
pub fn new(jobs: usize) -> Self {
|
|
41
|
+
let jobs = u32::try_from(jobs).unwrap_or(u32::MAX);
|
|
42
|
+
let limit = jobs.saturating_mul(PER_WORKER).clamp(FLOOR, HISTORY_LIMIT);
|
|
43
|
+
Self { supervisor: Supervisor::new(Strategy::OneForOne, Intensity::new(limit, WINDOW_NS)) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// A worker died at `now_ns` (monotonic): true to replace it, false to
|
|
47
|
+
/// give up. Once false, always false.
|
|
48
|
+
pub const fn died(&mut self, now_ns: u64) -> bool {
|
|
49
|
+
matches!(
|
|
50
|
+
self.supervisor.stopped(0, Policy::Permanent, Exit::Abnormal, now_ns),
|
|
51
|
+
Decision::Restart(_)
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Deaths allowed inside [`WINDOW_NS`].
|
|
56
|
+
#[must_use]
|
|
57
|
+
pub const fn limit(&self) -> u32 {
|
|
58
|
+
self.supervisor.intensity().restarts
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// The history must hold one more death than the limit to see it exceeded.
|
|
63
|
+
#[expect(clippy::cast_possible_truncation, reason = "HISTORY is 1024")]
|
|
64
|
+
const HISTORY_LIMIT: u32 = HISTORY as u32 - 1;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "padded_cell_native"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Ruby bindings for the padded_cell crate"
|
|
5
5
|
publish = false
|
|
6
6
|
edition.workspace = true
|
|
@@ -15,5 +15,6 @@ crate-type = ["cdylib"]
|
|
|
15
15
|
workspace = true
|
|
16
16
|
|
|
17
17
|
[dependencies]
|
|
18
|
+
deadline_machines.workspace = true
|
|
18
19
|
magnus.workspace = true
|
|
19
20
|
padded_cell.workspace = true
|
|
@@ -19,6 +19,7 @@ use magnus::{Error, RArray, RString, Ruby, function};
|
|
|
19
19
|
use padded_cell::{Discovery, FileError};
|
|
20
20
|
|
|
21
21
|
mod chart;
|
|
22
|
+
mod limits;
|
|
22
23
|
|
|
23
24
|
/// `Errno::*` for the path, as Ruby's own file reading raises.
|
|
24
25
|
fn file_error(ruby: &Ruby, error: &FileError) -> Error {
|
|
@@ -119,5 +120,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
119
120
|
module.define_module_function("discover_files", function!(discover_files, 1))?;
|
|
120
121
|
module.define_module_function("plan", function!(plan, 5))?;
|
|
121
122
|
module.define_module_function("chart_report", function!(chart::chart_report, 3))?;
|
|
123
|
+
limits::define(ruby, module)?;
|
|
122
124
|
Ok(())
|
|
123
125
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
//! What bounds a run: `PaddedCellNative::Deadline` (`--timeout` and
|
|
2
|
+
//! `--budget`) and `PaddedCellNative::Restarts` (dead workers). Times are
|
|
3
|
+
//! monotonic nanoseconds, the same clock in the runner, its workers and their
|
|
4
|
+
//! cells.
|
|
5
|
+
|
|
6
|
+
use std::cell::RefCell;
|
|
7
|
+
|
|
8
|
+
use deadline_machines::{Deadline, Instant};
|
|
9
|
+
use magnus::{Error, Module, Object, RModule, Ruby, function, method};
|
|
10
|
+
use padded_cell::Restarts;
|
|
11
|
+
|
|
12
|
+
/// A point in monotonic time past which a test (or the run) is cut short, or
|
|
13
|
+
/// never.
|
|
14
|
+
#[magnus::wrap(class = "PaddedCellNative::Deadline", free_immediately, size, frozen_shareable)]
|
|
15
|
+
struct RbDeadline(Deadline);
|
|
16
|
+
|
|
17
|
+
impl RbDeadline {
|
|
18
|
+
const fn after(now_ns: u64, ns: u64) -> Self {
|
|
19
|
+
Self(Deadline::after(Instant(now_ns), ns))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const fn never() -> Self {
|
|
23
|
+
Self(Deadline::never())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// `timeout_ns` from `now_ns`, never past this deadline; nil: this one.
|
|
27
|
+
fn derive(&self, now_ns: u64, timeout_ns: Option<u64>) -> Self {
|
|
28
|
+
Self(timeout_ns.map_or(self.0, |ns| self.0.derive(Instant(now_ns), ns)))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Nanoseconds left (0 once expired), nil when never.
|
|
32
|
+
const fn remaining_ns(&self, now_ns: u64) -> Option<u64> {
|
|
33
|
+
self.0.remaining_ns(Instant(now_ns))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const fn is_expired(&self, now_ns: u64) -> bool {
|
|
37
|
+
self.0.is_expired(Instant(now_ns))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Monotonic nanoseconds, nil when never.
|
|
41
|
+
fn expires_at(&self) -> Option<u64> {
|
|
42
|
+
self.0.expires_at().map(|at| at.0)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#[magnus::wrap(class = "PaddedCellNative::Restarts", free_immediately, size)]
|
|
47
|
+
struct RbRestarts(RefCell<Restarts>);
|
|
48
|
+
|
|
49
|
+
impl RbRestarts {
|
|
50
|
+
fn new(jobs: usize) -> Self {
|
|
51
|
+
Self(RefCell::new(Restarts::new(jobs)))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// True to replace the dead worker, false to give up.
|
|
55
|
+
fn died(&self, now_ns: u64) -> bool {
|
|
56
|
+
self.0.borrow_mut().died(now_ns)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fn limit(&self) -> u32 {
|
|
60
|
+
self.0.borrow().limit()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fn window_ns() -> u64 {
|
|
64
|
+
padded_cell::restarts::WINDOW_NS
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pub fn define(ruby: &Ruby, module: RModule) -> Result<(), Error> {
|
|
69
|
+
let deadline = module.define_class("Deadline", ruby.class_object())?;
|
|
70
|
+
deadline.define_singleton_method("after", function!(RbDeadline::after, 2))?;
|
|
71
|
+
deadline.define_singleton_method("never", function!(RbDeadline::never, 0))?;
|
|
72
|
+
deadline.define_method("derive", method!(RbDeadline::derive, 2))?;
|
|
73
|
+
deadline.define_method("remaining_ns", method!(RbDeadline::remaining_ns, 1))?;
|
|
74
|
+
deadline.define_method("expired?", method!(RbDeadline::is_expired, 1))?;
|
|
75
|
+
deadline.define_method("expires_at", method!(RbDeadline::expires_at, 0))?;
|
|
76
|
+
|
|
77
|
+
let restarts = module.define_class("Restarts", ruby.class_object())?;
|
|
78
|
+
restarts.define_singleton_method("new", function!(RbRestarts::new, 1))?;
|
|
79
|
+
restarts.define_singleton_method("window_ns", function!(RbRestarts::window_ns, 0))?;
|
|
80
|
+
restarts.define_method("died", method!(RbRestarts::died, 1))?;
|
|
81
|
+
restarts.define_method("limit", method!(RbRestarts::limit, 0))?;
|
|
82
|
+
Ok(())
|
|
83
|
+
}
|
data/lib/padded_cell/asylum.rb
CHANGED
|
@@ -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)) %
|
|
22
|
+
attempt == 1 ? seed : (seed + (attempt * 0x9e37)) % SEEDS
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
def initialize(tests, attempts:, seed:,
|
|
25
|
+
def initialize(tests, attempts:, seed:, limits: Limits.new)
|
|
24
26
|
@tests = tests
|
|
25
27
|
@attempts = attempts
|
|
26
28
|
@seed = seed
|
|
27
|
-
@
|
|
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:,
|
|
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)
|
data/lib/padded_cell/cell.rb
CHANGED
|
@@ -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
|
|
12
|
+
# result when the cell died or ran past its deadline.
|
|
13
13
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
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,
|
|
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
|
-
|
|
64
|
-
|
|
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,
|
|
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
|
|
85
|
+
remaining = deadline && Limits.remaining(deadline)
|
|
83
86
|
return if remaining&.<= 0
|
|
84
87
|
next unless io.wait_readable(remaining)
|
|
85
88
|
|
data/lib/padded_cell/cli.rb
CHANGED
|
@@ -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
|
data/lib/padded_cell/pool.rb
CHANGED
|
@@ -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
|
|
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:,
|
|
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
|
-
@
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
125
|
-
|
|
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 @
|
|
172
|
+
return unless @isolation == :worker
|
|
128
173
|
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
181
|
+
next unless slot.deadline.expired?(now)
|
|
136
182
|
|
|
137
183
|
Process.kill(:KILL, slot.pid)
|
|
138
184
|
reap(slot)
|
|
139
|
-
replace(slot,
|
|
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)
|
data/lib/padded_cell/runner.rb
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/padded_cell/version.rb
CHANGED
data/lib/padded_cell/worker.rb
CHANGED
|
@@ -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:,
|
|
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
|
-
@
|
|
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,
|
|
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.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -87,12 +87,15 @@ files:
|
|
|
87
87
|
- ext/padded_cell_native/core/src/parallel.rs
|
|
88
88
|
- ext/padded_cell_native/core/src/plan.rs
|
|
89
89
|
- ext/padded_cell_native/core/src/plan/tests.rs
|
|
90
|
+
- ext/padded_cell_native/core/src/restarts.rs
|
|
91
|
+
- ext/padded_cell_native/core/src/restarts/tests.rs
|
|
90
92
|
- ext/padded_cell_native/core/src/rng.rs
|
|
91
93
|
- ext/padded_cell_native/core/src/rng/tests.rs
|
|
92
94
|
- ext/padded_cell_native/extconf.rb
|
|
93
95
|
- ext/padded_cell_native/ffi/Cargo.toml
|
|
94
96
|
- ext/padded_cell_native/ffi/src/chart.rs
|
|
95
97
|
- ext/padded_cell_native/ffi/src/lib.rs
|
|
98
|
+
- ext/padded_cell_native/ffi/src/limits.rs
|
|
96
99
|
- lib/padded_cell.rb
|
|
97
100
|
- lib/padded_cell/active_support_hooks.rb
|
|
98
101
|
- lib/padded_cell/asylum.rb
|
|
@@ -111,6 +114,7 @@ files:
|
|
|
111
114
|
- lib/padded_cell/cli.rb
|
|
112
115
|
- lib/padded_cell/discovery.rb
|
|
113
116
|
- lib/padded_cell/hooks.rb
|
|
117
|
+
- lib/padded_cell/limits.rb
|
|
114
118
|
- lib/padded_cell/loader.rb
|
|
115
119
|
- lib/padded_cell/native.rb
|
|
116
120
|
- lib/padded_cell/pool.rb
|