evilution 0.16.1 → 0.18.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +47 -46
  4. data/CHANGELOG.md +48 -0
  5. data/README.md +143 -50
  6. data/docs/ast_pattern_syntax.md +210 -0
  7. data/lib/evilution/ast/pattern/filter.rb +25 -0
  8. data/lib/evilution/ast/pattern/matcher.rb +107 -0
  9. data/lib/evilution/ast/pattern/parser.rb +185 -0
  10. data/lib/evilution/ast/pattern.rb +4 -0
  11. data/lib/evilution/ast/sorbet_sig_detector.rb +52 -0
  12. data/lib/evilution/cli.rb +400 -24
  13. data/lib/evilution/config.rb +43 -2
  14. data/lib/evilution/disable_comment.rb +90 -0
  15. data/lib/evilution/hooks/loader.rb +35 -0
  16. data/lib/evilution/hooks/registry.rb +60 -0
  17. data/lib/evilution/hooks.rb +58 -0
  18. data/lib/evilution/integration/base.rb +4 -0
  19. data/lib/evilution/integration/rspec.rb +6 -2
  20. data/lib/evilution/isolation/fork.rb +5 -0
  21. data/lib/evilution/mcp/session_diff_tool.rb +5 -35
  22. data/lib/evilution/mutator/base.rb +4 -1
  23. data/lib/evilution/mutator/operator/collection_return.rb +33 -0
  24. data/lib/evilution/mutator/operator/defined_check.rb +16 -0
  25. data/lib/evilution/mutator/operator/index_assignment_removal.rb +18 -0
  26. data/lib/evilution/mutator/operator/index_to_dig.rb +58 -0
  27. data/lib/evilution/mutator/operator/index_to_fetch.rb +30 -0
  28. data/lib/evilution/mutator/operator/keyword_argument.rb +91 -0
  29. data/lib/evilution/mutator/operator/mixin_removal.rb +2 -1
  30. data/lib/evilution/mutator/operator/multiple_assignment.rb +47 -0
  31. data/lib/evilution/mutator/operator/pattern_matching_alternative.rb +46 -0
  32. data/lib/evilution/mutator/operator/pattern_matching_array.rb +97 -0
  33. data/lib/evilution/mutator/operator/pattern_matching_guard.rb +44 -0
  34. data/lib/evilution/mutator/operator/regex_capture.rb +43 -0
  35. data/lib/evilution/mutator/operator/scalar_return.rb +37 -0
  36. data/lib/evilution/mutator/operator/splat_operator.rb +46 -0
  37. data/lib/evilution/mutator/operator/superclass_removal.rb +2 -1
  38. data/lib/evilution/mutator/operator/yield_statement.rb +51 -0
  39. data/lib/evilution/mutator/registry.rb +17 -3
  40. data/lib/evilution/parallel/pool.rb +7 -51
  41. data/lib/evilution/parallel/work_queue.rb +224 -0
  42. data/lib/evilution/reporter/cli.rb +22 -1
  43. data/lib/evilution/reporter/html.rb +76 -3
  44. data/lib/evilution/reporter/json.rb +23 -2
  45. data/lib/evilution/reporter/suggestion.rb +115 -1
  46. data/lib/evilution/result/summary.rb +20 -2
  47. data/lib/evilution/runner.rb +133 -13
  48. data/lib/evilution/session/diff.rb +85 -0
  49. data/lib/evilution/session/store.rb +5 -2
  50. data/lib/evilution/version.rb +1 -1
  51. data/lib/evilution.rb +23 -0
  52. metadata +28 -2
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::PatternMatchingArray < Evilution::Mutator::Base
6
+ def visit_array_pattern_node(node)
7
+ mutate_array_elements(node)
8
+ super
9
+ end
10
+
11
+ def visit_find_pattern_node(node)
12
+ mutate_find_elements(node)
13
+ super
14
+ end
15
+
16
+ private
17
+
18
+ def mutate_array_elements(node)
19
+ requireds = node.requireds
20
+ posts = node.posts
21
+ rest = node.rest
22
+ elements = requireds + posts
23
+ return if elements.empty?
24
+
25
+ elements.each_with_index do |_element, index|
26
+ remove_array_element(node, requireds, posts, rest, index) if elements.length > 1
27
+ wildcard_array_element(node, requireds, posts, rest, index)
28
+ end
29
+ end
30
+
31
+ def remove_array_element(node, requireds, posts, rest, skip_index)
32
+ parts = build_array_parts(requireds, posts, rest, skip_index: skip_index)
33
+ replace_pattern(node, parts)
34
+ end
35
+
36
+ def wildcard_array_element(node, requireds, posts, rest, wildcard_index)
37
+ parts = build_array_parts(requireds, posts, rest, wildcard_index: wildcard_index)
38
+ replace_pattern(node, parts)
39
+ end
40
+
41
+ def build_array_parts(requireds, posts, rest, skip_index: nil, wildcard_index: nil)
42
+ parts = []
43
+ requireds.each_with_index do |req, i|
44
+ next if i == skip_index
45
+
46
+ parts << (i == wildcard_index ? "_" : source_for(req))
47
+ end
48
+ parts << source_for(rest) if rest
49
+ posts.each_with_index do |post, i|
50
+ adjusted = requireds.length + i
51
+ next if adjusted == skip_index
52
+
53
+ parts << (adjusted == wildcard_index ? "_" : source_for(post))
54
+ end
55
+ parts
56
+ end
57
+
58
+ def mutate_find_elements(node)
59
+ return if node.requireds.empty?
60
+
61
+ node.requireds.each_with_index do |_element, index|
62
+ remove_find_element(node, index) if node.requireds.length > 1
63
+ wildcard_find_element(node, index)
64
+ end
65
+ end
66
+
67
+ def remove_find_element(node, skip_index)
68
+ parts = [source_for(node.left)]
69
+ node.requireds.each_with_index do |req, i|
70
+ parts << source_for(req) unless i == skip_index
71
+ end
72
+ parts << source_for(node.right)
73
+ replace_pattern(node, parts)
74
+ end
75
+
76
+ def wildcard_find_element(node, wildcard_index)
77
+ parts = [source_for(node.left)]
78
+ node.requireds.each_with_index do |req, i|
79
+ parts << (i == wildcard_index ? "_" : source_for(req))
80
+ end
81
+ parts << source_for(node.right)
82
+ replace_pattern(node, parts)
83
+ end
84
+
85
+ def replace_pattern(node, parts)
86
+ add_mutation(
87
+ offset: node.location.start_offset,
88
+ length: node.location.length,
89
+ replacement: "[#{parts.join(", ")}]",
90
+ node: node
91
+ )
92
+ end
93
+
94
+ def source_for(node)
95
+ @file_source.byteslice(node.location.start_offset, node.location.length)
96
+ end
97
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::PatternMatchingGuard < Evilution::Mutator::Base
6
+ def visit_in_node(node)
7
+ pattern = node.pattern
8
+ mutate_guard(pattern, node) if guarded?(pattern)
9
+ super
10
+ end
11
+
12
+ private
13
+
14
+ def guarded?(pattern)
15
+ pattern.is_a?(Prism::IfNode) || pattern.is_a?(Prism::UnlessNode)
16
+ end
17
+
18
+ def mutate_guard(pattern, in_node)
19
+ guard_start = pattern.statements.location.start_offset + pattern.statements.location.length
20
+ guard_end = pattern.predicate.location.start_offset + pattern.predicate.location.length
21
+
22
+ remove_guard(guard_start, guard_end, in_node)
23
+ negate_guard(pattern, in_node)
24
+ end
25
+
26
+ def remove_guard(guard_start, guard_end, in_node)
27
+ add_mutation(
28
+ offset: guard_start,
29
+ length: guard_end - guard_start,
30
+ replacement: "",
31
+ node: in_node
32
+ )
33
+ end
34
+
35
+ def negate_guard(pattern, in_node)
36
+ pred_loc = pattern.predicate.location
37
+ add_mutation(
38
+ offset: pred_loc.start_offset,
39
+ length: pred_loc.length,
40
+ replacement: "!(#{@file_source.byteslice(pred_loc.start_offset, pred_loc.length)})",
41
+ node: in_node
42
+ )
43
+ end
44
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::RegexCapture < Evilution::Mutator::Base
6
+ def visit_numbered_reference_read_node(node)
7
+ mutate_replace_with_nil(node)
8
+ mutate_swap_number(node)
9
+
10
+ super
11
+ end
12
+
13
+ private
14
+
15
+ def mutate_replace_with_nil(node)
16
+ add_mutation(
17
+ offset: node.location.start_offset,
18
+ length: node.location.length,
19
+ replacement: "nil",
20
+ node: node
21
+ )
22
+ end
23
+
24
+ def mutate_swap_number(node)
25
+ number = node.number
26
+
27
+ if number > 1
28
+ add_mutation(
29
+ offset: node.location.start_offset,
30
+ length: node.location.length,
31
+ replacement: "$#{number - 1}",
32
+ node: node
33
+ )
34
+ end
35
+
36
+ add_mutation(
37
+ offset: node.location.start_offset,
38
+ length: node.location.length,
39
+ replacement: "$#{number + 1}",
40
+ node: node
41
+ )
42
+ end
43
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::ScalarReturn < Evilution::Mutator::Base
6
+ def visit_def_node(node)
7
+ body = node.body
8
+ if body.is_a?(Prism::StatementsNode) && body.body.length > 1
9
+ return_node = body.body.last
10
+ replacement = scalar_replacement(return_node)
11
+
12
+ if replacement
13
+ add_mutation(
14
+ offset: body.location.start_offset,
15
+ length: body.location.length,
16
+ replacement: replacement,
17
+ node: node
18
+ )
19
+ end
20
+ end
21
+
22
+ super
23
+ end
24
+
25
+ private
26
+
27
+ def scalar_replacement(node)
28
+ case node
29
+ when Prism::StringNode
30
+ '""' unless node.content.empty?
31
+ when Prism::IntegerNode
32
+ "0" unless node.value.zero?
33
+ when Prism::FloatNode
34
+ "0.0" unless node.value.zero?
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
6
+ def visit_splat_node(node)
7
+ mutate_remove_splat(node) if node.expression
8
+
9
+ super
10
+ end
11
+
12
+ def visit_hash_node(node)
13
+ node.elements.each { |el| hash_elements.add(el) }
14
+ super
15
+ end
16
+
17
+ def visit_assoc_splat_node(node)
18
+ mutate_remove_double_splat(node) if node.value && !hash_elements.include?(node)
19
+
20
+ super
21
+ end
22
+
23
+ private
24
+
25
+ def hash_elements
26
+ @hash_elements ||= Set.new.compare_by_identity
27
+ end
28
+
29
+ def mutate_remove_splat(node)
30
+ add_mutation(
31
+ offset: node.location.start_offset,
32
+ length: node.location.length,
33
+ replacement: node.expression.slice,
34
+ node: node
35
+ )
36
+ end
37
+
38
+ def mutate_remove_double_splat(node)
39
+ add_mutation(
40
+ offset: node.location.start_offset,
41
+ length: node.location.length,
42
+ replacement: node.value.slice,
43
+ node: node
44
+ )
45
+ end
46
+ end
@@ -5,10 +5,11 @@ require "prism"
5
5
  require_relative "../operator"
