raptor 0.20.2 → 0.21.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.
@@ -5,10 +5,9 @@ require "nio"
5
5
  require "red-black-tree"
6
6
 
7
7
  module Raptor
8
- # Multiplexes client connections and closes them when they overrun
9
- # their per-phase timeouts (first data, subsequent chunks, and
10
- # keep-alive idle). Feeds ready bytes to the parser pipeline and hands
11
- # completed requests back to the caller-provided handlers.
8
+ # Multiplexes client connections, manages connection deadlines, feeds
9
+ # ready bytes to the parser pipeline, and hands completed requests back
10
+ # to the caller-provided handlers.
12
11
  #
13
12
  class Reactor
14
13
  # A client connection node ordered by absolute expiry time so the
@@ -59,6 +58,8 @@ module Raptor
59
58
  # @rbs @first_data_timeout: Integer
60
59
  # @rbs @chunk_data_timeout: Integer
61
60
  # @rbs @persistent_data_timeout: Integer
61
+ # @rbs @http2_keepalive_interval: Integer
62
+ # @rbs @http2_keepalive_timeout: Integer
62
63
  # @rbs @selector: NIO::Selector
63
64
  # @rbs @queue: Queue[TCPSocket]
64
65
  # @rbs @timeouts: RedBlackTree[TimeoutClient]
@@ -67,6 +68,9 @@ module Raptor
67
68
  # @rbs @id_to_timeout: Hash[Integer, TimeoutClient]
68
69
  # @rbs @id_to_writer: Hash[Integer, untyped]
69
70
  # @rbs @id_to_flow_control: Hash[Integer, untyped]
71
+ # @rbs @id_to_http2_last_stream: Hash[Integer, Integer]
72
+ # @rbs @id_to_http2_drain_stream: Hash[Integer, Integer]
73
+ # @rbs @id_to_http2_keepalive: Hash[Integer, Hash[Symbol, untyped]]
70
74
 
71
75
  # Creates a new Reactor instance.
72
76
  #
@@ -78,16 +82,21 @@ module Raptor
78
82
  # @option connection_options [Integer] :chunk_data_timeout timeout for subsequent chunks
79
83
  # @param http1_options [Hash] HTTP/1.1-specific configuration
80
84
  # @option http1_options [Integer] :persistent_data_timeout timeout for keep-alive idle connections
85
+ # @param http2_options [Hash] HTTP/2-specific configuration
86
+ # @option http2_options [Integer] :keepalive_interval idle time before sending a PING
87
+ # @option http2_options [Integer] :keepalive_timeout time to await a PING acknowledgement
81
88
  # @return [void]
82
89
  #
83
- # @rbs (untyped http1_ractor_pool, untyped http2_ractor_pool, untyped thread_pool, connection_options: Hash[Symbol, untyped], http1_options: Hash[Symbol, untyped]) -> void
84
- def initialize(http1_ractor_pool, http2_ractor_pool, thread_pool, connection_options:, http1_options:)
90
+ # @rbs (untyped http1_ractor_pool, untyped http2_ractor_pool, untyped thread_pool, connection_options: Hash[Symbol, untyped], http1_options: Hash[Symbol, untyped], http2_options: Hash[Symbol, untyped]) -> void
91
+ def initialize(http1_ractor_pool, http2_ractor_pool, thread_pool, connection_options:, http1_options:, http2_options:)
85
92
  @http1_ractor_pool = http1_ractor_pool
86
93
  @http2_ractor_pool = http2_ractor_pool
87
94
  @thread_pool = thread_pool
88
95
  @first_data_timeout = connection_options[:first_data_timeout]
89
96
  @chunk_data_timeout = connection_options[:chunk_data_timeout]
90
97
  @persistent_data_timeout = http1_options[:persistent_data_timeout]
98
+ @http2_keepalive_interval = http2_options[:keepalive_interval]
99
+ @http2_keepalive_timeout = http2_options[:keepalive_timeout]
91
100
 
92
101
  @selector = NIO::Selector.new
93
102
  @queue = Queue.new
@@ -98,6 +107,9 @@ module Raptor
98
107
  @id_to_timeout = {}
99
108
  @id_to_writer = {}
100
109
  @id_to_flow_control = {}
110
+ @id_to_http2_last_stream = {}
111
+ @id_to_http2_drain_stream = {}
112
+ @id_to_http2_keepalive = {}
101
113
  end
102
114
 
103
115
  # Starts the reactor's main event loop in a new thread. Runs until
