gritz-native 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e8049bb49561c6146900ca5fdf2f40177d4da3352b488c0c090b834f28a76bd
4
- data.tar.gz: f55456efb1b318a0079ebf027b77f41d63413ca2abc723319804fe07ee403165
3
+ metadata.gz: c84c8135c9f1374962f4dd07181d16d2568498ceb55c4eb03c7d0dce59d475d8
4
+ data.tar.gz: c4dbff62a8188ea82f4c49eb4a7b470a4fee5cbf9199489f88bbab74b8ee08fb
5
5
  SHA512:
6
- metadata.gz: 7a98685d7f3d10fefba9d156e0d5f182aed3a497e3886534e082449629e6420422ef614fbe8e0b0b793ae6ada7cdcd03e357ec58db0bf3826d7627615b32cd09
7
- data.tar.gz: d5051b7e5a6739eac2d3be2b53316b0def376af8bfbe91c77958c1d554e1e08842d3a6a298f359a735ed736e3310e430e8c842ea00650900af4dd9f3be245173
6
+ metadata.gz: c61a25e22d150d219828f0a07f9dc39069fb792b3c8b5fefd82b6af6637071d7849df836736bcfe600da23eff7d8f39d604f8b5602966768e92dcc3915f71fb1
7
+ data.tar.gz: 1d4279464949f4bfacaf45405bc9c670de00b25673d3a5dc6ec941de86c24bd9cb475b6db5790000425fb6c74883042515f65b645b5fefb875e33ebfb739723b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - Connect `Gritz::Client` to all four native RPC forms with shared worker-local channels and deferred credentials.
6
+ - Cancel unfinished response streams and decode downstream status, trailers and protobuf rich error details into typed Gritz errors.
7
+ - Pass retry and load-balancing service configuration to the native channel.
8
+ - Run installed worker telemetry with real-server test helpers and close it after the final RPC observations.
9
+
10
+ ## 0.3.0
11
+
12
+ - Serve TLS and mTLS connections and expose authenticated client certificates to handlers.
13
+ - Provide standard gRPC Health Check and Watch methods linked to application health and draining.
14
+ - Finish accepted RPCs during graceful shutdown and phased worker replacement.
15
+ - Report actual native thread-pool usage, including active Health watches.
16
+
3
17
  ## 0.2.0
4
18
 
5
19
  - Detect native gRPC constructors in the master before fork.
data/README.md CHANGED
@@ -10,6 +10,10 @@ require "gritz/native"
10
10
 