6
6
 
7
7
  class Evilution::Mutator::Operator::SuperclassRemoval < Evilution::Mutator::Base
8
- def call(subject)
8
+ def call(subject, filter: nil)
9
9
  @subject = subject
10
10
  @file_source = File.read(subject.file_path)
11
11
  @mutations = []
12
+ @filter = filter
12
13
 
13
14
  tree = self.class.parsed_tree_for(subject.file_path, @file_source)
14
15
  enclosing = find_enclosing_class(tree, subject.line_number)
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::YieldStatement < Evilution::Mutator::Base
6
+ def visit_yield_node(node)
7
+ mutate_remove_yield(node)
8
+
9
+ if node.arguments
10
+ mutate_remove_arguments(node)
11
+ mutate_replace_value_with_nil(node)
12
+ end
13
+
14
+ super
15
+ end
16
+
17
+ private
18
+
19
+ def mutate_remove_yield(node)
20
+ add_mutation(
21
+ offset: node.location.start_offset,
22
+ length: node.location.length,
23
+ replacement: "nil",
24
+ node: node
25
+ )
26
+ end
27
+
28
+ def mutate_remove_arguments(node)
29
+ add_mutation(
30
+ offset: node.location.start_offset,
31
+ length: node.location.length,
32
+ replacement: "yield",
33
+ node: node
34
+ )
35
+ end
36
+
37
+ def mutate_replace_value_with_nil(node)
38
+ replacement = if node.lparen_loc
39
+ "yield(nil)"
40
+ else
41
+ "yield nil"
42
+ end
43
+
44
+ add_mutation(
45
+ offset: node.location.start_offset,
46
+ length: node.location.length,
47
+ replacement: replacement,
48
+ node: node
49
+ )
50
+ end
51
+ end
@@ -51,7 +51,21 @@ class Evilution::Mutator::Registry
51
51
  Evilution::Mutator::Operator::BitwiseReplacement,
