quicsilver 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/cibuildgem.yaml +93 -0
  4. data/.gitignore +3 -1
  5. data/CHANGELOG.md +32 -0
  6. data/Gemfile.lock +20 -2
  7. data/README.md +92 -29
  8. data/Rakefile +67 -2
  9. data/benchmarks/concurrent.rb +2 -2
  10. data/benchmarks/rails.rb +3 -3
  11. data/benchmarks/throughput.rb +2 -2
  12. data/examples/README.md +44 -91
  13. data/examples/benchmark.rb +111 -0
  14. data/examples/connection_pool_demo.rb +47 -0
  15. data/examples/example_helper.rb +18 -0
  16. data/examples/falcon_middleware.rb +44 -0
  17. data/examples/feature_demo.rb +125 -0
  18. data/examples/grpc_style.rb +97 -0
  19. data/examples/minimal_http3_server.rb +6 -18
  20. data/examples/priorities.rb +60 -0
  21. data/examples/protocol_http_server.rb +31 -0
  22. data/examples/rack_http3_server.rb +8 -20
  23. data/examples/rails_feature_test.rb +260 -0
  24. data/examples/simple_client_test.rb +2 -2
  25. data/examples/streaming_sse.rb +33 -0
  26. data/examples/trailers.rb +69 -0
  27. data/ext/quicsilver/extconf.rb +14 -0
  28. data/ext/quicsilver/quicsilver.c +39 -0
  29. data/lib/quicsilver/client/client.rb +138 -39
  30. data/lib/quicsilver/client/connection_pool.rb +106 -0
  31. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  32. data/lib/quicsilver/protocol/adapter.rb +176 -0
  33. data/lib/quicsilver/protocol/control_stream_parser.rb +106 -0
  34. data/lib/quicsilver/protocol/frame_parser.rb +142 -0
  35. data/lib/quicsilver/protocol/frame_reader.rb +55 -0
  36. data/lib/quicsilver/protocol/frames.rb +18 -7
  37. data/lib/quicsilver/protocol/priority.rb +56 -0
  38. data/lib/quicsilver/protocol/qpack/encoder.rb +39 -1
  39. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
  40. data/lib/quicsilver/protocol/request_parser.rb +28 -140
  41. data/lib/quicsilver/protocol/response_encoder.rb +27 -2
  42. data/lib/quicsilver/protocol/response_parser.rb +22 -130
  43. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  44. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  45. data/lib/quicsilver/quicsilver.bundle +0 -0
  46. data/lib/quicsilver/server/request_handler.rb +96 -44
  47. data/lib/quicsilver/server/server.rb +316 -42
  48. data/lib/quicsilver/transport/configuration.rb +10 -1
  49. data/lib/quicsilver/transport/connection.rb +92 -63
  50. data/lib/quicsilver/version.rb +1 -1
  51. data/lib/quicsilver.rb +26 -3
  52. data/quicsilver.gemspec +10 -2
  53. metadata +69 -5
  54. data/examples/setup_certs.sh +0 -57
@@ -15,6 +15,28 @@ module Quicsilver
15
15
  ServerStopError = Class.new(StandardError)
16
16
  DrainTimeoutError = Class.new(StandardError)
17
17
 
18
+ # Tracks an in-flight streaming request between RECEIVE and RECEIVE_FIN.
19
+ # The stream handle arrives at RECEIVE_FIN; the worker thread waits for it.
20
+ PendingStream = Struct.new(:connection, :body, :request, :stream_id, :stream_handle, :handle_ready, :frame_buffer, :priority, keyword_init: true) do
21
+ def initialize(**)
22
+ super
23
+ self.handle_ready = Queue.new
24
+ self.frame_buffer = "".b
25
+ end
26
+
27
+ # Called by RECEIVE_FIN handler to provide the stream handle
28
+ def complete(handle)
29
+ self.stream_handle = handle
30
+ handle_ready.push(true)
31
+ end
32
+
33
+ # Called by worker thread to wait for the stream handle
34
+ def wait_for_handle(timeout: 30)
35
+ handle_ready.pop(timeout: timeout)
36
+ stream_handle
37
+ end
38
+ end
39
+
18
40
  class << self
