evilution 1.4.0 → 1.5.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/.beads/interactions.jsonl +70 -0
- data/CHANGELOG.md +50 -0
- data/README.md +64 -22
- data/docs/architecture.md +56 -12
- data/docs/isolation.md +82 -0
- data/lib/evilution/ast/class_body.rb +101 -0
- data/lib/evilution/ast/included_block.rb +20 -6
- data/lib/evilution/ast/never_nil.rb +36 -0
- data/lib/evilution/ast/parser.rb +94 -31
- data/lib/evilution/ast/round_half_mode.rb +38 -0
- data/lib/evilution/ast/source_surgeon.rb +2 -3
- data/lib/evilution/ast/syntax_check.rb +56 -0
- data/lib/evilution/ast/uncovered_code.rb +7 -7
- data/lib/evilution/cli/exit_guard.rb +26 -4
- data/lib/evilution/integration/base.rb +10 -2
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +59 -14
- data/lib/evilution/integration/loading/concern_redeclaration.rb +33 -14
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +3 -3
- data/lib/evilution/integration/loading/mutation_applier.rb +33 -6
- data/lib/evilution/integration/loading/own_methods.rb +87 -0
- data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
- data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
- data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
- data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
- data/lib/evilution/isolation/fork.rb +82 -10
- data/lib/evilution/isolation/in_process.rb +79 -4
- data/lib/evilution/mcp/info_tool/status_glossary.rb +13 -2
- data/lib/evilution/mcp/info_tool.rb +3 -2
- data/lib/evilution/mutation.rb +18 -11
- data/lib/evilution/mutator/base.rb +27 -7
- data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
- data/lib/evilution/mutator/operator/alias_removal.rb +11 -81
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
- data/lib/evilution/mutator/operator/argument_removal.rb +2 -2
- data/lib/evilution/mutator/operator/array_literal.rb +29 -0
- data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
- data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
- data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
- data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
- data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
- data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
- data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
- data/lib/evilution/mutator/operator/data_struct_member.rb +1 -1
- data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
- data/lib/evilution/mutator/operator/float_literal.rb +54 -19
- data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
- data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
- data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
- data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
- data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
- data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
- data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
- data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
- data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
- data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
- data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
- data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
- data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
- data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
- data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
- data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
- data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +16 -24
- data/lib/evilution/mutator/operator/string_literal.rb +29 -0
- data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
- data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
- data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
- data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
- data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
- data/lib/evilution/mutator/primitives.rb +24 -2
- data/lib/evilution/mutator/receiver_positions.rb +79 -0
- data/lib/evilution/mutator/registry.rb +29 -2
- data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
- data/lib/evilution/parallel/work_queue/channel.rb +58 -5
- data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
- data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
- data/lib/evilution/parallel/work_queue/worker.rb +13 -4
- data/lib/evilution/parallel/work_queue.rb +15 -9
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
- data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
- data/lib/evilution/runner/subject_pipeline.rb +20 -51
- data/lib/evilution/runner.rb +1 -1
- data/lib/evilution/subject.rb +18 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +62 -0
- metadata +32 -2
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../operator"
|
|
6
|
+
require_relative "../../ast/class_body"
|
|
7
|
+
|
|
8
|
+
# Drop a visibility declaration from a class or module body: `private`,
|
|
9
|
+
# `protected` and `module_function`, bare or naming methods. A definition the
|
|
10
|
+
# declaration wraps is kept: `private def a; end` becomes `def a; end`.
|
|
11
|
+
#
|
|
12
|
+
# A survivor means no example checks what the declaration decides: that a
|
|
13
|
+
# private method cannot be called from outside, or that a module function
|
|
14
|
+
# can.
|
|
15
|
+
#
|
|
16
|
+
# Declarations sit in the class body, outside every method, where no subject
|
|
17
|
+
# reaches them. They are attributed to the method anchoring the enclosing
|
|
18
|
+
# body (see Evilution::AST::ClassBody); a top-level declaration has no body
|
|
19
|
+
# to be attributed to. A call inside a method, or one sent to another
|
|
20
|
+
# receiver, is not a declaration.
|
|
21
|
+
class Evilution::Mutator::Operator::VisibilityRemoval < Evilution::Mutator::Base
|
|
22
|
+
DECLARATIONS = %i[private protected module_function].freeze
|
|
23
|
+
|
|
24
|
+
# What a declaration can wrap and has to leave behind: a method definition,
|
|
25
|
+
# or a call (`private attr_reader :name`, `private helper_names`). The call
|
|
26
|
+
# runs with or without the declaration, so it stays.
|
|
27
|
+
WRAPPED = [Prism::DefNode, Prism::CallNode].freeze
|
|
28
|
+
|
|
29
|
+
def call(subject, filter: nil)
|
|
30
|
+
@subject = subject
|
|
31
|
+
@file_source = File.read(subject.file_path)
|
|
32
|
+
@mutations = []
|
|
33
|
+
@filter = filter
|
|
34
|
+
|
|
35
|
+
declarations_for(subject).each { |declaration| remove(declaration) }
|
|
36
|
+
@mutations
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def remove(declaration)
|
|
42
|
+
replacement = replacement_for(argument_nodes(declaration))
|
|
43
|
+
return if replacement.nil?
|
|
44
|
+
|
|
45
|
+
add_mutation(
|
|
46
|
+
offset: declaration.location.start_offset,
|
|
47
|
+
length: declaration.location.length,
|
|
48
|
+
replacement: replacement,
|
|
49
|
+
node: declaration
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# What is left of the declaration: nothing when it only names methods, the
|
|
54
|
+
# definition when it wraps one. A definition among several arguments
|
|
55
|
+
# (`private attr_reader(:a), :b`) cannot be left behind on its own, so that
|
|
56
|
+
# declaration is not mutated.
|
|
57
|
+
def replacement_for(arguments)
|
|
58
|
+
return "" if arguments.none? { |argument| WRAPPED.include?(argument.class) }
|
|
59
|
+
|
|
60
|
+
arguments.first.slice if arguments.one?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def declarations_for(subject)
|
|
64
|
+
tree = self.class.parsed_tree_for(subject.file_path, @file_source)
|
|
65
|
+
|
|
66
|
+
Evilution::AST::ClassBody.anchored_at(tree, subject.line_number).flat_map do |body|
|
|
67
|
+
body.declarations { |node| declaration?(node) }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def declaration?(node)
|
|
72
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? && DECLARATIONS.include?(node.name)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
require_relative "../mutator"
|
|
4
4
|
|
|
5
5
|
# Mutation shapes shared across the operator families: replacing an
|
|
6
|
-
# expression with `nil`,
|
|
7
|
-
# of
|
|
6
|
+
# expression with `nil`, replacing an expression with the source of one of
|
|
7
|
+
# its children, and deleting one element of a list. All are built on
|
|
8
|
+
# Base#add_mutation, so the
|
|
8
9
|
# equivalent-mutant filter and the heredoc-span guards still apply.
|
|
9
10
|
#
|
|
10
11
|
# Unlike a bare add_mutation call, these skip rather than emit when the
|
|
@@ -30,6 +31,20 @@ module Evilution::Mutator::Primitives
|
|
|
30
31
|
replace_span(node: node, target: target, replacement: source_of(child))
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
# Delete the element at `index` from a list written in source order (the
|
|
35
|
+
# elements of an array or hash literal), attributing the mutation to the
|
|
36
|
+
# element. It goes with the separator after it; the last one goes with the
|
|
37
|
+
# separator before it, so a trailing comma stays where it was. The list
|
|
38
|
+
# must hold another element to take the separator from.
|
|
39
|
+
def delete_element(elements, index)
|
|
40
|
+
element = elements[index]
|
|
41
|
+
following = elements[index + 1]
|
|
42
|
+
offset = following ? element.location.start_offset : elements[index - 1].location.end_offset
|
|
43
|
+
stop = following ? following.location.start_offset : element.location.end_offset
|
|
44
|
+
|
|
45
|
+
add_mutation(offset:, length: stop - offset, replacement: "", node: element, skip_unparseable: true)
|
|
46
|
+
end
|
|
47
|
+
|
|
33
48
|
def replace_span(node:, target:, replacement:)
|
|
34
49
|
return nil if target.nil?
|
|
35
50
|
|
|
@@ -45,6 +60,13 @@ module Evilution::Mutator::Primitives
|
|
|
45
60
|
)
|
|
46
61
|
end
|
|
47
62
|
|
|
63
|
+
# The arguments of a call, `super`, `yield` or the like, in source order.
|
|
64
|
+
# Prism gives nil, not an empty list, where there are none; this gives the
|
|
65
|
+
# empty list, so a reader can count or iterate without asking first.
|
|
66
|
+
def argument_nodes(node)
|
|
67
|
+
node.arguments ? node.arguments.arguments : []
|
|
68
|
+
end
|
|
69
|
+
|
|
48
70
|
def source_of(child)
|
|
49
71
|
location = child.location
|
|
50
72
|
byteslice_source(location.start_offset, location.length)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../mutator"
|
|
4
|
+
|
|
5
|
+
# Visits every node that sends a message to a receiver, or defines
|
|
6
|
+
# something on one, and hands that receiver to #receiver_found before the
|
|
7
|
+
# receiver itself is visited.
|
|
8
|
+
#
|
|
9
|
+
# A plain call is one such node, but not the only one. An operator or
|
|
10
|
+
# logical write on an attribute or an index (`a.b += c`, `a[b] ||= c`) and
|
|
11
|
+
# an attribute or index target of a multiple assignment (`a.b, c[d] = e`)
|
|
12
|
+
# each hold their receiver in a node of their own. An operator that treats
|
|
13
|
+
# "the receiver of a call" specially — replacing it with `nil` only earns a
|
|
14
|
+
# NoMethodError — has to mean all of them.
|
|
15
|
+
#
|
|
16
|
+
# A definition has a receiver too: the object a singleton method is defined
|
|
17
|
+
# on (`def self.build`, `def helper.extra`) and the one a singleton class is
|
|
18
|
+
# opened on (`class << self`). Replacing it does not change the method that
|
|
19
|
+
# is already there — `def nil.build` defines another one, on `nil` — so the
|
|
20
|
+
# mutant could never be killed.
|
|
21
|
+
#
|
|
22
|
+
# The includer defines #receiver_found(node). `node` is nil where the
|
|
23
|
+
# message or the definition has no explicit receiver.
|
|
24
|
+
module Evilution::Mutator::ReceiverPositions
|
|
25
|
+
def visit_call_node(node)
|
|
26
|
+
receiver_found(node.receiver)
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def visit_call_operator_write_node(node)
|
|
31
|
+
receiver_found(node.receiver)
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def visit_call_or_write_node(node)
|
|
36
|
+
receiver_found(node.receiver)
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def visit_call_and_write_node(node)
|
|
41
|
+
receiver_found(node.receiver)
|
|
42
|
+
super
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def visit_index_operator_write_node(node)
|
|
46
|
+
receiver_found(node.receiver)
|
|
47
|
+
super
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def visit_index_or_write_node(node)
|
|
51
|
+
receiver_found(node.receiver)
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def visit_index_and_write_node(node)
|
|
56
|
+
receiver_found(node.receiver)
|
|
57
|
+
super
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def visit_call_target_node(node)
|
|
61
|
+
receiver_found(node.receiver)
|
|
62
|
+
super
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def visit_index_target_node(node)
|
|
66
|
+
receiver_found(node.receiver)
|
|
67
|
+
super
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def visit_def_node(node)
|
|
71
|
+
receiver_found(node.receiver)
|
|
72
|
+
super
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def visit_singleton_class_node(node)
|
|
76
|
+
receiver_found(node.expression)
|
|
77
|
+
super
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -6,7 +6,8 @@ class Evilution::Mutator::Registry
|
|
|
6
6
|
STRICT_EXTRA_OPERATORS = [
|
|
7
7
|
Evilution::Mutator::Operator::PredicateToNil,
|
|
8
8
|
Evilution::Mutator::Operator::ExceptionSwallow,
|
|
9
|
-
Evilution::Mutator::Operator::StatementReorder
|
|
9
|
+
Evilution::Mutator::Operator::StatementReorder,
|
|
10
|
+
Evilution::Mutator::Operator::SafeNavigationInsertion
|
|
10
11
|
].freeze
|
|
11
12
|
|
|
12
13
|
def self.for_profile(profile)
|
|
@@ -37,6 +38,8 @@ class Evilution::Mutator::Registry
|
|
|
37
38
|
Evilution::Mutator::Operator::NilReplacement,
|
|
38
39
|
Evilution::Mutator::Operator::IntegerLiteral,
|
|
39
40
|
Evilution::Mutator::Operator::FloatLiteral,
|
|
41
|
+
Evilution::Mutator::Operator::ComplexLiteral,
|
|
42
|
+
Evilution::Mutator::Operator::RationalLiteral,
|
|
40
43
|
Evilution::Mutator::Operator::StringLiteral,
|
|
41
44
|
Evilution::Mutator::Operator::ArrayLiteral,
|
|
42
45
|
Evilution::Mutator::Operator::HashLiteral,
|
|
@@ -150,6 +153,7 @@ class Evilution::Mutator::Registry
|
|
|
150
153
|
Evilution::Mutator::Operator::KeywordValueSwap,
|
|
151
154
|
Evilution::Mutator::Operator::ComparisonOperandSwap,
|
|
152
155
|
Evilution::Mutator::Operator::IntegerDivisionToFdiv,
|
|
156
|
+
Evilution::Mutator::Operator::RoundHalfModeSwap,
|
|
153
157
|
Evilution::Mutator::Operator::OffByOneBoundary,
|
|
154
158
|
Evilution::Mutator::Operator::FormatSpecifierSwap,
|
|
155
159
|
Evilution::Mutator::Operator::AliasRemoval,
|
|
@@ -163,7 +167,23 @@ class Evilution::Mutator::Registry
|
|
|
163
167
|
Evilution::Mutator::Operator::RescueHandlerPromotion,
|
|
164
168
|
Evilution::Mutator::Operator::RescueHandlerConcatenation,
|
|
165
169
|
Evilution::Mutator::Operator::RescueElseConcatenation,
|
|
166
|
-
Evilution::Mutator::Operator::ReturnKeywordRemoval
|
|
170
|
+
Evilution::Mutator::Operator::ReturnKeywordRemoval,
|
|
171
|
+
Evilution::Mutator::Operator::MemoizationBreak,
|
|
172
|
+
Evilution::Mutator::Operator::ShortCircuitToEager,
|
|
173
|
+
Evilution::Mutator::Operator::FreezeRemoval,
|
|
174
|
+
Evilution::Mutator::Operator::RescueClassWidening,
|
|
175
|
+
Evilution::Mutator::Operator::VisibilityRemoval,
|
|
176
|
+
Evilution::Mutator::Operator::AttrAccessorSwap,
|
|
177
|
+
Evilution::Mutator::Operator::SleepToZero,
|
|
178
|
+
Evilution::Mutator::Operator::IndexReceiverToSelf,
|
|
179
|
+
Evilution::Mutator::Operator::IndexToKeyPredicate,
|
|
180
|
+
Evilution::Mutator::Operator::IndexRangeToDrop,
|
|
181
|
+
Evilution::Mutator::Operator::IndexWriteToValue,
|
|
182
|
+
Evilution::Mutator::Operator::ConstantReadToNil,
|
|
183
|
+
Evilution::Mutator::Operator::ConstantNamespaceStrip,
|
|
184
|
+
Evilution::Mutator::Operator::ConstantWriteToNil,
|
|
185
|
+
Evilution::Mutator::Operator::VariableReadToNil,
|
|
186
|
+
Evilution::Mutator::Operator::OperatorWriteReceiverPromotion
|
|
167
187
|
].each { |op| registry.register(op) }
|
|
168
188
|
registry
|
|
169
189
|
end
|
|
@@ -186,6 +206,13 @@ class Evilution::Mutator::Registry
|
|
|
186
206
|
end
|
|
187
207
|
end
|
|
188
208
|
|
|
209
|
+
# Whether any registered operator mutates subjects of this one's kind. A
|
|
210
|
+
# subject none does would only ever be listed with no mutations to its
|
|
211
|
+
# name.
|
|
212
|
+
def accepts?(subject)
|
|
213
|
+
@operators.any? { |operator_class| operator_class.subject_kinds.include?(subject.kind) }
|
|
214
|
+
end
|
|
215
|
+
|
|
189
216
|
def operator_count
|
|
190
217
|
@operators.length
|
|
191
218
|
end
|
|
@@ -14,6 +14,10 @@ module Evilution::Parallel::WorkQueue::Channel::Frame
|
|
|
14
14
|
# parent itself forked, transferred over a private pipe inside our process
|
|
15
15
|
# tree. No external/untrusted input ever reaches this code. See
|
|
16
16
|
# .rubocop.yml (Security/MarshalLoad) for the full rationale.
|
|
17
|
+
#
|
|
18
|
+
# Trusted is not the same as well-formed: a process the worker forked holds
|
|
19
|
+
# the pipe too. A frame of the announced length that does not load is a
|
|
20
|
+
# BadFrame; one shorter than announced is the end of the stream, nil.
|
|
17
21
|
def decode(header, payload)
|
|
18
22
|
return nil if header.nil? || header.bytesize < 4
|
|
19
23
|
|
|
@@ -21,5 +25,7 @@ module Evilution::Parallel::WorkQueue::Channel::Frame
|
|
|
21
25
|
return nil if payload.nil? || payload.bytesize < length
|
|
22
26
|
|
|
23
27
|
Marshal.load(payload)
|
|
28
|
+
rescue TypeError, ArgumentError => e
|
|
29
|
+
raise Evilution::Parallel::WorkQueue::Channel::BadFrame, "#{e.class}: #{e.message.gsub(/\s+/, " ")}"
|
|
24
30
|
end
|
|
25
31
|
end
|
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "io/wait"
|
|
3
4
|
require_relative "../work_queue"
|
|
4
5
|
|
|
5
6
|
module Evilution::Parallel::WorkQueue::Channel
|
|
7
|
+
# A frame that arrived but is not one the peer wrote: bytes that do not
|
|
8
|
+
# decode, or a frame that stops before the length its header announced.
|
|
9
|
+
#
|
|
10
|
+
# The pipes of the pool are inherited by the processes a worker forks, so
|
|
11
|
+
# the code under test can write into them. After such a frame the stream is
|
|
12
|
+
# out of step and nothing read from it later can be trusted; the reader
|
|
13
|
+
# gives the peer up. The end of the stream is not this: it reads as nil.
|
|
14
|
+
class BadFrame < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Seconds a frame may take to arrive once its first byte has, header and
|
|
17
|
+
# payload together. A writer sends a frame in one call, so the rest follows
|
|
18
|
+
# at once; a header that announces bytes nobody will send must not hold the
|
|
19
|
+
# reader for ever.
|
|
20
|
+
FRAME_TIMEOUT = 10
|
|
21
|
+
|
|
6
22
|
module_function
|
|
7
23
|
|
|
8
24
|
def write(io, object)
|
|
@@ -10,14 +26,51 @@ module Evilution::Parallel::WorkQueue::Channel
|
|
|
10
26
|
io.flush
|
|
11
27
|
end
|
|
12
28
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
29
|
+
# Waits for a frame without a limit, since an idle peer is no fault, and
|
|
30
|
+
# for the rest of one that has begun no longer than `frame_timeout`: one
|
|
31
|
+
# allowance for the whole frame.
|
|
32
|
+
def read(io, frame_timeout: FRAME_TIMEOUT)
|
|
33
|
+
io.wait_readable
|
|
34
|
+
allowance = Allowance.new(deadline: now + frame_timeout, seconds: frame_timeout)
|
|
35
|
+
header = read_exactly(io, 4, allowance)
|
|
36
|
+
return nil if header.bytesize < 4
|
|
16
37
|
|
|
17
|
-
|
|
18
|
-
payload = io.read(length)
|
|
38
|
+
payload = read_exactly(io, header.unpack1("N"), allowance)
|
|
19
39
|
Frame.decode(header, payload)
|
|
20
40
|
end
|
|
41
|
+
|
|
42
|
+
# What is left of the time a frame is given, and the figure it was given,
|
|
43
|
+
# which the message of a frame cut short names.
|
|
44
|
+
Allowance = Data.define(:deadline, :seconds)
|
|
45
|
+
private_constant :Allowance
|
|
46
|
+
|
|
47
|
+
# Up to `count` bytes: fewer only when the stream ended first.
|
|
48
|
+
def read_exactly(io, count, allowance)
|
|
49
|
+
buffer = String.new
|
|
50
|
+
|
|
51
|
+
while buffer.bytesize < count
|
|
52
|
+
chunk = io.read_nonblock(count - buffer.bytesize, exception: false)
|
|
53
|
+
return buffer if chunk.nil?
|
|
54
|
+
|
|
55
|
+
if chunk == :wait_readable
|
|
56
|
+
await(io, allowance, buffer.bytesize, count)
|
|
57
|
+
else
|
|
58
|
+
buffer << chunk
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
buffer
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def await(io, allowance, arrived, count)
|
|
65
|
+
remaining = allowance.deadline - now
|
|
66
|
+
return if remaining.positive? && io.wait_readable(remaining)
|
|
67
|
+
|
|
68
|
+
raise BadFrame, "frame cut short: #{arrived} of #{count} bytes arrived within #{allowance.seconds}s"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def now
|
|
72
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
73
|
+
end
|
|
21
74
|
end
|
|
22
75
|
|
|
23
76
|
require_relative "channel/frame"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../dispatcher"
|
|
4
|
+
require_relative "../channel"
|
|
5
|
+
|
|
6
|
+
# Reads what a worker sent and says what it is: a result of an item the worker
|
|
7
|
+
# holds, the end of its stream (nil), or something that cannot be trusted.
|
|
8
|
+
#
|
|
9
|
+
# The result pipe is inherited by the processes a worker forks, so a frame on
|
|
10
|
+
# it is not always the worker's. One that does not decode is UNREADABLE, and
|
|
11
|
+
# so is one that decodes to something other than a result: what a stray
|
|
12
|
+
# process writes may happen to be a marshal dump, and taken at its word it
|
|
13
|
+
# would move the Dispatcher's counters or be raised as the error of the run.
|
|
14
|
+
# After either, the stream is out of step, and the Dispatcher gives the worker
|
|
15
|
+
# up.
|
|
16
|
+
#
|
|
17
|
+
# Keeping the wire-level judgement here leaves the Dispatcher with what to do
|
|
18
|
+
# about each answer.
|
|
19
|
+
module Evilution::Parallel::WorkQueue::Dispatcher::Reply
|
|
20
|
+
UNREADABLE = Object.new.freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def from(worker)
|
|
25
|
+
message = worker.read_result
|
|
26
|
+
return nil if message.nil?
|
|
27
|
+
|
|
28
|
+
result_of?(worker, message) ? message : UNREADABLE
|
|
29
|
+
rescue Evilution::Parallel::WorkQueue::Channel::BadFrame
|
|
30
|
+
UNREADABLE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def result_of?(worker, message)
|
|
34
|
+
return false unless message.is_a?(Array) && message.length == 3
|
|
35
|
+
|
|
36
|
+
index, status, value = message
|
|
37
|
+
return false unless worker.in_flight_indices.include?(index)
|
|
38
|
+
|
|
39
|
+
status == :ok || (status == :error && value.is_a?(Exception))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -74,8 +74,9 @@ class Evilution::Parallel::WorkQueue::Dispatcher
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def handle(worker, io_to_worker, result_ios)
|
|
77
|
-
message = worker
|
|
77
|
+
message = Reply.from(worker)
|
|
78
78
|
return handle_dead(worker, io_to_worker, result_ios) if message.nil?
|
|
79
|
+
return handle_unreadable(worker, io_to_worker, result_ios) if message.equal?(Reply::UNREADABLE)
|
|
79
80
|
|
|
80
81
|
record(message, worker)
|
|
81
82
|
return false if recycle_and_dispatch(worker, io_to_worker, result_ios)
|
|
@@ -96,6 +97,17 @@ class Evilution::Parallel::WorkQueue::Dispatcher
|
|
|
96
97
|
@deadlines.refresh(worker)
|
|
97
98
|
end
|
|
98
99
|
|
|
100
|
+
# After a frame that is not the worker's, nothing read from its pipe later
|
|
101
|
+
# can be trusted: the stream is out of step. The worker is given up the way
|
|
102
|
+
# a dead one is, after being killed, since it may well be alive. Only the
|
|
103
|
+
# items it held lose their result.
|
|
104
|
+
def handle_unreadable(worker, io_to_worker, result_ios)
|
|
105
|
+
worker.kill
|
|
106
|
+
mark_unfinished(worker, Evilution::Parallel::WorkQueue::UNREADABLE)
|
|
107
|
+
retire_or_replace(worker, io_to_worker, result_ios)
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
99
111
|
# A worker that exited without replying loses only its in-flight item(s)
|
|
100
112
|
# (marked :died) and is recycled; the run continues rather than aborting.
|
|
101
113
|
def handle_dead(worker, io_to_worker, result_ios)
|
|
@@ -183,3 +195,4 @@ class Evilution::Parallel::WorkQueue::Dispatcher
|
|
|
183
195
|
end
|
|
184
196
|
|
|
185
197
|
require_relative "dispatcher/deadline_tracker"
|
|
198
|
+
require_relative "dispatcher/reply"
|
|
@@ -23,6 +23,11 @@ module Evilution::Parallel::WorkQueue::Worker::Loop
|
|
|
23
23
|
Evilution::Parallel::WorkQueue::Channel.write(
|
|
24
24
|
res_io, [Evilution::Parallel::WorkQueue::STATS, busy, wall]
|
|
25
25
|
)
|
|
26
|
+
rescue Evilution::Parallel::WorkQueue::Channel::BadFrame
|
|
27
|
+
# A command that does not decode leaves a stream this worker cannot follow.
|
|
28
|
+
# It ends without the stats frame, which the parent, still waiting for a
|
|
29
|
+
# result, would take for one; the closed pipe tells the parent to replace it.
|
|
30
|
+
nil
|
|
26
31
|
ensure
|
|
27
32
|
cmd_io.close unless cmd_io.closed?
|
|
28
33
|
res_io.close unless res_io.closed?
|
|
@@ -91,8 +91,8 @@ class Evilution::Parallel::WorkQueue::Worker
|
|
|
91
91
|
@in_flight_indices << index
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
def read_result
|
|
95
|
-
Evilution::Parallel::WorkQueue::Channel.read(@res_read)
|
|
94
|
+
def read_result(frame_timeout: Evilution::Parallel::WorkQueue::Channel::FRAME_TIMEOUT)
|
|
95
|
+
Evilution::Parallel::WorkQueue::Channel.read(@res_read, frame_timeout: frame_timeout)
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
def shutdown
|
|
@@ -138,16 +138,25 @@ class Evilution::Parallel::WorkQueue::Worker
|
|
|
138
138
|
|
|
139
139
|
private
|
|
140
140
|
|
|
141
|
+
# The grace period covers the read as well as the wait for it: a stats frame
|
|
142
|
+
# that begins inside it and never completes must not outlast it.
|
|
141
143
|
def drain_stats
|
|
142
144
|
zero = Timing.new(busy: 0.0, wall: 0.0)
|
|
145
|
+
deadline = monotonic_now + Evilution::Parallel::WorkQueue::TIMING_GRACE_PERIOD
|
|
143
146
|
return zero unless @res_read.wait_readable(Evilution::Parallel::WorkQueue::TIMING_GRACE_PERIOD)
|
|
144
147
|
|
|
145
|
-
message = read_result
|
|
146
|
-
return zero
|
|
148
|
+
message = read_result(frame_timeout: [deadline - monotonic_now, 0].max)
|
|
149
|
+
return zero unless message.is_a?(Array)
|
|
147
150
|
|
|
148
151
|
tag, busy, wall = message
|
|
149
152
|
return zero unless tag == Evilution::Parallel::WorkQueue::STATS
|
|
150
153
|
|
|
151
154
|
Timing.new(busy: busy, wall: wall)
|
|
155
|
+
rescue Evilution::Parallel::WorkQueue::Channel::BadFrame
|
|
156
|
+
zero
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def monotonic_now
|
|
160
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
152
161
|
end
|
|
153
162
|
end
|
|
@@ -16,6 +16,7 @@ class Evilution::Parallel::WorkQueue
|
|
|
16
16
|
Unfinished = Data.define(:reason)
|
|
17
17
|
TIMED_OUT = Unfinished.new(reason: :timeout)
|
|
18
18
|
DIED = Unfinished.new(reason: :died)
|
|
19
|
+
UNREADABLE = Unfinished.new(reason: :unreadable)
|
|
19
20
|
|
|
20
21
|
def initialize(size:, hooks: nil, prefetch: 1, item_timeout: nil, worker_max_items: nil)
|
|
21
22
|
Validators::PositiveInt.call!(:size, size)
|
|
@@ -80,17 +81,16 @@ class Evilution::Parallel::WorkQueue
|
|
|
80
81
|
deadline = monotonic_now + TIMING_GRACE_PERIOD
|
|
81
82
|
|
|
82
83
|
until io_to_worker.empty?
|
|
83
|
-
|
|
84
|
-
break
|
|
85
|
-
break unless poll_and_apply(io_to_worker, remaining)
|
|
84
|
+
break if deadline - monotonic_now <= 0
|
|
85
|
+
break unless poll_and_apply(io_to_worker, deadline)
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
-
def poll_and_apply(io_to_worker,
|
|
90
|
-
readable, = IO.select(io_to_worker.keys, nil, nil,
|
|
89
|
+
def poll_and_apply(io_to_worker, deadline)
|
|
90
|
+
readable, = IO.select(io_to_worker.keys, nil, nil, deadline - monotonic_now)
|
|
91
91
|
return false unless readable
|
|
92
92
|
|
|
93
|
-
readable.each { |io| apply_final_timing(io_to_worker.delete(io), io) }
|
|
93
|
+
readable.each { |io| apply_final_timing(io_to_worker.delete(io), io, deadline) }
|
|
94
94
|
true
|
|
95
95
|
end
|
|
96
96
|
|
|
@@ -98,15 +98,21 @@ class Evilution::Parallel::WorkQueue
|
|
|
98
98
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
# The timings are a courtesy: a frame that cannot be read here costs the
|
|
102
|
+
# figures and nothing else. The read gets what is left of the grace period,
|
|
103
|
+
# so a frame that begins inside it and never completes does not outlast it.
|
|
104
|
+
def apply_final_timing(worker, io, deadline)
|
|
105
|
+
allowed = [deadline - monotonic_now, 0].max
|
|
106
|
+
message = Evilution::Parallel::WorkQueue::Channel.read(io, frame_timeout: allowed)
|
|
107
|
+
return unless message.is_a?(Array)
|
|
104
108
|
|
|
105
109
|
tag, busy_time, wall_time = message
|
|
106
110
|
return unless tag == STATS
|
|
107
111
|
|
|
108
112
|
worker.busy_time = busy_time
|
|
109
113
|
worker.wall_time = wall_time
|
|
114
|
+
rescue Evilution::Parallel::WorkQueue::Channel::BadFrame
|
|
115
|
+
nil
|
|
110
116
|
end
|
|
111
117
|
end
|
|
112
118
|
|
|
@@ -13,6 +13,15 @@ class Evilution::Runner::MutationExecutor::Strategy::Parallel
|
|
|
13
13
|
error_class: "Evilution::Error"
|
|
14
14
|
}.freeze
|
|
15
15
|
|
|
16
|
+
# Compact result for an item whose worker sent a frame that was not its own:
|
|
17
|
+
# the worker was given up, and what it had to say about the item with it.
|
|
18
|
+
UNREADABLE_COMPACT = {
|
|
19
|
+
status: :error,
|
|
20
|
+
duration: 0.0,
|
|
21
|
+
error_message: "worker's result could not be read; the worker was replaced",
|
|
22
|
+
error_class: "Evilution::Error"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
16
25
|
def initialize(cache:, isolator:, packer:, pipeline:, notifier:, pool_factory:, config:, diagnostics: nil)
|
|
17
26
|
@cache = cache
|
|
18
27
|
@isolator = isolator
|
|
@@ -113,6 +122,7 @@ class Evilution::Runner::MutationExecutor::Strategy::Parallel
|
|
|
113
122
|
case result.reason
|
|
114
123
|
when :timeout then timeout_compact
|
|
115
124
|
when :died then DIED_COMPACT
|
|
125
|
+
when :unreadable then UNREADABLE_COMPACT
|
|
116
126
|
end
|
|
117
127
|
end
|
|
118
128
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../subject_pipeline"
|
|
4
|
+
|
|
5
|
+
# What `--target` asks for, read once from the text the user gave:
|
|
6
|
+
#
|
|
7
|
+
# source:lib/**/*.rb the files of a glob
|
|
8
|
+
# descendants:Base a class and everything inheriting from it
|
|
9
|
+
# User#adult? a method; `User`, `User#`, `Billing::*` for several
|
|
10
|
+
#
|
|
11
|
+
# NONE stands in when no target was given: it is no kind of target and
|
|
12
|
+
# selects every subject, so the pipeline never has to ask whether there is
|
|
13
|
+
# one.
|
|
14
|
+
class Evilution::Runner::SubjectPipeline::Target
|
|
15
|
+
PREFIXES = { "source:" => :source_glob, "descendants:" => :descendants }.freeze
|
|
16
|
+
private_constant :PREFIXES
|
|
17
|
+
|
|
18
|
+
def self.parse(text)
|
|
19
|
+
return NONE if text.nil?
|
|
20
|
+
|
|
21
|
+
prefix, kind = PREFIXES.find { |candidate, _| text.start_with?(candidate) }
|
|
22
|
+
return new(kind: :method, text: text, value: text) if prefix.nil?
|
|
23
|
+
|
|
24
|
+
new(kind: kind, text: text, value: text.delete_prefix(prefix))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The part after the prefix: the glob, the base class name, or the method
|
|
28
|
+
# name as written.
|
|
29
|
+
attr_reader :value
|
|
30
|
+
|
|
31
|
+
def initialize(kind:, text:, value:)
|
|
32
|
+
@kind = kind
|
|
33
|
+
@text = text
|
|
34
|
+
@value = value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def source_glob?
|
|
38
|
+
@kind == :source_glob
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def descendants?
|
|
42
|
+
@kind == :descendants
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def method?
|
|
46
|
+
@kind == :method
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The target as the user wrote it, for messages.
|
|
50
|
+
def to_s
|
|
51
|
+
@text.to_s
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Only a method target narrows subjects by name.
|
|
55
|
+
def selects?(subject)
|
|
56
|
+
!method? || matcher.call(subject.name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
NONE = new(kind: :none, text: nil, value: nil)
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def matcher
|
|
64
|
+
@matcher ||= build_matcher
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_matcher
|
|
68
|
+
return wildcard_matcher(value.chomp("*")) if value.end_with?("*")
|
|
69
|
+
return prefix_matcher(value) if value.end_with?("#", ".")
|
|
70
|
+
|
|
71
|
+
name_matcher(value)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def wildcard_matcher(prefix)
|
|
75
|
+
->(name) { name.split(/[#.]/).first.start_with?(prefix) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def prefix_matcher(prefix)
|
|
79
|
+
->(name) { name.start_with?(prefix) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A full method name matches itself; a class name, each of its methods.
|
|
83
|
+
def name_matcher(target)
|
|
84
|
+
->(name) { name == target || name.start_with?("#{target}#") || name.start_with?("#{target}.") }
|
|
85
|
+
end
|
|
86
|
+
end
|