nonnative 3.35.0 → 3.38.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: a9a3ed94a590e8273136387f186364d01270536a472d476fc806b8e9d1d06c07
4
- data.tar.gz: 9b1b14eb944a13c83c93bf816d4b485d0f1818080167a6f5339c7bba73d59c31
3
+ metadata.gz: 1d53bdf381bf4cb897538cc4520e499b0116e8fc31566930ae1612859dda4d6a
4
+ data.tar.gz: 809f8545b4fe5e6d49289840572247148c38f2a0cd3647919b5b77f473b88b5e
5
5
  SHA512:
6
- metadata.gz: 370d1071e0665f5ea99a06a5dcf4fbe18224aee3d47b9f4279f3fd881c03340869fc11b1b8a58349065bae3d98ffa7be5243a34c26351366b53063676a7cb859
7
- data.tar.gz: 94ad2439c76e863ee3b6a34655454e0610f3a7f8c0b5db23df9af0c0bfc54924ca63410008eccf4b261b6693f601c0495d2bc668bce5a96f9a5a319db44088da
6
+ metadata.gz: 34adbd873e0eedd1b994aff7afe5ebc33834ab3f820dcbd2b551d619c851b083ae897828c88ad6c5b185b3d174390364aca1fdd9be85440fc2a5175e574eb686
7
+ data.tar.gz: 06ea5ded95fd98a9ba589cf5b5a8a8d567e07e295fbe72445332997ccb21b1649aa8edb0fe21054a17b91c527e023aa4e44c0724ce66ca0e43c4f39c3f7c4a69
data/README.md CHANGED
@@ -78,8 +78,9 @@ Common runner fields:
78
78
  Process/server fields:
79
79
  - `ports`: client-facing ports. These are also used for readiness/shutdown port checks.
80
80
  - `timeout`: max time (seconds) for each readiness/shutdown check. For processes, the same value also
81
- bounds optional HTTP/gRPC probes and graceful child exit after the stop signal. Defaults to `1.0`.
82
- A value of `0` fails immediately; setting it to `nil` programmatically does the same.
81
+ bounds optional HTTP/gRPC probes and graceful child exit after the stop signal. For servers, it
82
+ also bounds worker-thread cleanup after the stop hook returns. Defaults to `1.0`. A value of `0`
83
+ fails immediately; setting it to `nil` programmatically does the same.
83
84
  - `wait`: small sleep (seconds) between lifecycle steps.
84
85
  - `log`: per-runner log file used by process output redirection or server implementations.
85
86
 
@@ -437,6 +438,13 @@ module Nonnative
437
438
  end
438
439
  ```
439
440
 
441
+ Rollback calls `perform_stop` for every successfully constructed server, even when a later
442
+ constructor fails before any server thread starts. Keep `perform_stop` safe in that state and use it
443
+ to release resources acquired during initialization. After `perform_stop` requests shutdown,
444
+ Nonnative waits up to the server `timeout` for the owned thread to finish. A thread that exceeds the
445
+ timeout is terminated and causes `Nonnative.stop` to raise `Nonnative::StopError`; the server can
446
+ still be started again afterward.
447
+
440
448
  Set it up programmatically:
441
449
 
442
450
  ```ruby
@@ -602,11 +610,13 @@ The system allows you to define an in-process HTTP forward proxy server for exte
602
610
  The upstream scheme defaults to HTTPS on the scheme's default port; pass `scheme:`/`port:` to
603
611
  `Nonnative::HTTPProxyServer.new` to target an `http://` upstream or a non-default port. The proxy
604
612
  forwards the request path and query for `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, and
605
- `OPTIONS`, while removing proxy credentials plus `Host` and `Accept-Encoding` before the upstream
606
- request.
613
+ `OPTIONS`, while removing proxy credentials, `Host`, `Accept-Encoding`, hop-by-hop headers, and
614
+ headers nominated by `Connection` before the upstream request.
607
615
 
608
616
  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.
609
617
 
618
+ When the upstream is unavailable, disconnects unexpectedly, or times out, the proxy returns a clean gateway response (`502` or `504`) instead of exposing an internal exception page.
619
+
610
620
  Define your server:
611
621
 
612
622
  ```ruby
@@ -14,7 +14,8 @@ module Nonnative
14
14
  # @return [Class] a class that implements `#initialize(service)` and the hooks expected by {Nonnative::Server}
15
15
  attr_accessor :klass
16
16
 
17
- # @return [Numeric] readiness timeout (seconds) used when waiting for ports to open/close (defaults to `1.0`)
17
+ # @return [Numeric] lifecycle timeout (seconds) used for port checks and worker-thread cleanup
18
+ # (defaults to `1.0`)
18
19
  attr_accessor :timeout
19
20
 
20
21
  # @return [String] log file path used by server implementations (for example Puma/gRPC log files)
@@ -52,6 +52,8 @@ module Nonnative
52
52
  # @see Nonnative::Proxy
53
53
  # @see Nonnative::SocketPairFactory
54
54
  class FaultInjectionProxy < Nonnative::Proxy
55
+ # Used both to flush the accept-queue barrier and as the aggregate deadline for draining active
56
+ # worker threads during stop.
55
57
  STOP_DRAIN_TIMEOUT = 1
56
58
 
57
59
  class Connection
@@ -62,10 +64,9 @@ module Nonnative
62
64
  @socket = socket
63
65
  end
64
66
 
65
- def close
67
+ def close_sockets
66
68
  pair&.close
67
69
  socket.close unless socket.closed?
68
- thread&.terminate
69
70
  end
70
71
  end
71
72
 
@@ -88,6 +89,7 @@ module Nonnative
88
89
  # @return [void]
89
90
  def start
90
91
  mutex.synchronize { @stopping = false }
92
+ logger
91
93
  @tcp_server = ::TCPServer.new(service.host, service.port)
92
94
  @thread = Thread.new { perform_start }
93
95
 
@@ -100,7 +102,7 @@ module Nonnative
100
102
  def stop
101
103
  server = tcp_server
102
104
  mark_stopping
103
- close_connections
105
+ drain_workers(close_connections)
104
106
  close_queued_connections(server)
105
107
  server&.close
106
108
 
@@ -113,6 +115,7 @@ module Nonnative
113
115
 
114
116
  @tcp_server = nil
115
117
  @thread = nil
118
+ close_logger
116
119
 
117
120
  Nonnative.logger.info "stopped with host '#{service.host}' and port '#{service.port}' for proxy 'fault_injection'"
118
121
  end
@@ -230,7 +233,7 @@ module Nonnative
230
233
 
231
234
  private
232
235
 
233
- attr_reader :tcp_server, :thread, :connections, :mutex, :state, :logger, :stopping
236
+ attr_reader :tcp_server, :thread, :connections, :mutex, :state, :stopping
234
237
 
235
238
  def perform_start
236
239
  loop do
@@ -240,10 +243,9 @@ module Nonnative
240
243
  local_socket.close
241
244
  next
242
245
  end
243
- connection_thread = Thread.start(local_socket) do |accepted_socket|
244
- accept_connection id, accepted_socket
246
+ Thread.start(local_socket) do |accepted_socket|
247
+ accept_connection(id, accepted_socket) if register_connection_thread(id)
245
248
  end
246
- attach_connection_thread(id, connection_thread)
247
249
  end
248
250
  rescue IOError, Errno::EBADF
249
251
  nil
@@ -281,8 +283,29 @@ module Nonnative
281
283
  active_connections.each do |id, connection|
282
284
  close_connection(id, connection)
283
285
  end
286
+
287
+ active_connections.filter_map { |_id, connection| connection.thread }
288
+ end
289
+
290
+ def drain_workers(workers)
291
+ deadline = monotonic_now + STOP_DRAIN_TIMEOUT
292
+ workers.each do |worker|
293
+ worker.join(remaining_time(deadline))
294
+ terminate_worker(worker)
295
+ end
284
296
  end
285
297
 
298
+ def terminate_worker(worker)
299
+ return unless worker.alive?
300
+
301
+ worker.kill
302
+ worker.join
303
+ end
304
+
305
+ def remaining_time(deadline) = [deadline - monotonic_now, 0].max
306
+
307
+ def monotonic_now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
308
+
286
309
  def close_queued_connections(server)
287
310
  return unless server
288
311
 
@@ -309,9 +332,7 @@ module Nonnative
309
332
  wait
310
333
  end
311
334
 
312
- def read_state
313
- mutex.synchronize { state }
314
- end
335
+ def read_state = mutex.synchronize { state }
315
336
 
316
337
  def register_connection(id, socket)
317
338
  mutex.synchronize do
@@ -322,26 +343,34 @@ module Nonnative
322
343
  end
323
344
  end
324
345
 
325
- def mark_stopping
326
- mutex.synchronize { @stopping = true }
327
- end
346
+ def mark_stopping = mutex.synchronize { @stopping = true }
328
347
 
329
- def attach_connection_thread(id, thread)
330
- mutex.synchronize { connections[id]&.thread = thread }
331
- end
348
+ def register_connection_thread(id)
349
+ mutex.synchronize do
350
+ connection = connections[id]
351
+ return false unless connection && !stopping
332
352
 
333
- def attach_connection_pair(id, pair)
334
- mutex.synchronize { connections[id]&.pair = pair }
353
+ connection.thread = Thread.current
354
+ true
355
+ end
335
356
  end
336
357
 
337
- def delete_connection(id)
338
- mutex.synchronize { connections.delete(id) }
339
- end
358
+ def attach_connection_pair(id, pair) = mutex.synchronize { connections[id]&.pair = pair }
359
+
360
+ def delete_connection(id) = mutex.synchronize { connections.delete(id) }
340
361
 
341
362
  def close_connection(id, connection)
342
363
  Nonnative.logger.info "closing connection for '#{id}' for proxy 'fault_injection'"
343
364
 
344
- connection.close
365
+ connection.close_sockets
345
366
  end
367
+
368
+ def close_logger
369
+ @logger&.close
370
+ ensure
371
+ @logger = nil
372
+ end
373
+
374
+ def logger = (@logger ||= Logger.new(service.proxy.log))
346
375
  end
347
376
  end
@@ -45,11 +45,14 @@ module Nonnative
45
45
  server.run
46
46
  end
47
47
 
48
- # Stops the gRPC server.
48
+ # Stops the gRPC server after it has started.
49
+ #
50
+ # Construction rollback can call this hook before `GRPC::RpcServer#run`; gRPC rejects `stop` in
51
+ # that state and has not acquired its listener yet, so there is nothing to release.
49
52
  #
50
53
  # @return [void]
51
54
  def perform_stop
52
- server.stop
55
+ server.stop unless server.running_state == :not_started
53
56
  end
54
57
 
55
58
  # Waits until the gRPC server reports it is running, or the configured timeout elapses.
@@ -44,13 +44,35 @@ module Nonnative
44
44
  ].freeze
45
45
 
46
46
  NON_FORWARDABLE_REQUEST_HEADERS = %w[
47
- Host
48
- Accept-Encoding
49
- Version
50
- Proxy-Authenticate
51
- Proxy-Authorization
47
+ host
48
+ accept-encoding
49
+ version
50
+ proxy-authenticate
51
+ proxy-authorization
52
+ proxy-connection
53
+ connection
54
+ keep-alive
55
+ te
56
+ trailer
57
+ transfer-encoding
58
+ upgrade
59
+ ].freeze
60
+
61
+ GATEWAY_TIMEOUT_ERRORS = [
62
+ RestClient::Exceptions::Timeout
52
63
  ].freeze
53
64
 
