gritz-core 0.4.0 → 0.6.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.
@@ -49,7 +49,7 @@ module Gritz
49
49
  end
50
50
  update_lifecycle
51
51
  @admin&.poll
52
- @observer&.write(status)
52
+ @observer&.write { status }
53
53
  @observer&.flush
54
54
  break if @stopping && @generations.empty?
55
55
 
@@ -138,6 +138,10 @@ module Gritz
138
138
  def configure_generation(generation, metadata)
139
139
  return if generation.metadata || @stopping
140
140
 
141
+ if metadata[:listener_strategy] && !%w[reuseport inherited_fd].include?(metadata[:listener_strategy])
142
+ raise ConfigurationError, "Invalid master listener_strategy"
143
+ end
144
+
141
145
  %i[admin_bind bind pid_file].each do |field|
142
146
  raise ConfigurationError, "Missing master #{field}" unless metadata[field].is_a?(String)
143
147
  end
@@ -149,7 +153,7 @@ module Gritz
149
153
  raise ConfigurationError, "Invalid master #{field}" unless value.is_a?(Numeric) && value.finite? && value >= 0
150
154
  end
151
155
  if @metadata
152
- %i[admin_bind bind pid_file].each do |field|
156
+ %i[admin_bind bind pid_file listener_strategy].each do |field|
153
157
  raise ConfigurationError, "USR2 cannot change #{field}" unless metadata[field] == @metadata[field]
154
158
  end
155
159
  else
@@ -160,6 +164,10 @@ module Gritz
160
164
  end
161
165
  generation.metadata = metadata
162
166
  generation.deadline = [generation.deadline, monotonic + metadata[:reexec_timeout]].min
167
+ if metadata[:listener_strategy] == "inherited_fd"
168
+ @listener ||= Listener.bind(metadata[:bind])
169
+ generation.channel.io.send_io(@listener)
170
+ end
163
171
  end
164
172
 
165
173
  def ready_workers(generation)
@@ -188,7 +196,7 @@ module Gritz
188
196
  return
189
197
  end
190
198
 
191
- if @active.metadata[:bind].end_with?(":0")
199
+ if @active.metadata[:bind].end_with?(":0") && !@listener
192
200
  @reexec = { state: "failed", error: "USR2 requires a fixed RPC port" }
193
201
  @logger.warn(@reexec[:error])
194
202
  return
@@ -371,6 +379,7 @@ module Gritz
371
379
  @admin&.close
372
380
  @observer&.close
373
381
  @signals&.close
382
+ @listener&.close
374
383
  if @written_pid && File.file?(@pid_path) && File.read(@pid_path).strip == @written_pid.to_s
375
384
  File.unlink(@pid_path)
376
385
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Gritz
6
+ module Supervisor
7
+ # Creates a plain TCP listener without initializing a transport or scheduler.
8
+ # @api private
9
+ module Listener
10
+ def self.bind(address, reuseport: false)
11
+ host, _, port = address.rpartition(":")
12
+ info = Addrinfo.tcp(host.delete_prefix("[").delete_suffix("]"), Integer(port))
13
+ socket = Socket.new(info.afamily, Socket::SOCK_STREAM)
14
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1)
15
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEPORT, 1) if reuseport
16
+ socket.bind(info)
17
+ socket.listen(Socket::SOMAXCONN)
18
+ socket
19
+ rescue StandardError
20
+ socket&.close
21
+ raise
22
+ end
23
+ end
24
+ end
25
+ end
@@ -10,12 +10,13 @@ module Gritz
10
10
  SIGNALS = %w[TERM INT QUIT TTIN TTOU HUP CHLD USR1 USR2].freeze
11
11
  attr_reader :workers
12
12
 
13
- def initialize(config, logger: Logger.new($stdout), status_io: nil, owner_channel: nil)
13
+ def initialize(config, logger: Logger.new($stdout), status_io: nil, owner_channel: nil, listener: nil)
14
14
  @config = config
15
15
  @logger = logger
16
16
  @workers = {}
17
17
  @desired = config.workers
18
18
  @owner_channel = owner_channel
19
+ @listener = listener
19
20
  @reports = owner_channel || (StatusChannel.new(status_io) if status_io)
20
21
  @metrics = Metrics::Aggregator.new
21
22
  @forwarded = []
@@ -27,7 +28,8 @@ module Gritz
27
28
  @config.validate_runtime!
28
29
  raise ConfigurationError, "Supervisor requires workers > 0" unless @desired.positive?
29
30
 
30
- require "gritz/native"
31
+ require "gritz/#{@config.transport}"
32
+ @listener ||= Listener.bind(@config.bind) if @config.listener_strategy == :inherited_fd
31
33
  @signals = SignalQueue.new(signals: SIGNALS)
32
34
  unless @owner_channel
33
35
  @admin = AdminServer.new(bind: @config.admin_bind, status: -> { status }, ready: -> { ready? },
@@ -36,7 +38,7 @@ module Gritz
36
38
  @guard = ForkGuard.activate(mode: @config.fork_mode == :clean ? @config.fork_guard : :off, logger: @logger)
37
39
  @config.preload! if @config.preload_app?
38
40
  Process.warmup if @config.preload_app? && Process.respond_to?(:warmup)
39
- if @config.workers > 1 && !RUBY_PLATFORM.include?("linux")
41
+ if @config.transport == :native && @config.workers > 1 && !RUBY_PLATFORM.include?("linux")
40
42
  @logger.warn("Multiple native workers require Linux for SO_REUSEPORT load balancing; use workers 0 on macOS")
41
43
  end
42
44
  maintain_worker_count
@@ -50,7 +52,10 @@ module Gritz
50
52
  advance_replacement
51
53
  maintain_worker_count unless @shutdown_at
52
54
  flush_forwarded
53
- @reports&.write(@owner_channel ? status.merge(type: "status") : status) if @forwarded.empty?
55
+ if @reports && @forwarded.empty? && (!@next_report_at || now >= @next_report_at || @shutdown_at) &&
56
+ @reports.write { @owner_channel ? status.merge(type: "status") : status }
57
+ @next_report_at = now + @config.status_interval
58
+ end
54
59
  @admin&.poll
55
60
  if @owner_channel&.closed?
56
61
  @exit_status = 1
@@ -115,7 +120,7 @@ module Gritz
115
120
  @admin&.close
116
121
  @workers.each_value(&:close)
117
122
  Transport::Native.postfork_child if experimental
118
- exit_status = Worker::Runner.new(index: index, status_io: writer, config: @config, logger: @logger).run
123
+ exit_status = Worker::Runner.new(index: index, status_io: writer, config: @config, logger: @logger, listener: @listener).run
119
124
  rescue StandardError, LoadError, SyntaxError, SystemExit => e
120
125
  @logger.error("Worker #{index} failed: #{e.full_message}")
121
126
  exit_status = 1
@@ -132,6 +137,7 @@ module Gritz
132
137
  handle = WorkerHandle.new(pid: pid, index: index, status_io: reader)
133
138
  handle.recycle_factor = 1.0 + (rand * @config.worker_recycle.fetch(:jitter, 0.0))
134
139
  @workers[pid] = handle
140
+ @next_report_at = nil
135
141
  @logger.info("Worker #{index} spawned pid=#{pid}")
136
142
  handle
137
143
  rescue StandardError
@@ -159,7 +165,9 @@ module Gritz
159
165
  end
160
166
  next
161
167
  end
168
+ changed = handle.state != message[:state] || (message.key?(:healthy) && handle.stats[:healthy] != message[:healthy])
162
169
  handle.update(message, now: now)
170
+ @next_report_at = nil if changed
163
171
  if message[:state] == "failed" && !@ever_ready
164
172
  @exit_status = 1
165
173
  begin_shutdown
@@ -207,6 +215,7 @@ module Gritz
207
215
  break if handle.channel.closed?
208
216
  end
209
217
  @workers.delete(pid)
218
+ @next_report_at = nil
210
219
  @metrics.forget(handle)
211
220
  handle.close
212
221
  child_status = result.last
@@ -223,17 +232,19 @@ module Gritz
223
232
  break unless @forwarded.empty?
224
233
  rescue Errno::ECHILD
225
234
  @workers.delete(pid)&.close
235
+ @next_report_at = nil
226
236
  end
227
237
  end
228
238
 
229
239
  def handle_signal(signal)
240
+ @next_report_at = nil
230
241
  case signal
231
242
  when "TERM", "INT" then begin_shutdown
232
243
  when "QUIT" then begin_shutdown(immediate: true)
233
244
  when "TTIN"
234
245
  if @replacement || !@replacement_queue.empty?
235
246
  @logger.warn("Wait for phased restart before resizing workers")
236
- elsif !@shutdown_at && @config.bind.end_with?(":0")
247
+ elsif !@shutdown_at && @config.bind.end_with?(":0") && !@listener
237
248
  @logger.warn("Cannot add a reuseport worker with port 0; configure a fixed bind port")
238
249
  elsif !@shutdown_at
239
250
  @desired += 1
@@ -284,7 +295,7 @@ module Gritz
284
295
  end
285
296
 
286
297
  def fixed_port?
287
- return true unless @config.bind.end_with?(":0")
298
+ return true if @listener || !@config.bind.end_with?(":0")
288
299
 
289
300
  @logger.warn("Worker replacement requires a fixed bind port")
290
301
  false
@@ -301,6 +312,7 @@ module Gritz
301
312
  kill(fresh) if fresh
302
313
  @logger.warn("Replacement failed; keeping previous workers")
303
314
  @replacement = nil
315
+ @next_report_at = nil
304
316
  @replacement_queue.clear
305
317
  @recycle_retry_at = now + @config.worker_boot_timeout
306
318
  return true
@@ -314,6 +326,7 @@ module Gritz
314
326
  return if old || !@replacement[:retired]
315
327
 
316
328
  @replacement = nil
329
+ @next_report_at = nil
317
330
  end
318
331
  while (entry = @replacement_queue.shift)
319
332
  old_pid, reason = entry
@@ -329,7 +342,7 @@ module Gritz
329
342
  def check_recycle
330
343
  return if @shutdown_at || @replacement || !@replacement_queue.empty? || @config.worker_recycle.empty?
331
344
  return if @recycle_retry_at && now < @recycle_retry_at
332
- return if @config.bind.end_with?(":0")
345
+ return if @config.bind.end_with?(":0") && !@listener
333
346
 
334
347
  @workers.each_value do |handle|
335
348
  next unless handle.state == "ready" && !handle.term_at
@@ -418,6 +431,7 @@ module Gritz
418
431
  @workers.clear
419
432
  @signals&.close
420
433
  @admin&.close
434
+ @listener&.close
421
435
  flush_forwarded
422
436
  @reports&.close
423
437
  end
@@ -10,6 +10,7 @@ module Gritz
10
10
  def initialize(signals:)
11
11
  @io, @writer = IO.pipe
12
12
  @pending = []
13
+ @read_buffer = +""
13
14
  @previous = {}
14
15
  signals.each do |name|
15
16
  @previous[name] = Signal.trap(name) do
@@ -26,7 +27,7 @@ module Gritz
26
27
 
27
28
  def drain
28
29
  loop do
29
- break unless @io.read_nonblock(4096, exception: false).is_a?(String)
30
+ break unless @io.read_nonblock(4096, @read_buffer, exception: false).is_a?(String)
30
31
  end
31
32
  # Swap instead of clearing: a signal arriving here belongs to the next drain.
32
33
  pending = @pending
@@ -16,14 +16,16 @@ module Gritz
16
16
  def initialize(io)
17
17
  @io = io
18
18
  @buffer = +""
19
+ @read_buffer = +""
19
20
  @pending = +""
20
21
  @discarding = false
21
22
  @closed = io.closed?
22
23
  end
23
24
 
24
- def write(status)
25
+ def write(status = nil)
25
26
  return false if closed? || flush_pending.positive?
26
27
 
28
+ status = yield if block_given?
27
29
  line = "#{JSON.generate(status)}\n"
28
30
  return false if line.bytesize > MAX_LINE_BYTES
29
31
 
@@ -54,7 +56,7 @@ module Gritz
54
56
 
55
57
  remaining = max_bytes
56
58
  while remaining.positive?
57
- chunk = @io.read_nonblock([4096, remaining].min, exception: false)
59
+ chunk = @io.read_nonblock([4096, remaining].min, @read_buffer, exception: false)
58
60
  break if chunk == :wait_readable
59
61
 
60
62
  if chunk.nil?
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Gritz
6
+ module Testing
7
+ # Starts a single-process server for integration tests.
8
+ # @api public
9
+ class Server
10
+ attr_reader :port, :address, :transport
11
+
12
+ def self.start(config = nil, controllers: nil, middleware: nil, logger: Logger.new(File::NULL))
13
+ config ||= Configuration.new.tap do |settings|
14
+ settings.workers = 0
15
+ settings.bind = "127.0.0.1:0"
16
+ end
17
+ config.controllers = controllers if controllers
18
+ config.middleware = middleware if middleware
19
+ server = new(config, logger:).start
20
+ return server unless block_given?
21
+
22
+ begin
23
+ yield server
24
+ ensure
25
+ server.stop
26
+ end
27
+ end
28
+
29
+ def initialize(config, logger:)
30
+ @config = config
31
+ @logger = logger
32
+ config.validate_single_process!
33
+ end
34
+
35
+ def start
36
+ raise ArgumentError, "Testing::Server is already started or stopped" if @started || @stopped
37
+
38
+ @started = true
39
+ begin
40
+ @config.preload! if @config.preload_app?
41
+ router = Router.new(controllers: @config.controllers, strict: @config.strict_routes, logger: @logger)
42
+ @lifecycle_started = true
43
+ @config.run_hooks(:on_worker_boot, 0)
44
+ @recorder = @config.metrics_recorder_factory&.call(worker: 0)
45
+ dispatcher = Dispatcher.new(router:, middleware: @config.middleware, logger: @logger, metrics: @recorder)
46
+ @transport = Transport.const_get(@config.transport.to_s.capitalize).new(config: @config, dispatcher:, logger: @logger)
47
+ @port = transport.bind
48
+ @address = @config.bind.sub(/:\d+\z/, ":#{port}")
49
+ transport.start
50
+ unless @config.health_checks.empty?
51
+ @health_stop = Queue.new
52
+ @health_thread = Thread.new do
53
+ transport.refresh_health until @health_stop.pop(timeout: @config.status_interval)
54
+ end
55
+ end
56
+ self
57
+ rescue StandardError
58
+ stop
59
+ raise
60
+ end
61
+ end
62
+
63
+ def stop
64
+ return if @stopped
65
+
66
+ @stopped = true
67
+ deadline = monotonic + @config.shutdown_timeout
68
+ begin
69
+ @health_stop&.push(true)
70
+ if @health_thread && !@health_thread.join([deadline - monotonic, 0].max)
71
+ @health_thread.kill.join
72
+ end
73
+ transport&.stop(deadline: Time.now + [deadline - monotonic, 0].max)
74
+ ensure
75
+ begin
76
+ @config.run_hooks(:on_worker_shutdown, 0) if @lifecycle_started
77
+ ensure
78
+ begin
79
+ @recorder&.observe_rejected(transport.stats.fetch(:rejected_total, 0)) if transport
80
+ @recorder&.observe_worker({ pid: Process.pid, index: 0, state: "stopped" })
81
+ ensure
82
+ @recorder&.close(timeout: [deadline - monotonic, 0].max)
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require "rspec/expectations"
5
+ require "grpc"
6
+ require "google/rpc/status_pb"
7
+ require "google/rpc/error_details_pb"
8
+ require "gritz/testing/server"
9
+
10
+ module Gritz
11
+ module Testing
12
+ # Shared wire tests using the official gRPC client as an independent oracle.
13
+ # Include with adapter:, service:, stub:, request:, reply: for a four-method fixture.
14
+ # Explicitly requiring this helper needs RSpec and grpc as test dependencies.
15
+ # @api public
16
+ module TransportContract
17
+ end
18
+ end
19
+ end
20
+
21
+ RSpec.shared_examples Gritz::Testing::TransportContract do |adapter:, service:, stub:, request:, reply:|
22
+ let(:contract_request) { request }
23
+ let(:contract_reply) { reply }
24
+
25
+ define_method(:contract_controller) do |&block|
26
+ response = reply
27
+ Class.new(Gritz::Controller) do
28
+ bind service
29
+ define_method(:say_hello) { response.new(message: self.request.message.name) }
30
+ define_method(:list_greetings) { 3.times { |i| stream.write(response.new(message: "#{self.request.message.name}:#{i}")) } }
31
+ define_method(:record_names) { response.new(count: self.request.each_message.count) }
32
+ define_method(:chat) { self.request.each_message { |msg| stream.write(response.new(message: msg.name)) } }
33
+ class_eval(&block) if block
34
+ end
35
+ end
36
+
37
+ define_method(:with_contract_server) do |controller = contract_controller, **settings, &block|
38
+ config = Gritz::Configuration.new
39
+ config.transport = adapter
40
+ config.listener_strategy = adapter == :async ? :inherited_fd : :reuseport
41
+ config.bind = "127.0.0.1:0"
42
+ settings.each { |key, value| config.public_send("#{key}=", value) }
43
+ Gritz::Testing::Server.start(config, controllers: [controller]) do |server|
44
+ client = stub.new(server.address, :this_channel_is_insecure)
45
+ block.call(client, server)
46
+ end
47
+ end
48
+
49
+ it "preserves unary protobuf messages" do
50
+ with_contract_server { |client| expect(client.say_hello(contract_request.new(name: "Ruby")).message).to eq("Ruby") }
51
+ end
52
+
53
+ it "preserves ordered server streaming messages" do
54
+ with_contract_server do |client|
55
+ expect(client.list_greetings(contract_request.new(name: "x")).map(&:message)).to eq(%w[x:0 x:1 x:2])
56
+ end
57
+ end
58
+
59
+ it "reads client streams through half-close" do
60
+ with_contract_server do |client|
61
+ expect(client.record_names(%w[a b c].map { |name| contract_request.new(name:) }).count).to eq(3)
62
+ end
63
+ end
64
+
65
+ it "replies to bidi input before the client half-closes" do
66
+ gate = Queue.new
67
+ requests = Enumerator.new do |output|
68
+ output << contract_request.new(name: "first")
69
+ gate.pop
70
+ output << contract_request.new(name: "second")
71
+ end
72
+ with_contract_server do |client|
73
+ responses = client.chat(requests, deadline: Time.now + 5)
74
+ expect(responses.next.message).to eq("first")
75
+ gate << true
76
+ expect(responses.next.message).to eq("second")
77
+ expect { responses.next }.to raise_error(StopIteration)
78
+ end
79
+ ensure
80
+ gate << true
81
+ end
82
+
83
+ it "sends streaming output before the controller returns" do
84
+ gate = Queue.new
85
+ response = contract_reply
86
+ controller = contract_controller do
87
+ define_method(:list_greetings) do
88
+ stream.write(response.new(message: "first"))
89
+ gate.pop
90
+ stream.write(response.new(message: "second"))
91
+ end
92
+ end
93
+ with_contract_server(controller) do |client|
94
+ responses = client.list_greetings(contract_request.new, deadline: Time.now + 5)
95
+ expect(responses.next.message).to eq("first")
96
+ gate << true
97
+ expect(responses.next.message).to eq("second")
98
+ expect { responses.next }.to raise_error(StopIteration)
99
+ end
100
+ ensure
101
+ gate << true
102
+ end
103
+
104
+ it "passes binary metadata, deadline and peer, with separate headers and trailers" do
105
+ observations = Queue.new
106
+ response = contract_reply
107
+ controller = contract_controller do
108
+ define_method(:say_hello) do
109
+ observations << [context.metadata, context.deadline, context.peer, context.peer_identity]
110
+ context.call.send_initial_metadata("answer" => "initial", "reply-bin" => "\x00\xff".b, "repeat-bin" => ["a".b, "b".b])
111
+ context.call.trailing_metadata["answer"] = "trailing"
112
+ context.call.trailing_metadata["trailer-bin"] = "\x00\xfe".b
113
+ response.new(message: context.request_id)
114
+ end
115
+ end
116
+ with_contract_server(controller) do |client|
117
+ deadline = Time.now + 5
118
+ operation = client.say_hello(contract_request.new, return_op: true, deadline:,
119
+ metadata: { "x-request-id" => "req-1", "request-bin" => "\x00\xfd".b, "repeat" => %w[a b] })
120
+ expect(operation.execute.message).to eq("req-1")
121
+ expect(operation.metadata).to include("answer" => "initial", "reply-bin" => "\x00\xff".b, "x-request-id" => "req-1")
122
+ expect(operation.trailing_metadata).to include("answer" => "trailing", "trailer-bin" => "\x00\xfe".b)
123
+ expect(operation.metadata["repeat-bin"]).to eq(%w[a b])
124
+ metadata, actual_deadline, peer, identity = observations.pop(timeout: 2)
125
+ expect(metadata).to include("request-bin" => "\x00\xfd".b)
126
+ expect(metadata["repeat"]).to eq(%w[a b])
127
+ expect(actual_deadline).to be_within(0.1).of(deadline)
128
+ expect(peer).to match(/ipv[46]:/)
129
+ expect(identity).to be_nil
130
+ end
131
+ end
132
+
133
+ it "encodes application status, metadata and rich protobuf details" do
134
+ controller = contract_controller do
135
+ def say_hello
136
+ fail!(:not_found, "missing", metadata: { "lookup" => "failed" },
137
+ details: [Google::Rpc::ResourceInfo.new(resource_type: "name", resource_name: "Ruby")])
138
+ end
139
+ end
140
+ with_contract_server(controller) do |client|
141
+ expect { client.say_hello(contract_request.new) }.to raise_error(GRPC::NotFound) do |error|
142
+ expect(error.details).to eq("missing")
143
+ expect(error.metadata).to include("lookup" => "failed")
144
+ status = Google::Rpc::Status.decode(error.metadata.fetch("grpc-status-details-bin"))
145
+ expect([status.code, status.message]).to eq([5, "missing"])
146
+ expect(status.details.first.unpack(Google::Rpc::ResourceInfo).resource_name).to eq("Ruby")
147
+ end
148
+ end
149
+ end
150
+
151
+ it "preserves a non-OK status after streamed messages" do
152
+ response = contract_reply
153
+ controller = contract_controller do
154
+ define_method(:list_greetings) do
155
+ stream.write(response.new(message: "first"))
156
+ fail!(:permission_denied, "revoked", metadata: { "reason" => "revoked" })
157
+ end
158
+ end
159
+ with_contract_server(controller) do |client|
160
+ responses = client.list_greetings(contract_request.new)
161
+ expect(responses.next.message).to eq("first")
162
+ expect { responses.next }.to raise_error(GRPC::PermissionDenied) do |error|
163
+ expect(error.metadata).to include("reason" => "revoked")
164
+ end
165
+ end
166
+ end
167
+
168
+ it "hides internal exception messages" do
169
+ controller = contract_controller { def say_hello = raise("database secret") }
170
+ with_contract_server(controller) do |client|
171
+ expect { client.say_hello(contract_request.new) }.to raise_error(GRPC::Internal) do |error|
172
+ expect(error.details).not_to include("database secret")
173
+ expect(error.metadata).to have_key("error-id")
174
+ end
175
+ end
176
+ end
177
+
178
+ it "rejects an unimplemented controller action" do
179
+ controller = Class.new(Gritz::Controller) { bind service }
180
+ with_contract_server(controller) do |client|
181
+ expect { client.say_hello(contract_request.new) }.to raise_error(GRPC::Unimplemented)
182
+ end
183
+ end
184
+
185
+ it "honors an active call deadline" do
186
+ controller = contract_controller { def say_hello = sleep(1) }
187
+ with_contract_server(controller) do |client|
188
+ expect { client.say_hello(contract_request.new, deadline: Time.now + 0.05) }.to raise_error(GRPC::DeadlineExceeded)
189
+ end
190
+ end
191
+
192
+ it "returns CANCELLED when the client cancels an active call" do
193
+ entered = Queue.new
194
+ gate = Queue.new
195
+ controller = contract_controller do
196
+ define_method(:say_hello) do
197
+ entered << true
198
+ gate.pop
199
+ fail!(:cancelled, "cancelled")
200
+ end
201
+ end
202
+ with_contract_server(controller) do |client|
203
+ operation = client.say_hello(contract_request.new, return_op: true, deadline: Time.now + 5)
204
+ caller = Thread.new do
205
+ operation.execute
206
+ rescue GRPC::BadStatus => e
207
+ e
208
+ end
209
+ expect(entered.pop(timeout: 2)).to be(true)
210
+ operation.cancel
211
+ expect(caller.join(2)).not_to be_nil
212
+ expect(caller.value).to be_a(GRPC::Cancelled)
213
+ gate << true
214
+ ensure
215
+ gate << true
216
+ operation&.cancel
217
+ caller&.join(5)
218
+ end
219
+ end
220
+
221
+ it "finishes an in-flight response during graceful shutdown" do
222
+ entered = Queue.new
223
+ gate = Queue.new
224
+ response = contract_reply
225
+ controller = contract_controller do
226
+ define_method(:say_hello) do
227
+ entered << true
228
+ gate.pop
229
+ response.new(message: "completed")
230
+ end
231
+ end
232
+ with_contract_server(controller) do |client, server|
233
+ caller = Thread.new { client.say_hello(contract_request.new, deadline: Time.now + 5) }
234
+ expect(entered.pop(timeout: 2)).to be(true)
235
+ stopper = Thread.new { server.transport.stop(deadline: Time.now + 3) }
236
+ sleep 0.02 # Let shutdown enter its drain phase while the application remains blocked.
237
+ gate << true
238
+ expect(caller.value.message).to eq("completed")
239
+ expect(stopper.join(4)).not_to be_nil
240
+ expect(server.transport.stats).to include(inflight: 0)
241
+ ensure
242
+ gate << true
243
+ caller&.join(5)
244
+ stopper&.join(5)
245
+ end
246
+ end
247
+
248
+ it "enforces the shutdown deadline on a blocked controller" do
249
+ entered = Queue.new
250
+ controller = contract_controller do
251
+ define_method(:say_hello) do
252
+ entered << true
253
+ Queue.new.pop
254
+ end
255
+ end
256
+ with_contract_server(controller) do |client, server|
257
+ caller = Thread.new do
258
+ client.say_hello(contract_request.new, deadline: Time.now + 5)
259
+ rescue GRPC::BadStatus => e
260
+ e
261
+ end
262
+ expect(entered.pop(timeout: 2)).to be(true)
263
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
264
+ server.transport.stop(deadline: Time.now + 0.05)
265
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 1
266
+ expect(caller.join(2)).not_to be_nil
267
+ expect(caller.value).to be_a(GRPC::BadStatus)
268
+ expect(server.transport.running?).to be(false)
269
+ end
270
+ end
271
+ end
@@ -7,11 +7,12 @@ module Gritz
7
7
  # Owns transport resources and heartbeats in one serving process.
8
8
  # @api private
9
9
  class Runner
10
- def initialize(index:, config:, logger:, status_io: nil, owner_channel: nil)
10
+ def initialize(index:, config:, logger:, status_io: nil, owner_channel: nil, listener: nil)
11
11
  @index = index
12
12
  @config = config
13
13
  @logger = logger
14
14
  @owner_channel = owner_channel
15
+ @listener = listener
15
16
  @status = owner_channel || (Supervisor::StatusChannel.new(status_io) if status_io)
16
17
  @recorder = Metrics::Recorder.new
17
18
  @metrics = Metrics::Aggregator.new
@@ -24,7 +25,7 @@ module Gritz
24
25
  begin
25
26
  @signals = Supervisor::SignalQueue.new(signals: %w[TERM INT QUIT HUP USR1 USR2])
26
27
  report("booting")
27
- require "gritz/native"
28
+ require "gritz/#{@config.transport}"
28
29
  @boot_started = true
29
30
  @config.preload! unless @config.preload_app?
30
31
  @config.run_hooks(:on_worker_boot, @index)
@@ -32,8 +33,8 @@ module Gritz
32
33
  router = Router.new(controllers: @config.controllers, strict: @config.strict_routes, logger: @logger)
33
34
  dispatcher = Dispatcher.new(router:, middleware: @config.middleware, logger: @logger, metrics: @recorder,
34
35
  worker: @index, log_format: @config.log_format, log_redact: @config.log_redact)
35
- @adapter = Transport::Native.new(config: @config, dispatcher:, logger: @logger)
36
- @port = @adapter.bind(@config.bind)
36
+ @adapter = Transport.const_get(@config.transport.to_s.capitalize).new(config: @config, dispatcher:, logger: @logger)
37
+ @port = @adapter.bind(@listener || @config.bind)
37
38
  @adapter.start
38
39
  unless @status
39
40
  @admin = Supervisor::AdminServer.new(bind: @config.admin_bind, status: -> { snapshot }, ready: -> { ready? },
@@ -95,7 +96,7 @@ module Gritz
95
96
  if @pending_reexec && @owner_channel.write(type: "reexec", pid: Process.pid)
96
97
  @pending_reexec = false
97
98
  end
98
- publish_metrics
99
+ publish_metrics if @pending_delta || now >= next_status
99
100
  @admin&.poll
100
101
  if now >= next_status
101
102
  report(@draining ? "draining" : "ready")