11
11
  The entry point loads `gritz-core` and registers `Gritz::Transport::Native`. The configuration value is `transport :native`. Use [gritz](https://github.com/gritzrpc/gritz) for the default combination and executable. The Fiber adapter `gritz-async` is planned separately.
12
12
 
13
+ The adapter also connects `Gritz::Client.define` to all four RPC forms, sharing channels within each worker and creating fresh connections after fork. It cancels unfinished streams, decodes rich downstream errors, and passes retry/load-balancing configuration to C-core. See the [client guide](https://github.com/gritzrpc/gritz-core/blob/main/docs/guides/clients.md).
14
+
15
+ The [three-service integration report](https://github.com/gritzrpc/gritz-otel/blob/main/docs/reports/T4-08-client-chain.md) records worker-local channels, propagated deadlines, connected server/client spans and real OTLP/HTTP metrics.
16
+
13
17
  ```ruby
14
18
  Gritz::Testing::Server.start(controllers: [GreeterController]) do |server|
15
19
  stub = Helloworld::Greeter::Stub.new(server.address, :this_channel_is_insecure)
@@ -17,7 +21,7 @@ Gritz::Testing::Server.start(controllers: [GreeterController]) do |server|
17
21
  end
18
22
  ```
19
23
 
20
- The helper opens an ephemeral port and stops the server when the block exits. The helper runs a single process. Supervised native servers also support forked workers on Linux. This release uses insecure sockets; use a trusted network or a TLS-terminating proxy. See the [framework documentation](https://github.com/gritzrpc/gritz) for configuration and limitations.
24
+ The helper opens an ephemeral port and stops the server when the block exits. The helper runs a single process. Supervised native servers support forked workers on Linux. TLS and required client certificate verification are configured with `tls cert:, key:, client_ca:`. Standard gRPC Health Check/Watch tracks application checks and draining. See the [Kubernetes guide](docs/guides/kubernetes.md) and [framework configuration](https://github.com/gritzrpc/gritz-core/blob/main/docs/guides/configuration.md).
21
25
 
22
26
  ## Development
23
27
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gritz
4
4
  module Native
5
- VERSION = "0.2.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
data/lib/gritz/native.rb CHANGED
@@ -11,4 +11,8 @@ require_relative "transport/native"
11
11
  require_relative "transport/native/call"
12
12
  require_relative "transport/native/bridge"
13
13
  require_relative "transport/native/server"
14
+ require_relative "transport/native/health"
15
+ require_relative "transport/native/client"
14
16
  require_relative "testing/server"
17
+
18
+ Gritz::Client.adapter = Gritz::Transport::Native::Client
@@ -39,13 +39,20 @@ module Gritz
39
39
  begin
40
40
  @config.preload! if @config.preload_app?
41
41
  router = Router.new(controllers: @config.controllers, strict: @config.strict_routes, logger: @logger)
42
- dispatcher = Dispatcher.new(router:, middleware: @config.middleware, logger: @logger)
43
- @transport = Transport::Native.new(config: @config, dispatcher:, logger: @logger)
44
42
  @lifecycle_started = true
45
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::Native.new(config: @config, dispatcher:, logger: @logger)
46
47
  @port = transport.bind
47
48
  @address = @config.bind.sub(/:\d+\z/, ":#{port}")
48
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
49
56
  self
50
57
  rescue StandardError
51
58
  stop
@@ -57,12 +64,30 @@ module Gritz
57
64
  return if @stopped
58
65
 
59
66
  @stopped = true
67
+ deadline = monotonic + @config.shutdown_timeout
60
68
  begin
61
- transport&.stop(deadline: Time.now + @config.shutdown_timeout)
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)
62
74
  ensure
63
- @config.run_hooks(:on_worker_shutdown, 0) if @lifecycle_started
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
64
85
  end
65
86
  end
87
+
88
+ private
89
+
90
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
91
  end
67
92
  end
68
93
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client_invocation"
4
+
5
+ module Gritz
6
+ module Transport
7
+ class Native
8
+ # Builds official gRPC connections only on the first process-local client call.
9
+ # @api private
10
+ class Client
11
+ Connection = Struct.new(:target, :credentials, :args, :channel, keyword_init: true)
12
+
13
+ def self.connect(target:, credentials:, args:)
14
+ resolved = credentials.respond_to?(:call) ? credentials.call : credentials
15
+ resolved = :this_channel_is_insecure if resolved == :insecure
16
+ channel_credentials = resolved.is_a?(GRPC::Core::CallCredentials) ? GRPC::Core::ChannelCredentials.new : resolved
17
+ channel = GRPC::ClientStub.setup_channel(nil, target, channel_credentials, args.dup)
18
+ Connection.new(target:, credentials: resolved, args:, channel:)
19
+ end
20
+
21
+ def self.invoke(stub_class:, connection:, method:, request:, options:, around:, &)
22
+ stub = stub_class.new(connection.target, connection.credentials,
23
+ channel_override: connection.channel, channel_args: connection.args.dup)
24
+ stub.extend(ClientInvocation)
25
+ stub.instance_variable_set(:@gritz_around, around)
26
+ stub.public_send(method, request, options, &)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gritz
4
+ module Transport
5
+ class Native
6
+ # Captures the generated stub's route and keeps middleware around native completion.
7
+ # @api private
8
+ module ClientInvocation
9
+ { request_response: :unary, client_streamer: :client_streaming,
10
+ server_streamer: :server_streaming, bidi_streamer: :bidi }.each do |native_method, kind|
11
+ define_method(native_method) do |method, request, marshal, unmarshal, **options, &block|
12
+ terminal = lambda do |context, &_reply|
13
+ execute_gritz_client(context) do |response|
14
+ input = context.method.client_streaming? ? context.each_request : context.request
15
+ super(method, input, marshal, unmarshal, **context.options.merge(return_op: true), &response)
16
+ end
17
+ end
18
+ @gritz_around.call(terminal, method:, kind:, request:, options:, &block)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def execute_gritz_client(context)
25
+ context.check_deadline!
26
+ context.check_cancelled!
27
+ response = if context.method.server_streaming?
28
+ lambda do |message|
29
+ context.check_deadline!
30
+ context.check_cancelled!
31
+ context.response_block.call(message)
32
+ end
33
+ end
34
+ operation = yield(response)
35
+ context.operation = operation
36
+ result = operation.execute
37
+ completed = true
38
+ result
39
+ rescue GRPC::BadStatus => e
40
+ raise decode_gritz_error(e)
41
+ ensure
42
+ if operation && !completed
43
+ context.store[:gritz_cancelled] = true
44
+ cancel_gritz_operation(operation)
45
+ end
46
+ end
47
+
48
+ def cancel_gritz_operation(operation)
49
+ operation.cancel
50
+ rescue GRPC::Core::CallError
51
+ # A received non-OK status may already have closed the native call.
52
+ nil
53
+ end
54
+
55
+ def decode_gritz_error(error)
56
+ status = begin
57
+ error.to_rpc_status
58
+ rescue Google::Protobuf::ParseError, TypeError, ArgumentError
59
+ nil
60
+ end
61
+ details = if status && status.code == error.code
62
+ status.details.map { |any| decode_gritz_detail(any) }
63
+ else
64
+ []
65
+ end
66
+ code = error.code.is_a?(Integer) && (1...Errors::CODES.length).cover?(error.code) ? error.code : 2
67
+ klass = Errors.for_code(code)
68
+ klass.new(error.details, details:, metadata: error.metadata.dup, remote: true)
69
+ end
70
+
71
+ def decode_gritz_detail(any)
72
+ descriptor = Google::Protobuf::DescriptorPool.generated_pool.lookup(any.type_name)
73
+ descriptor.respond_to?(:msgclass) ? any.unpack(descriptor.msgclass) : any
74
+ rescue Google::Protobuf::ParseError, TypeError, ArgumentError
75
+ any
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "grpc/health/v1/health_services_pb"
4
+
5
+ module Gritz
6
+ module Transport
7
+ class Native
8
+ # Standard health RPCs share the server pool; each open Watch uses one pool thread.
9
+ # @api private
10
+ class Health < Grpc::Health::V1::Health::Service
11
+ # Ruby's server call view cannot observe cancellation until the call ends.
12
+ # Watch needs a dedicated close observer, with at most one per occupied pool thread.
13
+ class WatchDescriptor < GRPC::RpcDesc
14
+ def handle_server_streamer(active_call, method, interception)
15
+ request = active_call.read_unary_request
16
+ call = active_call.single_req_view
17
+ raw = active_call.instance_variable_get(:@call)
18
+ closed = false
19
+ observer = Thread.new do
20
+ raw.run_batch(GRPC::Core::CallOps::RECV_CLOSE_ON_SERVER => nil)
21
+ rescue GRPC::Core::CallError
22
+ nil
23
+ ensure
24
+ closed = true
25
+ method.receiver.wake
26
+ end
27
+ call.define_singleton_method(:cancelled?) { closed }
28
+ status = begin
29
+ interception.intercept!(:server_streamer, method:, call:, request:) do
30
+ method.call(request, call).each { |reply| active_call.remote_send(reply) }
31
+ end
32
+ ::Struct::Status.new(GRPC::Core::StatusCodes::OK, "OK", active_call.output_metadata)
33
+ rescue GRPC::BadStatus => e
34
+ ::Struct::Status.new(e.code, e.details, e.metadata)
35
+ rescue GRPC::Core::OutOfTime
36
+ ::Struct::Status.new(GRPC::Core::StatusCodes::DEADLINE_EXCEEDED, "late", {})
37
+ rescue StandardError, NotImplementedError
38
+ ::Struct::Status.new(GRPC::Core::StatusCodes::UNKNOWN, "Health handler failed", {})
39
+ end
40
+ active_call.send_initial_metadata
41
+ raw.run_batch(GRPC::Core::CallOps::SEND_STATUS_FROM_SERVER => status)
42
+ observer.join
43
+ rescue GRPC::Core::CallError
44
+ nil
45
+ ensure
46
+ if observer
47
+ raw.cancel if observer.alive?
48
+ observer.join
49
+ # Finish only after the native close operation has released its call resources.
50
+ active_call.__send__(:set_output_stream_done)
51
+ end
52
+ end
53
+ end
54
+
55
+ rpc_descs[:Watch] = WatchDescriptor.new(*rpc_descs.fetch(:Watch).to_a)
56
+
57
+ def initialize(services)
58
+ super()
59
+ @services = ["", *services].to_set
60
+ @mutex = Mutex.new
61
+ @changed = ConditionVariable.new
62
+ @serving = @closed = false
63
+ end
64
+
65
+ def update(ready)
66
+ @mutex.synchronize do
67
+ serving = ready && !@closed
68
+ @changed.broadcast if serving != @serving
69
+ @serving = serving
70
+ end
71
+ end
72
+
73
+ def drain!
74
+ @mutex.synchronize do
75
+ @serving = false
76
+ @closed = true
77
+ @changed.broadcast
78
+ end
79
+ end
80
+
81
+ def wake = @mutex.synchronize { @changed.broadcast }
82
+
83
+ def check(request, _call)
84
+ raise GRPC::NotFound, "unknown service" unless @services.include?(request.service)
85
+
86
+ @mutex.synchronize { Grpc::Health::V1::HealthCheckResponse.new(status: status_for(request.service)) }
87
+ end
88
+
89
+ def watch(request, call)
90
+ Enumerator.new do |replies|
91
+ previous = nil
92
+ loop do
93
+ status, closed = @mutex.synchronize do
94
+ @changed.wait(@mutex, 0.05) while !@closed && !call.cancelled? && status_for(request.service) == previous
95
+ [status_for(request.service), @closed]
96
+ end
97
+ break if call.cancelled?
98
+
99
+ if status != previous
100
+ replies << Grpc::Health::V1::HealthCheckResponse.new(status:)
101
+ previous = status
102
+ end
103
+ break if closed
104
+ end
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def status_for(service)
111
+ return :SERVICE_UNKNOWN unless @services.include?(service)
112
+
113
+ @serving ? :SERVING : :NOT_SERVING
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -17,6 +17,14 @@ module Gritz
17
17
  result
18
18
  end
19
19
 
20
+ def busy_threads
21
+ # grpc 1.83 has no public busy count; its idle queues cover decode and
22
+ # response writes as well as handlers. Live threads also handle pre-start/stop.
23
+ workers = @pool.instance_variable_get(:@workers)
24
+ ready = @pool.instance_variable_get(:@ready_workers)
25
+ [workers.count(&:alive?) - ready.size, 0].max
26
+ end
27
+
20
28
  def stop(deadline:)
21
29
  # grpc 1.83's stop takes no deadline and reads @poll_period instead.
22
30
  # Set its relative grace from the caller's absolute shutdown deadline.
@@ -24,6 +32,37 @@ module Gritz
24
32
  super()
25
33
  end
26
34
 
35
+ # RpcServer's loop checks running_state before every RequestCall. Its stop
36
+ # holds that mutex during shutdown_and_notify, so an accepted RPC can leave
37
+ # the loop blocked while C-core still has unmatched calls. Keep consuming
38
+ # until C-core closes RequestCall instead of stopping on the Ruby state.
39
+ def loop_handle_server_calls
40
+ raise "not started" if running_state == :not_started
41
+
42
+ loop do
43
+ rpc = @server.request_call
44
+ break if rpc && rpc.call.nil?
45
+
46
+ active_call = new_active_server_call(rpc)
47
+ next unless active_call
48
+
49
+ @pool.schedule(active_call) do |pair|
50
+ call, method = pair
51
+ rpc_descs[method].run_server_method(call, rpc_handlers[method], @interceptors.build_context)
52
+ rescue StandardError
53
+ call.send_status(GRPC::Core::StatusCodes::INTERNAL, "Server handler failed")
54
+ end
55
+ rescue GRPC::Core::CallError, RuntimeError => e
56
+ break unless running_state == :running
57
+
58
+ GRPC.logger.warn("server call failed: #{e}")
59
+ end
60
+ @run_mutex.synchronize do
61
+ transition_running_state(:stopped)
62
+ @server.close
63
+ end
64
+ end
65
+
27
66
  def close_unstarted
28
67
  return unless running_state == :not_started
29
68
 
@@ -8,7 +8,7 @@ module Gritz
8
8
  # Runs generated services through Gritz's transport-independent dispatcher.
9
9
  # @api public
10
10
  class Native
11
- def self.capabilities = Set[:unary, :client_streaming, :server_streaming, :bidi, :reuseport].freeze
11
+ def self.capabilities = Set[:unary, :client_streaming, :server_streaming, :bidi, :reuseport, :health, :tls, :mtls].freeze
12
12
 
13
13
  def self.prefork
14
14
  GRPC.prefork
@@ -32,9 +32,9 @@ module Gritz
32
32
  # @return [Integer] the port selected by C-core
33
33
  def bind(listener_spec = @config.bind)
34
34
  raise ArgumentError, "transport is already bound" if @server
35
- raise ArgumentError, "TLS is not available in this version" unless @config.tls.empty?
36
35
  raise ArgumentError, "at least one controller must be registered" if @dispatcher.router.routes.empty?
37
36
 
37
+ credentials = server_credentials
38
38
  created_server = @server = Server.new(
39
39
  pool_size: @config.threads,
40
40
  # grpc 1.83 accepts but ignores this value: busy pools reject immediately.
@@ -47,7 +47,9 @@ module Gritz
47
47
  @dispatcher.router.routes.values.group_by(&:service_class).each do |service_class, descriptors|
48
48
  @server.handle(Bridge.build(service_class, descriptors, self))
49
49
  end
50
- @port = @server.add_http2_port(listener_spec, :this_port_is_insecure)
50
+ @health = Health.new(@dispatcher.router.routes.values.map(&:service).uniq)
51
+ @server.handle(@health)
52
+ @port = @server.add_http2_port(listener_spec, credentials)
51
53
  raise ArgumentError, "could not bind #{listener_spec}" unless @port.positive?
52
54
 
53
55
  @port
@@ -62,7 +64,10 @@ module Gritz
62
64
  raise ArgumentError, "transport is already started" if @thread
63
65
 
64
66
  @thread = Thread.new { @server.run }
65
- return self if @server.wait_till_running(5)
67
+ if @server.wait_till_running(5)
68
+ refresh_health
69
+ return self
70
+ end
66
71
 
67
72
  @thread.value unless @thread.alive?
68
73
  kill
@@ -76,6 +81,7 @@ module Gritz
76
81
  def running? = @thread&.alive? && @server.running?
77
82
 
78
83
  def stop(deadline:)
84
+ drain!
79
85
  unless @thread
80
86
  @server&.close_unstarted
81
87
  @server = nil
@@ -88,11 +94,35 @@ module Gritz
88
94
 
89
95
  def kill = stop(deadline: Time.now)
90
96
 
97
+ def update_health(ready:, checks: {})
98
+ healthy = ready && checks.values.all? && !@draining
99
+ @health&.update(healthy)
100
+ healthy
101
+ end
102
+
103
+ def drain!
104
+ @draining = true
105
+ @health&.drain!
106
+ self
107
+ end
108
+
109
+ # @api private
110
+ def refresh_health
111
+ checks = @config.health_checks.transform_values do |check|
112
+ check.call ? true : false
113
+ rescue StandardError => e
114
+ @logger.warn("Health check failed (#{e.class})")
115
+ false
116
+ end
117
+ update_health(ready: running?, checks:)
118
+ end
119
+
91
120
  def stats
121
+ busy = @server&.busy_threads || 0
92
122
  @lock.synchronize do
93
123
  oldest = @inflight.values.min
94
124
  {
95
- inflight: @inflight.size, busy: @inflight.size, capacity: @config.threads,
125
+ inflight: @inflight.size, busy: busy, capacity: @config.threads,
96
126
  rejected_total: @rejected_total, requests_total: @requests_total,
97
127
  oldest_inflight_age: oldest ? Process.clock_gettime(Process::CLOCK_MONOTONIC) - oldest : 0
98
128
  }
@@ -114,6 +144,14 @@ module Gritz
114
144
 
115
145
  private
116
146
 
147
+ def server_credentials
148
+ return :this_port_is_insecure if @config.tls.empty?
149
+
150
+ ca = @config.tls[:client_ca] && File.read(@config.tls[:client_ca])
151
+ GRPC::Core::ServerCredentials.new(ca, [{ private_key: File.read(@config.tls.fetch(:key)),
152
+ cert_chain: File.read(@config.tls.fetch(:cert)) }], !ca.nil?)
153
+ end
154
+
117
155
  def status_exception(call, error)
118
156
  metadata = call.trailing_metadata.merge(error.metadata)
119
157
  unless error.details.empty?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gritz-native
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -35,14 +35,14 @@ dependencies:
35
35
  requirements:
36
36
  - - '='
37
37
  - !ruby/object:Gem::Version
38
- version: 0.2.0
38
+ version: 0.4.0
39
39
  type: :runtime
40
40
  prerelease: false
41
41
  version_requirements: !ruby/object:Gem::Requirement
42
42
  requirements:
43
43
  - - '='
44
44
  - !ruby/object:Gem::Version
45
- version: 0.2.0
45
+ version: 0.4.0
46
46
  - !ruby/object:Gem::Dependency
47
47
  name: grpc
48
48
  requirement: !ruby/object:Gem::Requirement
@@ -81,6 +81,9 @@ files:
81
81
  - lib/gritz/transport/native.rb
82
82
  - lib/gritz/transport/native/bridge.rb
83
83
  - lib/gritz/transport/native/call.rb
84
+ - lib/gritz/transport/native/client.rb
85
+ - lib/gritz/transport/native/client_invocation.rb
86
+ - lib/gritz/transport/native/health.rb
84
87
  - lib/gritz/transport/native/server.rb
85
88
  homepage: https://github.com/gritzrpc/gritz-native
86
89
  licenses: