padded_cell 0.1.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 +38 -0
- data/Cargo.lock +16 -2
- data/Cargo.toml +2 -0
- data/README.md +170 -8
- data/ext/padded_cell_native/core/Cargo.toml +2 -1
- data/ext/padded_cell_native/core/src/chart/directives/tests.rs +56 -0
- data/ext/padded_cell_native/core/src/chart/directives.rs +144 -0
- data/ext/padded_cell_native/core/src/chart/json/tests.rs +46 -0
- data/ext/padded_cell_native/core/src/chart/json.rs +175 -0
- data/ext/padded_cell_native/core/src/chart/lcov.rs +50 -0
- data/ext/padded_cell_native/core/src/chart/markdown/tests.rs +93 -0
- data/ext/padded_cell_native/core/src/chart/markdown.rs +201 -0
- data/ext/padded_cell_native/core/src/chart/methods/tests.rs +38 -0
- data/ext/padded_cell_native/core/src/chart/methods.rs +62 -0
- data/ext/padded_cell_native/core/src/chart/output/tests.rs +45 -0
- data/ext/padded_cell_native/core/src/chart/output.rs +103 -0
- data/ext/padded_cell_native/core/src/chart/results/tests.rs +96 -0
- data/ext/padded_cell_native/core/src/chart/results.rs +379 -0
- data/ext/padded_cell_native/core/src/chart/source/tests.rs +64 -0
- data/ext/padded_cell_native/core/src/chart/source.rs +153 -0
- data/ext/padded_cell_native/core/src/chart/stats/tests.rs +52 -0
- data/ext/padded_cell_native/core/src/chart/stats.rs +218 -0
- data/ext/padded_cell_native/core/src/chart/test_support.rs +93 -0
- data/ext/padded_cell_native/core/src/chart/tests.rs +97 -0
- data/ext/padded_cell_native/core/src/chart/thresholds/tests.rs +36 -0
- data/ext/padded_cell_native/core/src/chart/thresholds.rs +116 -0
- data/ext/padded_cell_native/core/src/chart.rs +302 -0
- data/ext/padded_cell_native/core/src/discover/tests.rs +2 -0
- data/ext/padded_cell_native/core/src/discover.rs +7 -53
- data/ext/padded_cell_native/core/src/error.rs +24 -0
- data/ext/padded_cell_native/core/src/lib.rs +10 -3
- data/ext/padded_cell_native/core/src/parallel.rs +43 -0
- 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/chart.rs +205 -0
- data/ext/padded_cell_native/ffi/src/lib.rs +5 -0
- data/ext/padded_cell_native/ffi/src/limits.rs +83 -0
- data/lib/padded_cell/asylum.rb +93 -0
- data/lib/padded_cell/cell.rb +23 -14
- data/lib/padded_cell/chart/accumulator.rb +103 -0
- data/lib/padded_cell/chart/config.rb +129 -0
- data/lib/padded_cell/chart/delta.rb +148 -0
- data/lib/padded_cell/chart/document.rb +64 -0
- data/lib/padded_cell/chart/filter.rb +55 -0
- data/lib/padded_cell/chart/merge.rb +63 -0
- data/lib/padded_cell/chart/probe.rb +70 -0
- data/lib/padded_cell/chart/raw.rb +48 -0
- data/lib/padded_cell/chart/report.rb +128 -0
- data/lib/padded_cell/chart/source_files.rb +90 -0
- data/lib/padded_cell/chart.rb +38 -0
- data/lib/padded_cell/cli.rb +70 -4
- data/lib/padded_cell/limits.rb +45 -0
- data/lib/padded_cell/loader.rb +5 -1
- data/lib/padded_cell/pool.rb +82 -58
- data/lib/padded_cell/reporters.rb +20 -3
- data/lib/padded_cell/results.rb +13 -0
- data/lib/padded_cell/runner.rb +150 -22
- data/lib/padded_cell/version.rb +1 -1
- data/lib/padded_cell/worker.rb +63 -0
- data/lib/padded_cell.rb +8 -0
- metadata +43 -1
|
@@ -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
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
use std::path::PathBuf;
|
|
2
|
+
|
|
3
|
+
use magnus::{Error, RArray, RHash, Ruby, Symbol, TryConvert, Value};
|
|
4
|
+
use padded_cell::chart::{
|
|
5
|
+
self, Arm, CRITERIA, Condition, Criterion, FileCoverage, Format, Meta, Method, Options, Scope,
|
|
6
|
+
Span, Stats, Summary, Thresholds, Totals,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
use crate::file_error;
|
|
10
|
+
use padded_cell::FileError;
|
|
11
|
+
|
|
12
|
+
/// Reads every source, writes the reports, checks the thresholds, and
|
|
13
|
+
/// returns what it found:
|
|
14
|
+
///
|
|
15
|
+
/// ```text
|
|
16
|
+
/// files [[path, name, lines, branches or nil, methods or nil, loaded], ...]
|
|
17
|
+
/// branches [[type, l1, c1, l2, c2, [[type, l1, c1, l2, c2, hits], ...]], ...]
|
|
18
|
+
/// methods [[owner, name, l1, c1, l2, c2, hits], ...]
|
|
19
|
+
/// groups [[name, [file index, ...]], ...]
|
|
20
|
+
/// options {dir:, formats: ["lcov", "json"], nocov_token:, criteria: ["line", "branch"],
|
|
21
|
+
/// minimum:, minimum_by_file:, maximum_drop:, last_run: [line, branch, method] (nil: none),
|
|
22
|
+
/// minimum_by_group: [[name, [line, branch, method]], ...], write_last_run:,
|
|
23
|
+
/// root:, timestamp:, created_at:, commit:, project_name:, generator:}
|
|
24
|
+
///
|
|
25
|
+
/// => {total: stats, groups: [[name, stats], ...], files: [[name, stats], ...],
|
|
26
|
+
/// violations: [[scope, name or nil, criterion, limit, actual], ...]}
|
|
27
|
+
/// stats {line: [covered, missed, omitted, hits, percent, strength], branch: ..., method: ...}
|
|
28
|
+
/// ```
|
|
29
|
+
pub fn chart_report(
|
|
30
|
+
ruby: &Ruby,
|
|
31
|
+
files: RArray,
|
|
32
|
+
groups: Vec<(String, Vec<usize>)>,
|
|
33
|
+
options: RHash,
|
|
34
|
+
) -> Result<RHash, Error> {
|
|
35
|
+
let option = |key: &str| ruby.to_symbol(key);
|
|
36
|
+
let formats = options
|
|
37
|
+
.fetch::<_, Vec<String>>(option("formats"))?
|
|
38
|
+
.iter()
|
|
39
|
+
.map(|name| name.parse::<Format>())
|
|
40
|
+
.collect::<Result<Vec<_>, _>>()
|
|
41
|
+
.map_err(|error| Error::new(ruby.exception_arg_error(), error.to_string()))?;
|
|
42
|
+
let measured: Vec<Criterion> = options
|
|
43
|
+
.fetch::<_, Vec<String>>(option("criteria"))?
|
|
44
|
+
.iter()
|
|
45
|
+
.filter_map(|name| CRITERIA.into_iter().find(|criterion| criterion.singular() == name))
|
|
46
|
+
.collect();
|
|
47
|
+
let thresholds = Thresholds {
|
|
48
|
+
minimum: options.fetch(option("minimum"))?,
|
|
49
|
+
minimum_by_file: options.fetch(option("minimum_by_file"))?,
|
|
50
|
+
minimum_by_group: options.fetch(option("minimum_by_group"))?,
|
|
51
|
+
maximum_drop: options.fetch(option("maximum_drop"))?,
|
|
52
|
+
last_run: options.fetch(option("last_run"))?,
|
|
53
|
+
};
|
|
54
|
+
let meta = Meta {
|
|
55
|
+
root: options.fetch(option("root"))?,
|
|
56
|
+
timestamp: options.fetch(option("timestamp"))?,
|
|
57
|
+
created_at: options.fetch(option("created_at"))?,
|
|
58
|
+
commit: options.fetch(option("commit"))?,
|
|
59
|
+
project_name: options.fetch(option("project_name"))?,
|
|
60
|
+
generator: options.fetch(option("generator"))?,
|
|
61
|
+
};
|
|
62
|
+
let dir: PathBuf = options.fetch(option("dir"))?;
|
|
63
|
+
let analysis = Options {
|
|
64
|
+
nocov_token: options.fetch(option("nocov_token"))?,
|
|
65
|
+
methods: measured.contains(&Criterion::Method),
|
|
66
|
+
};
|
|
67
|
+
let files = (0..files.len())
|
|
68
|
+
.map(|index| file(files.entry(index.cast_signed())?))
|
|
69
|
+
.collect::<Result<Vec<_>, Error>>()?;
|
|
70
|
+
|
|
71
|
+
let failed = |error: FileError| file_error(ruby, &error);
|
|
72
|
+
let reports = chart::analyze_all(&files, &analysis).map_err(failed)?;
|
|
73
|
+
let totals = Totals::of_files(&reports);
|
|
74
|
+
let groups: Vec<(String, Vec<usize>, Totals)> = groups
|
|
75
|
+
.into_iter()
|
|
76
|
+
.map(|(name, members)| {
|
|
77
|
+
let totals = Totals::of_files(members.iter().filter_map(|&index| reports.get(index)));
|
|
78
|
+
(name, members, totals)
|
|
79
|
+
})
|
|
80
|
+
.collect();
|
|
81
|
+
let named: Vec<(String, Totals)> =
|
|
82
|
+
groups.iter().map(|(name, _, totals)| (name.clone(), *totals)).collect();
|
|
83
|
+
let violations = chart::violations(&thresholds, &measured, &totals, &reports, &named);
|
|
84
|
+
let summary = Summary { files: &reports, measured, totals, groups, violations, meta };
|
|
85
|
+
for format in formats {
|
|
86
|
+
chart::write(&dir, format, &summary).map_err(failed)?;
|
|
87
|
+
}
|
|
88
|
+
if options.fetch(option("write_last_run"))? {
|
|
89
|
+
chart::write_last_run_file(&dir, &summary).map_err(failed)?;
|
|
90
|
+
}
|
|
91
|
+
result(ruby, &summary)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fn result(ruby: &Ruby, summary: &Summary<'_>) -> Result<RHash, Error> {
|
|
95
|
+
let result = ruby.hash_new();
|
|
96
|
+
result.aset(ruby.to_symbol("total"), totals(ruby, &summary.totals)?)?;
|
|
97
|
+
let groups = ruby.ary_new_capa(summary.groups.len());
|
|
98
|
+
for (name, _, group) in &summary.groups {
|
|
99
|
+
groups.push((name.as_str(), totals(ruby, group)?))?;
|
|
100
|
+
}
|
|
101
|
+
result.aset(ruby.to_symbol("groups"), groups)?;
|
|
102
|
+
let files = ruby.ary_new_capa(summary.files.len());
|
|
103
|
+
for report in summary.files {
|
|
104
|
+
let stats = criteria(ruby, |criterion| report.stats.with_percent(criterion))?;
|
|
105
|
+
files.push((report.file.name.as_str(), stats))?;
|
|
106
|
+
}
|
|
107
|
+
result.aset(ruby.to_symbol("files"), files)?;
|
|
108
|
+
let violations = ruby.ary_new_capa(summary.violations.len());
|
|
109
|
+
for violation in &summary.violations {
|
|
110
|
+
let (scope, name) = match &violation.scope {
|
|
111
|
+
Scope::Total => ("total", None),
|
|
112
|
+
Scope::File(name) => ("file", Some(name.as_str())),
|
|
113
|
+
Scope::Group(name) => ("group", Some(name.as_str())),
|
|
114
|
+
Scope::Drop => ("drop", None),
|
|
115
|
+
};
|
|
116
|
+
let criterion = violation.criterion.singular();
|
|
117
|
+
violations.push((
|
|
118
|
+
ruby.to_symbol(scope),
|
|
119
|
+
name,
|
|
120
|
+
ruby.to_symbol(criterion),
|
|
121
|
+
violation.limit,
|
|
122
|
+
violation.actual,
|
|
123
|
+
))?;
|
|
124
|
+
}
|
|
125
|
+
result.aset(ruby.to_symbol("violations"), violations)?;
|
|
126
|
+
Ok(result)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
fn totals(ruby: &Ruby, totals: &Totals) -> Result<RHash, Error> {
|
|
130
|
+
criteria(ruby, |criterion| {
|
|
131
|
+
let stats = totals.get(criterion);
|
|
132
|
+
(stats, stats.percent())
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
fn criteria(ruby: &Ruby, stats: impl Fn(Criterion) -> (Stats, f64)) -> Result<RHash, Error> {
|
|
137
|
+
let hash = ruby.hash_new();
|
|
138
|
+
for criterion in CRITERIA {
|
|
139
|
+
let (stats, percent) = stats(criterion);
|
|
140
|
+
let row =
|
|
141
|
+
(stats.covered, stats.missed, stats.omitted, stats.hits, percent, stats.strength());
|
|
142
|
+
hash.aset(ruby.to_symbol(criterion.singular()), row)?;
|
|
143
|
+
}
|
|
144
|
+
Ok(hash)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
fn file(row: RArray) -> Result<FileCoverage, Error> {
|
|
148
|
+
Ok(FileCoverage {
|
|
149
|
+
path: row.entry(0)?,
|
|
150
|
+
name: row.entry(1)?,
|
|
151
|
+
lines: row.entry::<RArray>(2)?.to_vec()?,
|
|
152
|
+
branches: row.entry::<Option<RArray>>(3)?.map(conditions).transpose()?,
|
|
153
|
+
methods: row.entry::<Option<RArray>>(4)?.map(methods).transpose()?,
|
|
154
|
+
loaded: row.entry(5)?,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
fn conditions(rows: RArray) -> Result<Vec<Condition>, Error> {
|
|
159
|
+
rows.into_iter()
|
|
160
|
+
.map(|row| {
|
|
161
|
+
let row = RArray::try_convert(row)?;
|
|
162
|
+
let arms = RArray::try_convert(row.entry(5)?)?
|
|
163
|
+
.into_iter()
|
|
164
|
+
.map(|arm| {
|
|
165
|
+
let arm = RArray::try_convert(arm)?;
|
|
166
|
+
Ok(Arm { kind: name(arm.entry(0)?)?, span: span(arm, 1)?, hits: arm.entry(5)? })
|
|
167
|
+
})
|
|
168
|
+
.collect::<Result<_, Error>>()?;
|
|
169
|
+
Ok(Condition { kind: name(row.entry(0)?)?, span: span(row, 1)?, arms })
|
|
170
|
+
})
|
|
171
|
+
.collect()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fn methods(rows: RArray) -> Result<Vec<Method>, Error> {
|
|
175
|
+
rows.into_iter()
|
|
176
|
+
.map(|row| {
|
|
177
|
+
let row = RArray::try_convert(row)?;
|
|
178
|
+
Ok(Method {
|
|
179
|
+
owner: row.entry(0)?,
|
|
180
|
+
name: name(row.entry(1)?)?,
|
|
181
|
+
span: span(row, 2)?,
|
|
182
|
+
hits: row.entry(6)?,
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
.collect()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// Four integers from `at`: first line, first column, last line, last column.
|
|
189
|
+
fn span(row: RArray, at: isize) -> Result<Span, Error> {
|
|
190
|
+
Ok(Span {
|
|
191
|
+
start_line: row.entry(at)?,
|
|
192
|
+
start_column: row.entry(at.strict_add(1))?,
|
|
193
|
+
end_line: row.entry(at.strict_add(2))?,
|
|
194
|
+
end_column: row.entry(at.strict_add(3))?,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/// A branch type or method name: a Symbol from Ruby's Coverage, a String
|
|
199
|
+
/// once through JSON.
|
|
200
|
+
fn name(value: Value) -> Result<String, Error> {
|
|
201
|
+
match Symbol::from_value(value) {
|
|
202
|
+
Some(symbol) => Ok(symbol.name()?.into_owned()),
|
|
203
|
+
None => String::try_convert(value),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -18,6 +18,9 @@ use magnus::prelude::*;
|
|
|
18
18
|
use magnus::{Error, RArray, RString, Ruby, function};
|
|
19
19
|
use padded_cell::{Discovery, FileError};
|
|
20
20
|
|
|
21
|
+
mod chart;
|
|
22
|
+
mod limits;
|
|
23
|
+
|
|
21
24
|
/// `Errno::*` for the path, as Ruby's own file reading raises.
|
|
22
25
|
fn file_error(ruby: &Ruby, error: &FileError) -> Error {
|
|
23
26
|
let path = error.path.display().to_string();
|
|
@@ -116,5 +119,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
116
119
|
module.define_module_function("discover", function!(discover, 1))?;
|
|
117
120
|
module.define_module_function("discover_files", function!(discover_files, 1))?;
|
|
118
121
|
module.define_module_function("plan", function!(plan, 5))?;
|
|
122
|
+
module.define_module_function("chart_report", function!(chart::chart_report, 3))?;
|
|
123
|
+
limits::define(ruby, module)?;
|
|
119
124
|
Ok(())
|
|
120
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
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddedCell
|
|
4
|
+
# --asylum: tests that failed get more attempts once the run is over, one
|
|
5
|
+
# at a time, each in a fresh cell. A test that passes on retry counts as
|
|
6
|
+
# passed but is reported as flaky, with every attempt and what changed:
|
|
7
|
+
#
|
|
8
|
+
# attempt 1, same seed, run alone: timing, contention, or state left by
|
|
9
|
+
# the tests that ran around it
|
|
10
|
+
# attempt 2+, another seed: randomness or test order
|
|
11
|
+
#
|
|
12
|
+
# A test that fails every attempt keeps its first failure.
|
|
13
|
+
class Asylum
|
|
14
|
+
ALONE = 'timing, contention or state left by other tests'
|
|
15
|
+
RESEEDED = 'randomness or test order'
|
|
16
|
+
SEEDS = 2**64
|
|
17
|
+
|
|
18
|
+
# The seed of retry attempt, derived from the run's seed so a report can
|
|
19
|
+
# be replayed. Attempt 1 keeps the run's seed. Wraps within the planner's
|
|
20
|
+
# 64-bit seeds, as --seed accepts them.
|
|
21
|
+
def self.retry_seed(seed, attempt)
|
|
22
|
+
attempt == 1 ? seed : (seed + (attempt * 0x9e37)) % SEEDS
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(tests, attempts:, seed:, limits: Limits.new)
|
|
26
|
+
@tests = tests
|
|
27
|
+
@attempts = attempts
|
|
28
|
+
@seed = seed
|
|
29
|
+
@limits = limits
|
|
30
|
+
@success = true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# False when a retry's worker failed (its cleanup hook).
|
|
34
|
+
def success? = @success
|
|
35
|
+
|
|
36
|
+
# failures: {test index => first failing result}. Yields (index, result)
|
|
37
|
+
# once per test, with its final result. Its metadata holds the attempts:
|
|
38
|
+
# :flaky when it passed on retry, :asylum when it never did. Both are
|
|
39
|
+
# plain data, for Marshal and the JUnit and JSON reports.
|
|
40
|
+
def call(failures)
|
|
41
|
+
history = failures.to_h { |index, result| [index, [attempt_entry(@seed, false, result)]] }
|
|
42
|
+
pending = failures.keys
|
|
43
|
+
1.upto(@attempts) do |attempt|
|
|
44
|
+
break if pending.empty?
|
|
45
|
+
|
|
46
|
+
pending -= retry_round(pending, history, attempt) do |index, result|
|
|
47
|
+
result.metadata[:flaky] = flaky(failures[index], history[index], attempt)
|
|
48
|
+
yield index, result
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
pending.each do |index|
|
|
52
|
+
failures[index].metadata[:asylum] = { attempts: history[index].size, history: history[index] }
|
|
53
|
+
yield index, failures[index]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Runs the pending tests once more, alone; returns the ones that passed.
|
|
60
|
+
def retry_round(pending, history, attempt)
|
|
61
|
+
seed = self.class.retry_seed(@seed, attempt)
|
|
62
|
+
passed = []
|
|
63
|
+
pool = Pool.new(@tests, jobs: 1, isolation: :test, seed:, limits: @limits)
|
|
64
|
+
pool.run([pending.dup]) do |index, result|
|
|
65
|
+
recovered = result.passed? && !result.skipped?
|
|
66
|
+
history[index] << attempt_entry(seed, true, recovered ? nil : result)
|
|
67
|
+
next unless recovered
|
|
68
|
+
|
|
69
|
+
passed << index
|
|
70
|
+
yield index, result
|
|
71
|
+
end
|
|
72
|
+
@success &&= pool.success?
|
|
73
|
+
passed
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# One attempt: its seed, whether it ran alone, and how it ended.
|
|
77
|
+
def attempt_entry(seed, alone, failed)
|
|
78
|
+
{ seed:, alone:, outcome: failed ? summary(failed.failure)[:message] : 'passed' }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def flaky(first, history, attempt)
|
|
82
|
+
{
|
|
83
|
+
attempts: history.size, seed: history.last[:seed], cause: attempt == 1 ? ALONE : RESEEDED,
|
|
84
|
+
first_failure: summary(first.failure), history:
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def summary(failure)
|
|
89
|
+
type = failure.is_a?(Minitest::UnexpectedError) ? failure.error.class.name : failure.class.name
|
|
90
|
+
{ type:, message: failure.message.lines.first.to_s.strip }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/padded_cell/cell.rb
CHANGED
|
@@ -9,18 +9,23 @@ 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
|
-
|
|
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)
|
|
16
18
|
# Binary: the result is Marshal data, and with Encoding.default_internal
|
|
17
19
|
# set (Rails sets it) a text-mode pipe transcodes what is written to it.
|
|
18
20
|
reader, writer = IO.pipe
|
|
19
21
|
[reader, writer].each(&:binmode)
|
|
20
22
|
started = Minitest.clock_time
|
|
21
|
-
|
|
23
|
+
deadline = limits&.for_test
|
|
24
|
+
pid = fork_cell(klass, name, seed, reader, writer, close, probe)
|
|
22
25
|
writer.close
|
|
23
|
-
collect(pid, reader, klass, name, started,
|
|
26
|
+
delta, result = collect(pid, reader, klass, name, started, deadline) { limits.message(deadline) }
|
|
27
|
+
delta ? probe.absorb(delta) : probe&.lost
|
|
28
|
+
result
|
|
24
29
|
ensure
|
|
25
30
|
reader&.close
|
|
26
31
|
writer&.close unless writer&.closed?
|
|
@@ -34,7 +39,7 @@ module PaddedCell
|
|
|
34
39
|
Results.crashed(klass, name, "exit(#{e.status}) called during the test")
|
|
35
40
|
end
|
|
36
41
|
|
|
37
|
-
def fork_cell(klass, name, seed, reader, writer, close)
|
|
42
|
+
def fork_cell(klass, name, seed, reader, writer, close, probe)
|
|
38
43
|
$stdout.flush
|
|
39
44
|
$stderr.flush
|
|
40
45
|
Process.fork do
|
|
@@ -43,7 +48,9 @@ module PaddedCell
|
|
|
43
48
|
reader.close
|
|
44
49
|
close.each(&:close)
|
|
45
50
|
srand(seed)
|
|
46
|
-
|
|
51
|
+
probe&.start
|
|
52
|
+
result = execute(klass, name)
|
|
53
|
+
writer.write(Results.envelope(probe&.capture, result))
|
|
47
54
|
status = 0
|
|
48
55
|
rescue Exception => e # anything: report it, then exit!
|
|
49
56
|
warn "padded_cell: cell #{klass}##{name} failed to report: #{e.class}: #{e.message}"
|
|
@@ -54,26 +61,28 @@ module PaddedCell
|
|
|
54
61
|
end
|
|
55
62
|
end
|
|
56
63
|
|
|
57
|
-
|
|
58
|
-
|
|
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)
|
|
59
68
|
elapsed = Minitest.clock_time - started
|
|
60
69
|
if data.nil?
|
|
61
70
|
Process.kill(:KILL, pid)
|
|
62
71
|
Process.wait(pid)
|
|
63
|
-
return Results.crashed(klass, name,
|
|
72
|
+
return [nil, Results.crashed(klass, name, yield, time: elapsed)]
|
|
64
73
|
end
|
|
65
74
|
|
|
66
75
|
_, status = Process.wait2(pid)
|
|
67
|
-
return Results.
|
|
76
|
+
return Results.unwrap(data, klass, name) unless data.empty?
|
|
68
77
|
|
|
69
|
-
Results.crashed(klass, name, Results.describe(status, 'cell'), time: elapsed)
|
|
78
|
+
[nil, Results.crashed(klass, name, Results.describe(status, 'cell'), time: elapsed)]
|
|
70
79
|
end
|
|
71
80
|
|
|
72
|
-
# Everything until EOF, or nil when the deadline passes first.
|
|
81
|
+
# Everything until EOF, or nil when the deadline (if any) passes first.
|
|
73
82
|
def read(io, deadline)
|
|
74
83
|
data = String.new(encoding: Encoding::BINARY)
|
|
75
84
|
loop do
|
|
76
|
-
remaining = deadline && (deadline
|
|
85
|
+
remaining = deadline && Limits.remaining(deadline)
|
|
77
86
|
return if remaining&.<= 0
|
|
78
87
|
next unless io.wait_readable(remaining)
|
|
79
88
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'coverage'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
module PaddedCell
|
|
8
|
+
module Chart
|
|
9
|
+
# The runner's side. Starts Ruby's Coverage before the suite loads, hands
|
|
10
|
+
# each worker a Probe, then sums its own measure (loading the suite) with
|
|
11
|
+
# every frame the workers wrote.
|
|
12
|
+
class Accumulator
|
|
13
|
+
attr_reader :criteria
|
|
14
|
+
|
|
15
|
+
# criteria: what Ruby's Coverage measures, :lines always among them
|
|
16
|
+
def initialize(criteria)
|
|
17
|
+
@criteria = criteria
|
|
18
|
+
@root = File.realpath(Dir.pwd)
|
|
19
|
+
@roots = [Dir.pwd, @root].uniq.map { "#{it}/" }
|
|
20
|
+
@kept = {}
|
|
21
|
+
@lost = 0
|
|
22
|
+
@known = Set.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Nothing loaded before is measured.
|
|
26
|
+
def start
|
|
27
|
+
::Coverage.setup(**@criteria.to_h { [it, true] })
|
|
28
|
+
::Coverage.resume
|
|
29
|
+
@frames = Dir.mktmpdir('padded_cell-coverage')
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# After the suite loaded, before the first fork: the files whose
|
|
34
|
+
# structure the runner will report itself. Forks inherit the set.
|
|
35
|
+
def snapshot
|
|
36
|
+
::Coverage.peek_result.each_key { @known << it if keep?(it) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# For a worker, right after its fork.
|
|
40
|
+
def probe
|
|
41
|
+
Probe.new(file: File.join(@frames, "#{Process.pid}.cov"), known: @known, keep: method(:keep?),
|
|
42
|
+
methods: @criteria.include?(:methods), interval: Chart.config.interval)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A worker died: what it measured since its last flush is gone.
|
|
46
|
+
def lost_worker = @lost += 1
|
|
47
|
+
|
|
48
|
+
# Sums everything (see Raw); nil for an interrupted run.
|
|
49
|
+
def finish(interrupted: false)
|
|
50
|
+
return if interrupted
|
|
51
|
+
|
|
52
|
+
total = Delta.capture(::Coverage.peek_result, Set.new, method(:keep?))
|
|
53
|
+
cells = 0
|
|
54
|
+
frames.each do |frame|
|
|
55
|
+
Delta.merge!(total, frame[:delta])
|
|
56
|
+
cells += frame[:lost]
|
|
57
|
+
end
|
|
58
|
+
Raw.build(normalize(total), root: @root, criteria: @criteria, lost: { workers: @lost, cells: })
|
|
59
|
+
ensure
|
|
60
|
+
FileUtils.rm_rf(@frames) if @frames
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Files under the project root, which exist (not eval'd code).
|
|
66
|
+
def keep?(path)
|
|
67
|
+
@kept.fetch(path) { @kept[path] = @roots.any? { path.start_with?(it) } && File.file?(path) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Every complete frame of every worker. A worker killed while writing
|
|
71
|
+
# leaves a truncated last frame, skipped.
|
|
72
|
+
def frames
|
|
73
|
+
Dir[File.join(@frames, '*.cov')].flat_map do |file|
|
|
74
|
+
data = File.binread(file)
|
|
75
|
+
offset = 0
|
|
76
|
+
frames = []
|
|
77
|
+
while offset + 4 <= data.bytesize
|
|
78
|
+
size = data.unpack1('N', offset:)
|
|
79
|
+
break if offset + 4 + size > data.bytesize
|
|
80
|
+
|
|
81
|
+
frames << Marshal.load(data.byteslice(offset + 4, size)) # written by our own workers
|
|
82
|
+
offset += 4 + size
|
|
83
|
+
end
|
|
84
|
+
frames
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Keys by real path relative to the root: a file required through a
|
|
89
|
+
# symlink and through its real path is one file. A file deleted during
|
|
90
|
+
# the run is not reported.
|
|
91
|
+
def normalize(total)
|
|
92
|
+
total.each_with_object({}) do |(path, entry), files|
|
|
93
|
+
relative = File.realpath(path).delete_prefix("#{@root}/")
|
|
94
|
+
next if relative.start_with?('/')
|
|
95
|
+
|
|
96
|
+
files[relative] ? Delta.merge!(files, { relative => entry }) : files[relative] = entry
|
|
97
|
+
rescue SystemCallError
|
|
98
|
+
next
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|