19
41
  attr_accessor :instance
20
42
 
@@ -47,9 +69,13 @@ module Quicsilver
47
69
  @max_connections = max_connections
48
70
  @cancelled_streams = Set.new
49
71
  @cancelled_mutex = Mutex.new
72
+ @pending_streams = {} # stream_id => PendingStream (for streaming dispatch)
73
+ @pending_mutex = Mutex.new
74
+
75
+ protocol_app = wrap_app(@app, @server_configuration.mode)
50
76
 
51
77
  @request_handler = RequestHandler.new(
52
- app: @app,
78
+ app: protocol_app,
53
79
  configuration: @server_configuration,
54
80
  request_registry: @request_registry,
55
81
  cancelled_streams: @cancelled_streams,
@@ -164,8 +190,15 @@ module Quicsilver
164
190
  # Phase 2: Drain in-flight requests
165
191
  drain(timeout: timeout)
166
192
 
193
+ # Phase 2b: Send final GOAWAY with actual last processed stream ID (RFC 9114 §5.2)
194
+ @connections.each_value do |c|
195
+ c.send_goaway
196
+ rescue => e
197
+ Quicsilver.logger.debug("Second GOAWAY failed: #{e.message}")
198
+ end
199
+
167
200
  # Grace period: let pending responses reach clients
168
- sleep 0.5
201
+ sleep [0.5, timeout * 0.1].min
169
202
 
170
203
  # Log any requests that didn't complete
171
204
  unless @request_registry.empty?
@@ -177,7 +210,7 @@ module Quicsilver
177
210
 
178
211
  # Phase 3: Shutdown connections
179
212
  @connections.each_value(&:shutdown)
180
- sleep 0.1
213
+ sleep [0.1, timeout * 0.05].min
181
214
 
182
215
  # Phase 4: Hard stop
183
216
  stop
@@ -197,7 +230,8 @@ module Quicsilver
197
230
  return
198
231
  end
199
232
 
200
- connection = Transport::Connection.new(connection_handle, connection_data)
233
+ connection = Transport::Connection.new(connection_handle, connection_data,
234
+ max_header_size: @server_configuration.max_header_size)
201
235
  @connections[connection_handle] = connection
202
236
  connection.setup_http3_streams
203
237
 
@@ -207,45 +241,12 @@ module Quicsilver
207
241
 
208
242
  when STREAM_EVENT_SEND_COMPLETE
209
243
  # Buffer cleanup handled in C extension
210
-
211
244
  when STREAM_EVENT_RECEIVE
212
245
  return unless (connection = @connections[connection_handle])
213
-
214
- # Unidirectional streams (control, QPACK) must be processed incrementally —
215
- # they never send FIN, so waiting for RECEIVE_FIN would mean never parsing.
216
- if (stream_id & 0x02) != 0 # unidirectional
217
- begin
218
- connection.receive_unidirectional_data(stream_id, data)
219
- rescue Protocol::FrameError => e
220
- Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
221
- Quicsilver.connection_shutdown(connection_handle, e.error_code, false) rescue nil
222
- end
223
- else
224
- connection.buffer_data(stream_id, data)
225
- end
226
-
246
+ handle_receive(connection, connection_handle, stream_id, data, early_data: early_data)
227
247
  when STREAM_EVENT_RECEIVE_FIN
228
248
  return unless (connection = @connections[connection_handle])
229
-
230
- event = Transport::StreamEvent.new(data, "RECEIVE_FIN")
231
-
232
- full_data = connection.complete_stream(stream_id, event.data)
233
- stream = Transport::InboundStream.new(stream_id)
234
- stream.stream_handle = event.handle
235
- stream.append_data(full_data)
236
-
237
- if stream.bidirectional?
238
- connection.track_client_stream(stream_id)
239
- dispatch_request(connection, stream, early_data: early_data)
240
- else
241
- begin
242
- connection.handle_unidirectional_stream(stream)
243
- rescue Protocol::FrameError => e
244
- Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
245
- Quicsilver.connection_shutdown(connection_handle, e.error_code, false) rescue nil
246
- end
247
- end
248
-
249
+ handle_receive_fin(connection, connection_handle, stream_id, data, early_data: early_data)
249
250
  when STREAM_EVENT_STREAM_RESET
250
251
  return unless (connection = @connections[connection_handle])
251
252
  event = Transport::StreamEvent.new(data, "STREAM_RESET")
@@ -257,9 +258,10 @@ module Quicsilver
257
258
  Quicsilver.connection_shutdown(connection_handle, Protocol::H3_CLOSED_CRITICAL_STREAM, false) rescue nil
258
259
  else
259
260
  @cancelled_mutex.synchronize { @cancelled_streams.add(stream_id) }
261
+ pending = @pending_mutex.synchronize { @pending_streams.delete(stream_id) }
262
+ pending&.body&.close(RuntimeError.new("Stream #{stream_id} reset by peer"))
260
263
  @request_registry.complete(stream_id)
261
264
  end
262
-
263
265
  when STREAM_EVENT_STOP_SENDING
264
266
  return unless @connections[connection_handle]
265
267
  event = Transport::StreamEvent.new(data, "STOP_SENDING")
@@ -272,6 +274,30 @@ module Quicsilver
272
274
 
273
275
  private
274
276
 
277
+ # Wrap the user's app for the configured mode.
278
+ # Rack mode: inject rack.early_hints support, then wrap with protocol-rack.
279
+ # Falcon mode: pass through as-is (native protocol-http app).
280
+ def wrap_app(app, mode)
281
+ case mode
282
+ when :falcon then app
283
+ else ::Protocol::Rack::Adapter.new(with_early_hints(app))
284
+ end
285
+ end
286
+
287
+ # Bridges protocol-http's interim_response to Rack's rack.early_hints.
288
+ # In a Rails controller: send_early_hints("link" => '</style.css>; rel=preload')
289
+ def with_early_hints(app)
290
+ ->(env) {
291
+ request = env["protocol.http.request"]
292
+ if request&.respond_to?(:interim_response) && request.interim_response
293
+ env["rack.early_hints"] = ->(headers) {
294
+ request.send_interim_response(103, ::Protocol::HTTP::Headers[headers.map { |k, v| [k, v] }])
295
+ }
296
+ end
297
+ app.call(env)
298
+ }
299
+ end
300
+
275
301
  def setup_signal_handlers
276
302
  %w[INT TERM].each do |signal|
277
303
  trap(signal) { Thread.new { shutdown } }
@@ -301,6 +327,74 @@ module Quicsilver
301
327
 
302
328
  attr_reader :work_queue
303
329
 
330
+ def handle_receive(connection, connection_handle, stream_id, data, early_data: false)
331
+ # Unidirectional streams (control, QPACK) must be processed incrementally —
332
+ # they never send FIN, so waiting for RECEIVE_FIN would mean never parsing.
333
+ if (stream_id & 0x02) != 0 # unidirectional
334
+ begin
335
+ connection.receive_unidirectional_data(stream_id, data)
336
+ rescue Protocol::FrameError => e
337
+ Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
338
+ Quicsilver.connection_shutdown(connection_handle, e.error_code, false) rescue nil
339
+ end
340
+ else
341
+ handle_bidi_receive(connection, connection_handle, stream_id, data, early_data: early_data)
342
+ end
343
+ end
344
+
345
+ def handle_bidi_receive(connection, connection_handle, stream_id, data, early_data: false)
346
+ pending = @pending_mutex.synchronize { @pending_streams[stream_id] }
347
+ if pending
348
+ # Subsequent RECEIVE — append to frame buffer and extract complete DATA payloads.
349
+ # MsQuic splits data at arbitrary boundaries, so frames may span callbacks.
350
+ pending.frame_buffer << data
351
+ drain_data_frames(pending)
352
+ elsif contains_headers_frame?(data)
353
+ dispatch_streaming(connection, connection_handle, stream_id, data, early_data: early_data)
354
+ else
355
+ connection.buffer_data(stream_id, data)
356
+ end
357
+ end
358
+
359
+ def handle_receive_fin(connection, connection_handle, stream_id, data, early_data: false)
360
+ event = Transport::StreamEvent.new(data, "RECEIVE_FIN")
361
+
362
+ pending = @pending_mutex.synchronize { @pending_streams[stream_id] }
363
+ if pending
364
+ complete_streaming_request(pending, event)
365
+ else
366
+ complete_buffered_request(connection, connection_handle, stream_id, event, early_data: early_data)
367
+ end
368
+ end
369
+
370
+ def complete_streaming_request(pending, event)
371
+ if event.data && !event.data.empty?
372
+ pending.frame_buffer << event.data
373
+ drain_data_frames(pending)
374
+ end
375
+ pending.body.close_write
376
+ pending.complete(event.handle)
377
+ end
378
+
379
+ def complete_buffered_request(connection, connection_handle, stream_id, event, early_data: false)
380
+ full_data = connection.complete_stream(stream_id, event.data)
381
+ stream = Transport::InboundStream.new(stream_id)
382
+ stream.stream_handle = event.handle
383
+ stream.append_data(full_data)
384
+
385
+ if stream.bidirectional?
386
+ connection.track_client_stream(stream_id)
387
+ dispatch_request(connection, stream, early_data: early_data)
388
+ else
389
+ begin
390
+ connection.handle_unidirectional_stream(stream)
391
+ rescue Protocol::FrameError => e
392
+ Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
393
+ Quicsilver.connection_shutdown(connection_handle, e.error_code, false) rescue nil
394
+ end
395
+ end
396
+ end
397
+
304
398
  def dispatch_request(connection, stream, early_data: false)
305
399
  if @work_queue.size >= @max_queue_size
306
400
  Quicsilver.logger.warn("Work queue full (#{@max_queue_size}), rejecting request")
@@ -315,14 +409,194 @@ module Quicsilver
315
409
  thread = Thread.new do
316
410
  while (work = @work_queue.pop)
317
411
  break if work == :shutdown
318
- connection, stream, early_data = work
319
- @request_handler.call(connection, stream, early_data: early_data)
412
+
413
+ if work.is_a?(Array) && work[0] == :streaming
414
+ handle_streaming_request(work[1])
415
+ else
416
+ connection, stream, early_data = work
417
+ @request_handler.call(connection, stream, early_data: early_data)
418
+ end
320
419
  end
321
420
  end
322
421
  @handler_mutex.synchronize { @handler_threads << thread }
323
422
  end
324
423
  end
325
424
 
425
+ # Streaming dispatch: parse headers from first RECEIVE, dispatch immediately.
426
+ # Body data arrives via subsequent RECEIVE events into StreamInput.
427
+ def dispatch_streaming(connection, connection_handle, stream_id, data, early_data: false)
428
+ parser = Protocol::RequestParser.new(
429
+ data,
430
+ max_header_size: @server_configuration.max_header_size,
431
+ max_header_count: @server_configuration.max_header_count,
432
+ max_frame_payload_size: @server_configuration.max_frame_payload_size
433
+ )
434
+ parser.parse
435
+ parser.validate_headers!
436
+
437
+ headers = parser.headers
438
+ return if headers.empty?
439
+
440
+ method = headers[":method"]
441
+
442
+ if @server_configuration.early_data_policy == :reject &&
443
+ early_data && !RequestHandler::SAFE_METHODS.include?(method)
444
+ Quicsilver.logger.debug("Rejected 0-RTT #{method} on stream #{stream_id} (no stream handle to send 425)")
445
+ return
446
+ end
447
+
448
+ request, body = @request_handler.adapter.build_request(headers)
449
+ request.headers.add("quicsilver-early-data", early_data.to_s)
450
+
451
+ # Feed body data from the first RECEIVE.
452
+ # The parser consumed complete frames (HEADERS + any complete DATA frames).
453
+ if body
454
+ # Complete DATA frames the parser extracted
455
+ if parser.body && parser.body.size > 0
456
+ parser.body.rewind
457
+ body_data = parser.body.read
458
+ body.write(body_data) unless body_data.empty?
459
+ end
460
+ end
461
+
462
+ pending = PendingStream.new(
463
+ connection: connection,
464
+ body: body,
465
+ request: request,
466
+ stream_id: stream_id,
467
+ priority: parser.priority
468
+ )
469
+
470
+ # Unconsumed bytes go into the frame buffer for incremental parsing
471
+ remainder = data.byteslice(parser.bytes_consumed..-1)
472
+ if remainder && remainder.bytesize > 0
473
+ pending.frame_buffer << remainder
474
+ drain_data_frames(pending)
475
+ end
476
+ @pending_mutex.synchronize { @pending_streams[stream_id] = pending }
477
+
478
+ connection.track_client_stream(stream_id)
479
+ @request_registry.track(stream_id, connection_handle,
480
+ path: headers[":path"] || "/", method: method || "GET")
481
+
482
+ if @work_queue.size >= @max_queue_size
483
+ Quicsilver.logger.warn("Work queue full (#{@max_queue_size}), rejecting request")
484
+ body&.close
485
+ @pending_mutex.synchronize { @pending_streams.delete(stream_id) }
486
+ else
487
+ @work_queue.push([:streaming, pending])
488
+ end
489
+ rescue Protocol::FrameError => e
490
+ Quicsilver.logger.error("Frame error: #{e.message}")
491
+ Quicsilver.connection_shutdown(connection_handle, e.error_code, false) rescue nil
492
+ rescue Protocol::MessageError => e
493
+ Quicsilver.logger.error("Message error on stream #{stream_id}: #{e.message}")
494
+ rescue => e
495
+ Quicsilver.logger.error("Error in streaming dispatch: #{e.class} - #{e.message}")
496
+ end
497
+
498
+ def handle_streaming_request(pending)
499
+ response = @request_handler.adapter.call(pending.request)
500
+
501
+ # Wait for RECEIVE_FIN to provide the stream handle
502
+ stream_handle = pending.wait_for_handle(timeout: 30)
503
+ unless stream_handle
504
+ Quicsilver.logger.error("Timed out waiting for stream handle on stream #{pending.stream_id}")
505
+ return
506
+ end
507
+
508
+ return if cancelled_stream?(pending.stream_id)
509
+
510
+ headers = response.headers
511
+
512
+ trailers = if headers.respond_to?(:trailer?) && headers.trailer?
513
+ trailer_hash = {}
514
+ headers.trailer.each { |name, value| trailer_hash[name] = value }
515
+ trailer_hash
516
+ end
517
+
518
+ response_headers = {}
519
+ if headers.respond_to?(:header)
520
+ headers.header.each { |name, value| response_headers[name] = value }
521
+ else
522
+ headers&.each { |name, value| response_headers[name] = value }
523
+ end
524
+
525
+ if !response_headers.key?("content-length") && response.body&.length
526
+ response_headers["content-length"] = response.body.length.to_s
527
+ end
528
+
529
+ stream = Transport::InboundStream.new(pending.stream_id)
530
+ stream.stream_handle = stream_handle
531
+
532
+ pending.connection.apply_stream_priority(stream, pending.priority)
533
+ pending.connection.send_response(stream, response.status, response_headers, response.body,
534
+ head_request: pending.request.method == "HEAD", trailers: trailers)
535
+ @request_registry.complete(pending.stream_id)
536
+ rescue => e
537
+ Quicsilver.logger.error("Streaming request error: #{e.class} - #{e.message}")
538
+ if pending.stream_handle
539
+ stream = Transport::InboundStream.new(pending.stream_id)
540
+ stream.stream_handle = pending.stream_handle
541
+ pending.connection.send_error(stream, 500, "Internal Server Error") if stream.writable?
542
+ end
543
+ ensure
544
+ @pending_mutex.synchronize { @pending_streams.delete(pending.stream_id) }
545
+ @request_registry.complete(pending.stream_id) if @request_registry.include?(pending.stream_id)
546
+ @cancelled_mutex.synchronize { @cancelled_streams.delete(pending.stream_id) }
547
+ end
548
+
549
+ # Incrementally extract complete DATA frame payloads from the frame buffer.
550
+ # Handles MsQuic splitting frames across RECEIVE callbacks — partial frames
551
+ # remain in the buffer until the next callback completes them.
552
+ def drain_data_frames(pending)
553
+ buf = pending.frame_buffer
554
+
555
+ while buf.bytesize >= 2
556
+ type_byte = buf.getbyte(0)
557
+ if type_byte < 0x40
558
+ type = type_byte
559
+ type_len = 1
560
+ else
561
+ type, type_len = Protocol.decode_varint_str(buf, 0)
562
+ break if type_len == 0
563
+ end
564
+
565
+ len_byte = buf.getbyte(type_len)
566
+ break unless len_byte
567
+ if len_byte < 0x40
568
+ length = len_byte
569
+ length_len = 1
570
+ else
571
+ length, length_len = Protocol.decode_varint_str(buf, type_len)
572
+ break if length_len == 0
573
+ end
574
+
575
+ header_len = type_len + length_len
576
+ total = header_len + length
577
+
578
+ # Incomplete frame — wait for more data
579
+ break if buf.bytesize < total
580
+
581
+ if type == Protocol::FRAME_DATA
582
+ pending.body.write(buf.byteslice(header_len, length))
583
+ end
584
+ # Skip non-DATA frames (e.g. unknown extension frames)
585
+
586
+ buf = buf.byteslice(total..-1) || "".b
587
+ end
588
+
589
+ pending.frame_buffer = buf
590
+ end
591
+
592
+ # Heuristic: check if raw data starts with an HTTP/3 HEADERS frame (type 0x01).
593
+ # QUIC typically delivers complete frames, but if this misidentifies data,
594
+ # the parser will fail safely in dispatch_streaming's rescue handlers.
595
+ def contains_headers_frame?(data)
596
+ return false if data.nil? || data.bytesize < 2
597
+ data.getbyte(0) == Protocol::FRAME_HEADERS
598
+ end
599
+
326
600
  def stop_worker_pool
327
601
  @thread_pool_size.times { @work_queue.push(:shutdown) }
328
602
  @handler_mutex.synchronize do
@@ -9,7 +9,8 @@ module Quicsilver
9
9
  :keep_alive_interval_ms, :congestion_control_algorithm, :migration_enabled,
10
10
  :disconnect_timeout_ms, :handshake_idle_timeout_ms,
11
11
  :max_body_size, :max_header_size, :max_header_count, :max_frame_payload_size,
12
- :early_data_policy
12
+ :early_data_policy,
13
+ :mode
13
14
 
14
15
  QUIC_SERVER_RESUME_AND_ZERORTT = 1
15
16
  QUIC_SERVER_RESUME_ONLY = 2
@@ -84,6 +85,14 @@ module Quicsilver
84
85
  raise ServerConfigurationError, "Invalid early_data_policy: #{@early_data_policy.inspect} (must be :reject or :allow)"
85
86
  end
86
87
 
88
+ # Application interface mode:
89
+ # :rack (default) — app is a Rack app, auto-wrapped with Protocol::Rack::Adapter
90
+ # :falcon — app is a native protocol-http app, used directly
91
+ @mode = options.fetch(:mode, :rack)
92
+ unless %i[rack falcon].include?(@mode)
93
+ raise ServerConfigurationError, "Invalid mode: #{@mode.inspect} (must be :rack or :falcon)"
94
+ end
95
+
87
96
  @cert_file = cert_file.nil? ? DEFAULT_CERT_FILE : cert_file
88
97
  @key_file = key_file.nil? ? DEFAULT_KEY_FILE : key_file
89
98