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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +70 -0
  3. data/CHANGELOG.md +50 -0
  4. data/README.md +64 -22
  5. data/docs/architecture.md +56 -12
  6. data/docs/isolation.md +82 -0
  7. data/lib/evilution/ast/class_body.rb +101 -0
  8. data/lib/evilution/ast/included_block.rb +20 -6
  9. data/lib/evilution/ast/never_nil.rb +36 -0
  10. data/lib/evilution/ast/parser.rb +94 -31
  11. data/lib/evilution/ast/round_half_mode.rb +38 -0
  12. data/lib/evilution/ast/source_surgeon.rb +2 -3
  13. data/lib/evilution/ast/syntax_check.rb +56 -0
  14. data/lib/evilution/ast/uncovered_code.rb +7 -7
  15. data/lib/evilution/cli/exit_guard.rb +26 -4
  16. data/lib/evilution/integration/base.rb +10 -2
  17. data/lib/evilution/integration/loading/body_call_neutralizer.rb +59 -14
  18. data/lib/evilution/integration/loading/concern_redeclaration.rb +33 -14
  19. data/lib/evilution/integration/loading/concern_state_cleaner.rb +3 -3
  20. data/lib/evilution/integration/loading/mutation_applier.rb +33 -6
  21. data/lib/evilution/integration/loading/own_methods.rb +87 -0
  22. data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
  23. data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
  24. data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
  25. data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
  26. data/lib/evilution/isolation/fork.rb +82 -10
  27. data/lib/evilution/isolation/in_process.rb +79 -4
  28. data/lib/evilution/mcp/info_tool/status_glossary.rb +13 -2
  29. data/lib/evilution/mcp/info_tool.rb +3 -2
  30. data/lib/evilution/mutation.rb +18 -11
  31. data/lib/evilution/mutator/base.rb +27 -7
  32. data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
  33. data/lib/evilution/mutator/operator/alias_removal.rb +11 -81
  34. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
  35. data/lib/evilution/mutator/operator/argument_removal.rb +2 -2
  36. data/lib/evilution/mutator/operator/array_literal.rb +29 -0
  37. data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
  38. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
  39. data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
  40. data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
  41. data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
  42. data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
  43. data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
  44. data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
  45. data/lib/evilution/mutator/operator/data_struct_member.rb +1 -1
  46. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
  47. data/lib/evilution/mutator/operator/float_literal.rb +54 -19
  48. data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
  49. data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
  50. data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
  51. data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
  52. data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
  53. data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
  54. data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
  55. data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
  56. data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
  57. data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
  58. data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
  59. data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
  60. data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
  61. data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
  62. data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
  63. data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
  64. data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
  65. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
  66. data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
  67. data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
  68. data/lib/evilution/mutator/operator/splat_operator.rb +16 -24
  69. data/lib/evilution/mutator/operator/string_literal.rb +29 -0
  70. data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
  71. data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
  72. data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
  73. data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
  74. data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
  75. data/lib/evilution/mutator/primitives.rb +24 -2
  76. data/lib/evilution/mutator/receiver_positions.rb +79 -0
  77. data/lib/evilution/mutator/registry.rb +29 -2
  78. data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
  79. data/lib/evilution/parallel/work_queue/channel.rb +58 -5
  80. data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
  81. data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
  82. data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
  83. data/lib/evilution/parallel/work_queue/worker.rb +13 -4
  84. data/lib/evilution/parallel/work_queue.rb +15 -9
  85. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
  86. data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
  87. data/lib/evilution/runner/subject_pipeline.rb +20 -51
  88. data/lib/evilution/runner.rb +1 -1
  89. data/lib/evilution/subject.rb +18 -1
  90. data/lib/evilution/version.rb +1 -1
  91. data/lib/evilution.rb +62 -0
  92. 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`, and replacing an expression with the source of one
7
- # of its children. Both are built on Base#add_mutation, so the
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
- def read(io)
14
- header = io.read(4)
15
- return nil if header.nil? || header.bytesize < 4
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
- length = header.unpack1("N")
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.read_result
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 if message.nil?
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
- remaining = deadline - monotonic_now
84
- break if remaining <= 0
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, remaining)
90
- readable, = IO.select(io_to_worker.keys, nil, nil, remaining)
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
- def apply_final_timing(worker, io)
102
- message = Evilution::Parallel::WorkQueue::Channel.read(io)
103
- return if message.nil?
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