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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/Cargo.lock +16 -2
  4. data/Cargo.toml +2 -0
  5. data/README.md +170 -8
  6. data/ext/padded_cell_native/core/Cargo.toml +2 -1
  7. data/ext/padded_cell_native/core/src/chart/directives/tests.rs +56 -0
  8. data/ext/padded_cell_native/core/src/chart/directives.rs +144 -0
  9. data/ext/padded_cell_native/core/src/chart/json/tests.rs +46 -0
  10. data/ext/padded_cell_native/core/src/chart/json.rs +175 -0
  11. data/ext/padded_cell_native/core/src/chart/lcov.rs +50 -0
  12. data/ext/padded_cell_native/core/src/chart/markdown/tests.rs +93 -0
  13. data/ext/padded_cell_native/core/src/chart/markdown.rs +201 -0
  14. data/ext/padded_cell_native/core/src/chart/methods/tests.rs +38 -0
  15. data/ext/padded_cell_native/core/src/chart/methods.rs +62 -0
  16. data/ext/padded_cell_native/core/src/chart/output/tests.rs +45 -0
  17. data/ext/padded_cell_native/core/src/chart/output.rs +103 -0
  18. data/ext/padded_cell_native/core/src/chart/results/tests.rs +96 -0
  19. data/ext/padded_cell_native/core/src/chart/results.rs +379 -0
  20. data/ext/padded_cell_native/core/src/chart/source/tests.rs +64 -0
  21. data/ext/padded_cell_native/core/src/chart/source.rs +153 -0
  22. data/ext/padded_cell_native/core/src/chart/stats/tests.rs +52 -0
  23. data/ext/padded_cell_native/core/src/chart/stats.rs +218 -0
  24. data/ext/padded_cell_native/core/src/chart/test_support.rs +93 -0
  25. data/ext/padded_cell_native/core/src/chart/tests.rs +97 -0
  26. data/ext/padded_cell_native/core/src/chart/thresholds/tests.rs +36 -0
  27. data/ext/padded_cell_native/core/src/chart/thresholds.rs +116 -0
  28. data/ext/padded_cell_native/core/src/chart.rs +302 -0
  29. data/ext/padded_cell_native/core/src/discover/tests.rs +2 -0
  30. data/ext/padded_cell_native/core/src/discover.rs +7 -53
  31. data/ext/padded_cell_native/core/src/error.rs +24 -0
  32. data/ext/padded_cell_native/core/src/lib.rs +10 -3
  33. data/ext/padded_cell_native/core/src/parallel.rs +43 -0
  34. data/ext/padded_cell_native/core/src/restarts/tests.rs +29 -0
  35. data/ext/padded_cell_native/core/src/restarts.rs +64 -0
  36. data/ext/padded_cell_native/ffi/Cargo.toml +2 -1
  37. data/ext/padded_cell_native/ffi/src/chart.rs +205 -0
  38. data/ext/padded_cell_native/ffi/src/lib.rs +5 -0
  39. data/ext/padded_cell_native/ffi/src/limits.rs +83 -0
  40. data/lib/padded_cell/asylum.rb +93 -0
  41. data/lib/padded_cell/cell.rb +23 -14
  42. data/lib/padded_cell/chart/accumulator.rb +103 -0
  43. data/lib/padded_cell/chart/config.rb +129 -0
  44. data/lib/padded_cell/chart/delta.rb +148 -0
  45. data/lib/padded_cell/chart/document.rb +64 -0
  46. data/lib/padded_cell/chart/filter.rb +55 -0
  47. data/lib/padded_cell/chart/merge.rb +63 -0
  48. data/lib/padded_cell/chart/probe.rb +70 -0
  49. data/lib/padded_cell/chart/raw.rb +48 -0
  50. data/lib/padded_cell/chart/report.rb +128 -0
  51. data/lib/padded_cell/chart/source_files.rb +90 -0
  52. data/lib/padded_cell/chart.rb +38 -0
  53. data/lib/padded_cell/cli.rb +70 -4
  54. data/lib/padded_cell/limits.rb +45 -0
  55. data/lib/padded_cell/loader.rb +5 -1
  56. data/lib/padded_cell/pool.rb +82 -58
  57. data/lib/padded_cell/reporters.rb +20 -3
  58. data/lib/padded_cell/results.rb +13 -0
  59. data/lib/padded_cell/runner.rb +150 -22
  60. data/lib/padded_cell/version.rb +1 -1
  61. data/lib/padded_cell/worker.rb +63 -0
  62. data/lib/padded_cell.rb +8 -0
  63. 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.1.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
@@ -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 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
- def run(klass, name, seed:, timeout: nil, close: [])
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
- pid = fork_cell(klass, name, seed, reader, writer, close)
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, timeout)
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
- writer.write(Results.dump(execute(klass, name)))
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
- def collect(pid, reader, klass, name, started, timeout)
58
- 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)
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, "timed out after #{timeout}s", time: elapsed)
72
+ return [nil, Results.crashed(klass, name, yield, time: elapsed)]
64
73
  end
65
74
 
66
75
  _, status = Process.wait2(pid)
67
- return Results.load(data, klass, name) unless data.empty?
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 - Minitest.clock_time)
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