@@ -129,12 +141,7 @@ module Raptor
129
141
  @timeouts.delete!(to_client)
130
142
  id = to_client.client_data[:id]
131
143
  @id_to_timeout.delete(id)
132
- socket = @id_to_socket[id]
133
- next unless socket
134
-
135
- @selector.deregister(socket)
136
- socket.write(TIMEOUT_RESPONSE) rescue nil
137
- cleanup(socket)
144
+ handle_timeout(to_client, now)
138
145
  end
139
146
 
140
147
  until @queue.empty?
@@ -145,12 +152,16 @@ module Raptor
145
152
  end
146
153
  end
147
154
 
155
+ @id_to_flow_control.each_value(&:close)
148
156
  @id_to_socket.each_value { |socket| socket.close rescue nil }
149
157
  @id_to_socket.clear
150
158
  @socket_to_state.clear
151
159
  @id_to_timeout.clear
152
160
  @id_to_writer.clear
153
161
  @id_to_flow_control.clear
162
+ @id_to_http2_last_stream.clear
163
+ @id_to_http2_drain_stream.clear
164
+ @id_to_http2_keepalive.clear
154
165
  @timeouts.clear!
155
166
  @selector.close
156
167
  end
@@ -270,6 +281,42 @@ module Raptor
270
281
  @id_to_flow_control[id]
271
282
  end
272
283
 
284
+ # Records an HTTP/2 stream before application dispatch, rejecting streams
285
+ # that arrived after graceful draining began.
286
+ #
287
+ # @param id [Integer] unique connection identifier
288
+ # @param stream_id [Integer] HTTP/2 stream identifier
289
+ # @return [Boolean] whether the stream may be dispatched
290
+ #
291
+ # @rbs (Integer id, Integer stream_id) -> bool
292
+ def dispatch_http2_stream(id, stream_id)
293
+ if (last_stream = @id_to_http2_drain_stream[id])
294
+ return stream_id <= last_stream
295
+ end
296
+
297
+ current = @id_to_http2_last_stream[id] || 0
298
+ @id_to_http2_last_stream[id] = stream_id if stream_id > current
299
+ true
300
+ end
301
+
302
+ # Sends GOAWAY on every HTTP/2 connection and fixes the last stream each
303
+ # connection may dispatch while existing application work drains.
304
+ #
305
+ # @yieldparam stream_id [Integer] last dispatched stream identifier
306
+ # @yieldreturn [String] serialized GOAWAY frame
307
+ # @return [void]
308
+ #
309
+ # @rbs () { (Integer stream_id) -> String } -> void
310
+ def drain_http2
311
+ @id_to_flow_control.each_key do |id|
312
+ last_stream = @id_to_http2_last_stream[id] || 0
313
+ @id_to_http2_drain_stream[id] = last_stream
314
+ writer = @id_to_writer[id]
315
+ socket = @id_to_socket[id]
316
+ writer.write_frames(socket, [yield(last_stream)]) if writer && socket
317
+ end
318
+ end
319
+
273
320
  # Stores an HTTP/2 connection's socket, state, writer, and flow
274
321
  # controller in the reactor's per-connection maps.
275
322
  #
@@ -278,14 +325,34 @@ module Raptor
278
325
  # @param state [Hash] initial connection state
279
326
  # @param writer [Http2::Writer] per-connection frame writer
280
327
  # @param flow_control [Http2::FlowControl] per-connection outbound flow controller
328
+ # @param ping_frame [String] serialized PING frame for keepalive probes
329
+ # @param ping_payload [String] payload expected in the corresponding acknowledgement
281
330
  # @return [void]
282
331
  #
283
- # @rbs (id: Integer, socket: TCPSocket, state: Hash[Symbol, untyped], writer: untyped, flow_control: untyped) -> void
284
- def attach_http2(id:, socket:, state:, writer:, flow_control:)
332
+ # @rbs (id: Integer, socket: TCPSocket, state: Hash[Symbol, untyped], writer: untyped, flow_control: untyped, ping_frame: String, ping_payload: String) -> void
333
+ def attach_http2(id:, socket:, state:, writer:, flow_control:, ping_frame:, ping_payload:)
285
334
  @id_to_socket[id] = socket
286
335
  @socket_to_state[socket] = state
287
336
  @id_to_writer[id] = writer
288
337
  @id_to_flow_control[id] = flow_control
