mutineer 0.7.0 → 0.8.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 +29 -0
- data/README.md +34 -1
- data/lib/mutineer/baseline.rb +29 -13
- data/lib/mutineer/changed_lines.rb +29 -12
- data/lib/mutineer/cli.rb +57 -0
- data/lib/mutineer/config.rb +21 -0
- data/lib/mutineer/coverage_map.rb +86 -4
- data/lib/mutineer/isolation.rb +89 -40
- data/lib/mutineer/minitest_integration.rb +13 -9
- data/lib/mutineer/mutant_id.rb +24 -5
- data/lib/mutineer/mutation.rb +12 -6
- data/lib/mutineer/mutator_registry.rb +21 -3
- data/lib/mutineer/mutators/arithmetic.rb +8 -2
- data/lib/mutineer/mutators/base.rb +11 -3
- data/lib/mutineer/mutators/boolean_connector.rb +15 -5
- data/lib/mutineer/mutators/boolean_literal.rb +19 -6
- data/lib/mutineer/mutators/comparison.rb +7 -8
- data/lib/mutineer/mutators/condition_negation.rb +14 -5
- data/lib/mutineer/mutators/literal_mutation.rb +15 -4
- data/lib/mutineer/mutators/return_nil.rb +22 -7
- data/lib/mutineer/mutators/statement_removal.rb +6 -9
- data/lib/mutineer/pairing.rb +32 -13
- data/lib/mutineer/parser.rb +17 -7
- data/lib/mutineer/project.rb +73 -2
- data/lib/mutineer/reporter.rb +55 -0
- data/lib/mutineer/result.rb +100 -32
- data/lib/mutineer/runner.rb +23 -0
- data/lib/mutineer/subject.rb +10 -6
- data/lib/mutineer/test_runners/minitest.rb +5 -4
- data/lib/mutineer/test_runners/rspec.rb +10 -17
- data/lib/mutineer/test_runners.rb +9 -2
- data/lib/mutineer/version.rb +2 -1
- data/lib/mutineer/worker_pool.rb +34 -21
- data/lib/mutineer.rb +1 -0
- metadata +15 -1
data/lib/mutineer/result.rb
CHANGED
|
@@ -8,90 +8,154 @@ module Mutineer
|
|
|
8
8
|
# timeout — the parent SIGKILLed a child that overran its wall clock.
|
|
9
9
|
# skipped — the mutated source failed to re-parse (invalid); no fork.
|
|
10
10
|
# no_coverage — no test exercises the mutated line; not run, not scored.
|
|
11
|
-
# uncapturable — the line's would-be covering test errored during capture
|
|
12
|
-
# so coverage was lost. Excluded from the
|
|
13
|
-
# like no_coverage, but reported
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
11
|
+
# uncapturable — the line's would-be covering test errored during capture
|
|
12
|
+
# (#9), so coverage was lost. Excluded from the
|
|
13
|
+
# denominator exactly like no_coverage, but reported
|
|
14
|
+
# separately: it signals a broken harness (a test that
|
|
15
|
+
# failed to run), not a genuine coverage gap.
|
|
16
|
+
# ignored — a known-equivalent mutant the user suppressed (#10), via
|
|
17
|
+
# an inline `# mutineer:disable-line` comment or a
|
|
18
|
+
# `.mutineer.yml` `ignore:` id. A pre-fork classification
|
|
19
|
+
# (never run); excluded from the denominator so a strong
|
|
20
|
+
# file can reach 100%.
|
|
19
21
|
#
|
|
20
22
|
# `error` and `skipped` are deliberately distinct: skipped is a pre-fork
|
|
21
23
|
# validity failure (counted separately by the reporter), error is a runtime
|
|
22
24
|
# crash. Never conflate them via `details` string parsing. `no_coverage` and
|
|
23
|
-
# `uncapturable` are pre-fork selection results (M3/#9): both excluded from
|
|
24
|
-
# score denominator.
|
|
25
|
+
# `uncapturable` are pre-fork selection results (M3/#9): both excluded from
|
|
26
|
+
# the score denominator.
|
|
25
27
|
#
|
|
26
|
-
# `subject`, `mutation`, and `id` are nil when the Result is built by
|
|
27
|
-
# Runner (which only know the outcome); the orchestrator attaches
|
|
28
|
-
# via `result.with(subject:, mutation:, id:)` so the Reporter
|
|
29
|
-
# diffs and emit the stable id. `id` is the content-based
|
|
28
|
+
# `subject`, `mutation`, and `id` are nil when the Result is built by
|
|
29
|
+
# Isolation/Runner (which only know the outcome); the orchestrator attaches
|
|
30
|
+
# them afterwards via `result.with(subject:, mutation:, id:)` so the Reporter
|
|
31
|
+
# can render survivor diffs and emit the stable id. `id` is the content-based
|
|
32
|
+
# MutantId (#10).
|
|
30
33
|
Result = Data.define(:status, :details, :subject, :mutation, :id) do
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
# Builds a killed result.
|
|
35
|
+
#
|
|
36
|
+
# @return [Mutineer::Result] killed result.
|
|
37
|
+
def self.killed = new(status: :killed, details: nil, subject: nil, mutation: nil, id: nil)
|
|
38
|
+
|
|
39
|
+
# Builds a survived result.
|
|
40
|
+
#
|
|
41
|
+
# @return [Mutineer::Result] survived result.
|
|
42
|
+
def self.survived = new(status: :survived, details: nil, subject: nil, mutation: nil, id: nil)
|
|
43
|
+
|
|
44
|
+
# Builds an error result.
|
|
45
|
+
#
|
|
46
|
+
# @param details [String, nil] error details.
|
|
47
|
+
# @return [Mutineer::Result] error result.
|
|
33
48
|
def self.error(details = nil) = new(status: :error, details: details, subject: nil, mutation: nil, id: nil)
|
|
34
|
-
|
|
49
|
+
|
|
50
|
+
# Builds a timeout result.
|
|
51
|
+
#
|
|
52
|
+
# @return [Mutineer::Result] timeout result.
|
|
53
|
+
def self.timeout = new(status: :timeout, details: nil, subject: nil, mutation: nil, id: nil)
|
|
54
|
+
|
|
55
|
+
# Builds a skipped result.
|
|
56
|
+
#
|
|
57
|
+
# @param details [String, nil] skip details.
|
|
58
|
+
# @return [Mutineer::Result] skipped result.
|
|
35
59
|
def self.skipped(details = nil) = new(status: :skipped, details: details, subject: nil, mutation: nil, id: nil)
|
|
36
|
-
def self.no_coverage = new(status: :no_coverage, details: nil, subject: nil, mutation: nil, id: nil)
|
|
37
|
-
def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
|
|
38
|
-
def self.ignored = new(status: :ignored, details: nil, subject: nil, mutation: nil, id: nil)
|
|
39
60
|
|
|
61
|
+
# Builds a no_coverage result.
|
|
62
|
+
#
|
|
63
|
+
# @return [Mutineer::Result] no-coverage result.
|
|
64
|
+
def self.no_coverage = new(status: :no_coverage, details: nil, subject: nil, mutation: nil, id: nil)
|
|
65
|
+
|
|
66
|
+
# Builds an uncapturable result.
|
|
67
|
+
#
|
|
68
|
+
# @return [Mutineer::Result] uncapturable result.
|
|
69
|
+
def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
|
|
70
|
+
|
|
71
|
+
# Builds an ignored result.
|
|
72
|
+
#
|
|
73
|
+
# @return [Mutineer::Result] ignored result.
|
|
74
|
+
def self.ignored = new(status: :ignored, details: nil, subject: nil, mutation: nil, id: nil)
|
|
75
|
+
|
|
76
|
+
# @return [Boolean] true when the status is killed.
|
|
40
77
|
def killed? = status == :killed
|
|
78
|
+
# @return [Boolean] true when the status is survived.
|
|
41
79
|
def survived? = status == :survived
|
|
80
|
+
# @return [Boolean] true when the status is error.
|
|
42
81
|
def error? = status == :error
|
|
82
|
+
# @return [Boolean] true when the status is timeout.
|
|
43
83
|
def timeout? = status == :timeout
|
|
84
|
+
# @return [Boolean] true when the status is skipped.
|
|
44
85
|
def skipped? = status == :skipped
|
|
86
|
+
# @return [Boolean] true when the status is no_coverage.
|
|
45
87
|
def no_coverage? = status == :no_coverage
|
|
88
|
+
# @return [Boolean] true when the status is uncapturable.
|
|
46
89
|
def uncapturable? = status == :uncapturable
|
|
90
|
+
# @return [Boolean] true when the status is ignored.
|
|
47
91
|
def ignored? = status == :ignored
|
|
92
|
+
|
|
48
93
|
end
|
|
49
94
|
|
|
50
95
|
# Aggregates a flat list of Results into counts, the mutation score, and the
|
|
51
96
|
# surviving-mutant list. The score denominator is killed + survived ONLY
|
|
52
|
-
# (KTD-4): no-coverage, uncapturable, skipped (invalid), errored, timeout,
|
|
53
|
-
# ignored (#10 equivalent-mutant suppression) are each excluded and
|
|
54
|
-
# separately — so suppressing every survivor reaches 100%. An empty
|
|
55
|
-
# (rendered "N/A"), never 0.0 —
|
|
56
|
-
# "0% killed".
|
|
97
|
+
# (KTD-4): no-coverage, uncapturable, skipped (invalid), errored, timeout,
|
|
98
|
+
# and ignored (#10 equivalent-mutant suppression) are each excluded and
|
|
99
|
+
# surfaced separately — so suppressing every survivor reaches 100%. An empty
|
|
100
|
+
# denominator yields a nil score (rendered "N/A"), never 0.0 —
|
|
101
|
+
# distinguishing "no testable mutants" from "0% killed".
|
|
57
102
|
class AggregateResult
|
|
58
103
|
attr_reader :results
|
|
59
104
|
|
|
105
|
+
# Builds an aggregate from results.
|
|
106
|
+
#
|
|
107
|
+
# @param results [Array<Mutineer::Result>] classified results.
|
|
60
108
|
def initialize(results)
|
|
61
109
|
@results = results
|
|
62
110
|
@by_status = results.group_by(&:status)
|
|
63
111
|
end
|
|
64
112
|
|
|
113
|
+
# @return [Integer] killed count.
|
|
65
114
|
def killed_count = count(:killed)
|
|
115
|
+
# @return [Integer] survived count.
|
|
66
116
|
def survived_count = count(:survived)
|
|
117
|
+
# @return [Integer] no-coverage count.
|
|
67
118
|
def no_coverage_count = count(:no_coverage)
|
|
119
|
+
# @return [Integer] uncapturable count.
|
|
68
120
|
def uncapturable_count = count(:uncapturable)
|
|
121
|
+
# @return [Integer] skipped-invalid count.
|
|
69
122
|
def skipped_invalid_count = count(:skipped)
|
|
123
|
+
# @return [Integer] errored count.
|
|
70
124
|
def errored_count = count(:error)
|
|
125
|
+
# @return [Integer] timeout count.
|
|
71
126
|
def timeout_count = count(:timeout)
|
|
127
|
+
# @return [Integer] ignored count.
|
|
72
128
|
def ignored_count = count(:ignored)
|
|
73
129
|
|
|
74
130
|
# Every generated, classified mutation. NOT the score denominator.
|
|
131
|
+
#
|
|
132
|
+
# @return [Integer] total result count.
|
|
75
133
|
def total = @results.size
|
|
76
134
|
|
|
77
135
|
# The score denominator (also shown to the reader).
|
|
136
|
+
#
|
|
137
|
+
# @return [Integer] killed plus survived.
|
|
78
138
|
def covered_count = killed_count + survived_count
|
|
79
139
|
|
|
80
|
-
#
|
|
81
|
-
#
|
|
140
|
+
# Computes the mutation score.
|
|
141
|
+
#
|
|
142
|
+
# @return [Float, nil] score percentage or nil when nothing was testable.
|
|
82
143
|
def mutation_score
|
|
83
144
|
return nil if covered_count.zero?
|
|
84
145
|
|
|
85
146
|
(killed_count.to_f / covered_count * 100).round(1)
|
|
86
147
|
end
|
|
87
148
|
|
|
149
|
+
# Returns the surviving mutants.
|
|
150
|
+
#
|
|
151
|
+
# @return [Array<Mutineer::Result>] surviving results.
|
|
88
152
|
def surviving_mutants = @results.select(&:survived?)
|
|
89
153
|
|
|
90
|
-
# #11: split into { source_file => AggregateResult } so the Reporter
|
|
91
|
-
# breakdown) and #13 (per-source roll-up / baseline diff)
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
154
|
+
# #11: split into { source_file => AggregateResult } so the Reporter
|
|
155
|
+
# (per-source breakdown) and #13 (per-source roll-up / baseline diff) can
|
|
156
|
+
# reuse the same aggregate math.
|
|
157
|
+
#
|
|
158
|
+
# @return [Hash<String, Mutineer::AggregateResult>] source-file groups.
|
|
95
159
|
def by_source
|
|
96
160
|
@results.select { |r| r.subject }
|
|
97
161
|
.group_by { |r| r.subject.file }
|
|
@@ -100,6 +164,10 @@ module Mutineer
|
|
|
100
164
|
|
|
101
165
|
private
|
|
102
166
|
|
|
167
|
+
# Counts results for a status.
|
|
168
|
+
#
|
|
169
|
+
# @param status [Symbol] result status.
|
|
170
|
+
# @return [Integer] count for that status.
|
|
103
171
|
def count(status) = (@by_status[status] || []).size
|
|
104
172
|
end
|
|
105
173
|
end
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -32,6 +32,9 @@ module Mutineer
|
|
|
32
32
|
# The parent process `require`s each source file so its classes exist; forked
|
|
33
33
|
# children inherit them, so a covering test file's own require_relative of the
|
|
34
34
|
# source is a no-op and does not clobber the mutated `load` (spec §7).
|
|
35
|
+
#
|
|
36
|
+
# @param config [Mutineer::Config] run configuration.
|
|
37
|
+
# @return [Array(Mutineer::AggregateResult, Hash<String, String>)] aggregate and source map.
|
|
35
38
|
def self.execute(config)
|
|
36
39
|
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
37
40
|
|
|
@@ -215,6 +218,11 @@ module Mutineer
|
|
|
215
218
|
warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
|
|
216
219
|
end
|
|
217
220
|
|
|
221
|
+
# Removes stale mutant tempfiles from the given directories.
|
|
222
|
+
#
|
|
223
|
+
# @api private
|
|
224
|
+
# @param dirs [Array<String>] directories to sweep.
|
|
225
|
+
# @return [void]
|
|
218
226
|
def self.sweep_orphans(dirs)
|
|
219
227
|
dirs.each do |dir|
|
|
220
228
|
Dir.glob(File.join(dir, "mutineer_mutant*.rb")).each do |f|
|
|
@@ -223,6 +231,17 @@ module Mutineer
|
|
|
223
231
|
end
|
|
224
232
|
end
|
|
225
233
|
|
|
234
|
+
# Runs a single mutation through isolation.
|
|
235
|
+
#
|
|
236
|
+
# @param mutation [Mutineer::Mutation] mutation to run.
|
|
237
|
+
# @param source_file [String] source file path.
|
|
238
|
+
# @param coverage_map [Mutineer::CoverageMap, nil] coverage map.
|
|
239
|
+
# @param subject [Mutineer::Subject, nil] subject for surgical strategy.
|
|
240
|
+
# @param strategy [String] mutation strategy.
|
|
241
|
+
# @param timeout [Integer] child timeout in seconds.
|
|
242
|
+
# @param rails [Boolean] whether Rails reconnect handling is enabled.
|
|
243
|
+
# @param framework [String] test framework name.
|
|
244
|
+
# @return [Mutineer::Result] mutant result.
|
|
226
245
|
def self.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload",
|
|
227
246
|
timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest")
|
|
228
247
|
source = File.read(source_file)
|
|
@@ -257,6 +276,10 @@ module Mutineer
|
|
|
257
276
|
end
|
|
258
277
|
end
|
|
259
278
|
|
|
279
|
+
# Reconnects ActiveRecord in a forked child when available.
|
|
280
|
+
#
|
|
281
|
+
# @api private
|
|
282
|
+
# @return [void]
|
|
260
283
|
def self.reconnect_active_record
|
|
261
284
|
return unless defined?(ActiveRecord::Base)
|
|
262
285
|
|
data/lib/mutineer/subject.rb
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mutineer
|
|
4
|
-
# One discoverable method
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# One discoverable method and its AST node.
|
|
5
|
+
#
|
|
6
|
+
# Location, namespace context, and the live Prism::DefNode are kept together
|
|
7
|
+
# because mutators walk the def node directly.
|
|
7
8
|
Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, keyword_init: true) do
|
|
8
|
-
#
|
|
9
|
-
#
|
|
9
|
+
# Returns the fully-qualified subject name.
|
|
10
|
+
#
|
|
11
|
+
# @return [String] namespaced method name like `Billing::Invoice#total`.
|
|
10
12
|
def qualified_name
|
|
11
13
|
namespace.join("::") + (singleton ? "." : "#") + name.to_s
|
|
12
14
|
end
|
|
13
15
|
|
|
14
|
-
#
|
|
16
|
+
# Returns the body location for the subject, if any.
|
|
17
|
+
#
|
|
18
|
+
# @return [Prism::Location, nil] body location or nil for empty methods.
|
|
15
19
|
def body_loc
|
|
16
20
|
def_node.body&.location
|
|
17
21
|
end
|
|
@@ -4,11 +4,12 @@ require_relative "../minitest_integration"
|
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
6
|
module TestRunners
|
|
7
|
-
#
|
|
8
|
-
# selection (TestRunners.for) has one method shape across frameworks.
|
|
9
|
-
# MinitestIntegration stays the implementation — all its behaviour (autorun
|
|
10
|
-
# neutralisation, Runnable.reset, load, Minitest.run -> 0/1) is preserved.
|
|
7
|
+
# Thin wrapper around the shared Minitest integration runner.
|
|
11
8
|
module Minitest
|
|
9
|
+
# Runs the given Minitest files.
|
|
10
|
+
#
|
|
11
|
+
# @param test_files [String, Array<String>] one file or many files.
|
|
12
|
+
# @return [Integer] 0 on success, 1 on failure.
|
|
12
13
|
def self.run(test_files) = MinitestIntegration.run(test_files)
|
|
13
14
|
end
|
|
14
15
|
end
|
|
@@ -8,31 +8,21 @@ module Mutineer
|
|
|
8
8
|
class FrameworkUnavailable < StandardError; end
|
|
9
9
|
|
|
10
10
|
module TestRunners
|
|
11
|
-
# Child-process-only RSpec runner
|
|
12
|
-
# run the given spec files and return 0 (all passed) / 1 (any failure).
|
|
11
|
+
# Child-process-only RSpec runner.
|
|
13
12
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# No `rescue` around the run itself: Isolation.run's fork block is the single
|
|
18
|
-
# exception boundary (any exception there -> exit 2), keeping the 0/1 contract.
|
|
13
|
+
# Mirrors MinitestIntegration's contract: run the given spec files and
|
|
14
|
+
# return 0 (all passed) or 1 (any failure).
|
|
19
15
|
module RSpec
|
|
20
|
-
#
|
|
21
|
-
#
|
|
16
|
+
# Runs the given RSpec files.
|
|
17
|
+
#
|
|
18
|
+
# @param spec_files [String, Array<String>] one file or many files.
|
|
19
|
+
# @return [Integer] 0 on success, 1 on failure.
|
|
22
20
|
def self.run(spec_files)
|
|
23
21
|
require_rspec!
|
|
24
22
|
|
|
25
|
-
# rspec/autorun (if a spec_helper required it) installs an at_exit run;
|
|
26
|
-
# neutralise it like Minitest.autorun so it never double-fires.
|
|
27
23
|
::RSpec::Core::Runner.disable_autorun!
|
|
28
|
-
|
|
29
|
-
# Drop any RSpec world/configuration inherited across runs in one process
|
|
30
|
-
# (e.g. successive forks of a booted parent) so examples never accumulate.
|
|
31
24
|
::RSpec.reset
|
|
32
25
|
|
|
33
|
-
# Silence RSpec's formatter output (and any stray puts/deprecations) so it
|
|
34
|
-
# never pollutes Mutineer's report streams. The formatter writes to the
|
|
35
|
-
# passed IO; $stdout/$stderr are redirected to catch everything else.
|
|
36
26
|
sink = StringIO.new
|
|
37
27
|
orig_out = $stdout
|
|
38
28
|
orig_err = $stderr
|
|
@@ -48,6 +38,9 @@ module Mutineer
|
|
|
48
38
|
status.zero? ? 0 : 1
|
|
49
39
|
end
|
|
50
40
|
|
|
41
|
+
# Requires rspec-core from the project under test.
|
|
42
|
+
#
|
|
43
|
+
# @api private
|
|
51
44
|
def self.require_rspec!
|
|
52
45
|
require "rspec/core"
|
|
53
46
|
rescue LoadError
|
|
@@ -4,9 +4,16 @@ require_relative "test_runners/minitest"
|
|
|
4
4
|
require_relative "test_runners/rspec"
|
|
5
5
|
|
|
6
6
|
module Mutineer
|
|
7
|
-
# Picks the test-framework runner.
|
|
8
|
-
#
|
|
7
|
+
# Picks the test-framework runner.
|
|
8
|
+
#
|
|
9
|
+
# Each runner responds to `.run(files) -> 0/1` (0 = all passed, 1 = any
|
|
10
|
+
# failure) and is called only inside a forked child.
|
|
9
11
|
module TestRunners
|
|
12
|
+
# Returns the runner module for a framework name.
|
|
13
|
+
#
|
|
14
|
+
# @param framework [String, nil] framework name or nil.
|
|
15
|
+
# @return [Module] `Mutineer::TestRunners::Minitest` or `::RSpec`.
|
|
16
|
+
# @raise [Mutineer::ConfigError] when the framework is unknown.
|
|
10
17
|
def self.for(framework)
|
|
11
18
|
case framework
|
|
12
19
|
when "rspec" then RSpec
|
data/lib/mutineer/version.rb
CHANGED
data/lib/mutineer/worker_pool.rb
CHANGED
|
@@ -3,21 +3,30 @@
|
|
|
3
3
|
require_relative "result"
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
|
-
# Fixed-size fork pool (KTD1/KTD2). `run` forks up to `size` children at
|
|
7
|
-
# each child runs the block on one work item, marshals its Result to a
|
|
8
|
-
# pipe, and exits. The parent reaps any finished child with
|
|
9
|
-
# opening exactly one slot per reap, then refills.
|
|
10
|
-
# SAME ORDER as `items` regardless of finish
|
|
11
|
-
# a serial run (R4) and downstream output
|
|
6
|
+
# Fixed-size fork pool (KTD1/KTD2). `run` forks up to `size` children at
|
|
7
|
+
# once; each child runs the block on one work item, marshals its Result to a
|
|
8
|
+
# private pipe, and exits. The parent reaps any finished child with
|
|
9
|
+
# Process.wait2(-1), opening exactly one slot per reap, then refills.
|
|
10
|
+
# Results are returned in the SAME ORDER as `items` regardless of finish
|
|
11
|
+
# order, so verdicts are identical to a serial run (R4) and downstream output
|
|
12
|
+
# is stable.
|
|
12
13
|
#
|
|
13
14
|
# The block is run inside the child via `yield(*items[i])`; whatever it
|
|
14
|
-
# returns (a Result) is the marshaled payload. Per-mutant timeout is handled
|
|
15
|
-
# level down by Isolation (KTD2) — the pool adds no separate wall clock.
|
|
15
|
+
# returns (a Result) is the marshaled payload. Per-mutant timeout is handled
|
|
16
|
+
# one level down by Isolation (KTD2) — the pool adds no separate wall clock.
|
|
16
17
|
class WorkerPool
|
|
18
|
+
# Builds a pool.
|
|
19
|
+
#
|
|
20
|
+
# @param size [Integer] desired pool size.
|
|
17
21
|
def initialize(size)
|
|
18
22
|
@size = [size.to_i, 1].max
|
|
19
23
|
end
|
|
20
24
|
|
|
25
|
+
# Runs the work items through the pool.
|
|
26
|
+
#
|
|
27
|
+
# @param items [Array<Array>] work items.
|
|
28
|
+
# @yieldparam item [Array] one work item.
|
|
29
|
+
# @return [Array<Mutineer::Result>] results in input order.
|
|
21
30
|
def run(items)
|
|
22
31
|
results = Array.new(items.size)
|
|
23
32
|
queue = (0...items.size).to_a
|
|
@@ -33,17 +42,19 @@ module Mutineer
|
|
|
33
42
|
|
|
34
43
|
private
|
|
35
44
|
|
|
45
|
+
# R1: the child must ALWAYS hard-exit. If yield raises, marshal an error
|
|
46
|
+
# Result and exit! in `ensure` — otherwise the child unwinds normally and
|
|
47
|
+
# our Minitest at_exit autorun re-runs the parent suite inside the worker,
|
|
48
|
+
# losing the real error.
|
|
36
49
|
def fill(items, queue, running)
|
|
37
50
|
while running.size < @size && !queue.empty?
|
|
38
51
|
idx = queue.shift
|
|
39
52
|
rd, wr = IO.pipe
|
|
53
|
+
rd.binmode # #19: Marshal output is binary — keep the pipe byte-exact
|
|
54
|
+
wr.binmode
|
|
40
55
|
begin
|
|
41
56
|
pid = fork do
|
|
42
57
|
rd.close
|
|
43
|
-
# R1: the child must ALWAYS hard-exit. If yield raises, marshal an
|
|
44
|
-
# error Result and exit! in `ensure` — otherwise the child unwinds
|
|
45
|
-
# normally and our Minitest at_exit autorun re-runs the parent suite
|
|
46
|
-
# inside the worker, losing the real error.
|
|
47
58
|
payload =
|
|
48
59
|
begin
|
|
49
60
|
yield(*items[idx])
|
|
@@ -74,13 +85,13 @@ module Mutineer
|
|
|
74
85
|
end
|
|
75
86
|
end
|
|
76
87
|
|
|
77
|
-
# Drain pipes with IO.select and reap a child only on EOF (#4). The old
|
|
78
|
-
# reaped first and read after — but a child whose payload exceeds the
|
|
79
|
-
# buffer (~64KB) blocks on `write` before it can exit, so it was
|
|
80
|
-
# and the pool deadlocked. Reading concurrently keeps the
|
|
81
|
-
# child can finish and exit; EOF means it closed its
|
|
82
|
-
# We waitpid only OUR known pids (R6: never
|
|
83
|
-
# host suite's children).
|
|
88
|
+
# Drain pipes with IO.select and reap a child only on EOF (#4). The old
|
|
89
|
+
# code reaped first and read after — but a child whose payload exceeds the
|
|
90
|
+
# OS pipe buffer (~64KB) blocks on `write` before it can exit, so it was
|
|
91
|
+
# never reaped and the pool deadlocked. Reading concurrently keeps the
|
|
92
|
+
# pipe drained so the child can finish and exit; EOF means it closed its
|
|
93
|
+
# write end (done writing). We waitpid only OUR known pids (R6: never
|
|
94
|
+
# wait2(-1), which would steal the host suite's children).
|
|
84
95
|
def reap(results, running)
|
|
85
96
|
return if running.empty?
|
|
86
97
|
|
|
@@ -104,11 +115,13 @@ module Mutineer
|
|
|
104
115
|
end
|
|
105
116
|
end
|
|
106
117
|
|
|
118
|
+
# R6: a partial/garbage Marshal stream (dead worker) must not crash the
|
|
119
|
+
# pool — degrade to an error Result.
|
|
120
|
+
# @param data [String] marshaled payload.
|
|
121
|
+
# @return [Mutineer::Result] decoded result or error result.
|
|
107
122
|
def decode(data)
|
|
108
123
|
return Result.error("worker produced no result") if data.empty?
|
|
109
124
|
|
|
110
|
-
# R6: a partial/garbage Marshal stream (dead worker) must not crash the
|
|
111
|
-
# pool — degrade to an error Result.
|
|
112
125
|
Marshal.load(data)
|
|
113
126
|
rescue StandardError => e
|
|
114
127
|
Result.error("worker result unreadable: #{e.class}: #{e.message}")
|
data/lib/mutineer.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mutineer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Teren
|
|
@@ -37,6 +37,20 @@ dependencies:
|
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '13.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: yard
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.9'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.9'
|
|
40
54
|
description: Mutineer mutates your source one change at a time and runs your Minitest
|
|
41
55
|
suite against each mutant to find tests that don't actually test anything. Prism-based,
|
|
42
56
|
fork-isolated, zero runtime dependencies.
|