gritz-core 0.1.0 → 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.
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Gritz
6
+ module Metrics
7
+ # Per-worker RPC deltas; only one bounded detached batch is needed by the sender.
8
+ # @api private
9
+ class Recorder
10
+ DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, Float::INFINITY].freeze
11
+ MESSAGE_BUCKETS = [0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 1024, Float::INFINITY].freeze
12
+ SCALARS = %i[count duration_sum request_sum response_sum].freeze
13
+ BUCKET_FIELDS = %i[duration_buckets request_buckets response_buckets].freeze
14
+
15
+ def initialize
16
+ @lock = Mutex.new
17
+ @rpc = {}
18
+ @last_rejected = @rejected_delta = 0
19
+ end
20
+
21
+ def self.empty_row(service:, method:, code:)
22
+ { service: service.dup.freeze, method: method.dup.freeze, code: code, count: 0, duration_sum: 0.0, request_sum: 0, response_sum: 0,
23
+ duration_buckets: Array.new(DURATION_BUCKETS.size, 0), request_buckets: Array.new(MESSAGE_BUCKETS.size, 0),
24
+ response_buckets: Array.new(MESSAGE_BUCKETS.size, 0) }
25
+ end
26
+
27
+ def record_rpc(service:, method:, code:, duration:, requests:, responses:)
28
+ unless [service, method].all? { |label| label.is_a?(String) && !label.empty? } && code.is_a?(Integer) && code.between?(0, 16) &&
29
+ duration.is_a?(Numeric) && duration.real? && duration.to_f.finite? && duration >= 0 &&
30
+ [requests, responses].all? { |value| value.is_a?(Integer) && value >= 0 }
31
+ raise ArgumentError, "Invalid RPC metric observation"
32
+ end
33
+
34
+ @lock.synchronize do
35
+ row = @rpc[[service, method, code]]
36
+ unless row
37
+ row = self.class.empty_row(service: service, method: method, code: code)
38
+ @rpc[[row[:service], row[:method], code].freeze] = row
39
+ end
40
+ row[:count] += 1
41
+ row[:duration_sum] += duration.to_f
42
+ row[:request_sum] += requests
43
+ row[:response_sum] += responses
44
+ row[:duration_buckets][DURATION_BUCKETS.bsearch_index { |upper| duration <= upper }] += 1
45
+ row[:request_buckets][MESSAGE_BUCKETS.bsearch_index { |upper| requests <= upper }] += 1
46
+ row[:response_buckets][MESSAGE_BUCKETS.bsearch_index { |upper| responses <= upper }] += 1
47
+ end
48
+ end
49
+
50
+ def observe_rejected(total)
51
+ @lock.synchronize do
52
+ raise ArgumentError, "Rejected counter must be a nondecreasing integer" unless total.is_a?(Integer) && total >= @last_rejected
53
+
54
+ @rejected_delta += total - @last_rejected
55
+ @last_rejected = total
56
+ end
57
+ end
58
+
59
+ # The caller retains this detached delta until its status channel accepts the row.
60
+ def take_delta(max_bytes: Supervisor::StatusChannel::MAX_LINE_BYTES - 4096)
61
+ raise ArgumentError, "Metric packet budget must be positive" unless max_bytes.is_a?(Integer) && max_bytes.positive?
62
+
63
+ @lock.synchronize do
64
+ return nil if @rpc.empty? && @rejected_delta.zero?
65
+
66
+ delta = { rpc: [], rejected: @rejected_delta }
67
+ used_bytes = JSON.generate(delta).bytesize
68
+ raise ArgumentError, "Metric packet budget is too small" if used_bytes > max_bytes
69
+
70
+ selected = []
71
+ @rpc.each do |key, row|
72
+ row_bytes = JSON.generate(row).bytesize + (selected.empty? ? 0 : 1)
73
+ if used_bytes + row_bytes > max_bytes
74
+ raise ArgumentError, "Metric row exceeds the packet budget" if selected.empty?
75
+
76
+ break
77
+ end
78
+ delta[:rpc] << row
79
+ selected << key
80
+ used_bytes += row_bytes
81
+ end
82
+ selected.each { |key| @rpc.delete(key) }
83
+ @rejected_delta = 0
84
+ delta
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
- require "json"
5
4
 