338
+ @id_to_http2_last_stream[id] = 0
339
+ if @http2_keepalive_interval.positive?
340
+ @id_to_http2_keepalive[id] = {frame: ping_frame, payload: ping_payload}
341
+ end
342
+ end
343
+
344
+ # Records acknowledgements for server keepalive probes.
345
+ #
346
+ # @param id [Integer] unique connection identifier
347
+ # @param payloads [Array<String>, nil] acknowledged PING payloads
348
+ # @return [void]
349
+ #
350
+ # @rbs (Integer id, Array[String]? payloads) -> void
351
+ def acknowledge_http2_ping(id, payloads)
352
+ keepalive = @id_to_http2_keepalive[id]
353
+ return unless keepalive && payloads&.include?(keepalive[:payload])
354
+
355
+ keepalive.delete(:deadline)
289
356
  end
290
357
 
291
358
  # Registers an attached socket for future reactor-driven reads.
@@ -335,7 +402,10 @@ module Raptor
335
402
 
336
403
  @socket_to_state.delete(socket)
337
404
  @id_to_writer.delete(id)
338
- @id_to_flow_control.delete(id)
405
+ @id_to_flow_control.delete(id)&.close
406
+ @id_to_http2_last_stream.delete(id)
407
+ @id_to_http2_drain_stream.delete(id)
408
+ @id_to_http2_keepalive.delete(id)
339
409
  socket.close rescue nil
340
410
  end
341
411
 
@@ -372,19 +442,65 @@ module Raptor
372
442
  @selector.register(socket, :r).value = socket
373
443
 
374
444
  state = @socket_to_state[socket]
375
- client = TimeoutClient.new(state)
376
- timeout = if state[:persisted]
377
- @persistent_data_timeout
445
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
446
+ timeout_at = if state[:protocol] == :http2
447
+ if state[:http2_preface_received]
448
+ keepalive = @id_to_http2_keepalive[state[:id]]
449
+ keepalive && (keepalive[:deadline] || now + @http2_keepalive_interval)
450
+ else
451
+ now + @first_data_timeout
452
+ end
453
+ elsif state[:persisted]
454
+ now + @persistent_data_timeout
378
455
  elsif first_data_received?(state)
379
- @chunk_data_timeout
456
+ now + @chunk_data_timeout
380
457
  else
381
- @first_data_timeout
458
+ now + @first_data_timeout
382
459
  end
383
- client.timeout_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
460
+ track_timeout(state, timeout_at) if timeout_at
461
+ end
462
+
463
+ # Adds a connection deadline to the timeout tree.
464
+ #
465
+ # @param state [Hash] client connection state
466
+ # @param timeout_at [Float] absolute monotonic expiry time
467
+ # @return [void]
468
+ #
469
+ # @rbs (Hash[Symbol, untyped] state, Float timeout_at) -> void
470
+ def track_timeout(state, timeout_at)
471
+ client = TimeoutClient.new(state)
472
+ client.timeout_at = timeout_at
384
473
  @timeouts << client
385
474
  @id_to_timeout[state[:id]] = client
386
475
  end
387
476
 
477
+ # Handles an expired connection deadline, sending an HTTP/2 keepalive
478
+ # probe before closing the connection when its acknowledgement is late.
479
+ #
480
+ # @param client [TimeoutClient] expired client
481
+ # @param now [Float] current monotonic timestamp
482
+ # @return [void]
483
+ #
484
+ # @rbs (TimeoutClient client, Float now) -> void
485
+ def handle_timeout(client, now)
486
+ state = client.client_data
487
+ id = state[:id]
488
+ socket = @id_to_socket[id]
489
+ return unless socket
490
+
491
+ keepalive = @id_to_http2_keepalive[id] if state[:http2_preface_received]
492
+ if keepalive && !keepalive[:deadline]
493
+ keepalive[:deadline] = now + @http2_keepalive_timeout
494
+ @id_to_writer[id].write_frames(socket, [keepalive[:frame]])
495
+ track_timeout(state, keepalive[:deadline])
496
+ return
497
+ end
498
+
499
+ @selector.deregister(socket)
500
+ socket.write(TIMEOUT_RESPONSE) rescue nil unless state[:protocol] == :http2
501
+ cleanup(socket)
502
+ end
503
+
388
504
  # Handles socket wakeup by deregistering and queuing for processing.
389
505
  #
390
506
  # @param socket [TCPSocket] the socket that became ready
@@ -395,7 +511,7 @@ module Raptor
395
511
  @selector.deregister(socket)
