nonnative 3.36.0 → 3.40.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: 6969c5c21e416e67752d3421af108506fe44730230537c9b426529e28dad7934
4
- data.tar.gz: 148ab5d39ef4f367501cca5786de14bc50be175616048a19a792671eee27fc1a
3
+ metadata.gz: f3513aa619cd9dab8134d9c4e8d22d333dcd2300f92e271cb8dee10ed7c0de2c
4
+ data.tar.gz: 4aeae3fd49ad6bd1da6b929bfa666c17c3c346a73a56b430ca7214a82ee19698
5
5
  SHA512:
6
- metadata.gz: 85218dfe59a108dad82130912aaaa1bb0a79e60017595fd089383aafc005e4b9c9e72c74341cd38efc97ac2f31dbf460967cb2dc4eb2fcb34c565ad663b12e41
7
- data.tar.gz: 8125a4ef344c00e450f8b03df400cbe2df991f8e2905420673fa2fbd09032c1623f52b540c92cf7ab08c60967b7dfb1d9239c68fcf737341e97c21e1feffa10a
6
+ metadata.gz: d5c02d41c403e663ba1ffe2e8f36e67fe4a2a36910b60a921d0e02c3ac84b1c68be01158ff223c40ee3e6990e429d79faf79eada9e538195a26652523d89813a
7
+ data.tar.gz: 6be1f9067df0fd1c121d0604813d3f91b4e9bbe9cbecfcd10aacd8a96a06a76f27a0a42bb3a08398cb7f0c3e68678a42c0f55b62390f304461814332a1e7e1bf
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
 
@@ -181,8 +182,9 @@ expect(response.code).to eq(200)
181
182
  ```
182
183
 
183
184
  HTTP error statuses are returned as response objects so callers can inspect `.code` and `.body`.
184
- Request timeouts and broken connections raise their RestClient exceptions; observability requests are
185
- not retried automatically.
185
+ Broken connections raise their RestClient exceptions, and a request timeout raises either a RestClient
186
+ timeout exception or `Timeout::Error` (the latter when `read_timeout` bounds a Net::HTTP retry of an
187
+ idempotent verb); observability requests are not retried automatically.
186
188
 
187
189
  `Nonnative.grpc_health` is a helper for the standard gRPC health checking protocol:
188
190
 
@@ -437,6 +439,13 @@ module Nonnative
437
439
  end
438
440
  ```
439
441
 
442
+ Rollback calls `perform_stop` for every successfully constructed server, even when a later
443
+ constructor fails before any server thread starts. Keep `perform_stop` safe in that state and use it
444
+ to release resources acquired during initialization. After `perform_stop` requests shutdown,
445
+ Nonnative waits up to the server `timeout` for the owned thread to finish. A thread that exceeds the
446
+ timeout is terminated and causes `Nonnative.stop` to raise `Nonnative::StopError`; the server can
447
+ still be started again afterward.
448
+
440
449
  Set it up programmatically:
441
450
 
