seigen_watchdog 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a2a0a72ac34884493eebe4d7e9fe04e845136f0498408d0fc70dba907d9d54d9
4
+ data.tar.gz: ebcd461562d6755b976697096b2aff15c884278ecf075e63f8f505099f1412c9
5
+ SHA512:
6
+ metadata.gz: 3d4680614a457b4f74ce4958be27fe78b200a82e8f2cdb9b9207895e87fd49508ec982de39824c7702dba23a98448900b90f7dc569efde236b53a344334ccfe2
7
+ data.tar.gz: c64475ed1f9dc7b19bb9e581518798ed4b0b78bcb647f64b4defa44ddf6b3a7636a25f4d736f92ca49d4af538ccaf293327886c207df5f7cbe9cd162a02a3448
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,47 @@
1
+ plugins:
2
+ - rubocop-rake
3
+ - rubocop-rspec
4
+
5
+ AllCops:
6
+ TargetRubyVersion: 3.3
7
+ NewCops: enable
8
+
9
+ Style/StringLiterals:
10
+ EnforcedStyle: single_quotes
11
+
12
+ Style/StringLiteralsInInterpolation:
13
+ EnforcedStyle: single_quotes
14
+
15
+ Metrics/MethodLength:
16
+ Max: 20
17
+ Exclude:
18
+ - 'spec/**/*.rb'
19
+ - 'lib/seigen_watchdog/monitor.rb'
20
+
21
+ Metrics/BlockLength:
22
+ Exclude:
23
+ - 'spec/**/*.rb'
24
+
25
+ Metrics/ClassLength:
26
+ Max: 200
27
+
28
+ Metrics/ParameterLists:
29
+ CountKeywordArgs: false
30
+
31
+ Layout/LeadingCommentSpace:
32
+ AllowRBSInlineAnnotation: true
33
+
34
+ RSpec/NamedSubject:
35
+ Enabled: false
36
+
37
+ RSpec/MultipleExpectations:
38
+ Enabled: false
39
+
40
+ RSpec/MultipleMemoizedHelpers:
41
+ Enabled: false
42
+
43
+ RSpec/NestedGroups:
44
+ Max: 4
45
+
46
+ RSpec/ExampleLength:
47
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # 0.1.0
2
+ - initial release with basic functionality:
3
+ - RSS memory limiter
4
+ - Execution time limiter
5
+ - Iteration count limiter
6
+ - Custom condition limiter
7
+ - Signal-based killer strategy
8
+ - Threadsafe background watchdog
9
+ - Configurable check interval
10
+ - Optional logging and exception handling
11
+ # 0.2.0
12
+ - store limiters as Hash, access specific limiter by it's name, for ex. `monitor.limiter(:rss)`
13
+ - counter limiter all init, increment/decrement, and reset with specified value
14
+ # 0.3.0
15
+ - monitor prevents multiple kill calls (only calls `killer.kill!` once even if limiters continue to be exceeded)
16
+ - added `killed?` method to check if killer was invoked
17
+ - added `seconds_since_killed` method to track time elapsed since kill
18
+ - `before_kill` callback now receives limiter name and limiter instance: `->(name, limiter)`
19
+ - limiter keys are now standardized to Symbol type
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Denis Talakevich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,238 @@
1
+ # SeigenWatchdog
2
+
3
+ Seigen (制限 — “limit, restriction”).
4
+
5
+ Monitors and gracefully terminates a Ruby application based on configurable memory usage, execution time, iteration count, or custom conditions. Threadsafe and easy to integrate.
6
+
7
+ How it works:
8
+ After setting up SeigenWatchdog with desired limiters and a killer strategy, it spawns a background thread that periodically checks the defined conditions. If any limiter exceeds its threshold, the specified killer strategy is invoked to terminate the application gracefully.
9
+
10
+ ## Installation
11
+
12
+ Install the gem and add to the application's Gemfile by executing:
13
+
14
+ ```bash
15
+ bundle add seigen_watchdog
16
+ ```
17
+
18
+ If bundler is not being used to manage dependencies, install the gem by executing:
19
+
20
+ ```bash
21
+ gem install seigen_watchdog
22
+ ```
23
+
24
+ ## Requirements
25
+
26
+ - Ruby >= 3.3.0
27
+ - Dependencies:
28
+ - `get_process_mem` - for RSS memory monitoring
29
+ - `logger` - for optional debug logging
30
+
31
+ ## Usage
32
+
33
+ ```ruby
34
+ require 'seigen_watchdog'
35
+
36
+ SeigenWatchdog.start(
37
+ check_interval: 5, # seconds or nil for no periodic checks
38
+ killer: SeigenWatchdog::Killers::Signal.new(signal: 'INT'),
39
+ limiters: {
40
+ rss: SeigenWatchdog::Limiters::RSS.new(max_rss: 200 * 1024 * 1024), # 200 MB
41
+ time: SeigenWatchdog::Limiters::Time.new(max_duration: 24 * 60 * 60), # 24 hours
42
+ jobs: SeigenWatchdog::Limiters::Counter.new(max_count: 1_000_000), # 1 million iterations
43
+ custom: SeigenWatchdog::Limiters::Custom.new(checker: -> { SomeCondition.met? }) # custom condition
44
+ },
45
+ logger: Logger.new($stdout), # optional logger, logs DEBUG for each check, INFO when killer is invoked
46
+ on_exception: ->(e) { Sentry.capture_exception(e) }, # optional exception handler
47
+ before_kill: ->(name, limiter) { Prometheus::KillInstrument.send_metrics(name, limiter.class.name) } # optional callback before kill
48
+ )
49
+
50
+ # to increment particular count limiter
51
+ SeigenWatchdog.monitor.limiter(:jobs).increment # increment by 1
52
+ SeigenWatchdog.monitor.limiter(:jobs).increment(5) # increment by 5
53
+
54
+ # to perform check manually (if check_interval is nil)
55
+ SeigenWatchdog.monitor.check_once
56
+
57
+ # to stop the watchdog
58
+ SeigenWatchdog.stop
59
+
60
+ # to check if watchdog is running
61
+ SeigenWatchdog.started? # => true or false
62
+ ```
63
+
64
+ ## API Reference
65
+
66
+ ### Module Methods
67
+
68
+ #### `SeigenWatchdog.start(check_interval:, killer:, limiters:, logger: nil, on_exception: nil, before_kill: nil)`
69
+ Starts the watchdog monitor with the specified configuration.
70
+
71
+ **Parameters:**
72
+ - `check_interval` - Interval in seconds between checks, or `nil` for manual checks only
73
+ - `killer` - Killer strategy instance (e.g., `SeigenWatchdog::Killers::Signal.new(signal: 'INT')`)
74
+ - `limiters` - Hash of limiter instances (keys are limiter names, values are limiter instances)
75
+ - `logger` - Optional logger instance for debug/info logging
76
+ - `on_exception` - Optional callback proc for exception handling (receives exception as argument)
77
+ - `before_kill` - Optional callback proc invoked before killing (receives limiter name and limiter instance as arguments)
78
+
79
+ **Returns:** Monitor instance
80
+
81
+ #### `SeigenWatchdog.stop`
82
+ Stops the watchdog monitor and terminates the background thread.
83
+
84
+ #### `SeigenWatchdog.started?`
85
+ Returns `true` if the watchdog is currently running, `false` otherwise.
86
+
87
+ #### `SeigenWatchdog.monitor`
88
+ Returns the current monitor instance, or `nil` if not started.
89
+
90
+ ### Monitor Instance Methods
91
+
92
+ #### `monitor.check_once`
93
+ Performs a single manual check of all limiters. Useful when `check_interval` is `nil`.
94
+
95
+ **Returns:** `true` if a limit was exceeded and killer was invoked, `false` otherwise.
96
+
97
+ #### `monitor.limiter(name)`
98
+ Returns a limiter instance by its name.
99
+
100
+ **Parameters:**
101
+ - `name` - Symbol or String name of the limiter
102
+
103
+ **Returns:** Limiter instance
104
+
105
+ **Raises:** `KeyError` if limiter with the given name doesn't exist
106
+
107
+ #### `monitor.limiters`
108
+ Returns a hash of all limiters. Modifications to the returned hash won't affect internal state, but limiter instances are shared.
109
+
110
+ **Returns:** Hash of limiters (keys are names, values are limiter instances)
111
+
112
+ #### `monitor.killed?`
113
+ Returns whether the killer has been invoked.
114
+
115
+ **Returns:** `true` if killer was invoked, `false` otherwise.
116
+
117
+ #### `monitor.seconds_since_killed`
118
+ Returns the number of seconds elapsed since the killer was invoked.
119
+
120
+ **Returns:** Float representing seconds since kill, or `nil` if killer has not been invoked.
121
+
122
+ #### `monitor.seconds_after_last_check`
123
+ Returns the number of seconds elapsed since the last check was performed.
124
+
125
+ **Returns:** Float representing seconds since last check, or `nil` if no check has been performed.
126
+
127
+ ### Limiters
128
+
129
+ #### `SeigenWatchdog::Limiters::RSS.new(max_rss:)`
130
+ Monitors RSS (Resident Set Size) memory usage.
131
+ - `max_rss` - Maximum RSS in bytes
132
+
133
+ #### `SeigenWatchdog::Limiters::Time.new(max_duration:)`
134
+ Monitors execution time since limiter creation.
135
+ - `max_duration` - Maximum duration in seconds
136
+
137
+ #### `SeigenWatchdog::Limiters::Counter.new(max_count:, initial: 0)`
138
+ Monitors iteration count with manual incrementing.
139
+ - `max_count` - Maximum count before exceeding
140
+ - `initial` - Initial counter value (default: 0)
141
+
142
+ **Instance Methods:**
143
+ - `increment(count = 1)` - Increments the counter by the specified amount (default: 1)
144
+ - `decrement(count = 1)` - Decrements the counter by the specified amount (default: 1)
145
+ - `reset(initial = 0)` - Resets the counter to the specified value (default: 0)
146
+
147
+ **Usage:**
148
+ ```ruby
149
+ # Access counter limiter via monitor
150
+ counter = SeigenWatchdog.monitor.limiter(:jobs)
151
+ counter.increment # increment by 1
152
+ counter.increment(5) # increment by 5
153
+ counter.decrement # decrement by 1
154
+ counter.decrement(3) # decrement by 3
155
+ counter.reset # reset to 0
156
+ counter.reset(10) # reset to 10
157
+
158
+ # Create counter with custom initial value
159
+ SeigenWatchdog::Limiters::Counter.new(max_count: 100, initial: 50)
160
+ # Counter starts at 50, will exceed at 100
161
+ ```
162
+
163
+ #### `SeigenWatchdog::Limiters::Custom.new(checker:)`
164
+ Custom condition limiter using a proc.
165
+ - `checker` - Proc that returns `true` when limit is exceeded
166
+
167
+ ### Killers
168
+
169
+ #### `SeigenWatchdog::Killers::Signal.new(signal:)`
170
+ Terminates the process by sending a signal.
171
+ - `signal` - Signal name as string or symbol (e.g., `'INT'`, `:TERM`)
172
+
173
+ ## Callbacks
174
+
175
+ ### `before_kill` Callback
176
+
177
+ The `before_kill` callback is invoked immediately before the killer strategy is executed when a limiter exceeds its threshold. This allows you to perform cleanup operations, send metrics, or log information about which limit was exceeded.
178
+
179
+ **Callback signature:**
180
+ ```ruby
181
+ ->(name, limiter) { ... }
182
+ ```
183
+
184
+ **Arguments:**
185
+ - `name` - Symbol representing the limiter name (e.g., `:rss`, `:time`, `:jobs`)
186
+ - `limiter` - The limiter instance that exceeded its threshold
187
+
188
+ **Example use cases:**
189
+ ```ruby
190
+ # Send metrics to monitoring system
191
+ before_kill: ->(name, limiter) {
192
+ Prometheus::KillInstrument.send_metrics(name, limiter.class.name)
193
+ }
194
+
195
+ # Log detailed information with limiter name
196
+ before_kill: ->(name, limiter) {
197
+ Rails.logger.warn("Process killed due to #{name} (#{limiter.class.name}) limit exceeded")
198
+ }
199
+
200
+ # Send alert with limiter name
201
+ before_kill: ->(name, limiter) {
202
+ Sentry.capture_message("Watchdog killing process: #{name}")
203
+ }
204
+
205
+ # Perform cleanup based on limiter type
206
+ before_kill: ->(name, limiter) {
207
+ case limiter
208
+ when SeigenWatchdog::Limiters::RSS
209
+ Rails.logger.warn("Memory limit exceeded (#{name}): #{limiter.max_rss / 1024 / 1024} MB")
210
+ when SeigenWatchdog::Limiters::Time
211
+ Rails.logger.warn("Time limit exceeded (#{name}): #{limiter.max_duration} seconds")
212
+ end
213
+ }
214
+ ```
215
+
216
+ **Exception handling:**
217
+ If the `before_kill` callback raises an exception, it will be handled by the `on_exception` callback (if provided) and the killer will still be invoked to ensure the process terminates as expected.
218
+
219
+ ## Development
220
+
221
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
222
+
223
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
224
+
225
+
226
+ Generate sig using the following command:
227
+
228
+ ```bash
229
+ bundle exec rbs-inline --opt-out --output=sig lib
230
+ ```
231
+
232
+ ## Contributing
233
+
234
+ Bug reports and pull requests are welcome on GitHub at https://github.com/senid231/seigen_watchdog.
235
+
236
+ ## License
237
+
238
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/SECURITY.md ADDED
@@ -0,0 +1,12 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+
6
+ | Version | Supported |
7
+ | ------- | ------------------ |
8
+ | 0.x.x | :white_check_mark: |
9
+
10
+ ## Reporting a Vulnerability
11
+
12
+ Report vulnerability to issues
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeigenWatchdog
4
+ module Killers
5
+ # Base class for all killers
6
+ class Base
7
+ # @rbs return: void
8
+ def kill!
9
+ raise NotImplementedError, "#{self.class} must implement #kill!"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeigenWatchdog
4
+ module Killers
5
+ # Killer that sends a signal to the current process
6
+ class Signal < Base
7
+ # @rbs @signal: String
8
+
9
+ attr_reader :signal #: String
10
+
11
+ # @rbs signal: String | Symbol
12
+ # @rbs return: void
13
+ def initialize(signal:)
14
+ super()
15
+ @signal = signal.to_s
16
+ end
17
+
18
+ # Sends the signal to the current process
19
+ # @rbs return: void
20
+ def kill!
21
+ Process.kill(@signal, Process.pid)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeigenWatchdog
4
+ module Limiters
5
+ # Base class for all limiters
6
+ class Base
7
+ # @rbs return: bool
8
+ def exceeded?
9
+ raise NotImplementedError, "#{self.class} must implement #exceeded?"
10
+ end
11
+
12
+ # Called when the limiter is started (when monitor initializes)
13
+ # @rbs return: void
14
+ def started; end
15
+
16
+ # Called when the limiter is stopped (when monitor stops)
17
+ # @rbs return: void
18
+ def stopped; end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeigenWatchdog
4
+ module Limiters
5
+ # Limiter based on iteration count
6
+ class Counter < Base
7
+ # @rbs @max_count: Integer
8
+ # @rbs @count: Integer
9
+ # @rbs @mutex: Thread::Mutex
10
+
11
+ attr_reader :max_count #: Integer
12
+
13
+ # @rbs max_count: Integer
14
+ # @rbs initial: Integer
15
+ # @rbs return: void
16
+ def initialize(max_count:, initial: 0)
17
+ super()
18
+ @max_count = max_count
19
+ @count = initial
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ # Increments the counter by the specified amount
24
+ # @rbs count: Integer
25
+ # @rbs return: void
26
+ def increment(count = 1)
27
+ @mutex.synchronize { @count += count }
28
+ end
29
+
30
+ # Decrements the counter by the specified amount
31
+ # @rbs count: Integer
32
+ # @rbs return: void
33
+ def decrement(count = 1)
34
+ @mutex.synchronize { @count -= count }
35
+ end
36
+
37
+ # Resets the counter to the specified initial value
38
+ # @rbs initial: Integer
39
+ # @rbs return: void
40
+ def reset(initial = 0)
41
+ @mutex.synchronize { @count = initial }
42
+ end
43
+
44
+ # @rbs return: bool
45
+ def exceeded?
46
+ @mutex.synchronize { @count >= @max_count }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeigenWatchdog
4
+ module Limiters
5
+ # Limiter based on a custom checker lambda
6
+ class Custom < Base
7
+ # @rbs @checker: Proc | #call
8
+ # @rbs checker: Proc | #call
9
+ # @rbs return: void
10
+ def initialize(checker:)
11
+ super()
12
+ @checker = checker
13
+ end
14
+
15
+ # @rbs return: bool
16
+ def exceeded?
17
+ @checker.call
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'get_process_mem'
4
+
5
+ module SeigenWatchdog
6
+ module Limiters
7
+ # Limiter based on RSS memory usage
8
+ class RSS < Base
9
+ # @rbs @max_rss: Integer
10
+ # @rbs @mem: GetProcessMem
11
+
12
+ attr_reader :max_rss #: Integer
13
+
14
+ # @rbs max_rss: Integer
15
+ # @rbs return: void
16
+ def initialize(max_rss:)
17
+ super()
18
+ @max_rss = max_rss
19
+ @mem = GetProcessMem.new
20
+ end
21
+
22
+ # @rbs return: bool
23
+ def exceeded?
24
+ @mem.bytes >= @max_rss
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeigenWatchdog
4
+ module Limiters
5
+ # Limiter based on execution time
6
+ class Time < Base
7
+ # @rbs @max_duration: Numeric
8
+ # @rbs @start_time: Float?
9
+
10
+ attr_reader :start_time #: Float?
11
+ attr_reader :max_duration #: Numeric
12
+
13
+ # @rbs max_duration: Numeric
14
+ # @rbs return: void
15
+ def initialize(max_duration:)
16
+ super()
17
+ @max_duration = max_duration
18
+ @start_time = nil
19
+ end
20
+
21
+ # Called when monitor starts - records the start time
22
+ # @rbs return: void
23
+ def started
24
+ @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+ end
26
+
27
+ # @rbs return: bool
28
+ def exceeded?
29
+ elapsed_time >= @max_duration
30
+ end
31
+
32
+ private
33
+
34
+ # @rbs return: Float
35
+ def elapsed_time
36
+ return 0.0 if @start_time.nil?
37
+
38
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeigenWatchdog
4
+ # Monitor class that checks limiters and invokes the killer when needed
5
+ class Monitor
6
+ # @rbs @check_interval: Numeric?
7
+ # @rbs @killer: Killers::Base
8
+ # @rbs @limiters: Hash[Symbol, Limiters::Base]
9
+ # @rbs @logger: Logger?
10
+ # @rbs @on_exception: Proc?
11
+ # @rbs @before_kill: Proc?
12
+ # @rbs @checks: Integer
13
+ # @rbs @last_check_time: Float? | nil
14
+ # @rbs @time_killed: Float? | nil
15
+ # @rbs @thread: Thread? | nil
16
+ # @rbs @running: bool
17
+ # @rbs @mutex: Thread::Mutex
18
+
19
+ # @rbs!
20
+ # attr_reader checks: Integer
21
+ attr_reader :checks
22
+
23
+ # Interval in seconds between checks, nil to disable background thread
24
+ # @rbs check_interval: Numeric?
25
+ # The killer to invoke when a limit is exceeded
26
+ # @rbs killer: Killers::Base
27
+ # Hash of limiters to check
28
+ # @rbs limiters: Hash[Symbol, Limiters::Base]
29
+ # Optional logger for debugging
30
+ # @rbs logger: Logger?
31
+ # Optional callback when an exception occurs
32
+ # @rbs on_exception: Proc?
33
+ # Optional callback invoked before killing, receives exceeded limiter
34
+ # @rbs before_kill: Proc?
35
+ # @rbs return: void
36
+ def initialize(check_interval:, killer:, limiters:, logger: nil, on_exception: nil, before_kill: nil)
37
+ @check_interval = check_interval
38
+ @killer = killer
39
+ @limiters = limiters.transform_keys(&:to_sym)
40
+ @logger = logger
41
+ @on_exception = on_exception
42
+ @before_kill = before_kill
43
+ @checks = 0
44
+ @last_check_time = nil
45
+ @time_killed = nil
46
+ @thread = nil
47
+ @running = false
48
+ @mutex = Mutex.new
49
+
50
+ # Call started on all limiters to initialize their state
51
+ @limiters.each_value(&:started)
52
+
53
+ if @check_interval
54
+ log_info('Monitor started with background thread')
55
+ start_background_thread
56
+ else
57
+ log_info('Monitor initialized without background thread; manual checks required')
58
+ end
59
+ end
60
+
61
+ # Performs a single check of all limiters
62
+ # Returns true if any limiter exceeded and killer was invoked
63
+ # @rbs return: bool
64
+ def check_once
65
+ increment_checks
66
+ log_debug("Performing check ##{@checks}")
67
+
68
+ name, limiter = @limiters.find { |_k, v| v.exceeded? }
69
+ if limiter
70
+ if killed?
71
+ log_debug("Limit exceeded but killer already invoked #{seconds_since_killed} seconds ago")
72
+ else
73
+ log_info("Limit exceeded: #{name}, invoking killer")
74
+ perform_kill(name, limiter)
75
+ end
76
+ true
77
+ else
78
+ log_debug('All limiters within bounds')
79
+ false
80
+ end
81
+ rescue StandardError => e
82
+ handle_exception(e)
83
+ false
84
+ end
85
+
86
+ # Returns the number of seconds since the last check
87
+ # Seconds since last check, or nil if no check has been performed
88
+ # @rbs return: Float?
89
+ def seconds_after_last_check
90
+ return nil if @last_check_time.nil?
91
+
92
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_check_time
93
+ end
94
+
95
+ # Returns true if the killer has been invoked
96
+ # @rbs return: bool
97
+ def killed?
98
+ !@time_killed.nil?
99
+ end
100
+
101
+ # Returns the number of seconds since the killer was invoked
102
+ # Seconds since killer was invoked, or nil if killer has not been invoked
103
+ # @rbs return: Float?
104
+ def seconds_since_killed
105
+ return nil if @time_killed.nil?
106
+
107
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - @time_killed
108
+ end
109
+
110
+ # Stops the background thread if running
111
+ # @rbs return: void
112
+ def stop
113
+ if @thread
114
+ @mutex.synchronize { @running = false }
115
+ @thread.join(5) # Wait up to 5 seconds for thread to finish
116
+ @thread = nil
117
+ log_debug('Monitor stopped')
118
+ end
119
+
120
+ # Call stopped on all limiters to clean up their state
121
+ @limiters.each_value(&:stopped)
122
+ end
123
+
124
+ # Checks if the background thread is running
125
+ # Returns true if the background thread is running
126
+ # @rbs return: bool
127
+ def running?
128
+ @running && @thread&.alive?
129
+ end
130
+
131
+ # Returns a limiter by name
132
+ # @rbs name: Symbol | String
133
+ # @rbs return: Limiters::Base
134
+ def limiter(name)
135
+ @limiters.fetch(name.to_sym)
136
+ end
137
+
138
+ # Returns a hash of all limiters
139
+ # Returns a new hash with the same keys and values
140
+ # Modifications to the hash won't affect internal state, but limiter instances are shared
141
+ # @rbs return: Hash[Symbol | String, Limiters::Base]
142
+ def limiters
143
+ @limiters.to_h { |k, v| [k, v] }
144
+ end
145
+
146
+ private
147
+
148
+ # @rbs name: Symbol
149
+ # @rbs limiter: Limiters::Base
150
+ # @rbs return: void
151
+ def perform_kill(name, limiter)
152
+ run_before_kill(name, limiter)
153
+ @killer.kill!
154
+ @time_killed = Process.clock_gettime(Process::CLOCK_MONOTONIC)
155
+ end
156
+
157
+ # @rbs name: Symbol
158
+ # @rbs limiter: Limiters::Base
159
+ # @rbs return: void
160
+ def run_before_kill(name, limiter)
161
+ @before_kill&.call(name, limiter)
162
+ rescue StandardError => e
163
+ handle_exception(e)
164
+ end
165
+
166
+ # @rbs return: void
167
+ def increment_checks
168
+ @mutex.synchronize do
169
+ @checks += 1
170
+ @last_check_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
171
+ end
172
+ end
173
+
174
+ # @rbs return: void
175
+ def start_background_thread
176
+ @running = true
177
+ @thread = Thread.new { background_loop }
178
+ @thread.abort_on_exception = false
179
+ end
180
+
181
+ # @rbs return: void
182
+ def background_loop
183
+ while @running
184
+ check_once
185
+ sleep(@check_interval) if @running
186
+ end
187
+ rescue StandardError => e
188
+ handle_exception(e)
189
+ end
190
+
191
+ # @rbs exception: StandardError
192
+ # @rbs return: void
193
+ def handle_exception(exception)
194
+ log_error("Exception in monitor: #{exception.class}: #{exception.message}")
195
+ @on_exception&.call(exception)
196
+ end
197
+
198
+ # @rbs message: String
199
+ # @rbs return: void
200
+ def log_debug(message)
201
+ @logger&.debug { "SeigenWatchdog: #{message}" }
202
+ end
203
+
204
+ # @rbs message: String
205
+ # @rbs return: void
206
+ def log_info(message)
207
+ @logger&.info("SeigenWatchdog: #{message}")
208
+ end
209
+
210
+ # @rbs message: String
211
+ # @rbs return: void
212
+ def log_error(message)
213
+ @logger&.error("SeigenWatchdog: #{message}")
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeigenWatchdog
4
+ VERSION = '0.3.0' #: String
5
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'seigen_watchdog/version'
4
+
5
+ # Monitoring and watchdog module for Ruby applications
6
+ module SeigenWatchdog
7
+ # @rbs self.@monitor: Monitor?
8
+
9
+ class Error < StandardError; end
10
+
11
+ class << self
12
+ attr_reader :monitor #: Monitor?
13
+
14
+ # Starts the SeigenWatchdog monitor
15
+ # @rbs check_interval: Numeric?
16
+ # @rbs killer: Killers::Base
17
+ # @rbs limiters: Hash[Symbol | String, Limiters::Base]
18
+ # @rbs logger: Logger?
19
+ # @rbs on_exception: Proc?
20
+ # @rbs before_kill: Proc?
21
+ # @rbs return: Monitor
22
+ def start(check_interval:, killer:, limiters:, logger: nil, on_exception: nil, before_kill: nil)
23
+ stop if started?
24
+
25
+ @monitor = Monitor.new(
26
+ check_interval: check_interval,
27
+ killer: killer,
28
+ limiters: limiters,
29
+ logger: logger,
30
+ on_exception: on_exception,
31
+ before_kill: before_kill
32
+ )
33
+ end
34
+
35
+ # Stops the SeigenWatchdog monitor
36
+ # @rbs return: void
37
+ def stop
38
+ return unless @monitor
39
+
40
+ @monitor.stop
41
+ @monitor = nil
42
+ end
43
+
44
+ # Checks if the monitor has been started
45
+ # Returns true if the monitor is started
46
+ # @rbs return: bool
47
+ def started?
48
+ !@monitor.nil?
49
+ end
50
+ end
51
+ end
52
+
53
+ require_relative 'seigen_watchdog/limiters/base'
54
+ require_relative 'seigen_watchdog/limiters/rss'
55
+ require_relative 'seigen_watchdog/limiters/time'
56
+ require_relative 'seigen_watchdog/limiters/counter'
57
+ require_relative 'seigen_watchdog/limiters/custom'
58
+ require_relative 'seigen_watchdog/killers/base'
59
+ require_relative 'seigen_watchdog/killers/signal'
60
+ require_relative 'seigen_watchdog/monitor'
@@ -0,0 +1,11 @@
1
+ # Generated from lib/seigen_watchdog/killers/base.rb with RBS::Inline
2
+
3
+ module SeigenWatchdog
4
+ module Killers
5
+ # Base class for all killers
6
+ class Base
7
+ # @rbs return: void
8
+ def kill!: () -> void
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ # Generated from lib/seigen_watchdog/killers/signal.rb with RBS::Inline
2
+
3
+ module SeigenWatchdog
4
+ module Killers
5
+ # Killer that sends a signal to the current process
6
+ class Signal < Base
7
+ @signal: String
8
+
9
+ attr_reader signal: String
10
+
11
+ # @rbs signal: String | Symbol
12
+ # @rbs return: void
13
+ def initialize: (signal: String | Symbol) -> void
14
+
15
+ # Sends the signal to the current process
16
+ # @rbs return: void
17
+ def kill!: () -> void
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # Generated from lib/seigen_watchdog/limiters/base.rb with RBS::Inline
2
+
3
+ module SeigenWatchdog
4
+ module Limiters
5
+ # Base class for all limiters
6
+ class Base
7
+ # @rbs return: bool
8
+ def exceeded?: () -> bool
9
+
10
+ # Called when the limiter is started (when monitor initializes)
11
+ # @rbs return: void
12
+ def started: () -> void
13
+
14
+ # Called when the limiter is stopped (when monitor stops)
15
+ # @rbs return: void
16
+ def stopped: () -> void
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,39 @@
1
+ # Generated from lib/seigen_watchdog/limiters/counter.rb with RBS::Inline
2
+
3
+ module SeigenWatchdog
4
+ module Limiters
5
+ # Limiter based on iteration count
6
+ class Counter < Base
7
+ @max_count: Integer
8
+
9
+ @count: Integer
10
+
11
+ @mutex: Thread::Mutex
12
+
13
+ attr_reader max_count: Integer
14
+
15
+ # @rbs max_count: Integer
16
+ # @rbs initial: Integer
17
+ # @rbs return: void
18
+ def initialize: (max_count: Integer, ?initial: Integer) -> void
19
+
20
+ # Increments the counter by the specified amount
21
+ # @rbs count: Integer
22
+ # @rbs return: void
23
+ def increment: (?Integer count) -> void
24
+
25
+ # Decrements the counter by the specified amount
26
+ # @rbs count: Integer
27
+ # @rbs return: void
28
+ def decrement: (?Integer count) -> void
29
+
30
+ # Resets the counter to the specified initial value
31
+ # @rbs initial: Integer
32
+ # @rbs return: void
33
+ def reset: (?Integer initial) -> void
34
+
35
+ # @rbs return: bool
36
+ def exceeded?: () -> bool
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,16 @@
1
+ # Generated from lib/seigen_watchdog/limiters/custom.rb with RBS::Inline
2
+
3
+ module SeigenWatchdog
4
+ module Limiters
5
+ # Limiter based on a custom checker lambda
6
+ class Custom < Base
7
+ # @rbs @checker: Proc | #call
8
+ # @rbs checker: Proc | #call
9
+ # @rbs return: void
10
+ def initialize: (checker: untyped) -> void
11
+
12
+ # @rbs return: bool
13
+ def exceeded?: () -> bool
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ # Generated from lib/seigen_watchdog/limiters/rss.rb with RBS::Inline
2
+
3
+ module SeigenWatchdog
4
+ module Limiters
5
+ # Limiter based on RSS memory usage
6
+ class RSS < Base
7
+ @max_rss: Integer
8
+
9
+ @mem: GetProcessMem
10
+
11
+ attr_reader max_rss: Integer
12
+
13
+ # @rbs max_rss: Integer
14
+ # @rbs return: void
15
+ def initialize: (max_rss: Integer) -> void
16
+
17
+ # @rbs return: bool
18
+ def exceeded?: () -> bool
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,32 @@
1
+ # Generated from lib/seigen_watchdog/limiters/time.rb with RBS::Inline
2
+
3
+ module SeigenWatchdog
4
+ module Limiters
5
+ # Limiter based on execution time
6
+ class Time < Base
7
+ @max_duration: Numeric
8
+
9
+ @start_time: Float?
10
+
11
+ attr_reader start_time: Float?
12
+
13
+ attr_reader max_duration: Numeric
14
+
15
+ # @rbs max_duration: Numeric
16
+ # @rbs return: void
17
+ def initialize: (max_duration: Numeric) -> void
18
+
19
+ # Called when monitor starts - records the start time
20
+ # @rbs return: void
21
+ def started: () -> void
22
+
23
+ # @rbs return: bool
24
+ def exceeded?: () -> bool
25
+
26
+ private
27
+
28
+ # @rbs return: Float
29
+ def elapsed_time: () -> Float
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,125 @@
1
+ # Generated from lib/seigen_watchdog/monitor.rb with RBS::Inline
2
+
3
+ module SeigenWatchdog
4
+ # Monitor class that checks limiters and invokes the killer when needed
5
+ class Monitor
6
+ @mutex: Thread::Mutex
7
+
8
+ @running: bool
9
+
10
+ @thread: Thread? | nil
11
+
12
+ @time_killed: Float? | nil
13
+
14
+ @last_check_time: Float? | nil
15
+
16
+ @checks: Integer
17
+
18
+ @before_kill: Proc?
19
+
20
+ @on_exception: Proc?
21
+
22
+ @logger: Logger?
23
+
24
+ @limiters: Hash[Symbol, Limiters::Base]
25
+
26
+ @killer: Killers::Base
27
+
28
+ @check_interval: Numeric?
29
+
30
+ # @rbs!
31
+ # attr_reader checks: Integer
32
+ attr_reader checks: untyped
33
+
34
+ # Interval in seconds between checks, nil to disable background thread
35
+ # @rbs check_interval: Numeric?
36
+ # The killer to invoke when a limit is exceeded
37
+ # @rbs killer: Killers::Base
38
+ # Hash of limiters to check
39
+ # @rbs limiters: Hash[Symbol, Limiters::Base]
40
+ # Optional logger for debugging
41
+ # @rbs logger: Logger?
42
+ # Optional callback when an exception occurs
43
+ # @rbs on_exception: Proc?
44
+ # Optional callback invoked before killing, receives exceeded limiter
45
+ # @rbs before_kill: Proc?
46
+ # @rbs return: void
47
+ def initialize: (check_interval: Numeric?, killer: Killers::Base, limiters: Hash[Symbol, Limiters::Base], ?logger: Logger?, ?on_exception: Proc?, ?before_kill: Proc?) -> void
48
+
49
+ # Performs a single check of all limiters
50
+ # Returns true if any limiter exceeded and killer was invoked
51
+ # @rbs return: bool
52
+ def check_once: () -> bool
53
+
54
+ # Returns the number of seconds since the last check
55
+ # Seconds since last check, or nil if no check has been performed
56
+ # @rbs return: Float?
57
+ def seconds_after_last_check: () -> Float?
58
+
59
+ # Returns true if the killer has been invoked
60
+ # @rbs return: bool
61
+ def killed?: () -> bool
62
+
63
+ # Returns the number of seconds since the killer was invoked
64
+ # Seconds since killer was invoked, or nil if killer has not been invoked
65
+ # @rbs return: Float?
66
+ def seconds_since_killed: () -> Float?
67
+
68
+ # Stops the background thread if running
69
+ # @rbs return: void
70
+ def stop: () -> void
71
+
72
+ # Checks if the background thread is running
73
+ # Returns true if the background thread is running
74
+ # @rbs return: bool
75
+ def running?: () -> bool
76
+
77
+ # Returns a limiter by name
78
+ # @rbs name: Symbol | String
79
+ # @rbs return: Limiters::Base
80
+ def limiter: (Symbol | String name) -> Limiters::Base
81
+
82
+ # Returns a hash of all limiters
83
+ # Returns a new hash with the same keys and values
84
+ # Modifications to the hash won't affect internal state, but limiter instances are shared
85
+ # @rbs return: Hash[Symbol | String, Limiters::Base]
86
+ def limiters: () -> Hash[Symbol | String, Limiters::Base]
87
+
88
+ private
89
+
90
+ # @rbs name: Symbol
91
+ # @rbs limiter: Limiters::Base
92
+ # @rbs return: void
93
+ def perform_kill: (Symbol name, Limiters::Base limiter) -> void
94
+
95
+ # @rbs name: Symbol
96
+ # @rbs limiter: Limiters::Base
97
+ # @rbs return: void
98
+ def run_before_kill: (Symbol name, Limiters::Base limiter) -> void
99
+
100
+ # @rbs return: void
101
+ def increment_checks: () -> void
102
+
103
+ # @rbs return: void
104
+ def start_background_thread: () -> void
105
+
106
+ # @rbs return: void
107
+ def background_loop: () -> void
108
+
109
+ # @rbs exception: StandardError
110
+ # @rbs return: void
111
+ def handle_exception: (StandardError exception) -> void
112
+
113
+ # @rbs message: String
114
+ # @rbs return: void
115
+ def log_debug: (String message) -> void
116
+
117
+ # @rbs message: String
118
+ # @rbs return: void
119
+ def log_info: (String message) -> void
120
+
121
+ # @rbs message: String
122
+ # @rbs return: void
123
+ def log_error: (String message) -> void
124
+ end
125
+ end
@@ -0,0 +1,5 @@
1
+ # Generated from lib/seigen_watchdog/version.rb with RBS::Inline
2
+
3
+ module SeigenWatchdog
4
+ VERSION: String
5
+ end
@@ -0,0 +1,30 @@
1
+ # Generated from lib/seigen_watchdog.rb with RBS::Inline
2
+
3
+ # Monitoring and watchdog module for Ruby applications
4
+ module SeigenWatchdog
5
+ self.@monitor: Monitor?
6
+
7
+ class Error < StandardError
8
+ end
9
+
10
+ attr_reader monitor: Monitor?
11
+
12
+ # Starts the SeigenWatchdog monitor
13
+ # @rbs check_interval: Numeric?
14
+ # @rbs killer: Killers::Base
15
+ # @rbs limiters: Hash[Symbol | String, Limiters::Base]
16
+ # @rbs logger: Logger?
17
+ # @rbs on_exception: Proc?
18
+ # @rbs before_kill: Proc?
19
+ # @rbs return: Monitor
20
+ def self.start: (check_interval: Numeric?, killer: Killers::Base, limiters: Hash[Symbol | String, Limiters::Base], ?logger: Logger?, ?on_exception: Proc?, ?before_kill: Proc?) -> Monitor
21
+
22
+ # Stops the SeigenWatchdog monitor
23
+ # @rbs return: void
24
+ def self.stop: () -> void
25
+
26
+ # Checks if the monitor has been started
27
+ # Returns true if the monitor is started
28
+ # @rbs return: bool
29
+ def self.started?: () -> bool
30
+ end
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: seigen_watchdog
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Denis Talakevich
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-11-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: get_process_mem
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: logger
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.7'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.7'
41
+ description: Monitors and gracefully terminates a Ruby application based on configurable
42
+ memory usage,execution time, iteration count, or custom conditions
43
+ email:
44
+ - senid231@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - ".rspec"
50
+ - ".rubocop.yml"
51
+ - CHANGELOG.md
52
+ - LICENSE.txt
53
+ - README.md
54
+ - Rakefile
55
+ - SECURITY.md
56
+ - lib/seigen_watchdog.rb
57
+ - lib/seigen_watchdog/killers/base.rb
58
+ - lib/seigen_watchdog/killers/signal.rb
59
+ - lib/seigen_watchdog/limiters/base.rb
60
+ - lib/seigen_watchdog/limiters/counter.rb
61
+ - lib/seigen_watchdog/limiters/custom.rb
62
+ - lib/seigen_watchdog/limiters/rss.rb
63
+ - lib/seigen_watchdog/limiters/time.rb
64
+ - lib/seigen_watchdog/monitor.rb
65
+ - lib/seigen_watchdog/version.rb
66
+ - sig/seigen_watchdog.rbs
67
+ - sig/seigen_watchdog/killers/base.rbs
68
+ - sig/seigen_watchdog/killers/signal.rbs
69
+ - sig/seigen_watchdog/limiters/base.rbs
70
+ - sig/seigen_watchdog/limiters/counter.rbs
71
+ - sig/seigen_watchdog/limiters/custom.rbs
72
+ - sig/seigen_watchdog/limiters/rss.rbs
73
+ - sig/seigen_watchdog/limiters/time.rbs
74
+ - sig/seigen_watchdog/monitor.rbs
75
+ - sig/seigen_watchdog/version.rbs
76
+ homepage: https://github.com/senid231/seigen_watchdog
77
+ licenses:
78
+ - MIT
79
+ metadata:
80
+ homepage_uri: https://github.com/senid231/seigen_watchdog
81
+ source_code_uri: https://github.com/senid231/seigen_watchdog
82
+ changelog_uri: https://github.com/senid231/seigen_watchdog/blob/master/CHANGELOG.md
83
+ rubygems_mfa_required: 'true'
84
+ post_install_message:
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: 3.3.0
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 3.5.22
100
+ signing_key:
101
+ specification_version: 4
102
+ summary: Monitors and gracefully terminates a Ruby application based on configurable
103
+ memory usage,execution time, iteration count, or custom conditions
104
+ test_files: []