396
512
  state = @socket_to_state[socket]
397
513
  to_client = @id_to_timeout.delete(state[:id])
398
- @timeouts.delete!(to_client)
514
+ @timeouts.delete!(to_client) if to_client
399
515
  read_and_queue_for_parse(socket, state)
400
516
  end
401
517
 
@@ -441,7 +557,10 @@ module Raptor
441
557
  state = @socket_to_state.delete(socket)
442
558
  @id_to_socket.delete(state[:id])
443
559
  @id_to_writer.delete(state[:id])
444
- @id_to_flow_control.delete(state[:id])
560
+ @id_to_flow_control.delete(state[:id])&.close
561
+ @id_to_http2_last_stream.delete(state[:id])
562
+ @id_to_http2_drain_stream.delete(state[:id])
563
+ @id_to_http2_keepalive.delete(state[:id])
445
564
  socket.close
446
565
  end
447
566
 
data/lib/raptor/server.rb CHANGED
@@ -182,6 +182,18 @@ module Raptor
182
182
  return true
183
183
  end
184
184
 
185
+ if listener.is_a?(Binder::H2cListener)
186
+ @http2.eager_accept(
187
+ tcp_client,
188
+ tcp_client.object_id,
189
+ @reactor,
190
+ @thread_pool,
191
+ remote_addr,
192
+ HTTP_SCHEME
193
+ )
194
+ return true
195
+ end
196
+
185
197
  @http1.eager_accept(
186
198
  tcp_client,
187
199
  tcp_client.object_id,
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Raptor
5
- VERSION = "0.20.2"
5
+ VERSION = "0.21.0"
6
6
  end
@@ -1,9 +1,9 @@
1
1
  # Generated from lib/raptor/binder.rb with RBS::Inline
2
2
 
3
3
  module Raptor
4
- # Binds `tcp://`, `unix://`, and `ssl://` URIs to listening sockets and
5
- # holds them for the server. Reconstructs listeners from inherited file
6
- # descriptors when provided (systemd socket activation, hot restart).
4
+ # Binds TCP, Unix, SSL, and cleartext HTTP/2 URIs to listening sockets
5
+ # and holds them for the server. Reconstructs listeners from inherited
6
+ # file descriptors when provided (systemd socket activation, hot restart).
7
7
  class Binder
8
8
  SOCKET_BACKLOG: ::Integer
9
9
 
@@ -27,9 +27,21 @@ module Raptor
27
27
  def members: () -> [ :tcp_server, :ssl_context ]
28
28
  end
29
29
 
30
- @uri_listeners: Hash[String, Array[TCPServer | UNIXServer | SslListener]]
30
+ # Marks a TCPServer as accepting cleartext HTTP/2 connections.
31
+ class H2cListener < Data
32
+ attr_reader tcp_server(): untyped
33
+
34
+ def self.new: (untyped tcp_server) -> instance
35
+ | (tcp_server: untyped) -> instance
36
+
37
+ def self.members: () -> [ :tcp_server ]
38
+
39
+ def members: () -> [ :tcp_server ]
40
+ end
41
+
42
+ @uri_listeners: Hash[String, Array[TCPServer | UNIXServer | SslListener | H2cListener]]
31
43
 
32
- @listeners: Array[TCPServer | UNIXServer | SslListener]
44
+ @listeners: Array[TCPServer | UNIXServer | SslListener | H2cListener]
33
45
 
34
46
  @inherited_fds: Hash[String, Array[Integer]]
35
47
 
@@ -49,7 +61,7 @@ module Raptor
49
61
 
50
62
  # Returns the array of listening sockets.
51
63
  #
52
- # @return [Array<TCPServer, UNIXServer, SslListener>]
64
+ # @return [Array<TCPServer, UNIXServer, SslListener, H2cListener>]
53
65
  attr_reader listeners: untyped
54
66
 
55
67
  # Creates a new Binder and binds each URI. `localhost` expands to both
@@ -66,7 +78,8 @@ module Raptor
66
78
  def initialize: (Array[String] bind_uris, ?socket_backlog: Integer, ?inherited_fds: Hash[String, Array[Integer]]) -> void
67
79
 
68
80
  # Returns the bound addresses as strings: TCP as `host:port`, Unix as
69
- # the socket path, SSL as `ssl://host:port`.
81
+ # the socket path, SSL as `ssl://host:port`, and cleartext HTTP/2 as
82
+ # `h2c://host:port`.
70
83
  #
71
84
  # @return [Array<String>]
72
85
  #
@@ -124,22 +137,22 @@ module Raptor
124
137
  # Creates fresh listeners for the given URI.
125
138
  #
126
139
  # @param uri [URI] the parsed bind URI
127
- # @return [Array<TCPServer, UNIXServer, SslListener>]
140
+ # @return [Array<TCPServer, UNIXServer, SslListener, H2cListener>]
128
141
  # @raise [UnknownBindSchemeError] if the URI scheme is not supported
129
142
  #
130
- # @rbs (URI::Generic uri) -> Array[TCPServer | UNIXServer | SslListener]
131
- def create_listeners: (URI::Generic uri) -> Array[TCPServer | UNIXServer | SslListener]
143
+ # @rbs (URI::Generic uri) -> Array[TCPServer | UNIXServer | SslListener | H2cListener]
144
+ def create_listeners: (URI::Generic uri) -> Array[TCPServer | UNIXServer | SslListener | H2cListener]
132
145
 
133
146
  # Reconstructs listeners for the given URI from inherited file
134
147
  # descriptors.
135
148
  #
136
149
  # @param uri [URI] the parsed bind URI the FDs were bound to
137
150
  # @param filenos [Array<Integer>] file descriptors to wrap
138
- # @return [Array<TCPServer, UNIXServer, SslListener>]
151
+ # @return [Array<TCPServer, UNIXServer, SslListener, H2cListener>]
139
152
  # @raise [UnknownBindSchemeError] if the URI scheme is not supported
140
153
  #
141
- # @rbs (URI::Generic uri, Array[Integer] filenos) -> Array[TCPServer | UNIXServer | SslListener]
142
- def restore_listeners: (URI::Generic uri, Array[Integer] filenos) -> Array[TCPServer | UNIXServer | SslListener]
154
+ # @rbs (URI::Generic uri, Array[Integer] filenos) -> Array[TCPServer | UNIXServer | SslListener | H2cListener]
155
+ def restore_listeners: (URI::Generic uri, Array[Integer] filenos) -> Array[TCPServer | UNIXServer | SslListener | H2cListener]
143
156
 
144
157
  # Creates TCP server sockets for the given host and port.
145
158
  #
@@ -61,6 +61,18 @@ module Raptor
61
61
  # @rbs (Hash[String, untyped] env_template, Integer? max_body_size) -> ^(Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
62
62
  def self.parser_worker: (Hash[String, untyped] env_template, Integer? max_body_size) -> ^(Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
63
63
 
64
+ # Calls every `rack.response_finished` callback in reverse
65
+ # registration order, rescuing any that raise.
66
+ #
67
+ # @param env [Hash, nil] the Rack environment
68
+ # @param status [Integer, nil] the response status code
69
+ # @param headers [Hash, nil] the response headers
70
+ # @param error [Exception, nil] any error raised during processing, or nil on success
71
+ # @return [void]
72
+ #
73
+ # @rbs (Hash[String, untyped]? env, Integer? status, Hash[String, String | Array[String]]? headers, Exception? error) -> void
74
+ def self.call_response_finished: (Hash[String, untyped]? env, Integer? status, Hash[String, String | Array[String]]? headers, Exception? error) -> void
75
+
64
76
  # Writes a Common Log Format entry to `io`. Write failures are silently
65
77
  # ignored.
66
78
  #
@@ -580,18 +580,6 @@ module Raptor
580
580
  # @rbs (TCPSocket socket, String response, untyped body, bool use_chunked) -> void
581
581
  def write_enumerable_body: (TCPSocket socket, String response, untyped body, bool use_chunked) -> void
582
582
 
583
- # Calls every `rack.response_finished` callback in reverse
584
- # registration order, rescuing any that raise.
585
- #
586
- # @param env [Hash, nil] the Rack environment
587
- # @param status [Integer, nil] the response status code
588
- # @param headers [Hash, nil] the response headers
589
- # @param error [Exception, nil] any error raised during processing, or nil on success
590
- # @return [void]
591
- #
592
- # @rbs (Hash[String, untyped] env, Integer? status, Hash[String, String | Array[String]]? headers, Exception? error) -> void
593
- def call_response_finished: (Hash[String, untyped] env, Integer? status, Hash[String, String | Array[String]]? headers, Exception? error) -> void
594
-
595
583
  # Instance-level wrapper around {Http.write_access_log} that routes to
596
584
  # the configured `@access_log_io`.
597
585
  #