gritz-core 0.6.1 → 0.9.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 365ed02137d42c5182e6f09a10544c189d7fe0d70fc0faffd2864b752e13e40c
4
- data.tar.gz: 797d64d766c36257ef700d7647045d317c4027ed4b7295f959f54fec2139849e
3
+ metadata.gz: b938dbec401c131416b1a0a0b0f49fa9b140bf1567d8738f8f29d1c69dfa3ec1
4
+ data.tar.gz: df14f03e83c2ee4c72e62e7d7cedb9eab105541d41d9aae9ed2c71e929e7951c
5
5
  SHA512:
6
- metadata.gz: 8cdc413c2a357fb5e98ce5c397ae5e1b69d136f89752d479d4960367fc0a2cf6d99dabeebede608aa2f7c8f948ee542640ff4f269323a7aa203d5ef07c027296
7
- data.tar.gz: 3da9357cc46ca9f495f997803929d489e3ca691074cdad0b1e33f49457bcbc35d58ad37b3331b754b08967a3ff5376dfd60f6a02943a8e6e3e8fac34f78a35a7
6
+ metadata.gz: 453162303ec6b73e2dba7aa2cbd878c86b3afd3d69ac2641d8f0813abf8a27e5c66171ffa50eabfbb35e5cfcc591d0c99e0783a897c214fa2bc38082366a27e5
7
+ data.tar.gz: b84efb28c932187519075a5ad79016cd1322650deaef419f1dccf091a557cb328b88a8b988ec2855fd0efd0c012574e0ccd775f97b935f9053b47ce0980d6253
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.1
4
+
5
+ - Add `gritz stats`, `stop` and `restart` to inspect the running server and signal its lifecycle owner without executing application configuration.
6
+ - Honor configured `reexec_timeout` values above 60 seconds for initial and replacement master readiness, including time already spent starting the generation.
7
+ - Check cooperative cancellation and pool-slot recovery for all four RPC forms in the shared transport contract.
8
+
9
+ ## 0.9.0
10
+
11
+ - Start the 0.9 stabilization series with the documented public API and support policy.
12
+ - Reject directories and named pipes configured as TLS certificate, key or client CA files before allocating transport resources.
13
+
3
14
  ## 0.6.1
4
15
 
5
16
  - Avoid formatting suppressed RPC completion logs, preserving responses, status mapping and metrics when INFO logging is disabled.
data/README.md CHANGED
@@ -31,6 +31,10 @@ bundle exec rake build
31
31
 
32
32
  Tests, lint and packaging run independently in this repository. See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md) and the [release guide](docs/guides/releasing.md).
33
33
 
34
+ ## Documentation
35
+
36
+ Read the [published guides and API reference](https://gritzrpc.github.io/gritz/), [public API policy](https://github.com/gritzrpc/gritz/blob/main/docs/public-api.md), [support policy](https://github.com/gritzrpc/gritz/blob/main/docs/support-policy.md) and [stabilization gate](https://github.com/gritzrpc/gritz/blob/main/docs/stabilization.md).
37
+
34
38
  ## License
35
39
 
36
40
  [MIT](LICENSE.txt).
data/lib/gritz/cli.rb CHANGED
@@ -4,9 +4,11 @@ require "optparse"
4
4
  require "logger"
5
5
  require "rbconfig"
6
6
  require "socket"
7
+ require "net/http"
8
+ require "json"
7
9
 
8
10
  module Gritz
9
- # Starts a server, lists routes, or checks configuration and fork safety.
11
+ # Starts and operates servers, lists routes, or checks configuration and fork safety.
10
12
  # @api public
11
13
  class CLI
12
14
  CHILD_BOOTSTRAP = <<~RUBY
@@ -35,11 +37,13 @@ module Gritz
35
37
  path = nil
36
38
  overrides = {}
37
39
  parser = OptionParser.new do |options|
38
- options.banner = "Usage: gritz [start|routes|check] [-C config/gritz.rb] [options]"
40
+ options.banner = "Usage: gritz [start|routes|check|stats|stop|restart] [-C config/gritz.rb] [options]"
39
41
  options.on("-C", "--config PATH", "Configuration file") { |value| path = value }
40
42
  options.on("--workers N", Integer) { |value| overrides[:workers] = value }
41
43
  options.on("--threads N", Integer) { |value| overrides[:threads] = value }
42
44
  options.on("--bind ADDRESS") { |value| overrides[:bind] = value }
45
+ options.on("--admin-bind ADDRESS", "Admin HTTP host:port (operations do not load config)") { |value| overrides[:admin_bind] = value }
46
+ options.on("--pid-file PATH", "Active master PID file; operations fall back if Admin is unavailable") { |value| overrides[:pid_file] = value }
43
47
  options.on("--strict-routes") { overrides[:strict_routes] = true }
44
48
  options.on("-v", "--version") {
45
49
  @stdout.puts(Core::VERSION)
@@ -52,9 +56,15 @@ module Gritz
52
56
  end
53
57
  parser.parse!(args)
54
58
  command = args.shift || "start"
55
- raise ConfigurationError, "Unknown command #{command}" unless %w[start routes check].include?(command)
59
+ raise ConfigurationError, "Unknown command #{command}" unless %w[start routes check stats stop restart].include?(command)
56
60
  raise ConfigurationError, "Unexpected argument #{args.first}" unless args.empty?
57
61
 
62
+ if %w[stats stop restart].include?(command)
63
+ raise ConfigurationError, "#{command} does not load configuration; use --admin-bind or --pid-file instead of -C" if path
64
+
65
+ return run_operation(command, overrides)
66
+ end
67
+
58
68
  logger = Logger.new(@stdout)
59
69
  logger.formatter = ->(_severity, _time, _progname, message) { "#{message}\n" }
60
70
  if command == "start" && @launch && !@owner_channel
@@ -127,5 +137,116 @@ module Gritz
127
137
  @owner_channel&.close
128
138
  listener&.close unless listener&.closed?
129
139
  end
140
+
141
+ private
142
+
143
+ def run_operation(command, overrides)
144
+ address = overrides.fetch(:admin_bind) { @env.fetch("GRITZ_ADMIN_BIND", Configuration::DEFAULTS[:admin_bind]) }
145
+ path = overrides.fetch(:pid_file) { @env.fetch("GRITZ_PID_FILE", "") }
146
+ file_pid = read_pid_file(path) unless path.empty? || command == "stats"
147
+ status = read_admin_status(address, fallback: !file_pid.nil?)
148
+ if command == "stats"
149
+ @stdout.puts "State: #{status.fetch('state')} Master PID: #{status.fetch('pid')} Owner PID: #{status['owner_pid'] || 'n/a'}"
150
+ @stdout.puts "WORKER PID STATE RSS PSS"
151
+ status.fetch("workers").each do |worker|
152
+ @stdout.puts "#{worker['index']} #{worker.fetch('pid')} #{worker.fetch('state')} " \
153
+ "#{format_bytes(worker['rss_bytes'])} #{format_bytes(worker['pss_bytes'])}"
154
+ end
155
+ else
156
+ if status && file_pid && status.fetch("pid") != file_pid
157
+ raise ConfigurationError, "Admin master PID #{status.fetch('pid')} does not match PID file #{file_pid}"
158
+ end
159
+
160
+ pid = status ? status.fetch("owner_pid", status.fetch("pid")) : file_pid
161
+ signal = command == "stop" ? "TERM" : "USR2"
162
+ begin
163
+ Process.kill(signal, pid)
164
+ rescue SystemCallError => e
165
+ raise ConfigurationError, "Cannot send #{signal} to PID #{pid}: #{e.message}"
166
+ end
167
+ @stdout.puts "Sent #{signal} to PID #{pid}"
168
+ end
169
+ 0
170
+ end
171
+
172
+ def read_pid_file(path)
173
+ File.open(path, File::RDONLY | File::NONBLOCK) do |file|
174
+ raise ConfigurationError, "PID file must be a regular file: #{path}" unless file.stat.file?
175
+ raise ConfigurationError, "Invalid PID file: #{path}" if file.stat.size > 64
176
+
177
+ value = file.read(65).to_s.strip
178
+ raise ConfigurationError, "Invalid PID file: #{path}" unless value.match?(/\A[0-9]{1,10}\z/)
179
+
180
+ validate_pid!(Integer(value, 10))
181
+ end
182
+ rescue SystemCallError => e
183
+ raise ConfigurationError, "Cannot read PID file #{path}: #{e.message}"
184
+ end
185
+
186
+ def read_admin_status(address, fallback:)
187
+ match = address.match(/\A(\[[a-zA-Z0-9_.:%-]+\]|[a-zA-Z0-9_.-]+):([0-9]+)\z/)
188
+ raise ConfigurationError, "Invalid Admin address: #{address}" unless match && Integer(match[2], 10).between?(1, 65_535)
189
+
190
+ # Operations always target the requested endpoint, never an environment HTTP proxy.
191
+ http = Net::HTTP.new(match[1].delete_prefix("[").delete_suffix("]"), Integer(match[2], 10), nil)
192
+ http.open_timeout = http.read_timeout = 2
193
+ http.max_retries = 0
194
+ body = +""
195
+ response_started = false
196
+ http.start do |client|
197
+ client.request(Net::HTTP::Get.new("/status")) do |response|
198
+ response_started = true
199
+ raise ConfigurationError, "Admin /status returned HTTP #{response.code}" unless response.code == "200"
200
+
201
+ response.ignore_eof = false
202
+ response.read_body do |chunk|
203
+ raise ConfigurationError, "Admin /status response exceeds 1 MiB" if body.bytesize + chunk.bytesize > 1_048_576
204
+
205
+ body << chunk
206
+ end
207
+ end
208
+ end
209
+ validate_status!(JSON.parse(body))
210
+ rescue SystemCallError, IOError, SocketError, Timeout::Error => e
211
+ return nil if fallback && !response_started
212
+
213
+ raise ConfigurationError, "Admin /status unavailable at #{address}: #{e.message}; stop/restart can use --pid-file"
214
+ rescue JSON::ParserError, Net::ProtocolError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError => e
215
+ raise ConfigurationError, "Invalid Admin /status response: #{e.message}"
216
+ end
217
+
218
+ def validate_pid!(pid)
219
+ unless pid.is_a?(Integer) && pid.between?(2, 2_147_483_647) && pid != Process.pid
220
+ raise ConfigurationError, "Invalid server PID: #{pid.inspect}"
221
+ end
222
+
223
+ pid
224
+ end
225
+
226
+ def validate_status!(status)
227
+ unless status.is_a?(Hash) && %w[starting running draining].include?(status["state"]) && status["workers"].is_a?(Array)
228
+ raise ConfigurationError, "Invalid Admin /status process state"
229
+ end
230
+
231
+ validate_pid!(status["pid"])
232
+ validate_pid!(status["owner_pid"]) if status.key?("owner_pid")
233
+ status["workers"].each do |worker|
234
+ unless worker.is_a?(Hash) && Supervisor::WorkerHandle::STATES.include?(worker["state"]) &&
235
+ worker["index"].is_a?(Integer) && worker["index"] >= 0
236
+ raise ConfigurationError, "Invalid Admin /status worker state"
237
+ end
238
+
239
+ validate_pid!(worker["pid"])
240
+ %w[rss_bytes pss_bytes].each do |name|
241
+ value = worker[name]
242
+ unless value.nil? || (value.is_a?(Numeric) && value.real? && value.finite? && value >= 0)
243
+ raise ConfigurationError, "Invalid Admin /status #{name}"
244
+ end
245
+ end
246
+ end
247
+ status
248
+ end
249
+
250
+ def format_bytes(value) = value ? format("%.2f MiB", value / (1024.0 * 1024)) : "n/a"
130
251
  end
131
252
  end
@@ -184,6 +184,7 @@ module Gritz
184
184
  end
185
185
  end
186
186
 
187
+ # @api private
187
188
  class InterceptorAdapter
188
189
  def initialize(app, klass, options)
189
190
  @app = app
@@ -216,7 +216,8 @@ module Gritz
216
216
  end
217
217
 
218
218
  tls.each do |name, path|
219
- raise ConfigurationError, "Invalid tls #{name}" unless %i[cert key client_ca].include?(name) && path.is_a?(String) && File.readable?(path)
219
+ valid = %i[cert key client_ca].include?(name) && path.is_a?(String) && File.file?(path) && File.readable?(path)
220
+ raise ConfigurationError, "Invalid tls #{name}" unless valid
220
221
  end
221
222
  end
222
223
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gritz
4
4
  module Core
5
- VERSION = "0.6.1"
5
+ VERSION = "0.9.1"
6
6
  end
7
7
  end
data/lib/gritz/core.rb CHANGED
@@ -4,6 +4,8 @@ require "zeitwerk"
4
4
  require_relative "errors"
5
5
  require_relative "core/version"
6
6
 
7
+ # Transport-independent RPC controllers, middleware, clients and lifecycle tools.
8
+ # @api public
7
9
  module Gritz
8
10
  module Core
9
11
  # Core loading deliberately does not require grpc or initialize transports.
@@ -16,6 +16,7 @@ module Gritz
16
16
  end
17
17
  end
18
18
 
19
+ # @api private
19
20
  module ConstructorHook
20
21
  def new(...)
21
22
  Gritz::ForkGuard.check!(self)
@@ -9,7 +9,7 @@ module Gritz
9
9
  # Keeps process ownership and probes stable while fresh master interpreters replace each other.
10
10
  # @api private
11
11
  class Launcher
12
- Generation = Struct.new(:pid, :channel, :token, :role, :metadata, :snapshot, :deadline,
12
+ Generation = Struct.new(:pid, :channel, :token, :role, :metadata, :snapshot, :started_at, :deadline,
13
13
  :kill_at, :kill_sent, :exit_status, :group_reaped, :identities, keyword_init: true)
14
14
  SIGNALS = %w[TERM INT QUIT USR1 USR2 TTIN TTOU HUP CHLD].freeze
15
15
 
@@ -91,11 +91,12 @@ module Gritz
91
91
 
92
92
  def spawn_generation
93
93
  parent, child = UNIXSocket.pair
94
+ started_at = monotonic
94
95
  pid = Process.spawn(@env.to_h.merge("GRITZ_INTERNAL_OWNER_FD" => "3"), *@command,
95
96
  3 => child, out: @stdout, err: @stderr, pgroup: true)
96
97
  @serial += 1
97
98
  @candidate = Generation.new(pid: pid, channel: StatusChannel.new(parent), token: @serial, role: :candidate,
98
- deadline: monotonic + @startup_timeout, identities: {})
99
+ started_at:, deadline: started_at + @startup_timeout, identities: {})
99
100
  @generations[pid] = @candidate
100
101
  @reexec = { state: "starting", pid: pid } if @active
101
102
  rescue StandardError
@@ -137,6 +138,7 @@ module Gritz
137
138
 
138
139
  def configure_generation(generation, metadata)
139
140
  return if generation.metadata || @stopping
141
+ raise ConfigurationError, "replacement master configuration timed out" if monotonic >= generation.deadline
140
142
 
141
143
  if metadata[:listener_strategy] && !%w[reuseport inherited_fd].include?(metadata[:listener_strategy])
142
144
  raise ConfigurationError, "Invalid master listener_strategy"
@@ -163,7 +165,7 @@ module Gritz
163
165
  lock_pid_file(metadata[:pid_file]) unless metadata[:pid_file].empty?
164
166
  end
165
167
  generation.metadata = metadata
166
- generation.deadline = [generation.deadline, monotonic + metadata[:reexec_timeout]].min
168
+ generation.deadline = generation.started_at + metadata[:reexec_timeout]
167
169
  if metadata[:listener_strategy] == "inherited_fd"
168
170
  @listener ||= Listener.bind(metadata[:bind])
169
171
  generation.channel.io.send_io(@listener)
@@ -218,6 +218,58 @@ RSpec.shared_examples Gritz::Testing::TransportContract do |adapter:, service:,
218
218
  end
219
219
  end
220
220
 
221
+ %i[say_hello record_names list_greetings chat].each do |action|
222
+ it "lets a cooperative #{action} controller observe cancellation and releases its serving slot" do
223
+ entered = Queue.new
224
+ completed = Queue.new
225
+ release = Queue.new
226
+ response = contract_reply
227
+ controller = contract_controller do
228
+ define_method(action) do
229
+ observing = self.request.message.name == "cancel"
230
+ if observing
231
+ entered << true
232
+ context.check_cancelled! until release.pop(timeout: 0.005)
233
+ end
234
+ result = response.new(message: "available")
235
+ context.method.server_streaming? ? stream.write(result) : result
236
+ ensure
237
+ completed << true if observing
238
+ end
239
+ end
240
+ with_contract_server(controller, threads: 1) do |client, server|
241
+ streaming_input = %i[record_names chat].include?(action)
242
+ message = contract_request.new(name: "cancel")
243
+ operation = client.public_send(action, streaming_input ? [message] : message, return_op: true, deadline: Time.now + 5)
244
+ caller = Thread.new do
245
+ result = operation.execute
246
+ result.to_a if %i[list_greetings chat].include?(action)
247
+ rescue GRPC::BadStatus => e
248
+ e
249
+ end
250
+ expect(entered.pop(timeout: 2)).to be(true)
251
+ operation.cancel
252
+ expect(caller.join(2)).not_to be_nil
253
+ expect(caller.value).to be_a(GRPC::Cancelled)
254
+ expect(completed.pop(timeout: 1)).to be(true)
255
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1
256
+ until server.transport.stats[:inflight].zero? && (adapter == :async || server.transport.stats[:busy].zero?)
257
+ raise "cancelled controller retained its serving slot" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
258
+
259
+ sleep 0.005
260
+ end
261
+ message = contract_request.new
262
+ result = client.public_send(action, streaming_input ? [message] : message, deadline: Time.now + 1)
263
+ result = result.to_a.first if %i[list_greetings chat].include?(action)
264
+ expect(result.message).to eq("available")
265
+ ensure
266
+ release << true
267
+ operation&.cancel
268
+ caller&.join(2)
269
+ end
270
+ end
271
+ end
272
+
221
273
  it "finishes an in-flight response during graceful shutdown" do
222
274
  entered = Queue.new
223
275
  gate = Queue.new
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gritz-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -171,6 +171,7 @@ licenses:
171
171
  metadata:
172
172
  allowed_push_host: https://rubygems.org
173
173
  source_code_uri: https://github.com/gritzrpc/gritz-core
174
+ documentation_uri: https://gritzrpc.github.io/gritz/
174
175
  changelog_uri: https://github.com/gritzrpc/gritz-core/blob/main/CHANGELOG.md
175
176
  rubygems_mfa_required: 'true'
176
177
  rdoc_options: []