6
5
  module Gritz
7
6
  module Middleware
@@ -32,8 +31,7 @@ module Gritz
32
31
  end
33
32
 
34
33
  error_id = SecureRandom.uuid
35
- context.logger.error(JSON.generate(error_id:, request_id: context.request_id, error: e.class.name,
36
- message: e.message, backtrace: e.backtrace))
34
+ context.store[:gritz_error] = { error_id: error_id, error: e.class.name, message: e.message, backtrace: e.backtrace }
37
35
  raise Errors::Internal.new(@expose_errors ? e.message : "internal error (#{error_id})", metadata: { "error-id" => error_id })
38
36
  end
39
37
  end
@@ -23,9 +23,33 @@ module Gritz
23
23
  raise
24
24
  ensure
25
25
  duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
26
- context.logger.info(JSON.generate(request_id: context.request_id, service: context.method.service,
27
- method: context.method.name, code:, duration_ms: duration.round(3),
28
- peer: context.peer, pid: Process.pid))
26
+ fields = { request_id: context.request_id, service: context.method.service,
27
+ method: context.method.name, code: code, duration_ms: duration.round(3),
28
+ peer: context.peer, worker: context.worker, pid: Process.pid, bytes_in: context.bytes_in, bytes_out: context.bytes_out }
29
+ fields.merge!(context.store.fetch(:gritz_error, {}))
30
+ fields = prepare(fields, context.log_redact)
31
+ message = if context.log_format == :logfmt
32
+ fields.map do |key, value|
33
+ value = value.to_s if value.is_a?(Symbol)
34
+ value = JSON.generate(value) if value.is_a?(Hash) || value.is_a?(Array)
35
+ "#{key}=#{JSON.generate(value)}"
36
+ end.join(" ")
37
+ else
38
+ JSON.generate(fields)
39
+ end
40
+ context.logger.info(message)
41
+ end
42
+
43
+ private
44
+
45
+ def prepare(value, redacted)
46
+ case value
47
+ when Hash
48
+ value.to_h { |key, item| [key, redacted.include?(key.to_s) ? "[FILTERED]" : prepare(item, redacted)] }
49
+ when Array then value.map { |item| prepare(item, redacted) }
50
+ when String then value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
51
+ else value
52
+ end
29
53
  end
30
54
  end
31
55
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gritz
4
+ module Middleware
5
+ # One completion observation wraps the complete unary or streaming action.
6
+ # @api public
7
+ class Metrics
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(context)
13
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
+ code = 0
15
+ @app.call(context)
16
+ rescue Gritz::Error => e
17
+ code = e.grpc_code
18
+ raise
19
+ rescue StandardError
20
+ code = 13
21
+ raise
22
+ ensure
23
+ context.metrics&.record_rpc(service: context.method.service, method: context.method.name, code: code,
24
+ duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started,
25
+ requests: context.requests_count, responses: context.responses_count)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -18,7 +18,7 @@ module Gritz
18
18
  end
19
19
 
20
20
  def self.default
21
- new.use(RequestId).use(Context).use(Logging).use(ExceptionMapper)
21
+ new.use(RequestId).use(Context).use(Metrics).use(Logging).use(ExceptionMapper)
22
22
  end
23
23
 
