nonnative 3.22.0 → 3.29.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: 6fa10a31c57c45551a417924990d2d870e137d7561c1581577a6e965099472cc
4
- data.tar.gz: dcd07b46e9ce599562c9c0f517ff7b6f46b529dfcf70c8f462cef3fd548704eb
3
+ metadata.gz: d81874eb81ecd99758c560530adaf6dd701ac6136258ec36b04a074312e45e65
4
+ data.tar.gz: 05e90f242c9cd70a7c544705d2db49c593e7615ff7edfc28e6864154036e758b
5
5
  SHA512:
6
- metadata.gz: cb3cf0ffe148327fb056ad6a16851156b060373d69a06dc13b615197bb6a3aec5cb70faf412c4f31a68f8e92ce297646201fd3151c068758cc22017f5c1a745f
7
- data.tar.gz: 8caa697ae5eb5c0b005da119b5ff518f68407c52cf876ca065a522147bc98d3064fff7899e54da1897408b9b963b87124ba1f62fd729913c073e3e1de8016dab
6
+ metadata.gz: 59abcf2ef7f4c1cf0785ab43dbca7af059441f8a65379601adc1204c8860955b462fc7fa8dac8b1bbb93cd7a449296cac36ea18bee692b67baad567b83cea3ad
7
+ data.tar.gz: ac4407b3205ca54a8e2496e9e2ce2594ee8bf8d7fff8f9c0ae5bce929fdf4b707732c54c8b45ccb038e79bb34601cf52823a7ee99ae6c7ae05a8bbdfb60cc05a
data/README.md CHANGED
@@ -465,6 +465,30 @@ module Nonnative
465
465
  end
466
466
  ```
467
467
 
468
+ To run multiple Rack services on one managed port, pass a non-empty mount map. Each key must start
469
+ with `/`, and the mounted application receives the remaining `PATH_INFO`:
470
+
471
+ ```ruby
472
+ module Nonnative
473
+ module Features
474
+ class HealthService < Nonnative::HTTPService
475
+ get '/' do
476
+ 'ok'
477
+ end
478
+ end
479
+
480
+ class HTTPServer < Nonnative::HTTPServer
481
+ def initialize(service)
482
+ super({ '/api' => HelloService.new, '/health' => HealthService.new }, service)
483
+ end
484
+ end
485
+ end
486
+ end
487
+ ```
488
+
489
+ The existing single-service form remains supported. Nonnative converts a mount map to a
490
+ `Rack::URLMap` and keeps one server lifecycle and port. An empty mount map raises `ArgumentError`.
491
+
468
492
  Set it up programmatically:
469
493
 
470
494
  ```ruby
@@ -517,6 +541,8 @@ end
517
541
 
518
542
  The system allows you to define an in-process HTTP forward proxy server for external systems, e.g. `api.github.com`. This is a server implementation, not a fault-injection service proxy.
519
543
 
544
+ The proxy preserves the upstream status, body, and safe end-to-end response headers such as `Content-Type`, `ETag`, and application-specific metadata. It removes hop-by-hop, connection-nominated, proxy-authentication, and framing headers; `Set-Cookie`, `Location`, and `Content-Encoding` are not forwarded.
545
+
520
546
  Define your server:
521
547
 
522
548
  ```ruby
@@ -603,6 +629,30 @@ module Nonnative
603
629
  end
604
630
  ```
605
631
 
632
+ To serve multiple gRPC services on one managed port, pass a non-empty array of handler classes or
633
+ instances:
634
+
635
+ ```ruby
636
+ module Nonnative
637
+ module Features
638
+ class HealthService < Grpc::Health::V1::Health::Service
639
+ def check(_request, _call)
640
+ Grpc::Health::V1::HealthCheckResponse.new(status: :SERVING)
641
+ end
642
+ end
643
+
644
+ class GRPCServer < Nonnative::GRPCServer
645
+ def initialize(service)
646
+ super([Greeter.new, HealthService.new], service)
647
+ end
648
+ end
649
+ end
650
+ end
651
+ ```
652
+
653
+ The existing single-handler form remains supported. Nonnative registers each handler before the
654
+ server starts, so application and standard health handlers can share one lifecycle and endpoint.
655
+
606
656
  Set it up programmatically:
607
657
 
608
658
  ```ruby
@@ -802,6 +852,7 @@ Clients connect to the service `host`/`port`, while the proxy forwards traffic t
802
852
  - `timeout` - Accepts the connection and stalls traffic until reset or stop closes the connection, so clients exercise their own read timeout behavior.
803
853
  - `invalid_data` - Forwards client requests unchanged, then corrupts upstream responses before they reach the client.
804
854
  - `bandwidth` - Throttles forwarded throughput to `options.rate` kilobytes per second (1 KB = 1024 bytes) by sleeping in proportion to the bytes read, in both directions, so clients see a slow-but-alive dependency. When `rate` is absent or not positive, traffic forwards at full speed.
855
+ - `limit_data` - Forwards client requests unchanged, then sends the first `options.bytes` bytes of the upstream byte stream on each connection and gracefully closes the connection. When `bytes` is absent or not positive, traffic forwards at full speed.
805
856
 
806
857
  ###### 🧩 Fault Injection Services
807
858
 
@@ -814,6 +865,7 @@ service = Nonnative.pool.service_by_name(name)
814
865
  service.proxy.close_all # To use close_all.
815
866
  service.proxy.reset_peer # To reset (RST) client connections.
816
867
  service.proxy.timeout # To stall traffic until reset or stop.
868
+ service.proxy.limit_data # To truncate the upstream byte stream at options.bytes.
817
869
  service.proxy.reset # To reset it back to a good state.
818
870
  ```
819
871
 
@@ -823,6 +875,7 @@ Use the Cucumber proxy steps:
823
875
  Given I set the proxy for service 'service_1' to 'close_all'
824
876
  Given I set the proxy for service 'service_1' to 'reset_peer'
825
877
  Given I set the proxy for service 'service_1' to 'timeout'
878
+ Given I set the proxy for service 'service_1' to 'limit_data'
826
879
  Then I should reset the proxy for service 'service_1'
827
880
  ```
828
881
 
@@ -60,6 +60,7 @@ module Nonnative
60
60
  'timeout' => :timeout,
61
61
  'invalid_data' => :invalid_data,
62
62
  'bandwidth' => :bandwidth,
63
+ 'limit_data' => :limit_data,
63
64
  'reset' => :reset
64
65
  }.freeze
65
66
 
@@ -15,6 +15,7 @@ module Nonnative
15
15
  # - {#timeout}: accept connections and keep them silent until clients time out
16
16
  # - {#invalid_data}: forward requests unchanged and mutate upstream responses before they reach clients
17
17
  # - {#bandwidth}: throttle forwarded throughput to a configured rate (KB/s)
18
+ # - {#limit_data}: forward a configured number of response bytes, then gracefully close
18
19
  # - {#reset}: return to healthy pass-through behavior
19
20
  #
20
21
  # State changes terminate any active connections so new connections observe the new behavior.
@@ -36,6 +37,8 @@ module Nonnative
36
37
  # - `delay`: delay duration in seconds used by {#delay}
37
38
  # - `jitter`: optional random offset (seconds) added in `-jitter..jitter` to each `delay` (a
38
39
  # negative value uses its magnitude), so clients see variable latency instead of a flat value
40
+ # - `bytes`: positive response byte limit used by {#limit_data}; absent or non-positive values
41
+ # use pass-through behavior
39
42
  #
40
43
  # @see Nonnative::Proxy
41
44
  # @see Nonnative::SocketPairFactory
@@ -150,6 +153,17 @@ module Nonnative
150
153
  apply_state :bandwidth
151
154
  end
152
155
 
156
+ # Truncates the upstream byte stream after a configured number of bytes.
157
+ #
158
+ # Client requests are forwarded unchanged. The response byte limit is read from
159
+ # `service.proxy.options[:bytes]`; when it is absent or not positive, the connection forwards at
160
+ # full speed without truncation.
161
+ #
162
+ # @return [void]
163
+ def limit_data
164
+ apply_state :limit_data
165
+ end
166
+
153
167
  # Resets the proxy back to healthy pass-through behavior.
154
168
  #
155
169
  # @return [void]
@@ -12,13 +12,18 @@ module Nonnative
12
12
  #
13
13
  # @see Nonnative::Server
14
14
  class GRPCServer < Nonnative::Server
15
- # Creates a gRPC server and registers the provided service handler.
15
+ # Creates a gRPC server and registers the provided service handler(s).
16
16
  #
17
- # @param svc [Object] a gRPC service implementation (typically a `...::Service` subclass instance)
17
+ # @param svc [Object, Array<Object>] a gRPC service implementation or a non-empty array of
18
+ # implementations (typically `...::Service` subclasses or instances)
18
19
  # @param service [Nonnative::ConfigurationServer] server configuration
20
+ # @raise [ArgumentError] if `svc` is an empty array
19
21
  def initialize(svc, service)
20
22
  @server = GRPC::RpcServer.new
21
- server.handle(svc)
23
+ handlers = svc.is_a?(Array) ? svc : [svc]
24
+ raise ArgumentError, 'gRPC server requires at least one service handler' if handlers.empty?
25
+
26
+ handlers.each { |handler| server.handle(handler) }
22
27
 
23
28
  # Unfortunately gRPC has only one logger so the first server wins.
24
29
  GRPC.define_singleton_method(:logger) do
@@ -80,19 +80,57 @@ module Nonnative
80
80
  end
81
81
  end
82
82
 
83
+ # Performs a PATCH request.
84
+ #
85
+ # @param pathname [String] path relative to `host`
86
+ # @param payload [Object] request payload
87
+ # @param opts [Hash] RestClient request options
88
+ # @return [RestClient::Response, String] response for non-2xx errors, otherwise the RestClient result
89
+ def patch(pathname, payload, opts = {})
90
+ with_exception do
91
+ resource(pathname, opts).patch(payload)
92
+ end
93
+ end
94
+
95
+ # Performs a HEAD request.
96
+ #
97
+ # @param pathname [String] path relative to `host`
98
+ # @param opts [Hash] RestClient request options (e.g. `headers`, `read_timeout`, `open_timeout`)
99
+ # @return [RestClient::Response, String] response for non-2xx errors, otherwise the RestClient result
100
+ def head(pathname, opts = {})
101
+ with_exception do
102
+ resource(pathname, opts).head
103
+ end
104
+ end
105
+
106
+ # Performs an OPTIONS request.
107
+ #
108
+ # @param pathname [String] path relative to `host`
109
+ # @param opts [Hash] RestClient request options (e.g. `headers`, `read_timeout`, `open_timeout`)
110
+ # @return [RestClient::Response, String] response for non-2xx errors, otherwise the RestClient result
111
+ def options(pathname, opts = {})
112
+ with_exception do
113
+ RestClient::Request.execute(opts.merge(method: :options, url: request_url(pathname)))
114
+ end
115
+ end
116
+
83
117
  # Creates a RestClient resource for a relative path.
84
118
  #
85
119
  # @param pathname [String] path relative to `host`
86
120
  # @param opts [Hash] RestClient request options
87
121
  # @return [RestClient::Resource]
88
122
  def resource(pathname, opts)
89
- RestClient::Resource.new(URI.join(host, pathname).to_s, opts)
123
+ RestClient::Resource.new(request_url(pathname), opts)
90
124
  end
91
125
 
92
126
  private
93
127
 
94
128
  attr_reader :host, :exceptions
95
129
 
130
+ def request_url(pathname)
131
+ URI.join(host, pathname).to_s
132
+ end
133
+
96
134
  def with_exception
97
135
  yield
98
136
  rescue *exceptions => e
@@ -3,7 +3,8 @@
3
3
  # Sinatra-based HTTP forward proxy server used as an in-process Nonnative server.
4
4
  #
5
5
  # The proxy receives inbound HTTP requests and forwards them to an upstream host over HTTPS, returning
6
- # the upstream response status and body.
6
+ # the upstream response status, body, and safe end-to-end response headers. Hop-by-hop,
7
+ # connection-nominated, proxy-authentication, framing, and deferred response headers are not forwarded.
7
8
  #
8
9
  # This file defines two classes:
9
10
  #
@@ -21,9 +22,27 @@ module Nonnative
21
22
  #
22
23
  # The upstream host is configured via service settings (see {Nonnative::HTTPProxyServer}).
23
24
  #
24
- # Supported HTTP verbs: GET, POST, PUT, PATCH, DELETE.
25
+ # Supported HTTP verbs: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS.
25
26
  class HTTPProxy < Nonnative::HTTPService
26
- NON_FORWARDABLE_HEADERS = %w[
27
+ NON_FORWARDABLE_RESPONSE_HEADERS = %w[
28
+ connection
29
+ content-encoding
30
+ content-length
31
+ keep-alive
32
+ location
33
+ proxy-authenticate
34
+ proxy-authorization
35
+ proxy-authentication-info
36
+ proxy-connection
37
+ set-cookie
38
+ status
39
+ te
40
+ trailer
41
+ transfer-encoding
42
+ upgrade
43
+ ].freeze
44
+
45
+ NON_FORWARDABLE_REQUEST_HEADERS = %w[
27
46
  Host
28
47
  Accept-Encoding
29
48
  Version
@@ -37,14 +56,14 @@ module Nonnative
37
56
  #
38
57
  # @param request [Sinatra::Request] the incoming request
39
58
  # @return [Hash{String=>String}] headers to forward to the upstream
40
- def retrieve_headers(request)
59
+ def forward_request_headers(request)
41
60
  headers = request.env.each_with_object({}) do |(header, value), result|
42
- next unless forward_header?(header)
61
+ next unless forward_request_header?(header)
43
62
 
44
- result[normalized_header_name(header)] = value
63
+ result[normalized_request_header_name(header)] = value
45
64
  end
46
65
 
47
- headers.except(*NON_FORWARDABLE_HEADERS)
66
+ headers.except(*NON_FORWARDABLE_REQUEST_HEADERS)
48
67
  end
49
68
 
50
69
  # Builds the upstream URL for the given request.
@@ -86,23 +105,65 @@ module Nonnative
86
105
 
87
106
  private
88
107
 
89
- def forward_header?(header)
108
+ def forward_response_headers(response)
109
+ raw_headers = response.raw_headers
110
+ connection_headers = response_connection_headers(raw_headers)
111
+
112
+ raw_headers.each_with_object({}) do |(header, value), result|
113
+ normalized_header = normalized_response_header_name(header)
114
+ next unless forward_response_header?(normalized_header, connection_headers)
115
+
116
+ result[normalized_header] = Array(value).join(', ')
117
+ end
118
+ end
119
+
120
+ def response_connection_headers(raw_headers)
121
+ connection_headers = raw_headers.each_with_object([]) do |(header, values), result|
122
+ next unless normalized_response_header_name(header) == 'connection'
123
+
124
+ Array(values).each { |value| result.concat(value.to_s.split(',')) }
125
+ end
126
+
127
+ connection_headers.map { |header| normalized_response_header_name(header) }
128
+ end
129
+
130
+ def forward_response_header?(header, connection_headers)
131
+ !NON_FORWARDABLE_RESPONSE_HEADERS.include?(header) &&
132
+ !connection_headers.include?(header) &&
133
+ !header.start_with?('rack.')
134
+ end
135
+
136
+ def normalized_response_header_name(header)
137
+ header.to_s.strip.downcase
138
+ end
139
+
140
+ def forward_request_header?(header)
90
141
  header.start_with?('HTTP_') || %w[CONTENT_TYPE CONTENT_LENGTH].include?(header)
91
142
  end
92
143
 
93
- def normalized_header_name(header)
144
+ def normalized_request_header_name(header)
94
145
  header.delete_prefix('HTTP_').split('_').map(&:capitalize).join('-')
95
146
  end
96
147
 
97
- %w[get post put patch delete].each do |verb|
148
+ # Registered before `get` so it takes precedence over Sinatra's GET-generated HEAD route,
149
+ # which would otherwise forward HEAD requests upstream as GET.
150
+ head(/.*/) do
151
+ res = api_response(method: :head, url: build_url(request, settings), headers: forward_request_headers(request))
152
+
153
+ headers(forward_response_headers(res))
154
+ status res.code
155
+ end
156
+
157
+ %w[get post put patch delete options].each do |verb|
98
158
  send(verb, /.*/) do
99
159
  res = api_response(
100
160
  method: verb.to_sym,
101
161
  url: build_url(request, settings),
102
- headers: retrieve_headers(request),
162
+ headers: forward_request_headers(request),
103
163
  payload: retrieve_payload(request, verb)
104
164
  )
105
165
 
166
+ headers(forward_response_headers(res))
106
167
  status res.code
107
168
  res.body
108
169
  end
@@ -36,9 +36,16 @@ module Nonnative
36
36
  class HTTPServer < Nonnative::Server
37
37
  # Creates a Puma server for the given HTTP service and runner configuration.
38
38
  #
39
- # @param http_service [#call] an HTTP service instance
39
+ # @param http_service [#call, Hash<String, #call>] an HTTP service instance or mount map
40
40
  # @param service [Nonnative::ConfigurationServer] server configuration
41
+ # @raise [ArgumentError] if `http_service` is an empty mount map
41
42
  def initialize(http_service, service)
43
+ if http_service.is_a?(Hash)
44
+ raise ArgumentError, 'HTTP server requires at least one service mount' if http_service.empty?
45
+
46
+ http_service = Rack::URLMap.new(http_service)
47
+ end
48
+
42
49
  # Keep the log IO so the server lifecycle can release Puma's file handle on stop.
43
50
  @log = File.open(service.log, 'a')
44
51
  options = {
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nonnative
4
+ # Socket-pair variant used by the fault-injection proxy to truncate upstream responses.
5
+ #
6
+ # When active, client requests pass through unchanged, while the first `proxy.options[:bytes]`
7
+ # bytes of the upstream stream are forwarded to the client on each connection. Once the limit is
8
+ # reached, the pair's normal connection lifecycle closes both sockets gracefully. A missing or
9
+ # non-positive limit leaves the connection in pass-through mode.
10
+ #
11
+ # This behavior is enabled by calling {Nonnative::FaultInjectionProxy#limit_data}.
12
+ #
13
+ # @see Nonnative::FaultInjectionProxy
14
+ # @see Nonnative::SocketPairFactory
15
+ # @see Nonnative::SocketPair
16
+ class LimitDataSocketPair < SocketPair
17
+ # Tracks the client socket and response byte budget for this connection.
18
+ #
19
+ # @param local_socket [TCPSocket] the accepted client socket
20
+ # @return [void]
21
+ def connect(local_socket)
22
+ @local_socket = local_socket
23
+ @remaining = proxy.options[:bytes]
24
+ @limit_reached = false
25
+
26
+ super
27
+ ensure
28
+ @local_socket = nil
29
+ @remaining = nil
30
+ @limit_reached = nil
31
+ end
32
+
33
+ protected
34
+
35
+ # Stops the base forwarding loop after the response budget has been written.
36
+ #
37
+ # @param ready [Array<Array<IO>>] the result from `select`
38
+ # @param source_socket [IO] readable side
39
+ # @param destination_socket [IO] writable side
40
+ # @return [Boolean] whether the forwarding loop should terminate
41
+ def pipe?(ready, source_socket, destination_socket)
42
+ return true if @limit_reached
43
+
44
+ super || @limit_reached
45
+ end
46
+
47
+ # Limits writes to the client while leaving writes to the upstream unchanged.
48
+ #
49
+ # @param socket [IO] the socket to write to
50
+ # @param data [String] the original payload
51
+ # @return [Integer] number of bytes written
52
+ def write(socket, data)
53
+ return super unless socket.equal?(@local_socket)
54
+ return super if @remaining.nil? || @remaining <= 0
55
+
56
+ data = data.byteslice(0, @remaining)
57
+ @remaining -= data.bytesize
58
+ @limit_reached = @remaining.zero?
59
+
60
+ super
61
+ end
62
+ end
63
+ end
@@ -23,17 +23,14 @@ module Nonnative
23
23
  @processes = nil
24
24
  end
25
25
 
26
- # Starts all configured runners and yields results for each process/server.
26
+ # Starts all configured runners and collects lifecycle and readiness errors.
27
27
  #
28
28
  # Services are started first (proxy-only) and checked for opt-in readiness, then servers and processes are
29
- # started and checked for readiness.
29
+ # started and checked for readiness. Each readiness failure is described with the runner and, for a process
30
+ # that exited early, its termination detail.
30
31
  #
31
- # @yieldparam name [String, nil] runner name
32
- # @yieldparam values [Object] runner-specific return value from `start` (e.g. `[pid, running]` for processes)
33
- # @yieldparam result [Boolean] result of the port readiness check (`true` if ready in time)
34
- # @yieldparam port [Nonnative::Ports] checked port group
35
32
  # @return [Array<String>] lifecycle and readiness-check errors collected while starting
36
- def start(&)
33
+ def start
37
34
  errors = []
38
35
 
39
36
  errors.concat(service_lifecycle(services, :start, :start))
@@ -41,24 +38,20 @@ module Nonnative
41
38
  errors.concat(service_readiness_errors)
42
39
  return errors if service_readiness_errors.any?
43
40
 
44
- [servers, processes].each { |runners| errors.concat(run_lifecycle_checks(runners, :start, :open?, :start, &)) }
41
+ [servers, processes].each { |runners| errors.concat(run_lifecycle_checks(runners, :start, :open?, :start)) }
45
42
 
46
43
  errors
47
44
  end
48
45
 
49
- # Stops all configured runners and yields results for each process/server.
46
+ # Stops all configured runners and collects lifecycle and shutdown errors.
50
47
  #
51
48
  # Processes and servers are stopped first and checked for shutdown, then services are stopped (proxy-only).
52
49
  #
53
- # @yieldparam name [String, nil] runner name
54
- # @yieldparam id [Object] runner-specific identifier returned by `stop` (e.g. pid or object_id)
55
- # @yieldparam result [Boolean] result of the port shutdown check (`true` if closed in time)
56
- # @yieldparam port [Nonnative::Ports] checked port group
57
50
  # @return [Array<String>] lifecycle and shutdown-check errors collected while stopping
58
- def stop(&)
51
+ def stop
59
52
  errors = []
60
53
 
61
- [processes, servers].each { |runners| errors.concat(run_lifecycle_checks(runners, :stop, :closed?, :stop, &)) }
54
+ [processes, servers].each { |runners| errors.concat(run_lifecycle_checks(runners, :stop, :closed?, :stop)) }
62
55
  errors.concat(service_lifecycle(services, :stop, :stop))
63
56
 
64
57
  errors
@@ -69,16 +62,12 @@ module Nonnative
69
62
  # This is used to rollback partial startup after a failed {#start} without constructing new runner
70
63
  # wrappers as a side effect.
71
64
  #
72
- # @yieldparam name [String, nil] runner name
73
- # @yieldparam id [Object] runner-specific identifier returned by `stop`
74
- # @yieldparam result [Boolean] result of the port shutdown check (`true` if closed in time)
75
- # @yieldparam port [Nonnative::Ports] checked port group
76
65
  # @return [Array<String>] lifecycle and shutdown-check errors collected while rolling back
77
- def rollback(&)
66
+ def rollback
78
67
  errors = []
79
68
 
80
69
  [existing_processes, existing_servers].each do |runners|
81
- errors.concat(run_lifecycle_checks(runners, :stop, :closed?, :stop, &))
70
+ errors.concat(run_lifecycle_checks(runners, :stop, :closed?, :rollback))
82
71
  end
83
72
  errors.concat(service_lifecycle(existing_services, :stop, :stop))
84
73
 
@@ -196,7 +185,8 @@ module Nonnative
196
185
  end
197
186
  end
198
187
 
199
- def run_lifecycle_checks(runners, lifecycle_method, port_method, action, &)
188
+ def run_lifecycle_checks(runners, lifecycle_method, port_method, phase)
189
+ action = phase == :start ? :start : :stop
200
190
  checks = []
201
191
  errors = []
202
192
 
@@ -207,7 +197,7 @@ module Nonnative
207
197
  errors << lifecycle_error(action, runner, e)
208
198
  end
209
199
 
210
- errors.concat(yield_results(checks, action, &))
200
+ errors.concat(lifecycle_results(checks, phase, action))
211
201
  end
212
202
 
213
203
  def check_port(port, port_method)
@@ -216,17 +206,50 @@ module Nonnative
216
206
  { error: e }
217
207
  end
218
208
 
219
- def yield_results(checks, action, &)
220
- checks.each_with_object([]) do |(type, values, port, thread), errors|
209
+ def lifecycle_results(checks, phase, action)
210
+ checks.each_with_object([]) do |(runner, values, port, thread), errors|
221
211
  result = thread.value
222
212
  if result[:error]
223
- errors << port_error(action, type, result[:error])
224
- elsif block_given?
225
- yield type.name, values, result[:result], port
213
+ errors << port_error(action, runner, result[:error])
214
+ else
215
+ errors.concat(readiness_errors(phase, runner, values, result[:result], port))
226
216
  end
227
217
  end
228
218
  end
229
219
 
220
+ def readiness_errors(phase, runner, values, ready, port)
221
+ case phase
222
+ when :start then start_errors(runner, values, ready, port)
223
+ when :stop then stop_errors(runner, values, ready, port)
224
+ else rollback_errors(runner, values, ready, port)
225
+ end
226
+ end
227
+
228
+ def start_errors(runner, values, ready, port)
229
+ id, started = values
230
+ return [] if started && ready
231
+
232
+ message = "Started #{runner.name} with id #{id}, though did not respond in time for #{port.description}"
233
+ detail = runner.termination
234
+ [detail ? "#{message}; #{detail}" : message]
235
+ end
236
+
237
+ def stop_errors(runner, values, ready, port)
238
+ id, stopped = Array(values).then { |v| [v.first, v.fetch(1, true)] }
239
+ errors = []
240
+ errors << "Stopped #{runner.name} with id #{id}, though did not respond in time for #{port.description}" unless ready
241
+ errors << "Stopped #{runner.name} with id #{id}, though the process did not exit in time" unless stopped
242
+ errors
243
+ end
244
+
245
+ def rollback_errors(runner, values, ready, port)
246
+ id, stopped = Array(values).then { |v| [v.first, v.fetch(1, true)] }
247
+ errors = []
248
+ errors << "Rollback failed for #{runner.name} with id #{id}, because it did not stop in time for #{port.description}" unless ready
249
+ errors << "Rollback failed for #{runner.name} with id #{id}, because the process did not exit in time" unless stopped
250
+ errors
251
+ end
252
+
230
253
  def lifecycle_error(action, type, error)
231
254
  "#{action.to_s.capitalize} failed for #{runner_name(type)}: #{error.class} - #{error.message}"
232
255
  end
@@ -37,7 +37,10 @@ module Nonnative
37
37
  wait_start
38
38
  end
39
39
 
40
- [pid, ::Process.waitpid2(pid, ::Process::WNOHANG).nil?]
40
+ # Retain the reaped status for this lifecycle so startup diagnostics can report it.
41
+ status = ::Process.waitpid2(pid, ::Process::WNOHANG)
42
+ @status = status&.last
43
+ [pid, status.nil?]
41
44
  end
42
45
 
43
46
  # Stops the process if it is running.
@@ -62,6 +65,20 @@ module Nonnative
62
65
  @memory = nil
63
66
  end
64
67
 
68
+ # Describes how the process terminated when it exited before becoming ready.
69
+ #
70
+ # Returns `nil` while the process is still running, so callers can distinguish an early exit
71
+ # from a live process that merely missed its readiness window. The check is non-blocking and
72
+ # reuses the status captured during {#start} when available.
73
+ #
74
+ # @return [String, nil] termination detail (exit status or terminating signal), or `nil`
75
+ def termination
76
+ status = captured_status
77
+ return if status.nil?
78
+
79
+ terminated_description(status)
80
+ end
81
+
65
82
  protected
66
83
 
67
84
  def wait_stop
@@ -74,6 +91,28 @@ module Nonnative
74
91
 
75
92
  attr_reader :pid, :timeout
76
93
 
94
+ def captured_status
95
+ return @status unless @status.nil?
96
+ return if pid.nil?
97
+
98
+ # A process that exited during the readiness window has not been reaped yet.
99
+ @status = ::Process.waitpid2(pid, ::Process::WNOHANG)&.last
100
+ rescue Errno::ECHILD, Errno::ESRCH
101
+ nil
102
+ end
103
+
104
+ def terminated_description(status)
105
+ return "process exited before readiness with exit status #{status.exitstatus}" if status.exited?
106
+ return "process exited before readiness after being killed by signal #{signal_description(status.termsig)}" if status.signaled?
107
+
108
+ "process exited before readiness (#{status})"
109
+ end
110
+
111
+ def signal_description(signal)
112
+ name = Signal.signame(signal)
113
+ name ? "SIG#{name} (#{signal})" : signal.to_s
114
+ end
115
+
77
116
  def process_kill
78
117
  signal = Signal.list[service.signal || 'INT'] || Signal.list['INT']
79
118
  ::Process.kill(signal, pid)
@@ -26,6 +26,15 @@ module Nonnative
26
26
  service.name
27
27
  end
28
28
 
29
+ # Describes how the runner terminated before becoming ready, for lifecycle diagnostics.
30
+ #
31
+ # Base runners report nothing; {Nonnative::Process} overrides this to describe an early exit.
32
+ #
33
+ # @return [String, nil]
34
+ def termination
35
+ nil
36
+ end
37
+
29
38
  protected
30
39
 
31
40
  # Returns the underlying configuration object.
@@ -32,7 +32,14 @@ module Nonnative
32
32
  # - `true` (thread creation itself is considered started; readiness is checked separately)
33
33
  def start
34
34
  unless thread
35
- @thread = Thread.new { perform_start }
35
+ @error = nil
36
+ @thread = Thread.new do
37
+ perform_start
38
+ rescue StandardError => e
39
+ @error = e
40
+ raise
41
+ end
42
+ @thread.report_on_exception = true
36
43
 
37
44
  wait_start
38
45
 
@@ -61,8 +68,22 @@ module Nonnative
61
68
  object_id
62
69
  end
63
70
 
71
+ # Describes how the server thread terminated before becoming ready, for lifecycle diagnostics.
72
+ #
73
+ # Returns `nil` while the thread is still alive, so callers can distinguish a dead thread from a
74
+ # live server that merely missed its readiness window.
75
+ #
76
+ # @return [String, nil] termination detail (clean early return or uncaught exception), or `nil`
77
+ def termination
78
+ return if thread.nil? || thread.alive?
79
+
80
+ return "server thread raised #{error.class}: #{error.message}" if error
81
+
82
+ 'server thread exited before readiness'
83
+ end
84
+
64
85
  private
65
86
 
66
- attr_reader :thread, :timeout
87
+ attr_reader :thread, :timeout, :error
67
88
  end
68
89
  end
@@ -14,6 +14,7 @@ module Nonnative
14
14
  # - `:timeout` -> {Nonnative::TimeoutSocketPair}
15
15
  # - `:invalid_data` -> {Nonnative::InvalidDataSocketPair}
16
16
  # - `:bandwidth` -> {Nonnative::BandwidthSocketPair}
17
+ # - `:limit_data` -> {Nonnative::LimitDataSocketPair}
17
18
  #
18
19
  # @see Nonnative::FaultInjectionProxy
19
20
  # @see Nonnative::SocketPair
@@ -23,30 +24,28 @@ module Nonnative
23
24
  # @see Nonnative::TimeoutSocketPair
24
25
  # @see Nonnative::InvalidDataSocketPair
25
26
  # @see Nonnative::BandwidthSocketPair
27
+ # @see Nonnative::LimitDataSocketPair
26
28
  class SocketPairFactory
29
+ PAIR_BY_STATE = {
30
+ close_all: CloseAllSocketPair,
31
+ reset_peer: ResetPeerSocketPair,
32
+ delay: DelaySocketPair,
33
+ timeout: TimeoutSocketPair,
34
+ invalid_data: InvalidDataSocketPair,
35
+ bandwidth: BandwidthSocketPair,
36
+ limit_data: LimitDataSocketPair
37
+ }.freeze
38
+ private_constant :PAIR_BY_STATE
39
+
27
40
  class << self
28
41
  # Creates a socket-pair instance for the given proxy state.
29
42
  #
30
- # @param kind [Symbol] proxy state (e.g. `:none`, `:close_all`, `:reset_peer`, `:delay`, `:timeout`, `:invalid_data`, `:bandwidth`)
43
+ # @param kind [Symbol] proxy state (e.g. `:none`, `:close_all`, `:reset_peer`, `:delay`, `:timeout`,
44
+ # `:invalid_data`, `:bandwidth`, `:limit_data`)
31
45
  # @param proxy [Nonnative::ConfigurationProxy] proxy configuration (host/port/options)
32
46
  # @return [Nonnative::SocketPair] a socket-pair implementation instance
33
47
  def create(kind, proxy)
34
- pair = case kind
35
- when :close_all
36
- CloseAllSocketPair
37
- when :reset_peer
38
- ResetPeerSocketPair
39
- when :delay
40
- DelaySocketPair
41
- when :timeout
42
- TimeoutSocketPair
43
- when :invalid_data
44
- InvalidDataSocketPair
45
- when :bandwidth
46
- BandwidthSocketPair
47
- else
48
- SocketPair
49
- end
48
+ pair = PAIR_BY_STATE.fetch(kind, SocketPair)
50
49
 
51
50
  pair.new(proxy)
52
51
  end
@@ -4,5 +4,5 @@ module Nonnative
4
4
  # The current gem version.
5
5
  #
6
6
  # @return [String]
7
- VERSION = '3.22.0'
7
+ VERSION = '3.29.0'
8
8
  end
data/lib/nonnative.rb CHANGED
@@ -116,6 +116,7 @@ require 'nonnative/delay_socket_pair'
116
116
  require 'nonnative/timeout_socket_pair'
117
117
  require 'nonnative/invalid_data_socket_pair'
118
118
  require 'nonnative/bandwidth_socket_pair'
119
+ require 'nonnative/limit_data_socket_pair'
119
120
  require 'nonnative/socket_pair_factory'
120
121
  require 'nonnative/go_executable'
121
122
  require 'nonnative/cucumber'
@@ -271,10 +272,7 @@ module Nonnative
271
272
  def start
272
273
  @pool ||= Nonnative::Pool.new(configuration)
273
274
  errors = []
274
- errors.concat(@pool.start do |name, values, result, ports|
275
- id, started = values
276
- errors << "Started #{name} with id #{id}, though did not respond in time for #{ports.description}" if !started || !result
277
- end)
275
+ errors.concat(@pool.start)
278
276
  nil
279
277
  rescue StandardError => e
280
278
  errors << unexpected_lifecycle_error(:start, e)
@@ -294,11 +292,7 @@ module Nonnative
294
292
  errors = []
295
293
  return if @pool.nil?
296
294
 
297
- errors.concat(@pool.stop do |name, values, result, ports|
298
- id, stopped = Array(values).then { |v| [v.first, v.fetch(1, true)] }
299
- errors << "Stopped #{name} with id #{id}, though did not respond in time for #{ports.description}" unless result
300
- errors << "Stopped #{name} with id #{id}, though the process did not exit in time" unless stopped
301
- end)
295
+ errors.concat(@pool.stop)
302
296
  nil
303
297
  rescue StandardError => e
304
298
  errors << unexpected_lifecycle_error(:stop, e)
@@ -363,11 +357,7 @@ module Nonnative
363
357
  errors = []
364
358
  return errors if @pool.nil?
365
359
 
366
- errors.concat(@pool.rollback do |name, values, result, ports|
367
- id, stopped = Array(values).then { |v| [v.first, v.fetch(1, true)] }
368
- errors << "Rollback failed for #{name} with id #{id}, because it did not stop in time for #{ports.description}" unless result
369
- errors << "Rollback failed for #{name} with id #{id}, because the process did not exit in time" unless stopped
370
- end)
360
+ errors.concat(@pool.rollback)
371
361
  rescue StandardError => e
372
362
  errors << unexpected_lifecycle_error(:rollback, e)
373
363
  ensure
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nonnative
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.22.0
4
+ version: 3.29.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alejandro Falkowski
@@ -422,6 +422,7 @@ files:
422
422
  - lib/nonnative/http_service.rb
423
423
  - lib/nonnative/invalid_data_socket_pair.rb
424
424
  - lib/nonnative/jwt_token.rb
425
+ - lib/nonnative/limit_data_socket_pair.rb
425
426
  - lib/nonnative/no_proxy.rb
426
427
  - lib/nonnative/not_found_error.rb
427
428
  - lib/nonnative/observability.rb