52
52
  Evilution::Mutator::Operator::BitwiseComplement,
53
53
  Evilution::Mutator::Operator::ZsuperRemoval,
54
- Evilution::Mutator::Operator::ExplicitSuperMutation
54
+ Evilution::Mutator::Operator::ExplicitSuperMutation,
55
+ Evilution::Mutator::Operator::IndexToFetch,
56
+ Evilution::Mutator::Operator::IndexToDig,
57
+ Evilution::Mutator::Operator::IndexAssignmentRemoval,
58
+ Evilution::Mutator::Operator::PatternMatchingGuard,
59
+ Evilution::Mutator::Operator::PatternMatchingAlternative,
60
+ Evilution::Mutator::Operator::PatternMatchingArray,
61
+ Evilution::Mutator::Operator::CollectionReturn,
62
+ Evilution::Mutator::Operator::ScalarReturn,
63
+ Evilution::Mutator::Operator::KeywordArgument,
64
+ Evilution::Mutator::Operator::MultipleAssignment,
65
+ Evilution::Mutator::Operator::YieldStatement,
66
+ Evilution::Mutator::Operator::SplatOperator,
67
+ Evilution::Mutator::Operator::DefinedCheck,
68
+ Evilution::Mutator::Operator::RegexCapture
55
69
  ].each { |op| registry.register(op) }
