Class: Henitai::OrphanWatchdog
- Inherits:
-
Object
- Object
- Henitai::OrphanWatchdog
- Defined in:
- lib/henitai/orphan_watchdog.rb
Overview
Makes a forked mutant child exit when its parent dies.
Children setpgid(0, 0) into their own process group, and all parent-side
cleanup (timeout kills, graceful drain, signal traps) only runs while the
parent's event loop is alive. If the parent is SIGKILLed, OOM-killed, or
crashes, its children receive no signal at all: they reparent to init and
keep running, each holding a full Ruby and test-framework image. Runs have
been observed leaving a dozen such orphans behind, several gigabytes in
total.
A child cannot be told to die by a parent that is already gone, so it has to notice by itself. This is a poll: portable, and cheap enough at a multi-second interval that it costs nothing next to running a test suite. PDEATHSIG (Linux) and kqueue NOTE_EXIT (macOS) would be prompter but are platform-specific; they can be layered on later behind the same interface.
Constant Summary collapse
- DEFAULT_INTERVAL =
1.5- ENV_ENABLED =
"HENITAI_CHILD_WATCHDOG"- ENV_INTERVAL =
"HENITAI_CHILD_WATCHDOG_INTERVAL"- ORPHAN_EXIT_CODE =
Exit code used when the watchdog fires. 2 classifies as :compile_error (see ScenarioExecutionResult.status_for), which surfaces in reports and logs. Codes at 3 and above classify as :killed, which would let a false positive silently inflate the mutation score -- so a visibly wrong verdict is preferred to an invisibly wrong one. In the true-positive case the parent is dead and nothing classifies this child at all.
2- PPID =
Captured at load time, for the same reason as ProcessLiveness::KILL: the mutant child runs the host project's own suite, and a spec there stubbing
Process.ppidwould otherwise make this child believe it had been reparented and exit itself. Process.method(:ppid)
Class Method Summary collapse
-
.enabled?(env = ENV) ⇒ Boolean
Opt-OUT, deliberately inverted relative to HENITAI_DEBUG_CHILD's opt-in: a watchdog that defaulted to off would never protect the runs it exists for.
- .poll_interval(env = ENV) ⇒ Object
-
.start(parent_pid:, env: ENV) ⇒ Thread?
Starts the watchdog in a background thread.
Instance Method Summary collapse
-
#initialize(parent_pid:, interval: DEFAULT_INTERVAL, liveness: ProcessLiveness, ppid: PPID, on_orphan: nil, sleeper: nil) ⇒ OrphanWatchdog
constructor
Every collaborator is injectable so the decision logic can be specced without forking a process or waiting on a real clock.
-
#orphaned? ⇒ Boolean
Two arms, because neither alone is sufficient.
-
#run ⇒ Object
Polls until orphaned, then hands over to the orphan handler -- which by default calls exit! and so never returns.
Constructor Details
#initialize(parent_pid:, interval: DEFAULT_INTERVAL, liveness: ProcessLiveness, ppid: PPID, on_orphan: nil, sleeper: nil) ⇒ OrphanWatchdog
Every collaborator is injectable so the decision logic can be specced without forking a process or waiting on a real clock. rubocop:disable Metrics/ParameterLists
64 65 66 67 68 69 70 71 72 |
# File 'lib/henitai/orphan_watchdog.rb', line 64 def initialize(parent_pid:, interval: DEFAULT_INTERVAL, liveness: ProcessLiveness, ppid: PPID, on_orphan: nil, sleeper: nil) @parent_pid = parent_pid @interval = interval @liveness = liveness @ppid = ppid @on_orphan = on_orphan || -> { Kernel.exit!(ORPHAN_EXIT_CODE) } @sleeper = sleeper || ->(seconds) { Kernel.sleep(seconds) } end |
Class Method Details
.enabled?(env = ENV) ⇒ Boolean
Opt-OUT, deliberately inverted relative to HENITAI_DEBUG_CHILD's opt-in: a watchdog that defaulted to off would never protect the runs it exists for. Set HENITAI_CHILD_WATCHDOG=0 to disable.
41 42 43 |
# File 'lib/henitai/orphan_watchdog.rb', line 41 def self.enabled?(env = ENV) env[ENV_ENABLED] != "0" end |
.poll_interval(env = ENV) ⇒ Object
45 46 47 48 |
# File 'lib/henitai/orphan_watchdog.rb', line 45 def self.poll_interval(env = ENV) seconds = Float(env[ENV_INTERVAL], exception: false) seconds&.positive? ? seconds : DEFAULT_INTERVAL end |
.start(parent_pid:, env: ENV) ⇒ Thread?
Starts the watchdog in a background thread. Call in the child, right after fork, with the pid captured in the parent beforehand.
54 55 56 57 58 59 |
# File 'lib/henitai/orphan_watchdog.rb', line 54 def self.start(parent_pid:, env: ENV) return nil unless enabled?(env) watchdog = new(parent_pid:, interval: poll_interval(env)) Thread.new { watchdog.run } end |
Instance Method Details
#orphaned? ⇒ Boolean
Two arms, because neither alone is sufficient. A changed ppid is definitive -- we have been reparented, and no pid reuse can fake that -- but it stays equal while the parent lingers as a zombie, which the liveness probe catches.
79 80 81 |
# File 'lib/henitai/orphan_watchdog.rb', line 79 def orphaned? @ppid.call != @parent_pid || !@liveness.alive?(@parent_pid) end |
#run ⇒ Object
Polls until orphaned, then hands over to the orphan handler -- which by default calls exit! and so never returns. Checks before sleeping, so a child forked from an already-dead parent dies immediately rather than after one interval.
87 88 89 90 91 |
# File 'lib/henitai/orphan_watchdog.rb', line 87 def run @sleeper.call(@interval) until orphaned? @on_orphan.call end |