saboteur 0.6.0 → 0.7.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 +367 -0
- data/README.md +166 -40
- data/exe/saboteur +34 -7
- data/lib/saboteur/baseline/workflow.rb +2 -1
- data/lib/saboteur/baseline_health.rb +40 -6
- data/lib/saboteur/cli/exit_status.rb +49 -4
- data/lib/saboteur/cli/help_options.rb +16 -0
- data/lib/saboteur/cli/presets.rb +36 -0
- data/lib/saboteur/cli/run_budget_options.rb +37 -0
- data/lib/saboteur/cli/run_reporting.rb +74 -5
- data/lib/saboteur/cli.rb +67 -46
- data/lib/saboteur/config.rb +66 -11
- data/lib/saboteur/crash_report.rb +154 -0
- data/lib/saboteur/custom_map.rb +3 -1
- data/lib/saboteur/doctor.rb +40 -1
- data/lib/saboteur/engine/baseline_guard.rb +79 -5
- data/lib/saboteur/engine/boot.rb +12 -1
- data/lib/saboteur/engine/canary.rb +41 -10
- data/lib/saboteur/engine/candidate_selection.rb +9 -1
- data/lib/saboteur/engine/coverage_guard.rb +29 -0
- data/lib/saboteur/engine/coverage_store.rb +48 -6
- data/lib/saboteur/engine/diagnostics.rb +23 -2
- data/lib/saboteur/engine/diff_scope.rb +24 -5
- data/lib/saboteur/engine/distribution.rb +124 -8
- data/lib/saboteur/engine/early_stop_recording.rb +46 -0
- data/lib/saboteur/engine/result_cache.rb +2 -1
- data/lib/saboteur/engine/run_budget.rb +63 -0
- data/lib/saboteur/engine/run_fingerprint.rb +4 -0
- data/lib/saboteur/engine/sandbox_policy.rb +18 -11
- data/lib/saboteur/engine/scheduler.rb +114 -18
- data/lib/saboteur/engine/subject_selection.rb +84 -5
- data/lib/saboteur/engine/suite_files.rb +3 -1
- data/lib/saboteur/engine/test_guess.rb +145 -22
- data/lib/saboteur/engine/thread_warnings.rb +53 -0
- data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
- data/lib/saboteur/engine/verdict_check.rb +54 -18
- data/lib/saboteur/engine.rb +166 -95
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/runner.rb +179 -17
- data/lib/saboteur/execution/sandbox.rb +6 -1
- data/lib/saboteur/execution/supervisor.rb +18 -3
- data/lib/saboteur/execution/worker_pool.rb +87 -7
- data/lib/saboteur/help.rb +55 -11
- data/lib/saboteur/ignore_list.rb +11 -1
- data/lib/saboteur/install.rb +4 -3
- data/lib/saboteur/integration/base.rb +7 -2
- data/lib/saboteur/integration/rspec.rb +40 -4
- data/lib/saboteur/mutation_source/builtin.rb +20 -11
- data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
- data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
- data/lib/saboteur/mutation_source/method_swap.rb +45 -0
- data/lib/saboteur/mutation_source/mutation.rb +5 -3
- data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +4 -1
- data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
- data/lib/saboteur/preview.rb +2 -1
- data/lib/saboteur/reporter/agent_commands.rb +38 -13
- data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
- data/lib/saboteur/reporter/next_steps.rb +14 -4
- data/lib/saboteur/reporter/score_history.rb +5 -1
- data/lib/saboteur/reporter/stryker.rb +13 -4
- data/lib/saboteur/reporter/survivor_hints.rb +3 -0
- data/lib/saboteur/reporter/terminal.rb +34 -10
- data/lib/saboteur/result.rb +22 -7
- data/lib/saboteur/selection/coverage_map.rb +2 -1
- data/lib/saboteur/selection/git_diff.rb +15 -5
- data/lib/saboteur/selection/untracked_changes.rb +126 -0
- data/lib/saboteur/subject.rb +11 -0
- data/lib/saboteur/text.rb +42 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +16 -1
- metadata +17 -2
|
@@ -76,8 +76,9 @@ module Saboteur
|
|
|
76
76
|
attr_reader :warnings, :invalid_mutations, :generation_events, :omissions, :discovery_failures,
|
|
77
77
|
:hazard_suppressions, :inline_suppressions
|
|
78
78
|
|
|
79
|
-
def initialize(err)
|
|
79
|
+
def initialize(err, verbose: false)
|
|
80
80
|
@err = err
|
|
81
|
+
@verbose = verbose
|
|
81
82
|
@warnings = []
|
|
82
83
|
@invalid_mutations = 0
|
|
83
84
|
@generation_events = []
|
|
@@ -102,8 +103,11 @@ module Saboteur
|
|
|
102
103
|
@generation_events << GenerationEvent.new(*values)
|
|
103
104
|
end
|
|
104
105
|
|
|
106
|
+
# The selection and the test inference each walk the same changed
|
|
107
|
+
# files in one run; a failure either of them finds is recorded once.
|
|
105
108
|
def record_discovery_failure(file, reason:)
|
|
106
|
-
|
|
109
|
+
failure = DiscoveryFailure.new(file: file.to_s.dup.freeze, reason: reason.to_s.dup.freeze)
|
|
110
|
+
@discovery_failures << failure unless @discovery_failures.include?(failure)
|
|
107
111
|
end
|
|
108
112
|
|
|
109
113
|
# A mutant the hazard guard refused to generate (issue #102): counted
|
|
@@ -129,6 +133,23 @@ module Saboteur
|
|
|
129
133
|
@err.puts("saboteur: #{message}")
|
|
130
134
|
end
|
|
131
135
|
|
|
136
|
+
# --verbose: one line per finished subject with its result marks, so a
|
|
137
|
+
# survivor is attributable to a method as soon as the subject
|
|
138
|
+
# completes, before the whole run ends (#262).
|
|
139
|
+
def subject_progress(subject, results)
|
|
140
|
+
return unless @verbose
|
|
141
|
+
return if results.empty?
|
|
142
|
+
|
|
143
|
+
marks = results.map { |result| MARKS.fetch(result.status, "?") }.join
|
|
144
|
+
inform("#{subject.id} #{marks}")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A message that is both a report warning and a stderr note.
|
|
148
|
+
def announce(message)
|
|
149
|
+
warn(message)
|
|
150
|
+
inform(message)
|
|
151
|
+
end
|
|
152
|
+
|
|
132
153
|
# A heartbeat for a long phase: the supervisor's wait loop calls tick
|
|
133
154
|
# in the parent after the fork, so no extra thread is ever alive at
|
|
134
155
|
# fork time. Prints "<label>: N s elapsed" about every interval
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "../text"
|
|
5
|
+
|
|
4
6
|
module Saboteur
|
|
5
7
|
class Engine
|
|
6
8
|
# Review-shaped diff mode (--since). A reviewer asks "do the tests check
|
|
@@ -14,7 +16,7 @@ module Saboteur
|
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def restrict(subject, candidates)
|
|
17
|
-
changed =
|
|
19
|
+
changed = lines_for(subject)
|
|
18
20
|
candidates.select do |mutation, _|
|
|
19
21
|
mutation.coarse || mutation.edits.any? { |edit| edited_lines(subject, edit).any? { changed.include?(_1) } }
|
|
20
22
|
end
|
|
@@ -24,22 +26,39 @@ module Saboteur
|
|
|
24
26
|
# several lines is mutated as one piece and reported at its first line,
|
|
25
27
|
# which can sit above the line that changed; this names the changed line.
|
|
26
28
|
def changed_lines_for(subject, mutation)
|
|
27
|
-
changed =
|
|
29
|
+
changed = lines_for(subject)
|
|
28
30
|
mutation.edits.flat_map { |edit| edited_lines(subject, edit).to_a }.uniq.select { changed.include?(_1) }.sort
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
# Distance to the nearest changed line in this file. Unknown outside
|
|
32
|
-
# the diff is nil, not a made-up nearby location.
|
|
34
|
+
# the diff is nil, not a made-up nearby location. A range answers
|
|
35
|
+
# geometrically - 0 when the edit overlaps, else the gap to the nearer
|
|
36
|
+
# endpoint - so a whole-file range never materializes one entry per
|
|
37
|
+
# line.
|
|
33
38
|
def distance_for(subject, mutation)
|
|
34
|
-
changed =
|
|
39
|
+
changed = lines_for(subject)
|
|
35
40
|
mutation.edits.map do |edit|
|
|
36
41
|
first, last = edited_lines(subject, edit).minmax
|
|
37
|
-
changed.
|
|
42
|
+
if changed.is_a?(Range)
|
|
43
|
+
if last < changed.begin then changed.begin - last
|
|
44
|
+
elsif first > changed.end then first - changed.end
|
|
45
|
+
else 0
|
|
46
|
+
end
|
|
47
|
+
else
|
|
48
|
+
changed.map { |line| (line - line.clamp(first, last)).abs }.min
|
|
49
|
+
end
|
|
38
50
|
end.min
|
|
39
51
|
end
|
|
40
52
|
|
|
41
53
|
private
|
|
42
54
|
|
|
55
|
+
# subject.file carries the locale tag under a non-UTF-8 locale
|
|
56
|
+
# while the map keys are UTF-8; retag losslessly or a non-ASCII
|
|
57
|
+
# path never matches and every fine mutant is silently dropped.
|
|
58
|
+
def lines_for(subject)
|
|
59
|
+
@changed_lines.fetch(Text.utf8_identity(subject.file), Set.new)
|
|
60
|
+
end
|
|
61
|
+
|
|
43
62
|
def edited_lines(subject, edit)
|
|
44
63
|
first = subject.line_of(edit.start_byte)
|
|
45
64
|
last = subject.line_of([edit.end_byte - 1, edit.start_byte].max)
|
|
@@ -9,23 +9,31 @@ module Saboteur
|
|
|
9
9
|
# candidate list, so results look the same either way.
|
|
10
10
|
class Distribution
|
|
11
11
|
# After run: the subjects a stop_after callable left unrun (always empty
|
|
12
|
-
# without one)
|
|
13
|
-
|
|
12
|
+
# without one), and the subjects that started but stopped mid-list as
|
|
13
|
+
# [subject, omitted_candidate_count] pairs (a spent --max-duration
|
|
14
|
+
# breaking a subject's scheduler loop). unverified lists the subjects
|
|
15
|
+
# whose fresh kills the spent budget left unchecked (#296).
|
|
16
|
+
attr_reader :unreached, :truncated, :unverified
|
|
14
17
|
|
|
15
18
|
# stop_after: nil, or a callable that gets one subject's results and
|
|
16
19
|
# returns true to stop before the next subject (--fail-fast). It only
|
|
17
20
|
# works on one process: parallel workers have already started the
|
|
18
21
|
# other subjects.
|
|
19
22
|
def initialize(jobs:, scheduler:, diagnostics:, cache:, on_start: nil, after_subject: nil,
|
|
20
|
-
stop_after: nil
|
|
23
|
+
stop_after: nil, halt_check: nil,
|
|
24
|
+
drain_timeout: 30) # seconds; two minimum-strength mutant deadlines at default settings
|
|
21
25
|
@jobs = jobs
|
|
22
26
|
@stop_after = stop_after
|
|
27
|
+
@halt_check = halt_check
|
|
23
28
|
@unreached = []
|
|
29
|
+
@truncated = []
|
|
30
|
+
@unverified = []
|
|
24
31
|
@on_start = on_start
|
|
25
32
|
@scheduler = scheduler
|
|
26
33
|
@diagnostics = diagnostics
|
|
27
34
|
@cache = cache
|
|
28
35
|
@after_subject = after_subject
|
|
36
|
+
@drain_timeout = drain_timeout
|
|
29
37
|
end
|
|
30
38
|
|
|
31
39
|
# lists: each subject's [[mutation, text]] candidates, in subject order.
|
|
@@ -35,23 +43,116 @@ module Saboteur
|
|
|
35
43
|
# The watcher runs as each subject's row arrives, before the pool
|
|
36
44
|
# hands out more work, so a tripped canary stops the queue (jobs>1
|
|
37
45
|
# used to check only after the whole batch returned, issue #102).
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
# A tool error no longer raises on sight: one wiped log dir hits
|
|
47
|
+
# every worker, and a bystander whose current job ran no mutant
|
|
48
|
+
# reports "an unknown mutant", which could land before the report
|
|
49
|
+
# of the worker whose mutant did the damage (macOS CI on #285).
|
|
50
|
+
# The pool is halted instead - no new work, and the drain is
|
|
51
|
+
# bounded by drain_timeout so a wedged worker cannot stall the
|
|
52
|
+
# abort - and the abort raises the report with the best
|
|
53
|
+
# attribution.
|
|
54
|
+
# The abort reports and the pool are run state: a Distribution
|
|
55
|
+
# runs once. @pool is only read from arrivals, which fire inside
|
|
56
|
+
# @pool.map after the assignment.
|
|
57
|
+
# A budget already spent by boot, baselines or the coverage map
|
|
58
|
+
# launches nothing: every subject comes back unreached (#245).
|
|
59
|
+
if @halt_check&.call
|
|
60
|
+
@unreached = subjects
|
|
61
|
+
return []
|
|
40
62
|
end
|
|
41
|
-
|
|
42
|
-
|
|
63
|
+
|
|
64
|
+
@abort_reports = []
|
|
65
|
+
arrival = arrival_for(subjects, lists)
|
|
66
|
+
@pool = Execution::WorkerPool.new(jobs: @jobs, on_start: @on_start, on_result: arrival,
|
|
67
|
+
drain_timeout: @drain_timeout)
|
|
68
|
+
rows = @pool.map(subjects.each_index.to_a, weights: lists.map(&:size)) do |i|
|
|
43
69
|
work(subjects[i], lists[i])
|
|
44
70
|
end
|
|
45
|
-
|
|
71
|
+
raise_preferred_tool_error(@abort_reports) if @abort_reports.any?
|
|
72
|
+
record_unreached(rows, subjects, lists)
|
|
73
|
+
rows.each_with_index.flat_map do |row, i|
|
|
74
|
+
next [] if row.nil?
|
|
75
|
+
|
|
76
|
+
raise_if_tool_error(row)
|
|
77
|
+
rebuild(row, subjects[i], lists[i])
|
|
78
|
+
end
|
|
46
79
|
end
|
|
47
80
|
|
|
48
81
|
private
|
|
49
82
|
|
|
83
|
+
# @pool is only read here from arrivals, which fire inside @pool.map
|
|
84
|
+
# after the assignment. A spent --max-duration budget stops the queue
|
|
85
|
+
# without the safety-abort drain: in-flight subjects finish and
|
|
86
|
+
# report, and only never-started ones come back as nil holes, so
|
|
87
|
+
# unreached never mislabels an interrupted subject as unrun (#245).
|
|
88
|
+
def arrival_for(subjects, lists)
|
|
89
|
+
lambda do |i, row|
|
|
90
|
+
if row["tool_error"]
|
|
91
|
+
collect_abort_report(row)
|
|
92
|
+
else
|
|
93
|
+
check_subject(subjects, lists, i, row)
|
|
94
|
+
@pool.halt!(drain: false) if @halt_check&.call
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# nil rows never started; a short results list is a subject whose
|
|
100
|
+
# scheduler stopped mid-list at the budget deadline. Worker-level error
|
|
101
|
+
# rows synthesize an error per candidate, so they never look truncated.
|
|
102
|
+
def unverified_result?(result)
|
|
103
|
+
result.is_a?(MutantResult) && result.status == :unverified
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def record_unreached(rows, subjects, lists)
|
|
107
|
+
@unreached = []
|
|
108
|
+
@truncated = []
|
|
109
|
+
@unverified = []
|
|
110
|
+
rows.each_with_index do |row, i|
|
|
111
|
+
if row.nil?
|
|
112
|
+
@unreached << subjects[i]
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
next unless row["results"] && !row["error"]
|
|
116
|
+
|
|
117
|
+
@truncated << [subjects[i], lists[i].size - row["results"].size] if row["results"].size < lists[i].size
|
|
118
|
+
@unverified << subjects[i] if row["results"].any? { |result| result["status"] == "unverified" }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @pool is always assigned when an arrival fires: arrivals only fire
|
|
123
|
+
# from inside @pool.map, which runs after the assignment.
|
|
124
|
+
def collect_abort_report(row)
|
|
125
|
+
@abort_reports << row
|
|
126
|
+
@pool.halt!
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A canary trip from the end-of-subject check folds into the same
|
|
130
|
+
# record + halt + preferred-report path as a worker's own report:
|
|
131
|
+
# escaping the pool here would override a collected report that
|
|
132
|
+
# names the real culprit (#285 review).
|
|
133
|
+
def check_subject(subjects, lists, index, row)
|
|
134
|
+
@after_subject&.call(subjects[index], decode(row, subjects[index], lists[index]))
|
|
135
|
+
rescue Error => e
|
|
136
|
+
collect_abort_report(Execution::WorkerPool.tool_error_row(e))
|
|
137
|
+
end
|
|
138
|
+
|
|
50
139
|
def run_serially(subjects, lists)
|
|
51
140
|
results = []
|
|
52
141
|
subjects.zip(lists).each_with_index do |(subject, list), index|
|
|
142
|
+
# Same pre-dispatch budget check as the parallel first wave: an
|
|
143
|
+
# already-spent baseline starts no further subjects (#245).
|
|
144
|
+
if @halt_check&.call
|
|
145
|
+
@unreached = subjects.drop(index)
|
|
146
|
+
break
|
|
147
|
+
end
|
|
148
|
+
|
|
53
149
|
subject_results = @scheduler.call(subject, list)
|
|
54
150
|
results.concat(subject_results)
|
|
151
|
+
# A scheduler that stopped mid-list at the budget deadline: the
|
|
152
|
+
# completed results stay, the omitted candidates are accounted.
|
|
153
|
+
omitted = list.size - subject_results.size
|
|
154
|
+
@truncated << [subject, omitted] if omitted.positive?
|
|
155
|
+
@unverified << subject if subject_results.any? { |result| unverified_result?(result) }
|
|
55
156
|
@after_subject&.call(subject, subject_results)
|
|
56
157
|
next unless @stop_after&.call(subject_results)
|
|
57
158
|
|
|
@@ -91,6 +192,21 @@ module Saboteur
|
|
|
91
192
|
|
|
92
193
|
# Turns a worker row back into results, without diagnostics or caching:
|
|
93
194
|
# used both at result arrival (the canary) and at rebuild time.
|
|
195
|
+
# A tool error from the worker (a vanished cache dir, ...) aborts
|
|
196
|
+
# the whole run with exit 3; it is not a per-mutant outcome (#283).
|
|
197
|
+
def raise_if_tool_error(row)
|
|
198
|
+
raise Error, row["tool_error"] if row["tool_error"]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The abort names a culprit when any report can, ranked by
|
|
202
|
+
# attribution quality (#285 review): a report carrying its mutant's
|
|
203
|
+
# per-mutant digest first, then a canary trip (the hazard report
|
|
204
|
+
# still beats a bare report, #283), then the first bare report.
|
|
205
|
+
def raise_preferred_tool_error(rows)
|
|
206
|
+
row = rows.find { |r| r["culprit"] } || rows.find { |r| r["canary"] } || rows.first
|
|
207
|
+
raise Error, row["tool_error"]
|
|
208
|
+
end
|
|
209
|
+
|
|
94
210
|
def decode(row, subject, list)
|
|
95
211
|
return failed(row["error"], subject, list) if row["error"]
|
|
96
212
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Records the subjects an early stop left behind - whole subjects that
|
|
7
|
+
# never started and candidates skipped mid-way through a subject - so
|
|
8
|
+
# the report says the verdict covers less than the selection.
|
|
9
|
+
module EarlyStopRecording
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# The subjects a stop left unrun or mid-way: the verdict covers less than the selection.
|
|
13
|
+
def record_early_stop(distribution)
|
|
14
|
+
return if distribution.unreached.empty? && distribution.truncated.empty? && distribution.unverified.empty?
|
|
15
|
+
|
|
16
|
+
truncated = distribution.truncated.map { |subject, omitted| { subject: subject.id, omitted: omitted } }
|
|
17
|
+
@early_stop = EarlyStop.new(reason: stop_reason(distribution), unreached: distribution.unreached.map(&:id),
|
|
18
|
+
truncated: truncated, unverified: distribution.unverified.map(&:id))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Mid-subject omissions and skipped checks are budget work: only a
|
|
22
|
+
# stop that left whole subjects unreached can be fail_fast's.
|
|
23
|
+
def stop_reason(distribution)
|
|
24
|
+
return "max_duration" if distribution.truncated.any? || distribution.unverified.any?
|
|
25
|
+
|
|
26
|
+
run_early_stop_reason
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# max_duration was announced before fail_fast could stop the run: a
|
|
30
|
+
# budget stop overrides a failure stop announced later.
|
|
31
|
+
def run_early_stop_reason
|
|
32
|
+
@budget_announced ? "max_duration" : "fail_fast"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A baseline or coverage phase stopped because --max-duration ate its
|
|
36
|
+
# allowance: budget exhaustion, not a broken suite. The run reports
|
|
37
|
+
# partial with every selected subject unreached (exit 4) (#296).
|
|
38
|
+
def budget_phase_stop(error, subjects)
|
|
39
|
+
@diagnostics.warn(error.message)
|
|
40
|
+
@early_stop = EarlyStop.new(reason: "max_duration", unreached: subjects.map(&:id), truncated: [],
|
|
41
|
+
unverified: [])
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "fileutils"
|
|
6
6
|
require "json"
|
|
7
|
+
require_relative "../text"
|
|
7
8
|
|
|
8
9
|
module Saboteur
|
|
9
10
|
class Engine
|
|
@@ -65,7 +66,7 @@ module Saboteur
|
|
|
65
66
|
private
|
|
66
67
|
|
|
67
68
|
def load
|
|
68
|
-
data = JSON.parse(File.read(@path))
|
|
69
|
+
data = JSON.parse(Text.utf8_strict(File.read(@path)))
|
|
69
70
|
data["format"] == FORMAT ? data["entries"] : {}
|
|
70
71
|
rescue SystemCallError, JSON::ParserError
|
|
71
72
|
{}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# The --max-duration wall-clock budget (#245). The clock anchors at
|
|
7
|
+
# engine entry, so boot, baselines and the coverage map spend it too;
|
|
8
|
+
# schedulers get an absolute deadline so a spent budget also stops a
|
|
9
|
+
# long subject between mutants.
|
|
10
|
+
module RunBudget
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def start_budget_clock(started)
|
|
14
|
+
@run_started = started
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The whole run is budgeted, baselines included: an oversized
|
|
18
|
+
# baseline spends the budget by itself.
|
|
19
|
+
def budget_exceeded?
|
|
20
|
+
return false unless config["max_duration"]
|
|
21
|
+
|
|
22
|
+
exceeded = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @run_started >= config["max_duration"]
|
|
23
|
+
announce_budget_once if exceeded
|
|
24
|
+
exceeded
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A phase admitted inside the budget still gets only the time left
|
|
28
|
+
# in it: baseline passes and coverage builds cap at the deadline.
|
|
29
|
+
def budget_capped_timeout(timeout)
|
|
30
|
+
deadline = budget_deadline
|
|
31
|
+
return timeout unless deadline
|
|
32
|
+
|
|
33
|
+
(deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).clamp(0.0, timeout)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Absolute monotonic deadline shared with the schedulers (forked
|
|
37
|
+
# workers inherit it): a subject's remaining mutants stop once it
|
|
38
|
+
# passes, so the overshoot after a spent budget stays under one
|
|
39
|
+
# mutant deadline.
|
|
40
|
+
def budget_deadline
|
|
41
|
+
config["max_duration"] && (@run_started + config["max_duration"])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Boot and baselines can spend the budget by themselves: stop before
|
|
45
|
+
# an expensive cold coverage build and mark every selected subject
|
|
46
|
+
# unrun.
|
|
47
|
+
def abandon_for_budget?(subjects)
|
|
48
|
+
return false unless budget_exceeded?
|
|
49
|
+
|
|
50
|
+
@early_stop = EarlyStop.new(reason: "max_duration", unreached: subjects.map(&:id), truncated: [],
|
|
51
|
+
unverified: [])
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def announce_budget_once
|
|
56
|
+
return if @budget_announced
|
|
57
|
+
|
|
58
|
+
@budget_announced = true
|
|
59
|
+
@diagnostics.inform("--max-duration reached: stopping before the next subject; the report marks it partial")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "json"
|
|
6
|
+
require_relative "../text"
|
|
6
7
|
require_relative "data_inputs"
|
|
7
8
|
|
|
8
9
|
module Saboteur
|
|
@@ -252,6 +253,9 @@ module Saboteur
|
|
|
252
253
|
# undeclared dependency boundary, not a silently omitted source tree.
|
|
253
254
|
def project_files(issues, directory = @root)
|
|
254
255
|
Dir.children(directory).sort.flat_map do |name|
|
|
256
|
+
# Child names arrive locale-tagged; retag losslessly before
|
|
257
|
+
# joining with the UTF-8 root (#231).
|
|
258
|
+
name = Text.utf8_identity(name)
|
|
255
259
|
path = File.join(directory, name)
|
|
256
260
|
next [] if excluded?(path, name)
|
|
257
261
|
|
|
@@ -16,7 +16,10 @@ module Saboteur
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
# The third C1 layer (#152): each mutant child makes the project tree
|
|
19
|
-
# read-only except tmp
|
|
19
|
+
# read-only except tmp/ and the system temp dir - the cache dir stays
|
|
20
|
+
# read-only too, so a mutant cannot delete the baseline, the reports
|
|
21
|
+
# or other children's logs; its own log arrives as an inherited open
|
|
22
|
+
# descriptor (#234). The
|
|
20
23
|
# claim needs Landlock ABI 3 (Linux 6.2+): earlier ABIs cannot restrict
|
|
21
24
|
# truncate(), so the run degrades honestly - children run with the
|
|
22
25
|
# guard and canary only, noted once - unless c.sandbox = "strict",
|
|
@@ -28,14 +31,15 @@ module Saboteur
|
|
|
28
31
|
return restrict_hook if abi >= 3
|
|
29
32
|
|
|
30
33
|
if @config["sandbox"] == "strict"
|
|
31
|
-
raise Error, "sandbox is strict but #{sandbox_gap(abi)}; refusing to run without the promised " \
|
|
34
|
+
raise Error, "sandbox is strict but #{self.class.sandbox_gap(abi)}; refusing to run without the promised " \
|
|
32
35
|
"sandbox - set c.sandbox = true to run degraded, or false to opt out"
|
|
33
36
|
end
|
|
34
37
|
# Once, through the report's warnings only: warn + inform printed the
|
|
35
38
|
# same line twice (D's review, #178).
|
|
36
39
|
unless @sandbox_note_given
|
|
37
40
|
@sandbox_note_given = true
|
|
38
|
-
@diagnostics.warn("sandbox is on but #{sandbox_gap(abi)}; children run with the hazard guard
|
|
41
|
+
@diagnostics.warn("sandbox is on but #{self.class.sandbox_gap(abi)}; children run with the hazard guard " \
|
|
42
|
+
"and canary only")
|
|
39
43
|
end
|
|
40
44
|
nil
|
|
41
45
|
end
|
|
@@ -49,7 +53,7 @@ module Saboteur
|
|
|
49
53
|
"unsandboxed - set c.sandbox = false to proceed without it"
|
|
50
54
|
end
|
|
51
55
|
|
|
52
|
-
def sandbox_gap(abi)
|
|
56
|
+
def self.sandbox_gap(abi)
|
|
53
57
|
return "this kernel lacks Landlock (needs Linux 6.2+)" if abi.zero?
|
|
54
58
|
|
|
55
59
|
"this kernel's Landlock ABI is #{abi} but ABI 3 (Linux 6.2+) is needed to restrict truncate()"
|
|
@@ -62,20 +66,23 @@ module Saboteur
|
|
|
62
66
|
|
|
63
67
|
# c.sandbox_writable adds the dirs a suite genuinely needs (db/, log/,
|
|
64
68
|
# ...): without a grant, writes there fail with EACCES in the child.
|
|
69
|
+
# Only the user grants are created: the project tmp is granted when it
|
|
70
|
+
# already exists, never made for it (#236).
|
|
65
71
|
def sandbox_writable_dirs
|
|
66
72
|
user_dirs = @config["sandbox_writable"].map { |dir| File.expand_path(dir, @root) }
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
user_dirs.each { |dir| FileUtils.mkdir_p(dir) }
|
|
74
|
+
tmp = File.join(@root, "tmp")
|
|
75
|
+
dirs = Dir.exist?(tmp) ? [tmp] + user_dirs : user_dirs
|
|
69
76
|
real = (dirs.map { |dir| File.realpath(dir) } + [File.realpath(Dir.tmpdir)]).uniq
|
|
70
77
|
check_sandbox_write_set(real, user_dirs.map { |dir| File.realpath(dir) })
|
|
71
78
|
real
|
|
72
79
|
end
|
|
73
80
|
|
|
74
|
-
# The effective write set - every grant realpath'd,
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
81
|
+
# The effective write set - every grant realpath'd, the project tmp
|
|
82
|
+
# and the system temp dir included - must not contain the project
|
|
83
|
+
# root: a tmp symlink or a checkout inside the system temp dir would
|
|
84
|
+
# otherwise leave the whole tree writable without a word.
|
|
85
|
+
# c.sandbox = "strict" refuses: strict guards the effective
|
|
79
86
|
# policy, not only the kernel ABI. A user grant containing another
|
|
80
87
|
# grant only makes the narrower one redundant, so that stays a warning.
|
|
81
88
|
def check_sandbox_write_set(all_dirs, user_dirs)
|