56
70
  registry
57
71
  end
@@ -65,9 +79,9 @@ class Evilution::Mutator::Registry
65
79
  self
66
80
  end
67
81
 
68
- def mutations_for(subject)
82
+ def mutations_for(subject, filter: nil)
69
83
  @operators.flat_map do |operator_class|
70
- operator_class.new.call(subject)
84
+ operator_class.new.call(subject, filter: filter)
71
85
  end
72
86
  end
73
87
 
@@ -1,61 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../parallel"
3
+ require_relative "work_queue"
4
4
 
5
5
  class Evilution::Parallel::Pool
6
- def initialize(size:)
7
- raise ArgumentError, "pool size must be a positive integer, got #{size.inspect}" unless size.is_a?(Integer) && size >= 1
8
-
9
- @size = size
6
+ def initialize(size:, hooks: nil)
7
+ @queue = Evilution::Parallel::WorkQueue.new(size: size, hooks: hooks)
10
8
  end
11
9
 
12
- def map(items, &block)
13
- results = []
14
-
15
- items.each_slice(@size) do |batch|
16
- results.concat(run_batch(batch, &block))
17
- end
18
-
19
- results
10
+ def map(items, &)
11
+ @queue.map(items, &)
20
12
  end
21
13
 
22
- private
23
-
24
- def run_batch(items, &block)
25
- entries = items.map do |item|
26
- read_io, write_io = IO.pipe
27
- pid = fork_worker(item, read_io, write_io, &block)
28
- write_io.close
29
- { pid: pid, read_io: read_io }
30
- end
31
-
32
- collect_results(entries)
33
- end
34
-
35
- def fork_worker(item, read_io, write_io, &block)
36
- Process.fork do
37
- read_io.close
38
- result = block.call(item)
39
- Marshal.dump(result, write_io)
40
- rescue Exception => e # rubocop:disable Lint/RescueException
41
- Marshal.dump(e, write_io)
42
- ensure
43
- write_io.close
44
- exit!
45
- end
46
- end
47
-
48
- def collect_results(entries)
49
- entries.map do |entry|
50
- data = entry[:read_io].read
51
- entry[:read_io].close
52
- Process.wait(entry[:pid])
53
- raise Evilution::Error, "worker process failed with no result" if data.empty?
54
-
55
- result = Marshal.load(data) # rubocop:disable Security/MarshalLoad
56
- raise result if result.is_a?(Exception)
57
-
58
- result
59
- end
14
+ def worker_stats
15
+ @queue.worker_stats
60
16
  end
