tonic-rails 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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -2
  3. data/Cargo.lock +5 -5
  4. data/README.md +204 -13
  5. data/ext/tonic_rails_native/Cargo.toml +2 -2
  6. data/ext/tonic_rails_native/src/body.rs +113 -49
  7. data/ext/tonic_rails_native/src/dispatch.rs +67 -8
  8. data/ext/tonic_rails_native/src/framing.rs +50 -0
  9. data/ext/tonic_rails_native/src/gvl.rs +109 -21
  10. data/ext/tonic_rails_native/src/lib.rs +6 -1
  11. data/ext/tonic_rails_native/src/log.rs +131 -0
  12. data/ext/tonic_rails_native/src/net.rs +7 -1
  13. data/ext/tonic_rails_native/src/server.rs +67 -6
  14. data/lib/generators/tonic_rails/install/install_generator.rb +45 -0
  15. data/lib/generators/tonic_rails/install/templates/tonic_rails.rb.tt +21 -0
  16. data/lib/generators/tonic_rails/service/service_generator.rb +110 -0
  17. data/lib/generators/tonic_rails/service/templates/handler.rb.tt +38 -0
  18. data/lib/generators/tonic_rails/service/templates/service.proto.tt +16 -0
  19. data/lib/generators/tonic_rails/service/templates/service_spec.rb.tt +17 -0
  20. data/lib/generators/tonic_rails/service/templates/service_test.rb.tt +14 -0
  21. data/lib/puma/plugin/tonic_rails.rb +116 -0
  22. data/lib/tonic/rails/base.rb +169 -23
  23. data/lib/tonic/rails/configuration.rb +27 -4
  24. data/lib/tonic/rails/health.rb +105 -0
  25. data/lib/tonic/rails/instrumentation.rb +78 -0
  26. data/lib/tonic/rails/metadata.rb +50 -0
  27. data/lib/tonic/rails/peer.rb +60 -0
  28. data/lib/tonic/rails/railtie.rb +2 -2
  29. data/lib/tonic/rails/router.rb +1 -0
  30. data/lib/tonic/rails/rpc_error.rb +73 -6
  31. data/lib/tonic/rails/server.rb +82 -12
  32. data/lib/tonic/rails/tasks/tonic-rails.rake +13 -1
  33. data/lib/tonic/rails/testing/errors.rb +47 -0
  34. data/lib/tonic/rails/testing/minitest.rb +44 -0
  35. data/lib/tonic/rails/testing/rspec.rb +53 -0
  36. data/lib/tonic/rails/testing.rb +35 -4
  37. data/lib/tonic/rails/version.rb +1 -1
  38. data/lib/tonic-rails.rb +4 -0
  39. metadata +17 -1
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # What grpc.health.v1 reports, per service ("" = the whole server).
6
+ #
7
+ # A service reports SERVING only while the app hasn't marked it
8
+ # NOT_SERVING (Server#not_serving!) *and* the optional
9
+ # `config.health_check` passes. Neither overrides the other.
10
+ # @api private
11
+ class Health
12
+ def initialize(configuration, router)
13
+ @configuration = configuration
14
+ @router = router
15
+ @marks = {}
16
+ @check_passing = true
17
+ @native = nil
18
+ @checker = nil
19
+ end
20
+
21
+ def mark(services, serving)
22
+ names = services.empty? ? names_to_report : services.map(&:to_s)
23
+ names.each { |name| @marks[name] = serving }
24
+ publish(names)
25
+ end
26
+
27
+ # Start reporting through a running native server.
28
+ def attach(native)
29
+ @native = native
30
+ @check_passing = true
31
+ publish(names_to_report)
32
+ start_checker
33
+ end
34
+
35
+ # Stop the checker before shutdown flips everything NOT_SERVING, so
36
+ # a passing check can't flip it back.
37
+ def detach
38
+ stop_checker
39
+ @native = nil
40
+ end
41
+
42
+ private
43
+
44
+ def names_to_report
45
+ ["", *@router.service_names, *@marks.keys].uniq
46
+ end
47
+
48
+ def publish(names)
49
+ native = @native
50
+ return unless native
51
+
52
+ names.each { |name| native.set_health(name, @check_passing && @marks.fetch(name, true)) }
53
+ end
54
+
55
+ def start_checker
56
+ check = @configuration.health_check
57
+ return unless check
58
+
59
+ @wakeup = Thread::Queue.new
60
+ @checker = Thread.new do
61
+ Thread.current.name = "tonic-rails-health"
62
+ loop do
63
+ passing = run_check(check)
64
+ changed(passing) if passing != @check_passing
65
+ break if @wakeup.pop(timeout: @configuration.health_check_interval)
66
+ end
67
+ end
68
+ end
69
+
70
+ def stop_checker
71
+ return unless @checker
72
+
73
+ @wakeup << :stop
74
+ @checker.join(@configuration.drain_timeout.to_f + 5)
75
+ @checker = nil
76
+ end
77
+
78
+ def changed(passing)
79
+ @check_passing = passing
80
+ publish(names_to_report)
81
+ logger = @configuration.logger
82
+ return unless logger
83
+
84
+ if passing
85
+ logger.info("tonic-rails: health check passing; SERVING")
86
+ else
87
+ logger.warn("tonic-rails: health check failing; NOT_SERVING")
88
+ end
89
+ end
90
+
91
+ # Runs inside the Rails executor so database connections are returned.
92
+ def run_check(check)
93
+ result = if defined?(::Rails.application) && ::Rails.application
94
+ ::Rails.application.executor.wrap { check.call }
95
+ else
96
+ check.call
97
+ end
98
+ result ? true : false
99
+ rescue StandardError => e
100
+ Tonic::Rails.log_exception(e, "health check")
101
+ false
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/notifications"
5
+
6
+ module Tonic
7
+ module Rails
8
+ # Every RPC is wrapped in an ActiveSupport::Notifications event, so APM
9
+ # agents, OpenTelemetry and your own subscribers can observe it:
10
+ #
11
+ # ActiveSupport::Notifications.subscribe("process_rpc.tonic_rails") do |event|
12
+ # event.payload[:path] # "/demo.v1.ItemService/GetItem"
13
+ # event.payload[:service] # "demo.v1.ItemService"
14
+ # event.payload[:rpc] # "GetItem"
15
+ # event.payload[:metadata] # request metadata Hash
16
+ # event.payload[:code] # gRPC status code, 0 = OK
17
+ # event.payload[:status] # :ok, :not_found, :internal, ...
18
+ # event.duration # milliseconds
19
+ # end
20
+ #
21
+ # When a call fails, :exception_object holds the RpcError. If a handler
22
+ # raised something unexpected, that RpcError's #cause is the original
23
+ # exception.
24
+ module Instrumentation
25
+ EVENT = "process_rpc.tonic_rails"
26
+
27
+ module_function
28
+
29
+ # @api private
30
+ def instrument(path, metadata)
31
+ # Without subscribers, skip building the payload: this runs on
32
+ # every RPC.
33
+ return yield unless ActiveSupport::Notifications.notifier.listening?(EVENT)
34
+
35
+ service, rpc = path.delete_prefix("/").split("/", 2)
36
+ payload = { path: path, service: service, rpc: rpc, metadata: metadata, code: nil, status: nil }
37
+ ActiveSupport::Notifications.instrument(EVENT, payload) do
38
+ result = yield
39
+ payload[:code] = 0
40
+ payload[:status] = :ok
41
+ result
42
+ rescue RpcError => e
43
+ payload[:code] = e.code
44
+ payload[:status] = e.code_name
45
+ raise
46
+ rescue StandardError
47
+ payload[:code] = RpcError::CODES[:internal]
48
+ payload[:status] = :internal
49
+ raise
50
+ end
51
+ end
52
+
53
+ # Logs one line per RPC through the configured logger, e.g.
54
+ # gRPC /demo.v1.ItemService/GetItem OK in 1.4ms
55
+ # gRPC /demo.v1.ItemService/GetItem NOT_FOUND in 0.9ms: item 7 not found
56
+ # Enabled by `config.log_requests = true`.
57
+ class RequestLogger
58
+ def initialize(logger)
59
+ @logger = logger
60
+ end
61
+
62
+ def call(event)
63
+ payload = event.payload
64
+ status = (payload[:status] || :unknown).to_s.upcase
65
+ line = format("gRPC %<path>s %<status>s in %<ms>.1fms", path: payload[:path], status: status, ms: event.duration)
66
+ error = payload[:exception_object]
67
+ line = "#{line}: #{error.message}" if error && payload[:code] != 0
68
+ @logger.info(line)
69
+ end
70
+
71
+ # @return [Object] the subscription, for #unsubscribe
72
+ def subscribe
73
+ ActiveSupport::Notifications.subscribe(EVENT, self)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # Converts handler-supplied gRPC metadata into header pairs.
6
+ #
7
+ # Names are lower-cased and must be valid gRPC metadata names; names
8
+ # ending in "-bin" carry binary values, which are base64-encoded as the
9
+ # gRPC spec requires. Other values must be printable ASCII.
10
+ module Metadata
11
+ NAME = /\A[0-9a-z_.-]+\z/
12
+ PRINTABLE = /\A[\x20-\x7e]*\z/
13
+ RESERVED = %w[content-type te user-agent content-length connection transfer-encoding].freeze
14
+
15
+ module_function
16
+
17
+ # @param metadata [Hash{String,Symbol=>String,Array<String>}]
18
+ # @return [Hash{String=>String}]
19
+ # @raise [ArgumentError] for invalid or reserved names and values
20
+ def encode(metadata)
21
+ metadata.each_with_object({}) do |(name, value), out|
22
+ name = name.to_s.downcase
23
+ validate_name!(name)
24
+ out[name] = encode_value(name, value)
25
+ end
26
+ end
27
+
28
+ def validate_name!(name)
29
+ raise ArgumentError, "invalid gRPC metadata name #{name.inspect}" unless NAME.match?(name)
30
+ return unless RESERVED.include?(name) || (name.start_with?("grpc-") && name != "grpc-status-details-bin")
31
+
32
+ raise ArgumentError, "gRPC metadata name #{name.inspect} is reserved"
33
+ end
34
+
35
+ def encode_value(name, value)
36
+ values = Array(value).map(&:to_s)
37
+ if name.end_with?("-bin")
38
+ values.map { |v| [v].pack("m0") }.join(",")
39
+ else
40
+ values.each do |v|
41
+ next if PRINTABLE.match?(v)
42
+
43
+ raise ArgumentError, "gRPC metadata #{name} must be printable ASCII; use a -bin name for binary"
44
+ end
45
+ values.join(",")
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # The client on the other end of an RPC, available in handlers as
6
+ # `peer`:
7
+ #
8
+ # peer.address # => "10.0.4.17:51234"
9
+ # peer.ip # => "10.0.4.17"
10
+ # peer.certificate # => OpenSSL::X509::Certificate, with mutual TLS
11
+ # peer.certificate&.subject.to_s # => "/CN=billing-service"
12
+ class Peer
13
+ # @return [String, nil] "ip:port" ("[ipv6]:port" for IPv6)
14
+ attr_reader :address
15
+
16
+ # @param address [String, nil]
17
+ # @param certificates [Array<String>] DER-encoded chain, leaf first
18
+ def initialize(address = nil, certificates = [])
19
+ @address = address
20
+ @certificate_ders = certificates
21
+ end
22
+
23
+ # @return [String, nil]
24
+ def ip
25
+ return unless address
26
+
27
+ host = address.rpartition(":").first
28
+ host.delete_prefix("[").delete_suffix("]")
29
+ end
30
+
31
+ # @return [Integer, nil]
32
+ def port
33
+ address && Integer(address.rpartition(":").last, exception: false)
34
+ end
35
+
36
+ # The client certificate presented over mutual TLS, or nil.
37
+ # @return [OpenSSL::X509::Certificate, nil]
38
+ def certificate
39
+ certificate_chain.first
40
+ end
41
+
42
+ # @return [Array<OpenSSL::X509::Certificate>] leaf first
43
+ def certificate_chain
44
+ @certificate_chain ||= begin
45
+ require "openssl"
46
+ @certificate_ders.map { |der| OpenSSL::X509::Certificate.new(der) }
47
+ end
48
+ end
49
+
50
+ # True when the client authenticated with a certificate.
51
+ def authenticated?
52
+ !@certificate_ders.empty?
53
+ end
54
+
55
+ def inspect
56
+ "#<#{self.class.name} #{address || 'unknown'}#{' (certificate)' if authenticated?}>"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -28,8 +28,8 @@ module Tonic
28
28
  config.after_initialize do
29
29
  configuration = Tonic::Rails.configuration
30
30
  # Only inside an actual web server process — not consoles, rake
31
- # tasks, or test runs. Forking servers (Puma cluster mode) must
32
- # instead start the server per worker, e.g. in `on_worker_boot`.
31
+ # tasks, or test runs. With Puma, prefer `plugin :tonic_rails`
32
+ # (lib/puma/plugin/tonic_rails.rb), which also covers cluster mode.
33
33
  if configuration.auto_start && defined?(::Rails::Server)
34
34
  server = Tonic::Rails.server
35
35
  thread = Thread.new do
@@ -38,6 +38,7 @@ module Tonic
38
38
  reader: call[:reader],
39
39
  sender: call[:sender],
40
40
  timeout_ms: call[:timeout_ms],
41
+ peer: Peer.new(call[:peer_address], call[:peer_certificates] || []),
41
42
  )
42
43
  end
43
44
 
@@ -2,13 +2,23 @@
2
2
 
3
3
  module Tonic
4
4
  module Rails
5
- # Raise from a handler to end the RPC with a gRPC status.
5
+ # Raise from a handler (or interceptor) to end the RPC with a gRPC status.
6
6
  #
7
- # raise Tonic::Rails::RpcError.new("product not found", code: :not_found)
7
+ # raise Tonic::Rails::RpcError.new("item not found", code: :not_found)
8
8
  #
