Module: Lich::Common::ShutdownWatchdog
- Defined in:
- documented/common/shutdown_watchdog.rb
Overview
Guarantees that a shutting-down Lich process actually terminates.
The teardown sequence runs several steps that can block indefinitely --
notably inline +before_dying+/+at_exit+ script hooks, Vars.save,
Game.close (socket linger), database close, and lifecycle unregister IO.
None of them are individually time-bounded. If one hangs, the process
never reaches exit and continues to hold its OS resources (open sockets,
advisory locks) until it is killed by hand.
This watchdog is armed at the start of teardown and disarmed once teardown completes. If the deadline elapses first, it dumps every thread's backtrace (so the debug log records what was stuck) and then forces the process to exit, letting the OS reclaim all resources.
It is intentionally dependency-light and cross-platform: plain Ruby
threads, a condition variable for prompt disarm, and Process.exit!.
Constant Summary collapse
- DEFAULT_TIMEOUT_SECONDS =
Default deadline, in seconds, before a stuck shutdown is forced.
Chosen to comfortably exceed a healthy teardown (bounded script drain plus state/socket/database closeout) while still bounding a hang.
60- SETTING_NAME =
Name of the
lich_settingsrow that overrides DEFAULT_TIMEOUT_SECONDS.Operator reference:
- The value is a whole number of seconds.
- A positive value sets the force-exit deadline.
- An explicit
0or negative value disables the watchdog. - A missing or non-numeric value falls back to DEFAULT_TIMEOUT_SECONDS; a malformed value is logged and never silently disables the watchdog.
'shutdown_watchdog_timeout'
Class Method Summary collapse
-
.arm(timeout: configured_timeout, on_expire: -> { Process.exit!(1) }) ⇒ Boolean
Arms the watchdog.
-
.armed? ⇒ Boolean
Returns whether the watchdog is currently armed.
-
.configured_timeout ⇒ Integer
Resolves the configured deadline from
lich_settings. -
.disarm ⇒ void
Disarms the watchdog, waking the waiting thread so it exits without forcing termination.
Class Method Details
.arm(timeout: configured_timeout, on_expire: -> { Process.exit!(1) }) ⇒ Boolean
Arms the watchdog.
Spawns a single background thread that waits up to timeout seconds.
If the watchdog is still armed when the deadline elapses, it dumps
diagnostics and invokes on_expire. A subsequent disarm wakes the
thread immediately and prevents on_expire from running.
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'documented/common/shutdown_watchdog.rb', line 71 def arm(timeout: configured_timeout, on_expire: -> { Process.exit!(1) }) return false if timeout.to_f <= 0 @mutex.synchronize do return false if @armed @armed = true # Create the thread and record @thread while still holding the lock # so the thread can identify itself. An arm/disarm/arm sequence can # leave an earlier thread parked in the wait; gating on # @thread == Thread.current ensures only the currently armed # watchdog can expire, so a superseded thread never forces an exit. @thread = Thread.new do # Anchor the deadline to the monotonic clock and re-wait on every # wakeup. ConditionVariable#wait can return early on a spurious # wakeup, and disarm/broadcast also wakes it; only a wakeup at or # after the genuine deadline counts as expiry. Without this loop a # spurious wakeup while still armed would force an exit before the # configured timeout. deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_f expired = @mutex.synchronize do loop do break false unless @armed && @thread == Thread.current remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) break true if remaining <= 0 @condition.wait(@mutex, remaining) end end if expired dump_diagnostics(timeout) on_expire.call end end end true end |
.armed? ⇒ Boolean
Returns whether the watchdog is currently armed.
126 127 128 |
# File 'documented/common/shutdown_watchdog.rb', line 126 def armed? @mutex.synchronize { @armed } end |
.configured_timeout ⇒ Integer
Resolves the configured deadline from lich_settings.
Falls back to DEFAULT_TIMEOUT_SECONDS when the setting is unset,
unreadable, or non-numeric. A malformed value is logged and treated as
the default rather than being coerced to 0, so a typo cannot silently
disable the watchdog; only an explicit numeric value of 0 or less
disables it.
139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
# File 'documented/common/shutdown_watchdog.rb', line 139 def configured_timeout return DEFAULT_TIMEOUT_SECONDS unless defined?(Lich) && Lich.respond_to?(:db) && Lich.db raw = Lich.db.get_first_value("SELECT value FROM lich_settings WHERE name='#{SETTING_NAME}';") return DEFAULT_TIMEOUT_SECONDS if raw.nil? parsed = Integer(raw.to_s.strip, exception: false) if parsed.nil? log_line("invalid #{SETTING_NAME}=#{raw.inspect}; falling back to #{DEFAULT_TIMEOUT_SECONDS}s") return DEFAULT_TIMEOUT_SECONDS end parsed rescue StandardError DEFAULT_TIMEOUT_SECONDS end |
.disarm ⇒ void
This method returns an undefined value.
Disarms the watchdog, waking the waiting thread so it exits without forcing termination. Idempotent and safe to call when not armed.
115 116 117 118 119 120 121 |
# File 'documented/common/shutdown_watchdog.rb', line 115 def disarm @mutex.synchronize do @armed = false @condition.broadcast end nil end |