65
+ GATEWAY_CONNECTION_ERRORS = [
66
+ RestClient::ServerBrokeConnection,
67
+ Errno::ECONNREFUSED,
68
+ Errno::EHOSTUNREACH,
69
+ SocketError
70
+ ].freeze
71
+
72
+ # Match malformed percent escapes and bytes outside RFC 3986's path-safe set. Valid `%HH`
73
+ # escapes remain intact; each match is percent-encoded before building the upstream URI.
74
+ PATH_ESCAPE_PATTERN = %r{%(?![\da-fA-F]{2})|[^A-Za-z0-9\-._~!$&'()*+,;=:@/%]}
75
+
54
76
  # Extracts request headers from the Rack environment and normalizes them to standard HTTP names.
55
77
  #
56
78
  # Certain hop-by-hop or proxy-specific headers are removed.
@@ -64,7 +86,8 @@ module Nonnative
64
86
  result[normalized_request_header_name(header)] = value
65
87
  end
66
88
 
67
- headers.except(*NON_FORWARDABLE_REQUEST_HEADERS)
89
+ connection_headers = request_connection_headers(headers)
90
+ headers.reject { |header, _| non_forwardable_request_header?(header, connection_headers) }
68
91
  end
69
92
 
70
93
  # Builds the upstream URL for the given request.
@@ -75,8 +98,9 @@ module Nonnative
75
98
  def build_url(request, settings)
76
99
  uri_class = settings.scheme == 'http' ? URI::HTTP : URI::HTTPS
77
100
  query = request.query_string
101
+ path = escape_path(request.path_info)
78
102
 
79
- uri_class.build(host: settings.host, port: settings.port, path: request.path_info, query: query.empty? ? nil : query).to_s
103
+ uri_class.build(host: settings.host, port: settings.port, path:, query: query.empty? ? nil : query).to_s
80
104
  end
81
105
 
82
106
  # Executes the upstream request and returns the response.
@@ -86,13 +110,20 @@ module Nonnative
86
110
  # @param headers [Hash] request headers
87
111
  # @param payload [String, nil] request payload
88
112
  # @return [RestClient::Response] response for error statuses, otherwise RestClient return value
113
+ # @raise [Sinatra::Halt] with a gateway status when the upstream is unavailable or times out
89
114
  def api_response(method:, url:, headers:, payload: nil)
90
115
  options = { method:, url:, headers: }
91
116
  options[:payload] = payload unless payload.nil?
92
117
 
93
118
  RestClient::Request.execute(options)
119
+ rescue *GATEWAY_TIMEOUT_ERRORS
120
+ halt 504
121
+ rescue *GATEWAY_CONNECTION_ERRORS
122
+ halt 502
94
123
  rescue RestClient::Exception => e
95
- e.response
124
+ return e.response if e.response
125
+
126
+ halt 502
96
127
  end
97
128
 
98
129
  # Extracts the request payload for verbs that can carry a body.
@@ -149,6 +180,20 @@ module Nonnative
149
180
  header.delete_prefix('HTTP_').split('_').map(&:capitalize).join('-')
150
181
  end
151
182
 
183
+ def escape_path(path)
184
+ URI::DEFAULT_PARSER.escape(path, PATH_ESCAPE_PATTERN)
185
+ end
186
+
187
+ def request_connection_headers(headers)
188
+ headers.fetch('Connection', '').split(',').map { |header| header.strip.downcase }
189
+ end
190
+
191
+ def non_forwardable_request_header?(header, connection_headers)
192
+ normalized_header = header.downcase
193
+
194
+ NON_FORWARDABLE_REQUEST_HEADERS.include?(normalized_header) || connection_headers.include?(normalized_header)
195
+ end
196
+
152
197
  # Registered before `get` so it takes precedence over Sinatra's GET-generated HEAD route,
153
198
  # which would otherwise forward HEAD requests upstream as GET.
154
199
  head(/.*/) do
@@ -72,9 +72,14 @@ module Nonnative
72
72
 
73
73
  # Gracefully shuts down the Puma server.
74
74
  #
75
+ # Signals shutdown through Puma's own control pipe (`Puma::Server#stop`) rather than calling
76
+ # `graceful_shutdown` directly, so the accept loop wakes up reliably; calling `graceful_shutdown`
77
+ # without that signal leaves the accept loop dependent on noticing a closed socket, which is not
78
+ # guaranteed to happen promptly on every platform.
79
+ #
75
80
  # @return [void]
76
81
  def perform_stop
77
- server.graceful_shutdown
82
+ server.stop(true)
78
83
  ensure
79
84
  close_log
80
85
  end
@@ -46,8 +46,8 @@ module Nonnative
46
46
 
47
47
  # Stops all configured runners and collects lifecycle and shutdown errors.
48
48
  #
49
- # Processes and servers are stopped first and checked for shutdown, then service proxies are
50
- # stopped when configured.
49
+ # Processes and servers are stopped first and checked for shutdown, including bounded server
50
+ # thread cleanup, then service proxies are stopped when configured.
51
51
  #
52
52
  # @return [Array<String>] lifecycle and shutdown-check errors collected while stopping
53
53
  def stop
@@ -240,7 +240,7 @@ module Nonnative
240
240
  id, stopped = Array(values).then { |v| [v.first, v.fetch(1, true)] }
241
241
  errors = []
242
242
  errors << "Stopped #{runner.name} with id #{id}, though did not respond in time for #{port.description}" unless ready
243
- errors << "Stopped #{runner.name} with id #{id}, though the process did not exit in time" unless stopped
243
+ errors << "Stopped #{runner.name} with id #{id}, though the #{exit_subject(runner)} did not exit in time" unless stopped
244
244
  errors
245
245
  end
246
246
 
@@ -248,7 +248,7 @@ module Nonnative
248
248
  id, stopped = Array(values).then { |v| [v.first, v.fetch(1, true)] }
249
249
  errors = []
250
250
  errors << "Rollback failed for #{runner.name} with id #{id}, because it did not stop in time for #{port.description}" unless ready
251
- errors << "Rollback failed for #{runner.name} with id #{id}, because the process did not exit in time" unless stopped
251
+ errors << "Rollback failed for #{runner.name} with id #{id}, because the #{exit_subject(runner)} did not exit in time" unless stopped
252
252
  errors
253
253
  end
254
254
 
@@ -265,6 +265,10 @@ module Nonnative
265
265
  "Started #{runner_name(service)}, though did not respond in time for readiness: #{checks.map(&:endpoint).join(', ')}"
266
266
  end
267
267
 
268
+ def exit_subject(runner)
269
+ runner.is_a?(Nonnative::Server) ? 'server thread' : 'process'
270
+ end
271
+
268
272
  def runner_name(type)
269
273
  name = type.name
270
274
  return "runner '#{name}'" if name
@@ -12,6 +12,9 @@ module Nonnative
12
12
  # - {#perform_start} (to bind/listen and begin serving), and
13
13
  # - {#perform_stop} (to gracefully shut down).
14
14
  #
15
+ # {#stop} calls the stop hook once for every successfully constructed lifecycle, even if startup
16
+ # never created a worker thread. This lets rollback release resources acquired by a constructor.
17
+ #
15
18
  # The underlying configuration is a {Nonnative::ConfigurationServer}.
16
19
  #
17
20
  # @see Nonnative::ConfigurationServer
@@ -22,17 +25,22 @@ module Nonnative
22
25
  super
23
26
 
24
27
  @timeout = Nonnative::Timeout.new(service.timeout)
28
+ @cleanup_required = true
25
29
  end
26
30
 
27
- # Starts the server thread if it is not already started.
31
+ # Starts the server thread if it is not already running.
32
+ #
33
+ # A thread retained from a stop that exceeded its timeout is treated as not running, so a server
34
+ # can be restarted even if a prior {#stop} never observed that thread's actual termination.
28
35
  #
29
36
  # @return [Array<(Integer, TrueClass)>]
30
37
  # a tuple of:
31
38
  # - a stable identifier for this server instance (`object_id`)
32
39
  # - `true` (thread creation itself is considered started; readiness is checked separately)
33
40
  def start
34
- unless thread
41
+ unless thread&.alive?
35
42
  @error = nil
43
+ @cleanup_required = true
36
44
  @thread = Thread.new do
37
45
  perform_start
38
46
  rescue StandardError => e
@@ -49,23 +57,27 @@ module Nonnative
49
57
  [object_id, true]
50
58
  end
51
59
 
52
- # Stops the server if it is running.
60
+ # Stops the server if its current lifecycle still requires cleanup.
53
61
  #
54
- # Calls {#perform_stop}, terminates the server thread, and waits briefly.
62
+ # Calls {#perform_stop} even if startup never created a worker thread, then waits up to the
63
+ # configured timeout for any owned thread to finish. A thread that exceeds the timeout is
64
+ # terminated and reported through {Nonnative::Pool}. A later stop retries draining a retained
65
+ # thread without calling {#perform_stop} again.
55
66
  #
56
- # @return [Integer] the server identifier (`object_id`)
67
+ # @return [Integer, Array<(Integer, FalseClass)>] the server identifier when cleanup finishes, or
68
+ # the identifier and `false` when the owned thread exceeds the configured timeout
57
69
  def stop
58
- if thread
59
- perform_stop
60
- thread.terminate
70
+ owned_thread = thread
71
+ return object_id unless @cleanup_required || owned_thread
61
72
 
62
- @thread = nil
63
- wait_stop
73
+ perform_cleanup
74
+ stopped = drain_thread(owned_thread) == :drained
75
+ wait_stop
76
+ @thread = nil unless owned_thread&.alive?
64
77
 
65
- Nonnative.logger.info "stopped server '#{service.name}'"
66
- end
78
+ Nonnative.logger.info "stopped server '#{service.name}'" if stopped
67
79
 
68
- object_id
80
+ stopped ? object_id : [object_id, false]
69
81
  end
70
82
 
71
83
  # Describes how the server thread terminated before becoming ready, for lifecycle diagnostics.
@@ -85,5 +97,20 @@ module Nonnative
85
97
  private
86
98
 
87
99
  attr_reader :thread, :timeout, :error
100
+
101
+ def perform_cleanup
102
+ return unless @cleanup_required
103
+
104
+ perform_stop
105
+ @cleanup_required = false
106
+ end
107
+
108
+ def drain_thread(owned_thread)
109
+ return :drained if owned_thread.nil? || !owned_thread.alive?
110
+ return :drained if timeout.perform { owned_thread.join }
111
+
112
+ owned_thread.terminate
113
+ :timed_out
114
+ end
88
115
  end
89
116
  end
@@ -4,5 +4,5 @@ module Nonnative
4
4
  # The current gem version.
5
5
  #
6
6
  # @return [String]
7
- VERSION = '3.35.0'
7
+ VERSION = '3.38.0'
8
8
  end
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.35.0
4
+ version: 3.38.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alejandro Falkowski