442
451
  ```ruby
@@ -600,8 +609,10 @@ end
600
609
  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.
601
610
 
602
611
  The upstream scheme defaults to HTTPS on the scheme's default port; pass `scheme:`/`port:` to
603
- `Nonnative::HTTPProxyServer.new` to target an `http://` upstream or a non-default port. The proxy
604
- forwards the request path and query for `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, and
612
+ `Nonnative::HTTPProxyServer.new` to target an `http://` upstream or a non-default port. The whole
613
+ upstream call defaults to a one-second bound, including Net::HTTP's built-in retry of an
614
+ unresponsive idempotent request; pass `upstream_timeout:` to tune it for a slow dependency. The
615
+ proxy forwards the request path and query for `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, and
605
616
  `OPTIONS`, while removing proxy credentials, `Host`, `Accept-Encoding`, hop-by-hop headers, and
606
617
  headers nominated by `Connection` before the upstream request.
607
618
 
@@ -93,6 +93,8 @@ module Nonnative
93
93
  process = Nonnative::ConfigurationProcess.new
94
94
  yield process
95
95
 
96
+ raise ArgumentError, "Process '#{process.name}' requires 'command' or 'go'" if process.command.nil?
97
+
96
98
  processes << process
97
99
  end
98
100
 
@@ -157,7 +159,7 @@ module Nonnative
157
159
  tools = go.tools || []
158
160
 
159
161
  -> { Nonnative.go_argv(tools, go.output, go.executable, go.command, *params) }
160
- else
162
+ elsif process.command
161
163
  -> { process.command }
162
164
  end
163
165
  end
@@ -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.
@@ -18,7 +18,8 @@ module Nonnative
18
18
  @host = host
19
19
  @exceptions = [
20
20
  RestClient::Exceptions::Timeout,
21
- RestClient::ServerBrokeConnection
21
+ RestClient::ServerBrokeConnection,
22
+ ::Timeout::Error
22
23
  ]
23
24
  end
24
25
 
@@ -40,7 +41,7 @@ module Nonnative
40
41
  # @param opts [Hash] RestClient request options (e.g. `headers`, `read_timeout`, `open_timeout`)
41
42
  # @return [RestClient::Response, String] response for non-2xx errors, otherwise the RestClient result
42
43
  def get(pathname, opts = {})
43
- with_exception do
44
+ with_exception(opts) do
44
45
  resource(pathname, opts).get
45
46
  end
46
47
  end
@@ -52,7 +53,7 @@ module Nonnative
52
53
  # @param opts [Hash] RestClient request options
53
54
  # @return [RestClient::Response, String] response for non-2xx errors, otherwise the RestClient result
54
55
  def post(pathname, payload, opts = {})
55
- with_exception do
56
+ with_exception(opts) do
56
57
  resource(pathname, opts).post(payload)
57
58
  end
58
59
  end
@@ -63,7 +64,7 @@ module Nonnative
63
64
  # @param opts [Hash] RestClient request options
64
65
  # @return [RestClient::Response, String] response for non-2xx errors, otherwise the RestClient result
65
66
  def delete(pathname, opts = {})
66
- with_exception do
67
+ with_exception(opts) do
67
68
  resource(pathname, opts).delete
68
69
  end
69
70
  end
@@ -75,7 +76,7 @@ module Nonnative
75
76
  # @param opts [Hash] RestClient request options
76
77
  # @return [RestClient::Response, String] response for non-2xx errors, otherwise the RestClient result
77
78
  def put(pathname, payload, opts = {})
78
- with_exception do
79
+ with_exception(opts) do
79
80
  resource(pathname, opts).put(payload)
80
81
  end
81
82
  end
@@ -87,7 +88,7 @@ module Nonnative
87
88
  # @param opts [Hash] RestClient request options
88
89
  # @return [RestClient::Response, String] response for non-2xx errors, otherwise the RestClient result
89
90
  def patch(pathname, payload, opts = {})
90
- with_exception do
91
+ with_exception(opts) do
91
92
  resource(pathname, opts).patch(payload)
92
93
  end
93
94
  end
@@ -98,7 +99,7 @@ module Nonnative
98
99
  # @param opts [Hash] RestClient request options (e.g. `headers`, `read_timeout`, `open_timeout`)
99
100
  # @return [RestClient::Response, String] response for non-2xx errors, otherwise the RestClient result
100
101
  def head(pathname, opts = {})
101
- with_exception do
102
+ with_exception(opts) do
102
103
  resource(pathname, opts).head
103
104
  end
104
105
  end
@@ -109,7 +110,7 @@ module Nonnative
109
110
  # @param opts [Hash] RestClient request options (e.g. `headers`, `read_timeout`, `open_timeout`)
110
111
  # @return [RestClient::Response, String] response for non-2xx errors, otherwise the RestClient result
111
112
  def options(pathname, opts = {})
112
- with_exception do
113
+ with_exception(opts) do
113
114
  RestClient::Request.execute(opts.merge(method: :options, url: request_url(pathname)))
114
115
  end
115
116
  end
@@ -131,8 +132,12 @@ module Nonnative
131
132
  URI.join(host, pathname).to_s
132
133
  end
133
134
 
134
- def with_exception
135
- yield
135
+ # Net::HTTP silently retries idempotent verbs once on a read timeout, so bound the whole call
136
+ # (including any retry) by the wall clock rather than trusting `:read_timeout` alone.
137
+ def with_exception(opts = {}, &)
138
+ timeout = opts[:read_timeout]
139
+
140
+ timeout ? ::Timeout.timeout(timeout, &) : yield
136
141
  rescue *exceptions => e
137
142
  raise e
138
143
  rescue RestClient::Exception => e
@@ -59,7 +59,8 @@ module Nonnative
59
59
  ].freeze
60
60
 
61
61
  GATEWAY_TIMEOUT_ERRORS = [
62
- RestClient::Exceptions::Timeout
62
+ RestClient::Exceptions::Timeout,
63
+ ::Timeout::Error
63
64
  ].freeze
64
65
 
65
66
  GATEWAY_CONNECTION_ERRORS = [
@@ -112,10 +113,12 @@ module Nonnative
112
113
  # @return [RestClient::Response] response for error statuses, otherwise RestClient return value
113
114
  # @raise [Sinatra::Halt] with a gateway status when the upstream is unavailable or times out
114
115
  def api_response(method:, url:, headers:, payload: nil)
115
- options = { method:, url:, headers: }
116
+ options = { method:, url:, headers:, open_timeout: settings.upstream_timeout, read_timeout: settings.upstream_timeout }
116
117
  options[:payload] = payload unless payload.nil?
117
118
 
118
- RestClient::Request.execute(options)
119
+ # Net::HTTP retries idempotent verbs once on a read timeout, so bound the whole
120
+ # call (including any retry) by the wall clock rather than trusting read_timeout alone.
121
+ ::Timeout.timeout(settings.upstream_timeout) { RestClient::Request.execute(options) }
119
122
  rescue *GATEWAY_TIMEOUT_ERRORS
120
123
  halt 504
121
124
  rescue *GATEWAY_CONNECTION_ERRORS
@@ -246,11 +249,13 @@ module Nonnative
246
249
  # @param service [Nonnative::ConfigurationServer] server configuration
247
250
  # @param scheme [String] upstream scheme, `"http"` or `"https"`
248
251
  # @param port [Integer, nil] upstream port; `nil` uses the scheme's default port
249
- def initialize(host, service, scheme: 'https', port: nil)
252
+ # @param upstream_timeout [Numeric] maximum seconds for each upstream operation (defaults to `1.0`)
253
+ def initialize(host, service, scheme: 'https', port: nil, upstream_timeout: ConfigurationRunner::DEFAULT_TIMEOUT)
250
254
  http_service = Class.new(Nonnative::HTTPProxy) do
251
255
  set :host, host
252
256
  set :scheme, scheme
253
257
  set :port, port
258
+ set :upstream_timeout, upstream_timeout
254
259
  end
255
260
 
256
261
  super(http_service.new, service)
@@ -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
@@ -14,8 +14,9 @@ module Nonnative
14
14
  #
15
15
  # Requests are performed using {Nonnative::HTTPClient}, so callers may pass RestClient options
16
16
  # such as `headers`, `open_timeout`, and `read_timeout`. HTTP error statuses are returned as response
17
- # objects; timeouts and broken connections raise their RestClient exceptions. Requests are not
18
- # retried automatically.
17
+ # objects; broken connections raise their RestClient exceptions, and a timeout raises either a
18
+ # RestClient timeout exception or `Timeout::Error` (the latter when `read_timeout` bounds a Net::HTTP
19
+ # retry of an idempotent verb). Requests are not retried automatically.
19
20
  #
20
21
  # @example
21
22
  # Nonnative.configure do |config|
@@ -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.36.0'
7
+ VERSION = '3.40.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.36.0
4
+ version: 3.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alejandro Falkowski