9
- # The message is sent to the client, so keep internal details out of
10
- # it. Any other exception becomes INTERNAL with a generic message
11
- # and is logged server-side.
9
+ # Optionally attach trailing metadata and rich error details (protobuf
10
+ # messages such as google.rpc.BadRequest, sent in the standard
11
+ # grpc-status-details-bin trailer as a google.rpc.Status):
12
+ #
13
+ # raise Tonic::Rails::RpcError.new(
14
+ # "invalid item", code: :invalid_argument,
15
+ # metadata: { "x-request-id" => request_id },
16
+ # details: [Google::Rpc::BadRequest.new(field_violations: [...])],
17
+ # )
18
+ #
19
+ # The message, metadata and details go to the client, so keep internal
20
+ # details out of them. Any other exception becomes INTERNAL with a
21
+ # generic message and is logged server-side.
12
22
  class RpcError < Error
13
23
  CODES = {
14
24
  ok: 0,
@@ -35,13 +45,70 @@ module Tonic
35
45
  # @return [Integer] numeric gRPC status code
36
46
  attr_reader :code
37
47
 
48
+ # @return [Hash] trailing metadata sent with the status
49
+ attr_reader :metadata
50
+
51
+ # @return [Array<Google::Protobuf::AbstractMessage>] rich error details
52
+ attr_reader :details
53
+
38
54
  # @param message [String] status message sent to the client
39
55
  # @param code [Symbol, Integer] gRPC status code (see CODES)
40
- def initialize(message = nil, code: :unknown)
56
+ # @param metadata [Hash] trailing metadata (see Tonic::Rails::Metadata)
57
+ # @param details [Array<Google::Protobuf::AbstractMessage>] messages
58
+ # packed into google.rpc.Status.details
59
+ def initialize(message = nil, code: :unknown, metadata: {}, details: [])
41
60
  @code = self.class.normalize_code(code)
61
+ @metadata = metadata
62
+ @details = Array(details)
42
63
  super(message || code_name.to_s)
43
64
  end
44
65
 
66
+ # @api private
67
+ # @return [Hash{String=>String}] trailers to send with this status
68
+ def trailers
69
+ trailers = Metadata.encode(metadata)
70
+ trailers["grpc-status-details-bin"] = [status_proto].pack("m0") unless details.empty?
71
+ trailers
72
+ end
73
+
74
+ # The encoded google.rpc.Status (code = 1, message = 2, details = 3 as
75
+ # google.protobuf.Any), built directly so googleapis protos aren't a
76
+ # dependency.
77
+ def status_proto
78
+ require "google/protobuf/well_known_types"
79
+ buf = (+"").b
80
+ buf << field_key(1, 0) << varint(code)
81
+ buf << length_delimited(2, message.b)
82
+ details.each do |detail|
83
+ any = detail.is_a?(Google::Protobuf::Any) ? detail : Google::Protobuf::Any.pack(detail)
84
+ buf << length_delimited(3, Google::Protobuf::Any.encode(any))
85
+ end
86
+ buf
87
+ end
88
+
89
+ private
90
+
91
+ def field_key(field, wire_type)
92
+ varint((field << 3) | wire_type)
93
+ end
94
+
95
+ def length_delimited(field, bytes)
96
+ field_key(field, 2) + varint(bytes.bytesize) + bytes
97
+ end
98
+
99
+ def varint(value)
100
+ bytes = []
101
+ loop do
102
+ byte = value & 0x7f
103
+ value >>= 7
104
+ bytes << (value.zero? ? byte : byte | 0x80)
105
+ break if value.zero?
106
+ end
107
+ bytes.pack("C*")
108
+ end
109
+
110
+ public
111
+
45
112
  # @return [Symbol] e.g. :not_found
46
113
  def code_name
47
114
  CODE_NAMES.fetch(@code)
@@ -20,9 +20,9 @@ module Tonic
20
20
  @router = Router.new
21
21
  @native = nil
22
22
  @state = :stopped
23
- @stop_reader = nil
24
- @stop_writer = nil
23
+ @stop_signal = nil
25
24
  @stop_requested = false
25
+ @health = Health.new(configuration, @router)
26
26
  @lock = Mutex.new
27
27
  end
28
28
 
@@ -51,25 +51,34 @@ module Tonic
51
51
  @configuration.validate!
52
52
  Tonic::Rails.native_extension!
53
53
  Tonic::Rails._set_shutting_down(false)
54
- @stop_reader, @stop_writer = IO.pipe
54
+ # A Queue rather than a self-pipe: pushing to it is safe from trap
55
+ # handlers, and unlike pipes it can be waited on with a timeout
56
+ # on every platform (Windows select() only handles sockets).
57
+ @stop_signal = Thread::Queue.new
55
58
  stop if @stop_requested # requested while we were starting up
56
59
  @native = build_native_server
57
60
  @native.start_server
61
+ log_pump = spawn_log_pump
62
+ @health.attach(@native)
58
63
  @state = :running
59
64
 
65
+ request_log = subscribe_request_logger
60
66
  previous_handlers = install_signal_handlers ? trap_signals : {}
61
67
  workers = Array.new(@configuration.dispatch_threads) { |i| spawn_worker(i) }
62
68
  log_startup
63
69
  yield self if block_given?
64
70
 
65
71
  wait_for_stop(workers)
72
+ @health.detach
66
73
  shutdown(workers)
67
74
  ensure
75
+ ActiveSupport::Notifications.unsubscribe(request_log) if request_log
68
76
  previous_handlers&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
77
+ @health.detach
69
78
  @native&.stop_server
79
+ stop_log_pump(log_pump)
70
80
  @native = nil
71
- [@stop_reader, @stop_writer].each { |io| io.close if io && !io.closed? }
72
- @stop_reader = @stop_writer = nil
81
+ @stop_signal = nil
73
82
  Tonic::Rails._set_shutting_down(false)
74
83
  @state = :stopped
75
84
  end
@@ -79,15 +88,32 @@ module Tonic
79
88
  # other threads; returns immediately.
80
89
  def stop
81
90
  @stop_requested = true
82
- @stop_writer&.write_nonblock(".", exception: false)
83
- rescue IOError
84
- # Already stopped.
91
+ @stop_signal&.push(:stop)
85
92
  end
86
93
 
87
94
  def running?
88
95
  @state == :running
89
96
  end
90
97
 
98
+ # Mark the whole server, or the given services (fully-qualified
99
+ # names), SERVING in grpc.health.v1. Takes effect immediately when
100
+ # running, otherwise when the server starts.
101
+ #
102
+ # With `config.health_check`, a service reports SERVING only while the
103
+ # check passes *and* it's marked serving here.
104
+ def serving!(*services)
105
+ @health.mark(services, true)
106
+ self
107
+ end
108
+
109
+ # Mark the whole server, or the given services, NOT_SERVING (e.g. for
110
+ # maintenance), so load balancers and readiness probes route around
111
+ # this instance. Calls are still served.
112
+ def not_serving!(*services)
113
+ @health.mark(services, false)
114
+ self
115
+ end
116
+
91
117
  # @return [Hash{Symbol=>Integer}] request and connection counters
92
118
  def stats
93
119
  native = @native
@@ -113,7 +139,6 @@ module Tonic
113
139
  native.set_worker_threads(config.worker_threads || 0)
114
140
  native.set_keepalive(config.keepalive_interval.to_f, config.keepalive_timeout.to_f)
115
141
  native.set_dispatch_queue_size(config.dispatch_queue_size)
116
- native.set_log_requests(config.log_requests ? true : false)
117
142
  native.set_compression(config.compression ? true : false)
118
143
  native.set_reflection(config.reflection ? true : false)
119
144
  native.set_reflection_descriptors(Reflection.file_descriptors(@router.service_names)) if config.reflection
@@ -133,8 +158,9 @@ module Tonic
133
158
  end
134
159
 
135
160
  guarded = lambda do |path, metadata, transport|
136
- call.call(path, metadata, transport)
137
- rescue RpcError
161
+ Instrumentation.instrument(path, metadata) { call.call(path, metadata, transport) }
162
+ rescue RpcError => e
163
+ attach_error_trailers(transport, e)
138
164
  raise
139
165
  rescue StandardError => e
140
166
  Tonic::Rails.log_exception(e, path)
@@ -144,6 +170,15 @@ module Tonic
144
170
  wrap_with_rails_executor(guarded)
145
171
  end
146
172
 
173
+ # An RpcError's own metadata and rich details travel as trailers.
174
+ def attach_error_trailers(transport, error)
175
+ return if error.metadata.empty? && error.details.empty?
176
+
177
+ transport[:sender]&.add_trailers(error.trailers)
178
+ rescue StandardError => e
179
+ Tonic::Rails.log_exception(e, "RpcError trailers")
180
+ end
181
+
147
182
  # Each RPC runs inside the Rails executor (or reloader, when code
148
183
  # reloading is on): ActiveRecord connections are returned, query
149
184
  # caches and CurrentAttributes are reset — the same isolation Rack
@@ -156,6 +191,41 @@ module Tonic
156
191
  ->(path, metadata, transport) { wrapper.wrap { callback.call(path, metadata, transport) } }
157
192
  end
158
193
 
194
+ def subscribe_request_logger
195
+ return unless @configuration.log_requests && @configuration.logger
196
+
197
+ Instrumentation::RequestLogger.new(@configuration.logger).subscribe
198
+ end
199
+
200
+ # Transport events from the Rust side (rejected calls, expired
201
+ # deadlines, refused connections, faults) can't call Ruby directly;
202
+ # this thread drains them into the configured logger.
203
+ def spawn_log_pump
204
+ native = @native
205
+ logger = @configuration.logger
206
+ @log_pump_running = true
207
+ Thread.new do
208
+ Thread.current.name = "tonic-rails-log"
209
+ write_transport_logs(logger, native.next_log_events(0.25)) while @log_pump_running
210
+ write_transport_logs(logger, native.next_log_events(0)) # whatever shutdown produced
211
+ rescue StandardError => e
212
+ warn("tonic-rails: log thread stopped: #{e.class}: #{e.message}")
213
+ end
214
+ end
215
+
216
+ def stop_log_pump(thread)
217
+ return unless thread
218
+
219
+ @log_pump_running = false
220
+ thread.join(2)
221
+ end
222
+
223
+ def write_transport_logs(logger, events)
224
+ return unless logger
225
+
226
+ events.each { |level, message| logger.public_send(level, "tonic-rails: #{message}") }
227
+ end
228
+
159
229
  def spawn_worker(index)
160
230
  native = @native
161
231
  Thread.new do
@@ -174,7 +244,7 @@ module Tonic
174
244
 
175
245
  def wait_for_stop(workers)
176
246
  loop do
177
- break if @stop_reader.wait_readable(1)
247
+ break if @stop_signal.pop(timeout: 1)
178
248
 
179
249
  dead = workers.reject(&:alive?)
180
250
  unless dead.empty?
@@ -27,7 +27,19 @@ namespace :tonic_rails do
27
27
  ruby_out = ENV.fetch("RUBY_OUT", "lib/grpc")
28
28
  protos = Dir.glob(File.join(proto_dir, "**", "*.proto"))
29
29
  abort "No .proto files found in #{proto_dir}/" if protos.empty?
30
- abort "protoc not found on PATH. Install the Protocol Buffers compiler." unless system("protoc", "--version", out: File::NULL)
30
+ version = begin
31
+ IO.popen(%w[protoc --version], err: File::NULL, &:read)[/\d+(\.\d+)+/]
32
+ rescue SystemCallError
33
+ nil
34
+ end
35
+ abort "protoc not found on PATH. Install the Protocol Buffers compiler (22.0 or newer)." unless version
36
+ # protoc before 22 emits the Ruby DSL that google-protobuf 4 removed
37
+ # (`undefined method 'build' for DescriptorPool` at load time).
38
+ major = version.split(".").then { |parts| parts.first == "3" ? parts[1].to_i : parts.first.to_i }
39
+ if major < 22
40
+ abort "protoc #{version} is too old for google-protobuf 4: install protoc 22.0 or newer " \
41
+ "(distribution packages are often older; see https://github.com/protocolbuffers/protobuf/releases)."
42
+ end
31
43
 
32
44
  FileUtils.mkdir_p(ruby_out)
33
45
  sh "protoc", "--ruby_out=#{ruby_out}", "-I#{proto_dir}", *protos
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ module Testing
6
+ # Shared helpers for the RSpec matcher and Minitest assertions.
7
+ # @api private
8
+ module Errors
9
+ module_function
10
+
11
+ # Exceptions that carry a gRPC status: tonic-rails' own, and the
12
+ # official grpc gem client's, when it's loaded.
13
+ def classes
14
+ [Tonic::Rails::RpcError, (GRPC::BadStatus if defined?(GRPC::BadStatus))].compact
15
+ end
16
+
17
+ def status?(error)
18
+ classes.any? { |klass| error.is_a?(klass) }
19
+ end
20
+
21
+ # @return [Integer]
22
+ def code(error)
23
+ error.code
24
+ end
25
+
26
+ # @return [String] the status message the client sees. The grpc
27
+ # gem keeps it in #details (its #message adds the code).
28
+ def message(error)
29
+ text = defined?(GRPC::BadStatus) && error.is_a?(GRPC::BadStatus) ? error.details.to_s : error.message
30
+ text.dup.force_encoding(Encoding::UTF_8)
31
+ end
32
+
33
+ def code_name(code)
34
+ Tonic::Rails::RpcError::CODE_NAMES.fetch(code, code)
35
+ end
36
+
37
+ def message_matches?(expected, actual)
38
+ expected.is_a?(Regexp) ? expected.match?(actual) : expected.to_s == actual
39
+ end
40
+
41
+ def describe(error)
42
+ "#{code_name(code(error)).to_s.upcase} (#{message(error).inspect})"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end