24
24
  def use(middleware, **options, &block)
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "json"
5
+
6
+ module Gritz
7
+ module Supervisor
8
+ # Small, bounded HTTP probe listener polled by its owning process.
9
+ # @api private
10
+ class AdminServer
11
+ MAX_CLIENTS = 32
12
+ MAX_HEADER_BYTES = 8192
13
+ CLIENT_TIMEOUT = 2.0
14
+ attr_reader :io, :address
15
+
16
+ def initialize(bind:, status:, ready:, metrics:, logger:)
17
+ host, port = bind.match(/\A(\[[^\]]+\]|[^:]+):(\d+)\z/).captures
18
+ host = host.delete_prefix("[").delete_suffix("]")
19
+ @io = TCPServer.new(host, Integer(port))
20
+ @address = "#{host.include?(':') ? "[#{host}]" : host}:#{@io.addr[1]}"
21
+ @status = status
22
+ @ready = ready
23
+ @metrics = metrics
24
+ @logger = logger
25
+ @clients = {}
26
+ end
27
+
28
+ def ios = [@io, *@clients.keys]
29
+
30
+ def poll
31
+ 4.times do
32
+ socket = @io.accept_nonblock(exception: false)
33
+ break if socket == :wait_readable
34
+
35
+ if @clients.size >= MAX_CLIENTS
36
+ socket.close
37
+ else
38
+ @clients[socket] = { input: +"".b, output: nil, deadline: monotonic + CLIENT_TIMEOUT }
39
+ end
40
+ rescue Errno::ECONNABORTED
41
+ next
42
+ end
43
+ @clients.each do |socket, client|
44
+ if monotonic >= client[:deadline]
45
+ drop(socket)
46
+ next
47
+ end
48
+ read_request(socket, client) unless client[:output]
49
+ write_response(socket, client) if client[:output]
50
+ rescue IOError, SystemCallError
51
+ drop(socket)
52
+ end
53
+ end
54
+
55
+ def close
56
+ @clients.each_key(&:close)
57
+ @clients.clear
58
+ @io.close unless @io.closed?
59
+ end
60
+
61
+ private
62
+
63
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+
65
+ def drop(socket)
66
+ @clients.delete(socket)
67
+ socket.close unless socket.closed?
68
+ end
69
+
70
+ def read_request(socket, client)
71
+ chunk = socket.read_nonblock(4096, exception: false)
72
+ return if chunk == :wait_readable
73
+ return drop(socket) if chunk.nil?
74
+
75
+ client[:input] << chunk
76
+ if client[:input].bytesize > MAX_HEADER_BYTES
77
+ respond(client, 431, "Request headers too large\n")
78
+ elsif client[:input].include?("\r\n\r\n")
79
+ method, path, version = client[:input].lines.first.split
80
+ if !%w[HTTP/1.0 HTTP/1.1].include?(version)
81
+ respond(client, 400, "Invalid request\n")
82
+ elsif method != "GET"
83
+ respond(client, 405, "GET required\n")
84
+ else
85
+ route(client, path)
86
+ end
87
+ end
88
+ end
89
+
90
+ def route(client, path)
91
+ case path
92
+ when "/livez" then respond(client, 200, "ok\n")
93
+ when "/readyz"
94
+ ready = @ready.call
95
+ respond(client, ready ? 200 : 503, ready ? "ready\n" : "not ready\n")
96
+ when "/status" then respond(client, 200, "#{JSON.generate(@status.call)}\n", "application/json")
97
+ when "/metrics" then respond(client, 200, @metrics.call, "text/plain; version=0.0.4")
98
+ else respond(client, 404, "Not found\n")
99
+ end
100
+ rescue StandardError => e
101
+ @logger.error("Admin #{path}: #{e.message}")
102
+ respond(client, 500, "Internal error\n")
103
+ end
104
+
105
+ def respond(client, code, body, content_type = "text/plain")
106
+ reasons = { 200 => "OK", 400 => "Bad Request", 404 => "Not Found", 405 => "Method Not Allowed",
107
+ 431 => "Request Header Fields Too Large", 500 => "Internal Server Error", 503 => "Service Unavailable" }
108
+ client[:output] =
109
+ "HTTP/1.1 #{code} #{reasons.fetch(code)}\r\nContent-Type: #{content_type}\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
110
+ end
111
+
112
+ def write_response(socket, client)
113
+ written = socket.write_nonblock(client[:output], exception: false)
114
+ return if written == :wait_writable
115
+
116
+ client[:output] = client[:output].byteslice(written..)
117
+ drop(socket) if client[:output].empty?
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,381 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "fileutils"
5
+ require "tempfile"
6
+
7
+ module Gritz
8
+ module Supervisor
9
+ # Keeps process ownership and probes stable while fresh master interpreters replace each other.
10
+ # @api private
11
+ class Launcher
12
+ Generation = Struct.new(:pid, :channel, :token, :role, :metadata, :snapshot, :deadline,
13
+ :kill_at, :kill_sent, :exit_status, :group_reaped, :identities, keyword_init: true)
14
+ SIGNALS = %w[TERM INT QUIT USR1 USR2 TTIN TTOU HUP CHLD].freeze
15
+
16
+ def self.enable_subreaper!
17
+ return unless RUBY_PLATFORM.include?("linux")
18
+
19
+ require "fiddle"
20
+ function = Fiddle::Function.new(Fiddle.dlopen(nil)["prctl"],
21
+ [Fiddle::TYPE_INT, *Array.new(4, Fiddle::TYPE_LONG)], Fiddle::TYPE_INT)
22
+ raise "Cannot enable child process reaping" unless function.call(36, 1, 0, 0, 0).zero?
23
+ end
24
+
25
+ def initialize(command:, logger:, env: ENV, stdout: $stdout, stderr: $stderr, status_io: nil, startup_timeout: 60)
26
+ @command = command
27
+ @logger = logger
28
+ @env = env
29
+ @stdout = stdout
30
+ @stderr = stderr
31
+ @observer = StatusChannel.new(status_io) if status_io
32
+ @startup_timeout = startup_timeout
33
+ @generations = {}
34
+ @serial = 0
35
+ @metrics = Metrics::Aggregator.new
36
+ @exit_code = 0
37
+ end
38
+
39
+ def run
40
+ self.class.enable_subreaper!
41
+ @signals = SignalQueue.new(signals: SIGNALS)
42
+ spawn_generation
43
+ loop do
44
+ @signals.drain.each { |name| handle_signal(name) }
45
+ # Reading a reexec request can add a generation, so iterate a stable snapshot.
46
+ @generations.values.each do |generation| # rubocop:disable Style/HashEachMethods
47
+ read_generation(generation)
48
+ reap_generation(generation)
49
+ end
50
+ update_lifecycle
51
+ @admin&.poll
52
+ @observer&.write(status)
53
+ @observer&.flush
54
+ break if @stopping && @generations.empty?
55
+
56
+ ios = [@signals.io, *@generations.values.reject { |generation| generation.channel.closed? }.map { |generation| generation.channel.io },
57
+ *@admin&.ios].compact
58
+ IO.select(ios, nil, nil, 0.02)
59
+ end
60
+ @exit_code
61
+ rescue StandardError => e
62
+ @logger.error("Launcher failed: #{e.message}")
63
+ 1
64
+ ensure
65
+ cleanup
66
+ end
67
+
68
+ def status
69
+ active = @active&.snapshot || {}
70
+ pools = @generations.values.select { |generation| %i[active retiring].include?(generation.role) || (!@active && generation == @candidate) }
71
+ workers = pools.flat_map do |generation|
72
+ generation.snapshot&.fetch(:workers, [])&.map do |worker|
73
+ worker.merge(master_pid: generation.pid, retiring: generation.role == :retiring || worker[:retiring] == true)
74
+ end || []
75
+ end
76
+ state = if @stopping
77
+ "draining"
78
+ elsif @active
79
+ "running"
80
+ else
81
+ "starting"
82
+ end
83
+ active.merge(owner_pid: Process.pid, pid: (@active || @candidate)&.pid, state: state,
84
+ workers: workers, ready_workers: ready_workers(@active), admin_address: @admin&.address,
85
+ masters: @generations.values.map { |generation| { pid: generation.pid, state: generation.role.to_s } }, reexec: @reexec)
86
+ end
87
+
88
+ private
89
+
90
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
+
92
+ def spawn_generation
93
+ parent, child = UNIXSocket.pair
94
+ pid = Process.spawn(@env.to_h.merge("GRITZ_INTERNAL_OWNER_FD" => "3"), *@command,
95
+ 3 => child, out: @stdout, err: @stderr, pgroup: true)
96
+ @serial += 1
97
+ @candidate = Generation.new(pid: pid, channel: StatusChannel.new(parent), token: @serial, role: :candidate,
98
+ deadline: monotonic + @startup_timeout, identities: {})
99
+ @generations[pid] = @candidate
100
+ @reexec = { state: "starting", pid: pid } if @active
101
+ rescue StandardError
102
+ parent&.close
103
+ raise
104
+ ensure
105
+ child&.close
106
+ end
107
+
108
+ def read_generation(generation)
109
+ generation.channel.read.each do |row|
110
+ next unless row[:pid] == generation.pid
111
+
112
+ case row[:type]
113
+ when "configured" then configure_generation(generation, row)
114
+ when "status"
115
+ generation.snapshot = row.except(:type)
116
+ live_pids = row.fetch(:workers, []).map { |worker| worker[:pid] }
117
+ generation.identities.delete_if do |pid, key|
118
+ next false if live_pids.include?(pid)
119
+
120
+ @metrics.forget(key)
121
+ true
122
+ end
123
+ when "reexec" then request_reexec if generation == @active
124
+ when "restart" then @metrics.record_restart(reason: row[:reason])
125
+ when "metrics"
126
+ previous = generation.identities[row[:worker_pid]]
127
+ key = [generation.token, row[:worker_pid], row[:worker_started_at]].freeze
128
+ @metrics.forget(previous) if previous && previous != key
129
+ generation.identities[row[:worker_pid]] = key
130
+ @metrics.apply(key, row.slice(:seq, :delta))
131
+ end
132
+ end
133
+ rescue ConfigurationError, ArgumentError, SystemCallError => e
134
+ fail_candidate(e.message) if generation == @candidate
135
+ @logger.error("Master #{generation.pid}: #{e.message}")
136
+ end
137
+
138
+ def configure_generation(generation, metadata)
139
+ return if generation.metadata || @stopping
140
+
141
+ %i[admin_bind bind pid_file].each do |field|
142
+ raise ConfigurationError, "Missing master #{field}" unless metadata[field].is_a?(String)
143
+ end
144
+ %i[workers min_ready_workers].each do |field|
145
+ raise ConfigurationError, "Invalid master #{field}" unless metadata[field].is_a?(Integer) && metadata[field] >= 0
146
+ end
147
+ %i[reexec_timeout drain_delay shutdown_timeout].each do |field|
148
+ value = metadata[field]
149
+ raise ConfigurationError, "Invalid master #{field}" unless value.is_a?(Numeric) && value.finite? && value >= 0
150
+ end
151
+ if @metadata
152
+ %i[admin_bind bind pid_file].each do |field|
153
+ raise ConfigurationError, "USR2 cannot change #{field}" unless metadata[field] == @metadata[field]
154
+ end
155
+ else
156
+ @metadata = metadata
157
+ @admin = AdminServer.new(bind: metadata[:admin_bind], status: -> { status }, ready: -> { ready? },
158
+ metrics: -> { @metrics.render(workers: status[:workers]) }, logger: @logger)
159
+ lock_pid_file(metadata[:pid_file]) unless metadata[:pid_file].empty?
160
+ end
161
+ generation.metadata = metadata
162
+ generation.deadline = [generation.deadline, monotonic + metadata[:reexec_timeout]].min
163
+ end
164
+
165
+ def ready_workers(generation)
166
+ return 0 unless generation&.snapshot&.dig(:state) == "running"
167
+
168
+ generation.snapshot.fetch(:workers, []).count do |worker|
169
+ worker[:state] == "ready" && worker[:healthy] != false && worker[:retiring] != true
170
+ end
171
+ end
172
+
173
+ def booted_workers(generation)
174
+ return 0 unless generation&.snapshot&.dig(:state) == "running"
175
+
176
+ generation.snapshot.fetch(:workers, []).count { |worker| worker[:state] == "ready" && worker[:retiring] != true }
177
+ end
178
+
179
+ def ready?
180
+ !@stopping && @active && ready_workers(@active) >= @active.metadata[:min_ready_workers]
181
+ end
182
+
183
+ def request_reexec
184
+ return if @stopping
185
+
186
+ if @candidate || @generations.values.any? { |generation| generation.role == :retiring }
187
+ @pending_reexec = true
188
+ return
189
+ end
190
+
191
+ if @active.metadata[:bind].end_with?(":0")
192
+ @reexec = { state: "failed", error: "USR2 requires a fixed RPC port" }
193
+ @logger.warn(@reexec[:error])
194
+ return
195
+ end
196
+ spawn_generation
197
+ rescue StandardError => e
198
+ @reexec = { state: "failed", error: e.message }
199
+ @logger.error("USR2 failed: #{e.message}")
200
+ end
201
+
202
+ def update_lifecycle
203
+ if @candidate&.role == :candidate
204
+ if @candidate.exit_status
205
+ fail_candidate("replacement master exited before readiness", force: false)
206
+ elsif @candidate.channel.closed?
207
+ fail_candidate("replacement master closed its control channel before readiness", force: false)
208
+ elsif monotonic >= @candidate.deadline
209
+ fail_candidate("replacement master readiness timed out")
210
+ elsif @candidate.metadata && (@active ? ready_workers(@candidate) : booted_workers(@candidate)) >= [@candidate.metadata[:workers], 1].max
211
+ promote_candidate
212
+ end
213
+ end
214
+ if @active&.exit_status && !@stopping
215
+ begin_shutdown(code: @active.exit_status.success? ? 0 : 1)
216
+ elsif @active&.snapshot&.dig(:state) == "draining" && !@stopping
217
+ begin_shutdown
218
+ end
219
+ if @active&.exit_status&.exited? && !@active.exit_status.success?
220
+ @exit_code = 1
221
+ end
222
+ @generations.each_value do |generation|
223
+ kill_group(generation) if generation.kill_at && monotonic >= generation.kill_at
224
+ next unless generation.group_reaped && generation.channel.closed?
225
+
226
+ generation.identities.each_value { |key| @metrics.forget(key) }
227
+ @generations.delete(generation.pid)
228
+ @candidate = nil if @candidate == generation
229
+ end
230
+ if @pending_reexec && !@stopping && !@candidate && @generations.values.none? { |generation| generation.role == :retiring }
231
+ @pending_reexec = false
232
+ request_reexec
233
+ end
234
+ end
235
+
236
+ def promote_candidate
237
+ replacement = @candidate
238
+ write_pid(replacement.pid)
239
+ if @active
240
+ @active.role = :retiring
241
+ terminate(@active)
242
+ @metrics.record_restart(reason: "hot_reexec")
243
+ @reexec = { state: "complete", pid: replacement.pid }
244
+ end
245
+ replacement.role = :active
246
+ @active = replacement
247
+ @candidate = nil
248
+ rescue StandardError => e
249
+ fail_candidate(e.message)
250
+ end
251
+
252
+ def fail_candidate(message, force: true)
253
+ return unless @candidate
254
+
255
+ @candidate.role = :failed
256
+ if force
257
+ kill_group(@candidate)
258
+ else
259
+ @candidate.kill_at = monotonic + 1
260
+ end
261
+ @reexec = { state: "failed", error: message }
262
+ @logger.error("USR2 failed: #{message}")
263
+ unless @active
264
+ @exit_code = 1
265
+ @stopping = true
266
+ end
267
+ end
268
+
269
+ def reap_generation(generation)
270
+ reap_exited_group(generation)
271
+ kill_group(generation) if generation.exit_status && !generation.group_reaped
272
+ end
273
+
274
+ def reap_exited_group(generation)
275
+ loop do
276
+ result = Process.waitpid2(-generation.pid, Process::WNOHANG)
277
+ break unless result
278
+
279
+ generation.exit_status = result.last if result.first == generation.pid
280
+ end
281
+ rescue Errno::ECHILD
282
+ generation.group_reaped = true
283
+ end
284
+
285
+ def handle_signal(name)
286
+ case name
287
+ when "TERM", "INT" then begin_shutdown
288
+ when "QUIT"
289
+ @stopping = true
290
+ @candidate.role = :stopping if @candidate
291
+ @generations.each_value { |generation| kill_group(generation) }
292
+ when "USR2" then request_reexec if @active
293
+ when "HUP"
294
+ @logger.reopen if @logger.respond_to?(:reopen)
295
+ @generations.each_value { |generation| signal_generation(generation, name) if %i[active retiring].include?(generation.role) }
296
+ when "USR1"
297
+ if @active && !@stopping
298
+ if @active.metadata[:workers].zero?
299
+ @logger.warn("USR1 requires worker processes; use USR2 to reload a single-process server")
300
+ else
301
+ signal_generation(@active, name)
302
+ end
303
+ end
304
+ when "TTIN", "TTOU" then signal_generation(@active, name) if @active && !@stopping
305
+ end
306
+ end
307
+
308
+ def begin_shutdown(code: 0)
309
+ @exit_code = code if code != 0
310
+ return if @stopping
311
+
312
+ @stopping = true
313
+ @generations.each_value do |generation|
314
+ generation.role = :stopping if generation.role == :candidate
315
+ terminate(generation)
316
+ end
317
+ end
318
+
319
+ def terminate(generation)
320
+ signal_generation(generation, "TERM")
321
+ metadata = generation.metadata || {}
322
+ generation.kill_at = monotonic + metadata.fetch(:drain_delay, 0) + metadata.fetch(:shutdown_timeout, 1) + 1
323
+ end
324
+
325
+ def signal_generation(generation, name)
326
+ Process.kill(name, generation.pid) unless generation.exit_status
327
+ rescue Errno::ESRCH
328
+ nil
329
+ end
330
+
331
+ def kill_group(generation)
332
+ reap_exited_group(generation)
333
+ return if generation.group_reaped || generation.kill_sent
334
+
335
+ Process.kill("KILL", -generation.pid)
336
+ generation.kill_sent = true
337
+ generation.kill_at = nil
338
+ rescue Errno::ESRCH
339
+ generation.kill_sent = true
340
+ end
341
+
342
+ def lock_pid_file(path)
343
+ @pid_path = File.expand_path(path)
344
+ FileUtils.mkdir_p(File.dirname(@pid_path))
345
+ @pid_lock = File.open("#{@pid_path}.lock", File::RDWR | File::CREAT, 0o644)
346
+ raise ConfigurationError, "PID file is already owned: #{@pid_path}" unless @pid_lock.flock(File::LOCK_EX | File::LOCK_NB)
347
+ end
348
+
349
+ def write_pid(pid)
350
+ return unless @pid_path
351
+
352
+ Tempfile.create([".gritz-pid", ".tmp"], File.dirname(@pid_path)) do |file|
353
+ file.write("#{pid}\n")
354
+ file.flush
355
+ File.rename(file.path, @pid_path)
356
+ end
357
+ @written_pid = pid
358
+ end
359
+
360
+ def cleanup
361
+ @generations.each_value do |generation|
362
+ kill_group(generation)
363
+ generation.channel.close
364
+ begin
365
+ loop { Process.waitpid(-generation.pid) }
366
+ rescue Errno::ECHILD
367
+ nil
368
+ end
369
+ end
370
+ @generations.clear
371
+ @admin&.close
372
+ @observer&.close
373
+ @signals&.close
374
+ if @written_pid && File.file?(@pid_path) && File.read(@pid_path).strip == @written_pid.to_s
375
+ File.unlink(@pid_path)
376
+ end
377
+ @pid_lock&.close
378
+ end
379
+ end
380
+ end
381
+ end