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.
- checksums.yaml +4 -4
- data/.beads/.migration-hint-ts +1 -1
- data/.beads/issues.jsonl +47 -46
- data/CHANGELOG.md +48 -0
- data/README.md +143 -50
- data/docs/ast_pattern_syntax.md +210 -0
- data/lib/evilution/ast/pattern/filter.rb +25 -0
- data/lib/evilution/ast/pattern/matcher.rb +107 -0
- data/lib/evilution/ast/pattern/parser.rb +185 -0
- data/lib/evilution/ast/pattern.rb +4 -0
- data/lib/evilution/ast/sorbet_sig_detector.rb +52 -0
- data/lib/evilution/cli.rb +400 -24
- data/lib/evilution/config.rb +43 -2
- data/lib/evilution/disable_comment.rb +90 -0
- data/lib/evilution/hooks/loader.rb +35 -0
- data/lib/evilution/hooks/registry.rb +60 -0
- data/lib/evilution/hooks.rb +58 -0
- data/lib/evilution/integration/base.rb +4 -0
- data/lib/evilution/integration/rspec.rb +6 -2
- data/lib/evilution/isolation/fork.rb +5 -0
- data/lib/evilution/mcp/session_diff_tool.rb +5 -35
- data/lib/evilution/mutator/base.rb +4 -1
- data/lib/evilution/mutator/operator/collection_return.rb +33 -0
- data/lib/evilution/mutator/operator/defined_check.rb +16 -0
- data/lib/evilution/mutator/operator/index_assignment_removal.rb +18 -0
- data/lib/evilution/mutator/operator/index_to_dig.rb +58 -0
- data/lib/evilution/mutator/operator/index_to_fetch.rb +30 -0
- data/lib/evilution/mutator/operator/keyword_argument.rb +91 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +2 -1
- data/lib/evilution/mutator/operator/multiple_assignment.rb +47 -0
- data/lib/evilution/mutator/operator/pattern_matching_alternative.rb +46 -0
- data/lib/evilution/mutator/operator/pattern_matching_array.rb +97 -0
- data/lib/evilution/mutator/operator/pattern_matching_guard.rb +44 -0
- data/lib/evilution/mutator/operator/regex_capture.rb +43 -0
- data/lib/evilution/mutator/operator/scalar_return.rb +37 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +46 -0
- data/lib/evilution/mutator/operator/superclass_removal.rb +2 -1
- data/lib/evilution/mutator/operator/yield_statement.rb +51 -0
- data/lib/evilution/mutator/registry.rb +17 -3
- data/lib/evilution/parallel/pool.rb +7 -51
- data/lib/evilution/parallel/work_queue.rb +224 -0
- data/lib/evilution/reporter/cli.rb +22 -1
- data/lib/evilution/reporter/html.rb +76 -3
- data/lib/evilution/reporter/json.rb +23 -2
- data/lib/evilution/reporter/suggestion.rb +115 -1
- data/lib/evilution/result/summary.rb +20 -2
- data/lib/evilution/runner.rb +133 -13
- data/lib/evilution/session/diff.rb +85 -0
- data/lib/evilution/session/store.rb +5 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +23 -0
- 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 "
|
|
3
|
+
require_relative "work_queue"
|
|
4
4
|
|
|
5
5
|
class Evilution::Parallel::Pool
|
|
6
|
-
def initialize(size:)
|
|
7
|
-
|
|
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, &
|
|
13
|
-
|
|
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
|
-
|
|
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
|