evilution 0.13.0 → 0.14.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 +8 -8
- data/CHANGELOG.md +17 -0
- data/lib/evilution/ast/parser.rb +69 -68
- data/lib/evilution/ast/source_surgeon.rb +7 -9
- data/lib/evilution/ast.rb +4 -0
- data/lib/evilution/baseline.rb +73 -75
- data/lib/evilution/cache.rb +75 -77
- data/lib/evilution/cli.rb +408 -173
- data/lib/evilution/config.rb +141 -136
- data/lib/evilution/equivalent/detector.rb +25 -27
- data/lib/evilution/equivalent/heuristic/alias_swap.rb +29 -33
- data/lib/evilution/equivalent/heuristic/dead_code.rb +41 -45
- data/lib/evilution/equivalent/heuristic/method_body_nil.rb +11 -15
- data/lib/evilution/equivalent/heuristic/noop_source.rb +5 -9
- data/lib/evilution/equivalent/heuristic.rb +6 -0
- data/lib/evilution/equivalent.rb +4 -0
- data/lib/evilution/git/changed_files.rb +35 -37
- data/lib/evilution/git.rb +4 -0
- data/lib/evilution/integration/base.rb +5 -7
- data/lib/evilution/integration/rspec.rb +114 -116
- data/lib/evilution/integration.rb +4 -0
- data/lib/evilution/isolation/fork.rb +98 -100
- data/lib/evilution/isolation/in_process.rb +59 -61
- data/lib/evilution/isolation.rb +4 -0
- data/lib/evilution/mcp/mutate_tool.rb +172 -143
- data/lib/evilution/mcp/server.rb +12 -11
- data/lib/evilution/mcp/session_diff_tool.rb +89 -0
- data/lib/evilution/mcp/session_list_tool.rb +46 -0
- data/lib/evilution/mcp/session_show_tool.rb +53 -0
- data/lib/evilution/mcp.rb +4 -0
- data/lib/evilution/memory/leak_check.rb +80 -84
- data/lib/evilution/memory.rb +34 -36
- data/lib/evilution/mutation.rb +40 -42
- data/lib/evilution/mutator/base.rb +46 -48
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +32 -36
- data/lib/evilution/mutator/operator/argument_removal.rb +32 -36
- data/lib/evilution/mutator/operator/arithmetic_replacement.rb +26 -30
- data/lib/evilution/mutator/operator/array_literal.rb +18 -22
- data/lib/evilution/mutator/operator/block_removal.rb +16 -20
- data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +38 -42
- data/lib/evilution/mutator/operator/boolean_operator_replacement.rb +41 -45
- data/lib/evilution/mutator/operator/collection_replacement.rb +32 -36
- data/lib/evilution/mutator/operator/comparison_replacement.rb +24 -28
- data/lib/evilution/mutator/operator/compound_assignment.rb +114 -118
- data/lib/evilution/mutator/operator/conditional_branch.rb +25 -29
- data/lib/evilution/mutator/operator/conditional_flip.rb +26 -30
- data/lib/evilution/mutator/operator/conditional_negation.rb +25 -29
- data/lib/evilution/mutator/operator/float_literal.rb +22 -26
- data/lib/evilution/mutator/operator/hash_literal.rb +18 -22
- data/lib/evilution/mutator/operator/integer_literal.rb +2 -0
- data/lib/evilution/mutator/operator/method_body_replacement.rb +12 -16
- data/lib/evilution/mutator/operator/method_call_removal.rb +12 -16
- data/lib/evilution/mutator/operator/negation_insertion.rb +12 -16
- data/lib/evilution/mutator/operator/nil_replacement.rb +13 -17
- data/lib/evilution/mutator/operator/range_replacement.rb +12 -16
- data/lib/evilution/mutator/operator/receiver_replacement.rb +16 -20
- data/lib/evilution/mutator/operator/regexp_mutation.rb +15 -19
- data/lib/evilution/mutator/operator/return_value_removal.rb +12 -16
- data/lib/evilution/mutator/operator/send_mutation.rb +36 -40
- data/lib/evilution/mutator/operator/statement_deletion.rb +13 -17
- data/lib/evilution/mutator/operator/string_literal.rb +18 -22
- data/lib/evilution/mutator/operator/symbol_literal.rb +17 -21
- data/lib/evilution/mutator/operator.rb +6 -0
- data/lib/evilution/mutator/registry.rb +54 -56
- data/lib/evilution/mutator.rb +4 -0
- data/lib/evilution/parallel/pool.rb +56 -58
- data/lib/evilution/parallel.rb +4 -0
- data/lib/evilution/reporter/cli.rb +99 -101
- data/lib/evilution/reporter/html.rb +242 -244
- data/lib/evilution/reporter/json.rb +57 -59
- data/lib/evilution/reporter/suggestion.rb +326 -328
- data/lib/evilution/reporter.rb +4 -0
- data/lib/evilution/result/mutation_result.rb +43 -46
- data/lib/evilution/result/summary.rb +80 -81
- data/lib/evilution/result.rb +4 -0
- data/lib/evilution/runner.rb +334 -323
- data/lib/evilution/session/store.rb +147 -0
- data/lib/evilution/session.rb +4 -0
- data/lib/evilution/spec_resolver.rb +49 -47
- data/lib/evilution/subject.rb +14 -16
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +13 -0
- metadata +19 -2
|
@@ -2,53 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
require "English"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
module Git
|
|
7
|
-
class ChangedFiles
|
|
8
|
-
MAIN_BRANCHES = %w[main master origin/main origin/master].freeze
|
|
9
|
-
SOURCE_PREFIXES = %w[lib/ app/].freeze
|
|
5
|
+
require_relative "../git"
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
diff_output = run_git("diff", "--name-only", "--diff-filter=ACMR", "#{merge_base}..HEAD")
|
|
7
|
+
class Evilution::Git::ChangedFiles
|
|
8
|
+
MAIN_BRANCHES = %w[main master origin/main origin/master].freeze
|
|
9
|
+
SOURCE_PREFIXES = %w[lib/ app/].freeze
|
|
15
10
|
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
def call
|
|
12
|
+
main_branch = detect_main_branch
|
|
13
|
+
merge_base = run_git("merge-base", "HEAD", main_branch)
|
|
14
|
+
diff_output = run_git("diff", "--name-only", "--diff-filter=ACMR", "#{merge_base}..HEAD")
|
|
18
15
|
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
files = diff_output.split("\n").select { |f| ruby_source_file?(f) }
|
|
17
|
+
raise Evilution::Error, "no changed Ruby files found since merge base with #{main_branch}" if files.empty?
|
|
21
18
|
|
|
22
|
-
|
|
19
|
+
files
|
|
20
|
+
end
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
MAIN_BRANCHES.each do |branch|
|
|
26
|
-
return branch if branch_exists?(branch)
|
|
27
|
-
end
|
|
22
|
+
private
|
|
28
23
|
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
def detect_main_branch
|
|
25
|
+
MAIN_BRANCHES.each do |branch|
|
|
26
|
+
return branch if branch_exists?(branch)
|
|
27
|
+
end
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
true
|
|
35
|
-
rescue Error => e
|
|
36
|
-
raise if e.message.include?("not a git repository")
|
|
29
|
+
raise Evilution::Error, "could not detect main branch (tried #{MAIN_BRANCHES.join(", ")})"
|
|
30
|
+
end
|
|
37
31
|
|
|
38
|
-
|
|
39
|
-
|
|
32
|
+
def branch_exists?(name)
|
|
33
|
+
run_git("rev-parse", "--verify", name)
|
|
34
|
+
true
|
|
35
|
+
rescue Evilution::Error => e
|
|
36
|
+
raise if e.message.include?("not a git repository")
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
end
|
|
38
|
+
false
|
|
39
|
+
end
|
|
44
40
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
raise Error, "git command failed: git #{args.join(" ")}: #{output}" unless $CHILD_STATUS.success?
|
|
41
|
+
def ruby_source_file?(path)
|
|
42
|
+
path.end_with?(".rb") && SOURCE_PREFIXES.any? { |prefix| path.start_with?(prefix) }
|
|
43
|
+
end
|
|
49
44
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
def run_git(*args)
|
|
46
|
+
output = `git #{args.join(" ")} 2>&1`.strip
|
|
47
|
+
raise Evilution::Error, "not a git repository" if output.include?("not a git repository")
|
|
48
|
+
raise Evilution::Error, "git command failed: git #{args.join(" ")}: #{output}" unless $CHILD_STATUS.success?
|
|
49
|
+
|
|
50
|
+
output
|
|
53
51
|
end
|
|
54
52
|
end
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
end
|
|
9
|
-
end
|
|
3
|
+
require_relative "../integration"
|
|
4
|
+
|
|
5
|
+
class Evilution::Integration::Base
|
|
6
|
+
def call(mutation)
|
|
7
|
+
raise NotImplementedError, "#{self.class}#call must be implemented"
|
|
10
8
|
end
|
|
11
9
|
end
|
|
@@ -6,122 +6,120 @@ require "tmpdir"
|
|
|
6
6
|
require_relative "base"
|
|
7
7
|
require_relative "../spec_resolver"
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def resolve_require_subpath(file_path)
|
|
77
|
-
absolute = File.expand_path(file_path)
|
|
78
|
-
|
|
79
|
-
$LOAD_PATH.each do |entry|
|
|
80
|
-
dir = File.expand_path(entry)
|
|
81
|
-
prefix = dir.end_with?("/") ? dir : "#{dir}/"
|
|
82
|
-
next unless absolute.start_with?(prefix)
|
|
83
|
-
|
|
84
|
-
return absolute.delete_prefix(prefix)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
nil
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def run_rspec(mutation)
|
|
91
|
-
# When used via the Runner with Isolation::Fork, each mutation is executed
|
|
92
|
-
# in its own forked child process, so RSpec state (loaded example groups,
|
|
93
|
-
# world, configuration) cannot accumulate across mutation runs — the child
|
|
94
|
-
# process exits after each run.
|
|
95
|
-
#
|
|
96
|
-
# This integration can also be invoked directly (e.g. in specs or alternative
|
|
97
|
-
# runners) without fork isolation. RSpec.reset is called here as
|
|
98
|
-
# defense-in-depth to clear RSpec state between mutation runs in those cases.
|
|
99
|
-
::RSpec.reset
|
|
100
|
-
|
|
101
|
-
out = StringIO.new
|
|
102
|
-
err = StringIO.new
|
|
103
|
-
command = "rspec"
|
|
104
|
-
args = build_args(mutation)
|
|
105
|
-
command = "rspec #{args.join(" ")}"
|
|
106
|
-
|
|
107
|
-
status = ::RSpec::Core::Runner.run(args, out, err)
|
|
108
|
-
|
|
109
|
-
{ passed: status.zero?, test_command: command }
|
|
110
|
-
rescue StandardError => e
|
|
111
|
-
{ passed: false, error: e.message, test_command: command }
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def build_args(mutation)
|
|
115
|
-
files = resolve_test_files(mutation)
|
|
116
|
-
["--format", "progress", "--no-color", "--order", "defined", *files]
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def resolve_test_files(mutation)
|
|
120
|
-
return test_files if test_files
|
|
121
|
-
|
|
122
|
-
resolved = SpecResolver.new.call(mutation.file_path)
|
|
123
|
-
resolved ? [resolved] : ["spec"]
|
|
124
|
-
end
|
|
9
|
+
require_relative "../integration"
|
|
10
|
+
|
|
11
|
+
class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
12
|
+
def initialize(test_files: nil)
|
|
13
|
+
@test_files = test_files
|
|
14
|
+
@rspec_loaded = false
|
|
15
|
+
super()
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(mutation)
|
|
19
|
+
@original_content = nil
|
|
20
|
+
@temp_dir = nil
|
|
21
|
+
@lock_file = nil
|
|
22
|
+
ensure_rspec_loaded
|
|
23
|
+
apply_mutation(mutation)
|
|
24
|
+
run_rspec(mutation)
|
|
25
|
+
ensure
|
|
26
|
+
restore_original(mutation)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :test_files
|
|
32
|
+
|
|
33
|
+
def ensure_rspec_loaded
|
|
34
|
+
return if @rspec_loaded
|
|
35
|
+
|
|
36
|
+
require "rspec/core"
|
|
37
|
+
@rspec_loaded = true
|
|
38
|
+
rescue LoadError => e
|
|
39
|
+
raise Evilution::Error, "rspec-core is required but not available: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def apply_mutation(mutation)
|
|
43
|
+
subpath = resolve_require_subpath(mutation.file_path)
|
|
44
|
+
|
|
45
|
+
if subpath
|
|
46
|
+
@temp_dir = Dir.mktmpdir("evilution")
|
|
47
|
+
dest = File.join(@temp_dir, subpath)
|
|
48
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
49
|
+
File.write(dest, mutation.mutated_source)
|
|
50
|
+
$LOAD_PATH.unshift(@temp_dir)
|
|
51
|
+
else
|
|
52
|
+
# Fallback: direct write when file isn't under any $LOAD_PATH entry.
|
|
53
|
+
# Acquire an exclusive lock to prevent concurrent workers from corrupting the file.
|
|
54
|
+
lock_path = File.join(Dir.tmpdir, "evilution-#{File.expand_path(mutation.file_path).hash.abs}.lock")
|
|
55
|
+
@lock_file = File.open(lock_path, File::CREAT | File::RDWR)
|
|
56
|
+
@lock_file.flock(File::LOCK_EX)
|
|
57
|
+
@original_content = File.read(mutation.file_path)
|
|
58
|
+
File.write(mutation.file_path, mutation.mutated_source)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def restore_original(mutation)
|
|
63
|
+
if @temp_dir
|
|
64
|
+
$LOAD_PATH.delete(@temp_dir)
|
|
65
|
+
$LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
|
|
66
|
+
FileUtils.rm_rf(@temp_dir)
|
|
67
|
+
@temp_dir = nil
|
|
68
|
+
elsif @original_content
|
|
69
|
+
File.write(mutation.file_path, @original_content)
|
|
70
|
+
@lock_file&.flock(File::LOCK_UN)
|
|
71
|
+
@lock_file&.close
|
|
72
|
+
@lock_file = nil
|
|
125
73
|
end
|
|
126
74
|
end
|
|
75
|
+
|
|
76
|
+
def resolve_require_subpath(file_path)
|
|
77
|
+
absolute = File.expand_path(file_path)
|
|
78
|
+
|
|
79
|
+
$LOAD_PATH.each do |entry|
|
|
80
|
+
dir = File.expand_path(entry)
|
|
81
|
+
prefix = dir.end_with?("/") ? dir : "#{dir}/"
|
|
82
|
+
next unless absolute.start_with?(prefix)
|
|
83
|
+
|
|
84
|
+
return absolute.delete_prefix(prefix)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def run_rspec(mutation)
|
|
91
|
+
# When used via the Runner with Isolation::Fork, each mutation is executed
|
|
92
|
+
# in its own forked child process, so RSpec state (loaded example groups,
|
|
93
|
+
# world, configuration) cannot accumulate across mutation runs — the child
|
|
94
|
+
# process exits after each run.
|
|
95
|
+
#
|
|
96
|
+
# This integration can also be invoked directly (e.g. in specs or alternative
|
|
97
|
+
# runners) without fork isolation. RSpec.reset is called here as
|
|
98
|
+
# defense-in-depth to clear RSpec state between mutation runs in those cases.
|
|
99
|
+
::RSpec.reset
|
|
100
|
+
|
|
101
|
+
out = StringIO.new
|
|
102
|
+
err = StringIO.new
|
|
103
|
+
command = "rspec"
|
|
104
|
+
args = build_args(mutation)
|
|
105
|
+
command = "rspec #{args.join(" ")}"
|
|
106
|
+
|
|
107
|
+
status = ::RSpec::Core::Runner.run(args, out, err)
|
|
108
|
+
|
|
109
|
+
{ passed: status.zero?, test_command: command }
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
{ passed: false, error: e.message, test_command: command }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build_args(mutation)
|
|
115
|
+
files = resolve_test_files(mutation)
|
|
116
|
+
["--format", "progress", "--no-color", "--order", "defined", *files]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def resolve_test_files(mutation)
|
|
120
|
+
return test_files if test_files
|
|
121
|
+
|
|
122
|
+
resolved = Evilution::SpecResolver.new.call(mutation.file_path)
|
|
123
|
+
resolved ? [resolved] : ["spec"]
|
|
124
|
+
end
|
|
127
125
|
end
|
|
@@ -4,106 +4,104 @@ require "fileutils"
|
|
|
4
4
|
require "tmpdir"
|
|
5
5
|
require_relative "../memory"
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
write_io.close
|
|
28
|
-
result = wait_for_result(pid, read_io, timeout)
|
|
29
|
-
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
30
|
-
|
|
31
|
-
build_mutation_result(mutation, result, duration)
|
|
32
|
-
ensure
|
|
33
|
-
read_io&.close
|
|
34
|
-
write_io&.close
|
|
35
|
-
restore_original_source(mutation)
|
|
36
|
-
FileUtils.rm_rf(sandbox_dir) if sandbox_dir
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
private
|
|
40
|
-
|
|
41
|
-
def restore_original_source(mutation)
|
|
42
|
-
return if File.read(mutation.file_path) == mutation.original_source
|
|
43
|
-
|
|
44
|
-
File.write(mutation.file_path, mutation.original_source)
|
|
45
|
-
rescue StandardError => e
|
|
46
|
-
warn("Warning: failed to restore #{mutation.file_path}: #{e.message}")
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def suppress_child_output
|
|
50
|
-
$stdout.reopen(File::NULL, "w")
|
|
51
|
-
$stderr.reopen(File::NULL, "w")
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def execute_in_child(mutation, test_command)
|
|
55
|
-
result = test_command.call(mutation)
|
|
56
|
-
{ child_rss_kb: Memory.rss_kb }.merge(result)
|
|
57
|
-
rescue StandardError => e
|
|
58
|
-
{ passed: false, error: e.message }
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def wait_for_result(pid, read_io, timeout)
|
|
62
|
-
if read_io.wait_readable(timeout)
|
|
63
|
-
data = read_io.read
|
|
64
|
-
::Process.wait(pid)
|
|
65
|
-
return { timeout: false }.merge(Marshal.load(data)) unless data.empty? # rubocop:disable Security/MarshalLoad
|
|
66
|
-
|
|
67
|
-
::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
68
|
-
{ timeout: false, passed: false, error: "empty result from child" }
|
|
69
|
-
else
|
|
70
|
-
terminate_child(pid)
|
|
71
|
-
{ timeout: true }
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def terminate_child(pid)
|
|
76
|
-
::Process.kill("TERM", pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
77
|
-
_, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
|
|
78
|
-
return if status
|
|
79
|
-
|
|
80
|
-
sleep(GRACE_PERIOD)
|
|
81
|
-
_, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
|
|
82
|
-
return if status
|
|
83
|
-
|
|
84
|
-
::Process.kill("KILL", pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
85
|
-
::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def build_mutation_result(mutation, result, duration)
|
|
89
|
-
status = if result[:timeout]
|
|
90
|
-
:timeout
|
|
91
|
-
elsif result[:error]
|
|
92
|
-
:error
|
|
93
|
-
elsif result[:passed]
|
|
94
|
-
:survived
|
|
95
|
-
else
|
|
96
|
-
:killed
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
Result::MutationResult.new(
|
|
100
|
-
mutation: mutation,
|
|
101
|
-
status: status,
|
|
102
|
-
duration: duration,
|
|
103
|
-
test_command: result[:test_command],
|
|
104
|
-
child_rss_kb: result[:child_rss_kb]
|
|
105
|
-
)
|
|
106
|
-
end
|
|
7
|
+
require_relative "../isolation"
|
|
8
|
+
|
|
9
|
+
class Evilution::Isolation::Fork
|
|
10
|
+
GRACE_PERIOD = 2
|
|
11
|
+
|
|
12
|
+
def call(mutation:, test_command:, timeout:)
|
|
13
|
+
sandbox_dir = Dir.mktmpdir("evilution-run")
|
|
14
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
15
|
+
read_io, write_io = IO.pipe
|
|
16
|
+
|
|
17
|
+
pid = ::Process.fork do
|
|
18
|
+
ENV["TMPDIR"] = sandbox_dir
|
|
19
|
+
read_io.close
|
|
20
|
+
suppress_child_output
|
|
21
|
+
result = execute_in_child(mutation, test_command)
|
|
22
|
+
Marshal.dump(result, write_io)
|
|
23
|
+
write_io.close
|
|
24
|
+
exit!(result[:passed] ? 0 : 1)
|
|
107
25
|
end
|
|
26
|
+
|
|
27
|
+
write_io.close
|
|
28
|
+
result = wait_for_result(pid, read_io, timeout)
|
|
29
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
30
|
+
|
|
31
|
+
build_mutation_result(mutation, result, duration)
|
|
32
|
+
ensure
|
|
33
|
+
read_io&.close
|
|
34
|
+
write_io&.close
|
|
35
|
+
restore_original_source(mutation)
|
|
36
|
+
FileUtils.rm_rf(sandbox_dir) if sandbox_dir
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def restore_original_source(mutation)
|
|
42
|
+
return if File.read(mutation.file_path) == mutation.original_source
|
|
43
|
+
|
|
44
|
+
File.write(mutation.file_path, mutation.original_source)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
warn("Warning: failed to restore #{mutation.file_path}: #{e.message}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def suppress_child_output
|
|
50
|
+
$stdout.reopen(File::NULL, "w")
|
|
51
|
+
$stderr.reopen(File::NULL, "w")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def execute_in_child(mutation, test_command)
|
|
55
|
+
result = test_command.call(mutation)
|
|
56
|
+
{ child_rss_kb: Evilution::Memory.rss_kb }.merge(result)
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
{ passed: false, error: e.message }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def wait_for_result(pid, read_io, timeout)
|
|
62
|
+
if read_io.wait_readable(timeout)
|
|
63
|
+
data = read_io.read
|
|
64
|
+
::Process.wait(pid)
|
|
65
|
+
return { timeout: false }.merge(Marshal.load(data)) unless data.empty? # rubocop:disable Security/MarshalLoad
|
|
66
|
+
|
|
67
|
+
::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
68
|
+
{ timeout: false, passed: false, error: "empty result from child" }
|
|
69
|
+
else
|
|
70
|
+
terminate_child(pid)
|
|
71
|
+
{ timeout: true }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def terminate_child(pid)
|
|
76
|
+
::Process.kill("TERM", pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
77
|
+
_, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
|
|
78
|
+
return if status
|
|
79
|
+
|
|
80
|
+
sleep(GRACE_PERIOD)
|
|
81
|
+
_, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
|
|
82
|
+
return if status
|
|
83
|
+
|
|
84
|
+
::Process.kill("KILL", pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
85
|
+
::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_mutation_result(mutation, result, duration)
|
|
89
|
+
status = if result[:timeout]
|
|
90
|
+
:timeout
|
|
91
|
+
elsif result[:error]
|
|
92
|
+
:error
|
|
93
|
+
elsif result[:passed]
|
|
94
|
+
:survived
|
|
95
|
+
else
|
|
96
|
+
:killed
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
Evilution::Result::MutationResult.new(
|
|
100
|
+
mutation: mutation,
|
|
101
|
+
status: status,
|
|
102
|
+
duration: duration,
|
|
103
|
+
test_command: result[:test_command],
|
|
104
|
+
child_rss_kb: result[:child_rss_kb]
|
|
105
|
+
)
|
|
108
106
|
end
|
|
109
107
|
end
|