61
17
  end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parallel"
4
+
5
+ class Evilution::Parallel::WorkQueue
6
+ SHUTDOWN = :__shutdown__
7
+
8
+ STATS = :__stats__
9
+
10
+ WorkerStat = Struct.new(:pid, :items_completed, :busy_time, :wall_time) do
11
+ def idle_time
12
+ wall_time - busy_time
13
+ end
14
+
15
+ def utilization
16
+ return 0.0 if wall_time.nil? || wall_time.zero?
17
+
18
+ busy_time / wall_time
19
+ end
20
+ end
21
+
22
+ def initialize(size:, hooks: nil, prefetch: 1)
23
+ raise ArgumentError, "pool size must be a positive integer, got #{size.inspect}" unless size.is_a?(Integer) && size >= 1
24
+ raise ArgumentError, "prefetch must be a positive integer, got #{prefetch.inspect}" unless prefetch.is_a?(Integer) && prefetch >= 1
25
+
26
+ @size = size
27
+ @hooks = hooks
28
+ @prefetch = prefetch
29
+ @worker_stats = []
30
+ end
31
+
32
+ def map(items, &)
33
+ return [] if items.empty?
34
+
35
+ worker_count = [@size, items.length].min
36
+ workers = spawn_workers(worker_count, &)
37
+
38
+ begin
39
+ distribute_and_collect(items, workers)
40
+ ensure
41
+ shutdown_workers(workers)
42
+ @worker_stats = build_worker_stats(workers)
43
+ end
44
+ end
45
+
46
+ def worker_stats
47
+ @worker_stats.map { |stat| stat.dup.freeze }
48
+ end
49
+
50
+ private
51
+
52
+ def spawn_workers(count, &)
53
+ count.times.map do
54
+ cmd_read, cmd_write = IO.pipe
55
+ res_read, res_write = IO.pipe
56
+
57
+ pid = Process.fork do
58
+ cmd_write.close
59
+ res_read.close
60
+ worker_loop(cmd_read, res_write, &)
61
+ end
62
+
63
+ cmd_read.close
64
+ res_write.close
65
+
66
+ { pid: pid, cmd_write: cmd_write, res_read: res_read, items_completed: 0 }
67
+ end
68
+ end
69
+
70
+ def worker_loop(cmd_read, res_write, &block)
71
+ @hooks.fire(:worker_process_start) if @hooks
72
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
73
+ busy_time = 0.0
74
+
75
+ loop do
76
+ data = read_command(cmd_read)
77
+ break if data == SHUTDOWN
78
+
79
+ index, item = data
80
+ begin
81
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ result = block.call(item)
83
+ busy_time += Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
84
+ write_message(res_write, [index, :ok, result])
85
+ rescue Exception => e # rubocop:disable Lint/RescueException
86
+ busy_time += Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
87
+ write_message(res_write, [index, :error, e])
88
+ end
89
+ end
90
+
91
+ wall_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
92
+ write_message(res_write, [STATS, busy_time, wall_time])
93
+ ensure
94
+ cmd_read.close
95
+ res_write.close
96
+ exit!
97
+ end
98
+
99
+ def distribute_and_collect(items, workers)
100
+ state = CollectionState.new(items.length)
101
+ seed_workers(items, workers, state)
102
+ collect_results(items, workers, state)
103
+ raise state.first_error if state.first_error
104
+
105
+ state.results
106
+ end
107
+
108
+ def seed_workers(items, workers, state)
109
+ @prefetch.times do
110
+ workers.each do |worker|
111
+ break unless state.next_index < items.length
112
+
113
+ send_item(worker, items, state)
114
+ end
115
+ end
116
+ end
117
+
118
+ def collect_results(items, workers, state)
119
+ io_to_worker = workers.to_h { |w| [w[:res_read], w] }
120
+ result_ios = io_to_worker.keys
121
+
122
+ while state.in_flight.positive?
123
+ readable, = IO.select(result_ios)
124
+ readable.each { |io| handle_result(io, io_to_worker[io], items, state) }
125
+ end
126
+ end
127
+
128
+ def handle_result(io, worker, items, state)
129
+ message = read_result(io)
130
+
131
+ if message.nil?
132
+ state.first_error = Evilution::Error.new("worker process exited unexpectedly") if state.first_error.nil?
133
+ state.in_flight -= 1
134
+ return
135
+ end
136
+
137
+ index, status, value = message
138
+ state.first_error = value if status == :error && state.first_error.nil?
139
+ state.results[index] = value if status == :ok
140
+ state.in_flight -= 1
141
+ worker[:items_completed] += 1
142
+
143
+ send_item(worker, items, state) if state.next_index < items.length && state.first_error.nil?
144
+ end
145
+
146
+ def send_item(worker, items, state)
147
+ write_message(worker[:cmd_write], [state.next_index, items[state.next_index]])
148
+ state.next_index += 1
149
+ state.in_flight += 1
150
+ end
151
+
152
+ def build_worker_stats(workers)
153
+ workers.map do |worker|
154
+ WorkerStat.new(worker[:pid], worker[:items_completed], worker[:busy_time] || 0.0, worker[:wall_time] || 0.0)
155
+ end
156
+ end
157
+
158
+ def shutdown_workers(workers)
159
+ workers.each do |worker|
160
+ write_message(worker[:cmd_write], SHUTDOWN)
161
+ rescue Errno::EPIPE
162
+ # Worker already exited
163
+ end
164
+
165
+ collect_worker_timing(workers)
166
+
167
+ workers.each do |worker|
168
+ worker[:cmd_write].close unless worker[:cmd_write].closed?
169
+ worker[:res_read].close unless worker[:res_read].closed?
170
+ Process.wait(worker[:pid])
171
+ rescue Errno::ECHILD
172
+ # Already reaped
173
+ end
174
+ end
175
+
176
+ def collect_worker_timing(workers)
177
+ workers.each do |worker|
178
+ message = read_result(worker[:res_read])
179
+ next if message.nil?
180
+
181
+ tag, busy_time, wall_time = message
182
+ next unless tag == STATS
183
+
184
+ worker[:busy_time] = busy_time
185
+ worker[:wall_time] = wall_time
186
+ end
187
+ end
188
+
189
+ def write_message(io, data)
190
+ payload = Marshal.dump(data)
191
+ io.write([payload.bytesize].pack("N"))
192
+ io.write(payload)
193
+ io.flush
194
+ end
195
+
196
+ def read_command(io)
197
+ header = io.read(4)
198
+ return SHUTDOWN if header.nil? || header.bytesize < 4
199
+
200
+ length = header.unpack1("N")
201
+ payload = io.read(length)
202
+ return SHUTDOWN if payload.nil? || payload.bytesize < length
203
+
204
+ Marshal.load(payload) # rubocop:disable Security/MarshalLoad
205
+ end
206
+
207
+ def read_result(io)
208
+ header = io.read(4)
209
+ return nil if header.nil? || header.bytesize < 4
210
+
211
+ length = header.unpack1("N")
212
+ payload = io.read(length)
213
+ return nil if payload.nil? || payload.bytesize < length
214
+
215
+ Marshal.load(payload) # rubocop:disable Security/MarshalLoad
216
+ end
217
+
218
+ CollectionState = Struct.new(:results, :in_flight, :next_index, :first_error) do
219
+ def initialize(item_count)
220
+ super(Array.new(item_count), 0, 0, nil)
221
+ end
222
+ end
223
+ private_constant :CollectionState
224
+ end