mutineer 1.5.0 → 1.7.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/CHANGELOG.md +473 -0
- data/README.md +352 -26
- data/lib/mutineer/child_coverage.rb +53 -0
- data/lib/mutineer/cli.rb +184 -24
- data/lib/mutineer/concern_blocks.rb +80 -0
- data/lib/mutineer/config.rb +148 -8
- data/lib/mutineer/coverage_map.rb +541 -92
- data/lib/mutineer/daemon_backend.rb +227 -86
- data/lib/mutineer/daemon_client.rb +72 -9
- data/lib/mutineer/daemon_server.rb +229 -51
- data/lib/mutineer/external_backend.rb +12 -1
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/init.rb +124 -0
- data/lib/mutineer/isolation.rb +15 -2
- data/lib/mutineer/job_plan.rb +458 -0
- data/lib/mutineer/migrate.rb +705 -0
- data/lib/mutineer/minitest_integration.rb +48 -1
- data/lib/mutineer/mutator_registry.rb +6 -2
- data/lib/mutineer/mutators/base.rb +14 -7
- data/lib/mutineer/mutators/call_to_receiver.rb +135 -0
- data/lib/mutineer/mutators/chain_link.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +100 -0
- data/lib/mutineer/project.rb +411 -21
- data/lib/mutineer/rails_run_env.rb +73 -0
- data/lib/mutineer/rails_worker_db.rb +1477 -48
- data/lib/mutineer/redaction.rb +98 -0
- data/lib/mutineer/reporter.rb +83 -12
- data/lib/mutineer/result.rb +49 -8
- data/lib/mutineer/runner.rb +66 -290
- data/lib/mutineer/statement_lines.rb +54 -7
- data/lib/mutineer/subject.rb +24 -2
- data/lib/mutineer/test_runners/rspec.rb +54 -3
- data/lib/mutineer/test_runners.rb +3 -2
- data/lib/mutineer/triage.rb +111 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +5 -0
- metadata +11 -1
|
@@ -62,7 +62,12 @@ module Mutineer
|
|
|
62
62
|
# Drop runnables inherited from the parent suite (this is the child's
|
|
63
63
|
# private copy — the parent is unaffected) so only the target test runs.
|
|
64
64
|
Minitest::Runnable.reset
|
|
65
|
-
|
|
65
|
+
# Each test class's file position: the file that first defined it.
|
|
66
|
+
rank = {}
|
|
67
|
+
Array(test_files).each_with_index do |f, i|
|
|
68
|
+
load f
|
|
69
|
+
Minitest::Runnable.runnables.each { |klass| rank[klass] ||= i }
|
|
70
|
+
end
|
|
66
71
|
|
|
67
72
|
armed =
|
|
68
73
|
if record_to
|
|
@@ -73,6 +78,7 @@ module Mutineer
|
|
|
73
78
|
# Pin the seed only when a hook is armed; an unknown Minitest shape gets
|
|
74
79
|
# the normal full, randomly ordered run.
|
|
75
80
|
args = armed && !ENV["SEED"] ? ["--seed", STOP_AT_FIRST_FAILURE_SEED.to_s] : []
|
|
81
|
+
keep_file_order(rank) if armed
|
|
76
82
|
# No silencing here: the fork boundary that calls this method has already
|
|
77
83
|
# pointed stdout at File::NULL (see ChildStdout).
|
|
78
84
|
passed = Minitest.run(args)
|
|
@@ -86,5 +92,46 @@ module Mutineer
|
|
|
86
92
|
StopAtFirstFailure.disarm!
|
|
87
93
|
KillRecorder.disarm!
|
|
88
94
|
end
|
|
95
|
+
|
|
96
|
+
# Runs the test classes in the order of their files (#203). Minitest
|
|
97
|
+
# shuffles the classes with the seed, then runs the serial classes before
|
|
98
|
+
# the parallel (`parallelize_me!`) ones. A stable sort by file position
|
|
99
|
+
# after that shuffle puts the files in the given order and keeps the
|
|
100
|
+
# seeded order within one file. The serial-then-parallel split is
|
|
101
|
+
# unchanged. Only the child's own class list gets {FileOrder}.
|
|
102
|
+
#
|
|
103
|
+
# @api private
|
|
104
|
+
# @param rank [Hash{Class => Integer}] each class's file position.
|
|
105
|
+
# @return [void]
|
|
106
|
+
def self.keep_file_order(rank)
|
|
107
|
+
FileOrder.rank = rank
|
|
108
|
+
::Minitest::Runnable.runnables.extend(FileOrder)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Extends the list of test classes: its `shuffle` keeps the file order.
|
|
112
|
+
module FileOrder
|
|
113
|
+
class << self
|
|
114
|
+
# Each test class's file position.
|
|
115
|
+
#
|
|
116
|
+
# @return [Hash{Class => Integer}, nil]
|
|
117
|
+
attr_accessor :rank
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The seeded shuffle, then a stable sort by file position.
|
|
121
|
+
#
|
|
122
|
+
# @return [Array<Class>]
|
|
123
|
+
def shuffle(...)
|
|
124
|
+
rank = FileOrder.rank || {}
|
|
125
|
+
super.sort_by.with_index { |klass, i| [rank.fetch(klass, rank.size), i] }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Minitest 5.15 and older drop empty classes before the shuffle, so the
|
|
129
|
+
# filtered list keeps the file order too.
|
|
130
|
+
#
|
|
131
|
+
# @return [Array<Class>]
|
|
132
|
+
def reject(...)
|
|
133
|
+
super.extend(FileOrder)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
89
136
|
end
|
|
90
137
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "config"
|
|
3
4
|
require_relative "mutators/arithmetic"
|
|
4
5
|
require_relative "mutators/comparison"
|
|
5
6
|
require_relative "mutators/boolean_connector"
|
|
@@ -15,6 +16,7 @@ require_relative "mutators/safe_navigation"
|
|
|
15
16
|
require_relative "mutators/range_literal"
|
|
16
17
|
require_relative "mutators/negation_removal"
|
|
17
18
|
require_relative "mutators/chain_link"
|
|
19
|
+
require_relative "mutators/call_to_receiver"
|
|
18
20
|
require_relative "mutators/operand_removal"
|
|
19
21
|
require_relative "mutators/array_literal"
|
|
20
22
|
require_relative "mutators/condition_forcing"
|
|
@@ -46,6 +48,7 @@ module Mutineer
|
|
|
46
48
|
"range" => Mutators::RangeLiteral,
|
|
47
49
|
"negation_removal" => Mutators::NegationRemoval,
|
|
48
50
|
"chain_link" => Mutators::ChainLink,
|
|
51
|
+
"call_to_receiver" => Mutators::CallToReceiver,
|
|
49
52
|
"operand_removal" => Mutators::OperandRemoval,
|
|
50
53
|
"array_literal" => Mutators::ArrayLiteral,
|
|
51
54
|
"condition_true" => Mutators::ConditionTrue,
|
|
@@ -58,7 +61,7 @@ module Mutineer
|
|
|
58
61
|
# Tier-2 operators that remain opt-in.
|
|
59
62
|
TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
|
|
60
63
|
safe_navigation range negation_removal
|
|
61
|
-
chain_link operand_removal array_literal
|
|
64
|
+
chain_link call_to_receiver operand_removal array_literal
|
|
62
65
|
condition_true condition_false operator_assignment].freeze
|
|
63
66
|
|
|
64
67
|
# Short human-readable descriptions for each operator.
|
|
@@ -78,6 +81,7 @@ module Mutineer
|
|
|
78
81
|
"range" => ".. <-> ...",
|
|
79
82
|
"negation_removal" => "!x, not x -> x",
|
|
80
83
|
"chain_link" => "drop one call from a chain: a.b.c -> a.c",
|
|
84
|
+
"call_to_receiver" => "drop the last call of a chain: foo.bar -> foo",
|
|
81
85
|
"operand_removal" => "a && b -> a, b",
|
|
82
86
|
"array_literal" => "[a, b] -> []",
|
|
83
87
|
"condition_true" => "if/elsif/unless/ternary/modifier/case-in guard condition -> true",
|
|
@@ -91,7 +95,7 @@ module Mutineer
|
|
|
91
95
|
# @return [Array<Class>] mutator classes in the requested order.
|
|
92
96
|
# @raise [ArgumentError] when a name is unknown.
|
|
93
97
|
def self.resolve(names = DEFAULT_NAMES)
|
|
94
|
-
names.map { |n| ALL.fetch(n) { raise ArgumentError, "Unknown operator: #{n.inspect}" } }
|
|
98
|
+
names.map { |n| ALL.fetch(n) { raise ArgumentError, "Unknown operator: #{n.inspect}#{Mutineer.did_you_mean(n, ALL.keys)}" } }
|
|
95
99
|
end
|
|
96
100
|
|
|
97
101
|
# Returns whether the operator is part of the default Tier-1 set.
|
|
@@ -52,24 +52,27 @@ module Mutineer
|
|
|
52
52
|
|
|
53
53
|
private
|
|
54
54
|
|
|
55
|
-
# Drops a mutation that
|
|
55
|
+
# Drops a mutation that splits a heredoc opener from its body.
|
|
56
56
|
#
|
|
57
57
|
# The body sits past the opener's node. Replacing the opener with text
|
|
58
58
|
# that does not contain it turns the body into Ruby, which parses and
|
|
59
59
|
# then raises. A replacement that still contains the opener keeps the
|
|
60
|
-
# body attached, so that mutation stays.
|
|
60
|
+
# body attached, so that mutation stays. The reverse also breaks: when
|
|
61
|
+
# the replaced range holds the body but the replacement keeps only the
|
|
62
|
+
# opener, the opener has no body and the source does not parse. A
|
|
63
|
+
# replacement that copies the opener and the body together stays.
|
|
61
64
|
#
|
|
62
65
|
# @return [void]
|
|
63
66
|
def drop_dangling_heredocs
|
|
64
67
|
@mutations.reject! { |mutation| dangling_heredoc?(mutation) }
|
|
65
68
|
end
|
|
66
69
|
|
|
67
|
-
# Returns whether the mutation
|
|
68
|
-
#
|
|
70
|
+
# Returns whether the mutation replaces a heredoc opener and keeps the
|
|
71
|
+
# opener without its body, or the body without its opener.
|
|
69
72
|
#
|
|
70
73
|
# @param mutation [Mutineer::Mutation] candidate edit.
|
|
71
74
|
# @param node [Prism::Node, nil] subtree to search. Defaults to the body.
|
|
72
|
-
# @return [Boolean] true when the edit would
|
|
75
|
+
# @return [Boolean] true when the edit would split a heredoc from its body.
|
|
73
76
|
def dangling_heredoc?(mutation, node = @body)
|
|
74
77
|
return false if node.nil? || !node.is_a?(Prism::Node)
|
|
75
78
|
|
|
@@ -78,9 +81,13 @@ module Mutineer
|
|
|
78
81
|
if opening && opening.start_offset >= mutation.start_offset && opening.start_offset < mutation.end_offset
|
|
79
82
|
closing = node.respond_to?(:closing_loc) ? node.closing_loc : nil
|
|
80
83
|
hangs = closing.nil? || closing.end_offset > mutation.end_offset
|
|
84
|
+
opener = @source.byteslice(opening.start_offset, opening.end_offset - opening.start_offset)
|
|
85
|
+
kept = !opener.empty? && mutation.replacement.include?(opener)
|
|
81
86
|
if hangs
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
return true unless kept
|
|
88
|
+
elsif kept
|
|
89
|
+
whole = @source.byteslice(opening.start_offset, closing.end_offset - opening.start_offset)
|
|
90
|
+
return true unless mutation.replacement.include?(whole)
|
|
84
91
|
end
|
|
85
92
|
end
|
|
86
93
|
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "chain_link"
|
|
5
|
+
|
|
6
|
+
module Mutineer
|
|
7
|
+
module Mutators
|
|
8
|
+
# Call-to-receiver mutator (Tier-2).
|
|
9
|
+
#
|
|
10
|
+
# Replaces the last call of a chain with its receiver, with the arguments
|
|
11
|
+
# and the block of that call: `user.full_name` becomes `user`, and
|
|
12
|
+
# `ids.uniq.sort` becomes `ids.uniq`. A chain begins at a receiver that is
|
|
13
|
+
# not a dotted call, as in {ChainLink}. A call in an argument or a block
|
|
14
|
+
# starts a chain of its own.
|
|
15
|
+
#
|
|
16
|
+
# The mutant survives when no test checks what the last call does: a strip,
|
|
17
|
+
# a uniq, a compact or a lookup the tests never see.
|
|
18
|
+
#
|
|
19
|
+
# The operator does not overlap with {ChainLink}. `chain_link` drops a link
|
|
20
|
+
# in the middle of a chain, and this operator drops the last call. A call
|
|
21
|
+
# that is the receiver of a dotted call, or of a call write or a call
|
|
22
|
+
# target, is a link, so it gets no mutant here. The two operators give the
|
|
23
|
+
# same source only when the last call repeats the link before it, as in
|
|
24
|
+
# `node.name.name`.
|
|
25
|
+
#
|
|
26
|
+
# Calls in {ChainLink::SKIPPED} are never replaced. On the last call,
|
|
27
|
+
# `x.to_s` is the same as `x` for the same reason as on a link. `Foo.new`
|
|
28
|
+
# becomes the class, and almost any use of the class raises.
|
|
29
|
+
#
|
|
30
|
+
# A call is not replaced when its name is an operator (`a.+(1)`), or a
|
|
31
|
+
# setter (`a.b = 1`). A call with no dot (`a + b`, `a[0]`) is not replaced.
|
|
32
|
+
class CallToReceiver < Base
|
|
33
|
+
# Matches the name of a call written as a word, not as an operator.
|
|
34
|
+
WORD_NAME = /\A[[:alpha:]_]/
|
|
35
|
+
|
|
36
|
+
# Resets the per-subject record of calls that are links.
|
|
37
|
+
#
|
|
38
|
+
# @param subject [Mutineer::Subject] subject whose body is visited.
|
|
39
|
+
# @param source [String] full source text for byte-based slicing.
|
|
40
|
+
# @return [Array<Mutineer::Mutation>] collected mutations.
|
|
41
|
+
def mutations_for(subject, source)
|
|
42
|
+
@links = {}.compare_by_identity
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Visits a call. A dotted call that is not a link is the last call of its
|
|
47
|
+
# chain.
|
|
48
|
+
#
|
|
49
|
+
# @param node [Prism::CallNode] call node to inspect.
|
|
50
|
+
# @return [void]
|
|
51
|
+
def visit_call_node(node)
|
|
52
|
+
mark(node)
|
|
53
|
+
replace(node) if dotted?(node) && !@links.key?(node)
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Visits `a.b.c += 1`. Its receiver is a link.
|
|
58
|
+
#
|
|
59
|
+
# @param node [Prism::CallOperatorWriteNode] node to inspect.
|
|
60
|
+
# @return [void]
|
|
61
|
+
def visit_call_operator_write_node(node)
|
|
62
|
+
mark(node)
|
|
63
|
+
super
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Visits `a.b.c ||= 1`. Its receiver is a link.
|
|
67
|
+
#
|
|
68
|
+
# @param node [Prism::CallOrWriteNode] node to inspect.
|
|
69
|
+
# @return [void]
|
|
70
|
+
def visit_call_or_write_node(node)
|
|
71
|
+
mark(node)
|
|
72
|
+
super
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Visits `a.b.c &&= 1`. Its receiver is a link.
|
|
76
|
+
#
|
|
77
|
+
# @param node [Prism::CallAndWriteNode] node to inspect.
|
|
78
|
+
# @return [void]
|
|
79
|
+
def visit_call_and_write_node(node)
|
|
80
|
+
mark(node)
|
|
81
|
+
super
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Visits a call used as an assignment target, as in `a.b.c, d = 1, 2`.
|
|
85
|
+
# Its receiver is a link.
|
|
86
|
+
#
|
|
87
|
+
# @param node [Prism::CallTargetNode] node to inspect.
|
|
88
|
+
# @return [void]
|
|
89
|
+
def visit_call_target_node(node)
|
|
90
|
+
mark(node)
|
|
91
|
+
super
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Records the receiver of a node as a link when the node is a dotted call
|
|
97
|
+
# or a call write or target. The receiver of a call with no dot is not a
|
|
98
|
+
# link. It is the last call of a chain of its own.
|
|
99
|
+
#
|
|
100
|
+
# @param node [Prism::Node] node that responds to `receiver`.
|
|
101
|
+
# @return [void]
|
|
102
|
+
def mark(node)
|
|
103
|
+
@links[node.receiver] = true if dotted?(node) || !node.is_a?(Prism::CallNode)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Emits the replacement of a call by its receiver, unless the name is an
|
|
107
|
+
# operator, a setter, or in {ChainLink::SKIPPED}.
|
|
108
|
+
#
|
|
109
|
+
# @param node [Prism::CallNode] the dotted call to replace.
|
|
110
|
+
# @return [void]
|
|
111
|
+
def replace(node)
|
|
112
|
+
name = node.name.to_s
|
|
113
|
+
return unless name.match?(WORD_NAME)
|
|
114
|
+
return if name.end_with?("=")
|
|
115
|
+
return if ChainLink::SKIPPED.include?(node.name)
|
|
116
|
+
|
|
117
|
+
receiver = node.receiver.location
|
|
118
|
+
@mutations << Mutation.new(
|
|
119
|
+
start_offset: node.location.start_offset,
|
|
120
|
+
end_offset: node.location.end_offset,
|
|
121
|
+
replacement: @source.byteslice(receiver.start_offset, receiver.end_offset - receiver.start_offset),
|
|
122
|
+
operator: :call_to_receiver
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Returns whether a node is a call through `.`, `&.` or `::`.
|
|
127
|
+
#
|
|
128
|
+
# @param node [Prism::Node, nil] node to inspect.
|
|
129
|
+
# @return [Boolean] true for a call node with a call operator.
|
|
130
|
+
def dotted?(node)
|
|
131
|
+
node.is_a?(Prism::CallNode) && !node.call_operator_loc.nil?
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -25,7 +25,7 @@ module Mutineer
|
|
|
25
25
|
# the tests.
|
|
26
26
|
class ChainLink < Base
|
|
27
27
|
# Method names whose link is never dropped: core conversions and copies,
|
|
28
|
-
# plus `new`.
|
|
28
|
+
# plus `new`. {CallToReceiver} uses the same list for the last call.
|
|
29
29
|
SKIPPED = %i[
|
|
30
30
|
to_s to_str to_sym to_i to_int to_f to_r to_c to_a to_ary to_h to_hash to_proc to_set
|
|
31
31
|
dup clone freeze itself
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# Ends a forked child's process group once the process that forked it is
|
|
5
|
+
# gone (#101). A mutant or capture child leads its own process group, so a
|
|
6
|
+
# parent that dies, or is killed by a client that gave up on it, does not
|
|
7
|
+
# take the child's tests with it; without this, a hung test would run on and
|
|
8
|
+
# keep its worker database open. Needs no other part of Mutineer, so the
|
|
9
|
+
# app-side daemon can load it.
|
|
10
|
+
module OrphanGuard
|
|
11
|
+
# Seconds between checks. A forked child gets no signal on parent death.
|
|
12
|
+
POLL = 0.5
|
|
13
|
+
# `Tempfile.create([".mutineer_daemon", ".rb"])` names the file
|
|
14
|
+
# `.mutineer_daemon<YYYYMMDD>-<pid>-<rand>.rb` (without the dot before #253).
|
|
15
|
+
# The pid is the child that is loading the mutant.
|
|
16
|
+
DAEMON_TEMP_PID = /\A\.?mutineer_daemon\d{8}-(\d+)-/
|
|
17
|
+
|
|
18
|
+
# Starts the watchdog in the current (child) process.
|
|
19
|
+
#
|
|
20
|
+
# @param parent [Integer] the parent's pid, read in the parent before
|
|
21
|
+
# `fork`, so a parent that dies before this call is still noticed.
|
|
22
|
+
# @return [Thread]
|
|
23
|
+
def self.start(parent)
|
|
24
|
+
Thread.new do
|
|
25
|
+
sleep(POLL) while Process.ppid == parent
|
|
26
|
+
# Group 0 only when this child leads its own group: a failed setpgid
|
|
27
|
+
# leaves it in the parent's group, which must not be killed.
|
|
28
|
+
Process.kill(:KILL, Process.getpgrp == Process.pid ? 0 : Process.pid)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# True when a daemon mutant file still belongs to a live process. A boot
|
|
33
|
+
# sweep must leave it alone. The database lock is taken later, so the
|
|
34
|
+
# sweep is the step that used to delete another run's file.
|
|
35
|
+
#
|
|
36
|
+
# The creating pid is in the file name. A process that still holds the
|
|
37
|
+
# file lock counts too, even when that pid is already gone. A dead pid
|
|
38
|
+
# and a free lock is an orphan a killed child left behind.
|
|
39
|
+
#
|
|
40
|
+
# @param path [String] absolute path of a `.mutineer_daemon*.rb` file.
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def self.mutant_file_in_use?(path)
|
|
43
|
+
creator_alive?(path) || locked_by_another?(path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when the pid embedded in a daemon mutant file name is still running.
|
|
47
|
+
# Pid 0 is this process group, so it is never treated as an owner.
|
|
48
|
+
#
|
|
49
|
+
# @param path [String]
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def self.creator_alive?(path)
|
|
52
|
+
pid_text = File.basename(path)[DAEMON_TEMP_PID, 1]
|
|
53
|
+
return false unless pid_text
|
|
54
|
+
|
|
55
|
+
pid = Integer(pid_text, 10)
|
|
56
|
+
return false if pid <= 0
|
|
57
|
+
|
|
58
|
+
Process.kill(0, pid)
|
|
59
|
+
!zombie?(pid)
|
|
60
|
+
rescue Errno::ESRCH, Errno::EINVAL, RangeError
|
|
61
|
+
false
|
|
62
|
+
rescue SystemCallError
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# True when `pid` has exited but nobody reaped it. A container whose pid 1
|
|
67
|
+
# does not reap orphans keeps a dead mutant child as a zombie for good, and
|
|
68
|
+
# `Process.kill(0)` still finds it (#250). Linux only: elsewhere there is no
|
|
69
|
+
# `/proc`, so a zombie still counts as running.
|
|
70
|
+
#
|
|
71
|
+
# @param pid [Integer]
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def self.zombie?(pid)
|
|
74
|
+
stat = File.read("/proc/#{pid}/stat")
|
|
75
|
+
# The name before the state is in parentheses and may hold ") " itself.
|
|
76
|
+
# An empty read (the process exited mid-read) has no ")": not a zombie.
|
|
77
|
+
close = stat.rindex(")")
|
|
78
|
+
!close.nil? && stat[close + 2] == "Z"
|
|
79
|
+
rescue SystemCallError
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# True when another process holds an exclusive lock on `path`. A lock the
|
|
84
|
+
# kernel rejects as unsupported is not ownership: the pid check still applies.
|
|
85
|
+
# A file we cannot inspect is left in place.
|
|
86
|
+
#
|
|
87
|
+
# @param path [String]
|
|
88
|
+
# @return [Boolean]
|
|
89
|
+
def self.locked_by_another?(path)
|
|
90
|
+
File.open(path, File::RDONLY) do |file|
|
|
91
|
+
file.flock(File::LOCK_EX | File::LOCK_NB) == false
|
|
92
|
+
end
|
|
93
|
+
rescue Errno::ENOENT, Errno::ENOTSUP, Errno::EOPNOTSUPP
|
|
94
|
+
false
|
|
95
|
+
rescue SystemCallError
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
private_class_method :creator_alive?, :zombie?, :locked_by_another?
|
|
99
|
+
end
|
|
100
|
+
end
|