Class: Mutineer::Isolation
- Inherits:
-
Object
- Object
- Mutineer::Isolation
- Defined in:
- lib/mutineer/isolation.rb
Overview
Fork-based isolation for running one mutant. The block runs in a child process; the parent enforces a wall-clock timeout and decodes the child's exit status into a Result.
Exit-status contract (the block's return value, or an explicit exit, is the child's status): 0 => survived, 1 => killed, 2 => error. Timeout is detected by the parent's monitor flag, not by status.signaled? (which is true for ANY signal death, e.g. SIGSEGV — it cannot tell our SIGKILL apart from the OS's).
mutineer: the reload strategy this enables (whole-file load) re-executes the
entire file — any top-level code runs again. Acceptable for POROs; document
if users hit issues with initializers/callbacks. Alternative: the redefine
strategy (surgical single-method redefinition).
Constant Summary collapse
- DEFAULT_TIMEOUT =
seconds
10
Class Method Summary collapse
-
.apply_surgical(mutation, subject, source) ⇒ Object
Redefine strategy: extract just the enclosing DefNode, apply the mutation to that snippet, wrap it in its real namespace, and
loadonly that one method back into the running process. -
.apply_whole_file(mutated, source_file) ⇒ Object
Strategy 7a (default): write the whole mutated file and
loadit, which reopens its classes and redefines every method in place. - .decode(status) ⇒ Object
- .method_visibility(mod, name) ⇒ Object
-
.nesting_keywords(namespace) ⇒ Object
Resolve each namespace ELEMENT to its live Module and pick the correct keyword (reopening a class with
module— or vice versa — raises TypeError), so the textual wrapper matches the real definitions. -
.run(timeout: DEFAULT_TIMEOUT) ⇒ Object
Runs the block in a forked child.
Class Method Details
.apply_surgical(mutation, subject, source) ⇒ Object
Redefine strategy: extract just the enclosing DefNode, apply the mutation to
that snippet, wrap it in its real namespace, and load only that one method
back into the running process. No file-level side effects re-run. Child-only.
The snippet keeps its own def self.x for singletons, so the namespace
wrapper redefines instance and singleton methods correctly without any
special-casing.
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/mutineer/isolation.rb', line 88 def self.apply_surgical(mutation, subject, source) loc = subject.def_node.location def_start = loc.start_offset # Byte slicing (C1): Prism offsets are byte offsets. snippet = source.byteslice(def_start...loc.end_offset) rel_s = mutation.start_offset - def_start rel_e = mutation.end_offset - def_start mutated_def = snippet.byteslice(0...rel_s) + mutation.replacement + snippet.byteslice(rel_e..) # Rebuild the FULL namespace nesting textually so unqualified enclosing- # namespace constants resolve exactly as the reload strategy would. A bare # redefinition on the owner would collapse Module.nesting to [owner] and # raise NameError on such constants (C2 scope-collapse). keywords = nesting_keywords(subject.namespace) prefix = keywords.map { |kw, name| "#{kw} #{name}" }.join("\n") prefix += "\n" unless prefix.empty? wrapped = "#{prefix}#{mutated_def}#{"\nend" * keywords.size}" # A snippet that fails to reparse must NOT silently fall through to running # the ORIGINAL method (C2 false-survived). Raise -> the fork block aborts # before any test runs -> Result.error, never a bogus `survived`. raise "surgical snippet failed to reparse" if Parser.parse_string(wrapped).errors.any? # Preserve original visibility — class/module bodies define methods public, # but 7a's `load` would re-apply the file's private/protected (C2). owner = subject.namespace.empty? ? Object : Object.const_get(subject.namespace.join("::")) target = subject.singleton ? owner.singleton_class : owner vis = method_visibility(target, subject.name) # Write the wrapped snippet to a tempfile and `load` it: `load` runs it at # top level, so the textual class/module wrappers rebuild Module.nesting # identically, with no dynamic string execution for scanners to flag. The # input is the project's OWN source (the enclosing method, textually # mutated), loaded only in this forked child. Tempfile.create(["mutineer_surgical", ".rb"]) do |f| f.write(wrapped) f.flush load f.path end target.send(vis, subject.name) if vis && vis != :public end |
.apply_whole_file(mutated, source_file) ⇒ Object
Strategy 7a (default): write the whole mutated file and load it, which
reopens its classes and redefines every method in place. Re-runs file-level
side effects. Child-only — mutates the loaded program.
The tempfile is created in the ORIGINAL file's directory, not the system
temp dir, so any require_relative in the mutated source resolves against
its real neighbours (e.g. a mutator's require_relative "base"). Writing it
elsewhere makes those requires resolve to the temp dir and raise LoadError.
73 74 75 76 77 78 79 |
# File 'lib/mutineer/isolation.rb', line 73 def self.apply_whole_file(mutated, source_file) Tempfile.create(["mutineer_mutant", ".rb"], File.dirname(source_file)) do |f| f.write(mutated) f.flush load f.path end end |
.decode(status) ⇒ Object
156 157 158 159 160 161 162 163 |
# File 'lib/mutineer/isolation.rb', line 156 def self.decode(status) case status.exitstatus when 0 then Result.survived when 1 then Result.killed when 2 then Result.error("child exited with status 2") else Result.error("unexpected exit status: #{status.exitstatus.inspect}") end end |
.method_visibility(mod, name) ⇒ Object
148 149 150 151 152 153 154 |
# File 'lib/mutineer/isolation.rb', line 148 def self.method_visibility(mod, name) return :private if mod.private_method_defined?(name) return :protected if mod.protected_method_defined?(name) return :public if mod.public_method_defined?(name) nil end |
.nesting_keywords(namespace) ⇒ Object
Resolve each namespace ELEMENT to its live Module and pick the correct
keyword (reopening a class with module — or vice versa — raises
TypeError), so the textual wrapper matches the real definitions.
#5: a compact element like "Foo::Bar" stays a SINGLE wrapper class Foo::Bar
(nesting [Foo::Bar]), matching how a whole-file load (reload) sees it.
Splitting it into module Foo; class Bar gave nesting [Foo::Bar, Foo], so an
unqualified constant defined only in Foo would resolve under redefine but not
reload — a strategy disagreement.
140 141 142 143 144 145 146 |
# File 'lib/mutineer/isolation.rb', line 140 def self.nesting_keywords(namespace) mod = Object namespace.map do |name| mod = mod.const_get(name) # const_get resolves a compact "Foo::Bar" too [mod.is_a?(Class) ? "class" : "module", name] end end |
.run(timeout: DEFAULT_TIMEOUT) ⇒ Object
Runs the block in a forked child. The block's return value (an Integer
exit code) or any explicit exit is honoured; an unhandled exception
becomes exit 2 with the cause written to STDERR.
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 |
# File 'lib/mutineer/isolation.rb', line 27 def self.run(timeout: DEFAULT_TIMEOUT) pid = fork do code = 0 begin result = yield code = result.is_a?(Integer) ? result : 0 rescue SystemExit => e code = e.status rescue Exception => e # rubocop:disable Lint/RescueException warn "[mutineer-child] #{e.class}: #{e.}" code = 2 end $stderr.flush # exit! skips at_exit handlers — critical, since a child forked from # inside our own Minitest suite would otherwise re-run the parent's # at_exit autorun hook on the way out. exit!(code) end # Single-threaded deadline poll (R2): we are the ONLY caller of waitpid on # this pid, so we never reap-then-kill. We SIGKILL only after WNOHANG shows # the child is still alive past the deadline — so the kill can never hit a # reaped/recycled pid. Timeout is a parent-side fact (deadline reached), not # status.signaled? (which is true for ANY signal death, e.g. SIGSEGV). deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout loop do reaped, status = Process.waitpid2(pid, Process::WNOHANG) return decode(status) if reaped if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier Process.waitpid(pid) rescue nil # rubocop:disable Style/RescueModifier return Result.timeout end sleep 0.005 end end |