quicsilver 0.4.0 → 0.5.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -1
  3. data/.github/workflows/cibuildgem.yaml +29 -5
  4. data/.gitignore +5 -0
  5. data/CHANGELOG.md +50 -0
  6. data/Gemfile.lock +8 -1
  7. data/README.md +128 -15
  8. data/Rakefile +10 -3
  9. data/benchmarks/baseline.rb +175 -0
  10. data/benchmarks/compare.rb +398 -0
  11. data/benchmarks/helpers.rb +138 -64
  12. data/benchmarks/latency.rb +128 -0
  13. data/benchmarks/loss.rb +193 -0
  14. data/benchmarks/mobile.rb +125 -0
  15. data/docs/connection_identity.md +141 -0
  16. data/examples/benchmark.rb +80 -0
  17. data/examples/connection_pool_demo.rb +1 -1
  18. data/examples/example_helper.rb +51 -0
  19. data/examples/feature_demo.rb +7 -7
  20. data/examples/grpc_style.rb +2 -2
  21. data/examples/latency_aware_routing.rb +382 -0
  22. data/examples/lifecycle_drain.rb +264 -0
  23. data/examples/priorities.rb +2 -2
  24. data/examples/rails_feature_test.rb +92 -72
  25. data/examples/server_stats.rb +117 -0
  26. data/examples/simple_client_test.rb +3 -3
  27. data/examples/trailers.rb +23 -23
  28. data/examples/webtransport_smoke.rb +546 -0
  29. data/examples/worker_load_balancing.rb +425 -0
  30. data/ext/quicsilver/extconf.rb +100 -15
  31. data/ext/quicsilver/include/msquic.h +2226 -0
  32. data/ext/quicsilver/include/msquic_posix.h +696 -0
  33. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  34. data/ext/quicsilver/msquic_patch.rb +68 -0
  35. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  36. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  37. data/ext/quicsilver/quicsilver.c +1640 -222
  38. data/lib/quicsilver/client/client.rb +419 -60
  39. data/lib/quicsilver/client/connection_pool.rb +122 -26
  40. data/lib/quicsilver/client/request.rb +106 -14
  41. data/lib/quicsilver/errors.rb +39 -0
  42. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  43. data/lib/quicsilver/protocol/adapter.rb +41 -21
  44. data/lib/quicsilver/protocol/capsule.rb +38 -0
  45. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  46. data/lib/quicsilver/protocol/control_stream_parser.rb +12 -1
  47. data/lib/quicsilver/protocol/datagram.rb +40 -0
  48. data/lib/quicsilver/protocol/frame_parser.rb +5 -6
  49. data/lib/quicsilver/protocol/frame_reader.rb +31 -2
  50. data/lib/quicsilver/protocol/frames.rb +128 -12
  51. data/lib/quicsilver/protocol/priority.rb +9 -0
  52. data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
  53. data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
  54. data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
  55. data/lib/quicsilver/protocol/request_encoder.rb +6 -3
  56. data/lib/quicsilver/protocol/request_parser.rb +16 -2
  57. data/lib/quicsilver/protocol/response_encoder.rb +18 -8
  58. data/lib/quicsilver/protocol/response_parser.rb +6 -0
  59. data/lib/quicsilver/protocol/stream_input.rb +3 -3
  60. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  61. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  62. data/lib/quicsilver/rack/context.rb +33 -0
  63. data/lib/quicsilver/response.rb +40 -0
  64. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  65. data/lib/quicsilver/server/receive_queue.rb +106 -0
  66. data/lib/quicsilver/server/request_handler.rb +24 -13
  67. data/lib/quicsilver/server/request_registry.rb +18 -5
  68. data/lib/quicsilver/server/scheduler.rb +50 -0
  69. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  70. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  71. data/lib/quicsilver/server/server.rb +655 -185
  72. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  73. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  74. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  75. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  76. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  77. data/lib/quicsilver/transport/configuration.rb +103 -10
  78. data/lib/quicsilver/transport/connection.rb +302 -87
  79. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  80. data/lib/quicsilver/transport/event_loop.rb +36 -0
  81. data/lib/quicsilver/transport/inbound_stream.rb +21 -1
  82. data/lib/quicsilver/transport/stream.rb +18 -3
  83. data/lib/quicsilver/transport/stream_event.rb +25 -2
  84. data/lib/quicsilver/transport/stream_id.rb +26 -0
  85. data/lib/quicsilver/version.rb +1 -1
  86. data/lib/quicsilver.rb +22 -11
  87. data/lib/rackup/handler/quicsilver.rb +57 -38
  88. data/quicsilver.gemspec +10 -3
  89. metadata +54 -8
  90. data/benchmarks/concurrent.rb +0 -110
  91. data/benchmarks/quicsilver_server.rb +0 -46
  92. data/benchmarks/rails.rb +0 -170
  93. data/benchmarks/throughput.rb +0 -113
  94. data/lib/quicsilver/quicsilver.bundle +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4926d24fbce141791505bb14d59e543a30a6c5cbf8ca3c3fd36c9123b2941db
4
- data.tar.gz: 45c128c6bf9fc48b8267ba2927414f8429607e8f99e4169235d12ebe26863ccc
3
+ metadata.gz: 9f3ec155af92a08642dd37953aa75728a021f9868c964c0240a61b98399f72be
4
+ data.tar.gz: 6c5effb85af855cfd60761fc7aef9bdf0cc6901df1c56b478d22557705c55925
5
5
  SHA512:
6
- metadata.gz: ad6f92657375909bac67ce88bfa64d47c53a2dd69bb424a36f297927778389247714c092b004287cc278c5ecdf94c0f186a4c15f4fa4cfff62dbef9fcfc155f6
7
- data.tar.gz: 1f5d2812bfb18a15b1821c8afcb18f779663ec3066495ef69dfd9a1c79f8d83f13f7c6287b8ce56be318476ea69fd6c520c1dc4298ceb807c64f6de0747b3035
6
+ metadata.gz: 26490f0003dc3424abb07afaddd5fa9057d36a3cb2e974be2ea46b740069dc4114f95a46afa8d43e8f48c2d8dcc0a294187e6c9852806a97ae83f6841e01c9d9
7
+ data.tar.gz: 793d530b463494c8df0e6a0268686f0cd3588246300f1ef1763462f770949bbe0c04b20ef55599046486c3fb1383cf7207c9f317d8a0002a7c579b61f95d668a
@@ -20,9 +20,17 @@ jobs:
20
20
  test:
21
21
 
22
22
  runs-on: ubuntu-24.04
23
+ continue-on-error: ${{ matrix.allow-failure }}
23
24
  strategy:
25
+ fail-fast: false
24
26
  matrix:
25
- ruby-version: ["3.4"]
27
+ include:
28
+ - ruby-version: "3.4"
29
+ allow-failure: false
30
+ - ruby-version: "4.0"
31
+ allow-failure: false
32
+ - ruby-version: "head"
33
+ allow-failure: true
26
34
 
27
35
  steps:
28
36
  - uses: actions/checkout@v4
@@ -9,6 +9,7 @@ on:
9
9
  default: false
10
10
  env:
11
11
  CIBUILDGEM: 1
12
+ RELEASE: "true"
12
13
  jobs:
13
14
  compile:
14
15
  timeout-minutes: 20
@@ -28,11 +29,32 @@ jobs:
28
29
  ruby-version: "3.4"
29
30
  bundler-cache: true
30
31
  - name: "Build MsQuic"
31
- run: bundle exec rake build_msquic
32
+ run: bundle exec rake build_msquic bundle_msquic
33
+ - name: "Verify bundled MsQuic"
34
+ run: |
35
+ ls -la lib/quicsilver
36
+ test -f lib/quicsilver/libmsquic.2.dylib || test -f lib/quicsilver/libmsquic.so.2
37
+ - name: "Verify package file list"
38
+ run: |
39
+ ruby -e 'puts "RELEASE=#{ENV["RELEASE"].inspect}"; spec = Gem::Specification.load("quicsilver.gemspec"); puts spec.files.grep(/libmsquic/)'
32
40
  - name: "Run cibuildgem"
33
41
  uses: "shopify/cibuildgem/.github/actions/cibuildgem@main"
34
42
  with:
35
43
  step: "compile"
44
+ - name: "Upload staged artifacts with MsQuic"
45
+ uses: "actions/upload-artifact@v4"
46
+ with:
47
+ name: "stage-${{ runner.os }}-${{ runner.arch }}"
48
+ path: |
49
+ ./tmp/**/*.so
50
+ ./tmp/**/*.so.*
51
+ ./tmp/**/*.bundle
52
+ ./tmp/**/*.dylib
53
+ ./tmp/**/*.dylib.*
54
+ ./pkg/*.gem
55
+ if-no-files-found: error
56
+ retention-days: 1
57
+ overwrite: true
36
58
  test:
37
59
  timeout-minutes: 20
38
60
  name: "Run the test suite"
@@ -41,7 +63,7 @@ jobs:
41
63
  matrix:
42
64
  os: ["macos-latest", "ubuntu-22.04"]
43
65
  rubies: ["3.4", "4.0"]
44
- type: ["cross", "native"]
66
+ type: ["cross"]
45
67
  runs-on: "${{ matrix.os }}"
46
68
  steps:
47
69
  - name: "Checkout code"
@@ -68,10 +90,12 @@ jobs:
68
90
  uses: "ruby/setup-ruby@v1"
69
91
  with:
70
92
  ruby-version: "4.0.0"
71
- - name: "Run cibuildgem"
72
- uses: "shopify/cibuildgem/.github/actions/cibuildgem@main"
93
+ - name: "Download platform gems"
94
+ uses: "actions/download-artifact@v5"
73
95
  with:
74
- step: "install"
96
+ name: "stage-${{ runner.os }}-${{ runner.arch }}"
97
+ - name: "Install platform gem"
98
+ run: ruby -e 'puts Dir["pkg/*-#{Gem::Platform.local}.gem"].fetch(0)' | xargs gem install
75
99
  release:
76
100
  environment: release
77
101
  permissions:
data/.gitignore CHANGED
@@ -10,6 +10,11 @@
10
10
  /certs/*
11
11
  /certificates/*
12
12
  *.bundle
13
+ *.bundle.*
14
+ *.so
15
+ *.so.*
16
+ *.dylib
17
+ *.dylib.*
13
18
  lib/quicsilver/quicsilver.bundle
14
19
  *.gem
15
20
  autoresearch.*
data/CHANGELOG.md CHANGED
@@ -5,6 +5,56 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.5.0] - 2026-11-02
11
+
12
+ ### Added
13
+
14
+ - WebTransport session flow control (draft-ietf-webtrans-http3-16 §5): the `WT_MAX_DATA`, `WT_MAX_STREAMS`, `WT_DATA_BLOCKED` and `WT_STREAMS_BLOCKED` capsules, negotiation via `SETTINGS_WT_INITIAL_MAX_*`, enforcement of limits we advertise, spending of limits a peer grants, and proactive grants as the application consumes. `WebTransportStream#writable?` and `WebTransportSession#on_writable` report send credit. Prohibited per-stream capsules fail the session. §5.1 requires both endpoints to advertise a non-zero initial limit; browsers tested do not, so sessions with them run without it and remain limited to one per connection.
15
+
16
+ - `Transport::StreamEvent#final_size` reports how many bytes the peer sent on a stream the peer reset, from the RESET_STREAM frame (RFC 9000 §4.5); `nil` when the final size is not settled. WebTransport session flow control needs it to charge the bytes a reset discarded (draft-ietf-webtrans-http3-16 §5.4). MsQuic keeps the value but does not expose it, so it is read through a new vendored patch, `ext/quicsilver/patches/stream-final-size.patch`.
17
+ - Shared connection multiplexing — single QUIC connection serves all threads concurrently (5x faster: 16,000 req/s vs 3,175 req/s exclusive). Default pool mode.
18
+ - `TransportError` base class with MsQuic status code parsing — typed errors instead of string-matching RuntimeError from C extension
19
+ - `StreamFailedToOpenError` wraps `StreamOpen`/`StreamStart` failures with parsed hex status
20
+ - Stream flow control visibility — `QUIC_STREAM_START_FLAG_INDICATE_PEER_ACCEPT` dispatches `STREAM_START_COMPLETE` and `STREAM_PEER_ACCEPTED` events to Ruby
21
+ - H3 Datagram support (RFC 9221/9297) — `client.datagram_send`, `server.datagram_send`, `on_datagram` callbacks, `SETTINGS_H3_DATAGRAM` advertised
22
+ - Client priority support — send `priority` header with extensible priorities (RFC 9218)
23
+ - Client request body streaming — `build_request('POST', path, body: :stream)` with `Request#stream_body`
24
+ - Client response body streaming — `request.streaming_response` returns headers immediately, body arrives incrementally
25
+ - Per-request timeout support
26
+ - Connection pool blocks with timeout (ConditionVariable) instead of raising immediately when full
27
+ - Response unification — single response representation across buffered and streaming paths
28
+ - Multi-threaded benchmarks
29
+
30
+ ### Fixed
31
+
32
+ - Repeated response headers are sent as separate fields. They were flattened into a Hash keyed by name, which kept only the last value; a Rails response setting a session cookie alongside any other lost the session, so sign-in succeeded and the next request arrived unauthenticated. `set-cookie` MUST NOT be combined (RFC 9110 §5.3).
33
+ - WebTransport rejects the `WT_MAX_STREAM_DATA` and `WT_STREAM_DATA_BLOCKED` capsules. Per-stream data limits belong to the HTTP/2 binding; over HTTP/3 each WebTransport stream is a QUIC stream with its own limits, so receipt is a session error carrying `WT_FLOW_CONTROL_ERROR` (draft-ietf-webtrans-http3-16 §5.4). They were previously ignored as unknown capsule types. Unknown types are still ignored.
34
+ - WebTransport CLOSE and DRAIN capsules use HTTP/3 DATA frames. Session close sends FIN, validates peer close payloads, and terminates both directions on protocol errors.
35
+ - WT_STREAM (`0x41`) is rejected with the connection error `H3_FRAME_ERROR` when it appears anywhere other than the first bytes of a request stream, including on the control stream (draft-ietf-webtrans-http3-16 §4.3). It is only a signal value at the front of a WebTransport stream, never a frame elsewhere.
36
+ - WebTransport capsules sent optimistically on the CONNECT stream are no longer processed before the server responds. They are held unparsed, then processed once `accept!` sends the 2xx or discarded if the session is rejected (§3.2). The hold is bounded at 64 KiB; exceeding it fails the session with `H3_MESSAGE_ERROR`. Previously a CLOSE capsule arriving before the application responded would terminate the session and make it unacceptable.
37
+ - Release GVL during `wait_for_connection` — no longer blocks all Ruby threads during handshake
38
+ - Stream cleanup after response sent — `Connection#@streams` entries removed on completion, not just on connection close
39
+ - Error hierarchy — `FrameError`, `MessageError`, `StreamFailedToOpenError` all inherit from `Quicsilver::Error`
40
+ - Large response crash and concurrent connection race condition
41
+ - Rack adapter owns early hints and trailer wiring (removed from server)
42
+
43
+ ### Changed
44
+
45
+ - MsQuic callbacks no longer call into Ruby. The poll thread runs the whole MsQuic iteration with the GVL released and queues events; a dispatcher thread delivers them to `Server.handle_stream` in order. `Quicsilver.defer_stream_receive` and `Transport::Stream#defer_receive` are removed: delivery is asynchronous, so a receive cannot be handed back to MsQuic, and a method that always declined would be indistinguishable from one that declined this time. With `receive_backpressure: true`, the one WebTransport receive that can arrive before the first credit grant is delivered whole and may exceed `receive_buffer_bytes`; it is bounded by the initial QUIC stream window instead (RFC 9000 §4.1), and no credit is granted until the reader drains below the limit. Previously the excess was deferred. Credit bounds every receive after that as before. Each connection may have at most 128 datagrams queued ahead of the dispatcher (quic-go's figure); past that the oldest is dropped, as RFC 9221 §5.3 permits and as quiche and neqo do. `Server#stats["datagrams"]["dropped"]` counts drops across connections. `receive_buffer_bytes` was never a bound on what a single `read` returns.
46
+ - `Quicsilver.send_stream` no longer copies the body. The Ruby String is frozen and MsQuic sends from its bytes directly, with the String retained until the send completes. Callers that went on mutating a buffer after sending it must `dup` first; nothing in the library did. Large responses gain about 4% on server-side time; small ones are unchanged. The poll thread's idle spin before sleeping is now configurable via `QUICSILVER_POLL_IDLE_US` (default 0, the previous behaviour); it measured as having no effect under load and is exposed for low-concurrency latency-sensitive deployments to evaluate.
47
+ - WebTransport child streams have bounded receive queues, and a child that fills its buffer now pauses instead of losing its stream. `accept!` takes `receive_buffer_bytes` (default 1 MiB), `receive_buffer_chunks` (default 1024), `receive_overflow_code` and `receive_backpressure` (default `true`); delivery stops until the application reads. A reader that never drains leaves its stream paused indefinitely, and a paused stream holds connection credit, so it can stall its siblings. `receive_backpressure: false` restores the previous policy: an overflowing child is sent `STOP_SENDING` with `receive_overflow_code`, its queued input is discarded and its pending reads fail, while its write side stays open. Limits apply to each child's queued Ruby input, not to native buffers or the whole connection. `WebTransportStream#initialize` now requires these four options rather than defaulting them, so the defaults have a single home in `WebTransportSession::DEFAULT_RECEIVE_OPTIONS`.
48
+ - WebTransport stream resets are directional. `RESET_STREAM` closes only the read side and invokes the new `on_peer_reset`; `STOP_SENDING` closes only the write side and invokes the new `on_peer_stop_sending`. `on_close` fires once both directions are closed. Previously either signal tore down the whole stream and both reported through `on_reset`, which is removed — the `peer_` prefix distinguishes these inbound events from the outbound `reset` and `abort` methods.
49
+ - WebTransport CONNECT waits for peer SETTINGS before reaching Rack, including when headers arrive with FIN. The `webtransport-h3` token requires `WT_ENABLED=1` and `H3_DATAGRAM=1`; the legacy `webtransport` token requires `H3_DATAGRAM=1`. Waiting is limited to one CONNECT and 64 KiB of body data per connection, with cleanup on cancellation or connection closure.
50
+ - WebTransport permits one active session per QUIC connection until session flow control is implemented. Additional CONNECT requests are rejected with `H3_REQUEST_REJECTED`; closing a session allows another. SETTINGS no longer advertise session flow-control credit, and the legacy session limit is one.
51
+ - WebTransport `close(code:, reason:)` rejects invalid UTF-8 and codes outside the unsigned 32-bit range before changing session state. Valid UTF-8 binary strings remain supported; long valid reasons are truncated at a UTF-8 character boundary.
52
+ - Connection pool default mode is `:shared` (multiplexed) — `:exclusive` available as opt-in
53
+ - `open_stream` wraps C extension errors into typed `StreamFailedToOpenError` / `TransportError`
54
+ - MsQuic submodule updated from v2.5.0 to v2.5.7
55
+ - Uni stream default documented: 10 (3 required by HTTP/3, headroom for GREASE and extensions)
56
+ - Scheduler base class extracted for extensibility
57
+
8
58
  ## [0.4.0] - 2026-04-25
9
59
 
10
60
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- quicsilver (0.4.0)
4
+ quicsilver (0.5.0)
5
5
  console
6
6
  localhost (~> 1.6)
7
7
  logger
@@ -14,6 +14,10 @@ GEM
14
14
  remote: https://rubygems.org/
15
15
  specs:
16
16
  benchmark-ips (2.14.0)
17
+ cibuildgem (0.3.0)
18
+ prism
19
+ rake-compiler
20
+ thor
17
21
  console (1.34.3)
18
22
  fiber-annotation
19
23
  fiber-local (~> 1.1)
@@ -29,6 +33,7 @@ GEM
29
33
  minitest (5.27.0)
30
34
  minitest-focus (1.4.0)
31
35
  minitest (>= 4, < 6)
36
+ prism (1.9.0)
32
37
  protocol-http (0.60.0)
33
38
  protocol-rack (0.22.1)
34
39
  io-stream (>= 0.10)
@@ -41,6 +46,7 @@ GEM
41
46
  rake-compiler (1.3.0)
42
47
  rake
43
48
  rake-compiler-dock (1.9.1)
49
+ thor (1.5.0)
44
50
 
45
51
  PLATFORMS
46
52
  arm64-darwin-23
@@ -49,6 +55,7 @@ PLATFORMS
49
55
  DEPENDENCIES
50
56
  benchmark-ips (~> 2.12)
51
57
  bundler (~> 2.0)
58
+ cibuildgem
52
59
  minitest (~> 5.0)
53
60
  minitest-focus (~> 1.3)
54
61
  quicsilver!
data/README.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  HTTP/3 server and client for Ruby with Rack support.
4
4
 
5
+ ## Why HTTP/3?
6
+
7
+ - **No head-of-line blocking** — one slow response doesn't stall others. HTTP/2 multiplexes over TCP, but a single lost packet freezes all streams. QUIC streams are independent.
8
+ - **Faster connections** — QUIC handshake is 1 round trip (TCP+TLS is 3). Returning users get 0-RTT — zero round trips.
9
+ - **Connection migration** — users switch from wifi to cellular without dropping the connection.
10
+ - **True multiplexing** — many concurrent requests on one connection, each on its own stream.
11
+
5
12
  ## Features
6
13
 
7
14
  - **HTTP/3 server** — serve any Rack app over QUIC/HTTP/3
@@ -14,7 +21,7 @@ HTTP/3 server and client for Ruby with Rack support.
14
21
  - **GOAWAY** (RFC 9114 §7.2.6) — graceful connection draining with validation
15
22
  - **0-RTT** — fast reconnection with replay protection
16
23
  - **Connection pooling** — client reuses connections automatically
17
- - **protocol-http integration** — works with Falcon and protocol-http ecosystem
24
+ - **Falcon middleware compatible** — use Falcon's caching and content encoding over HTTP/3
18
25
 
19
26
  ## Installation
20
27
 
@@ -31,12 +38,19 @@ rake compile
31
38
 
32
39
  ```ruby
33
40
  require "quicsilver"
41
+ require "localhost/authority"
34
42
 
35
43
  app = ->(env) {
36
44
  [200, {"content-type" => "text/plain"}, ["Hello HTTP/3!"]]
37
45
  }
38
46
 
39
- server = Quicsilver::Server.new(4433, app: app)
47
+ authority = Localhost::Authority.fetch
48
+ config = Quicsilver::Transport::Configuration.new(
49
+ authority.certificate_path,
50
+ authority.key_path
51
+ )
52
+
53
+ server = Quicsilver::Server.new(4433, app: app, server_configuration: config)
40
54
  server.start
41
55
  ```
42
56
 
@@ -45,18 +59,20 @@ server.start
45
59
  ```ruby
46
60
  require "quicsilver"
47
61
 
48
- # Class-level API with automatic connection pooling
49
- response = Quicsilver::Client.get("127.0.0.1", 4433, "/")
62
+ # Class-level API with automatic connection pooling.
63
+ # Clients verify TLS certificates by default, so the hostname must match the cert.
64
+ response = Quicsilver::Client.get("localhost", 4433, "/")
50
65
  puts response[:status] # => 200
51
66
  puts response[:body] # => "Hello HTTP/3!"
52
67
 
53
68
  # POST with body
54
- response = Quicsilver::Client.post("127.0.0.1", 4433, "/api/users",
69
+ response = Quicsilver::Client.post("localhost", 4433, "/api/users",
55
70
  body: '{"name": "alice"}',
56
71
  headers: { "content-type" => "application/json" })
57
72
 
58
- # Instance-level for more control
59
- client = Quicsilver::Client.new("127.0.0.1", 4433, unsecure: true)
73
+ # Instance-level for more control. Use unsecure: true only for
74
+ # local/self-signed test servers.
75
+ client = Quicsilver::Client.new("localhost", 4433, unsecure: true)
60
76
  response = client.get("/")
61
77
  client.disconnect
62
78
  ```
@@ -67,14 +83,111 @@ client.disconnect
67
83
  rackup -s quicsilver -p 4433
68
84
  ```
69
85
 
86
+ In development, the Rackup handler can use the `localhost` gem to generate
87
+ local certificates automatically. In production, pass both `cert_file` and
88
+ `key_file` Rackup options explicitly:
89
+
90
+ ```bash
91
+ rackup -s quicsilver -p 4433 \
92
+ -O cert_file=/path/to/fullchain.pem \
93
+ -O key_file=/path/to/privkey.pem
94
+ ```
95
+
70
96
  ### curl
71
97
 
72
98
  ```bash
73
- curl --http3-only https://localhost:4433/
99
+ curl --http3-only -k https://localhost:4433/
100
+ ```
101
+
102
+ ### TLS Certificates
103
+
104
+ HTTP/3 always runs over TLS. Core server configuration never guesses
105
+ certificate paths: `Quicsilver::Transport::Configuration` requires both a
106
+ certificate file and a private key file.
107
+
108
+ For production, point Quicsilver at the same publicly-trusted certificate and
109
+ key you use for your TCP HTTPS server:
110
+
111
+ ```ruby
112
+ config = Quicsilver::Transport::Configuration.new(
113
+ ENV.fetch("TLS_CERT_FILE"),
114
+ ENV.fetch("TLS_KEY_FILE")
115
+ )
116
+ ```
117
+
118
+ For local development, Quicsilver depends on the
119
+ [localhost](https://github.com/socketry/localhost) gem, which can generate a
120
+ certificate for `localhost` or your local development hostname:
121
+
122
+ ```ruby
123
+ require "localhost/authority"
124
+
125
+ authority = Localhost::Authority.fetch
126
+ config = Quicsilver::Transport::Configuration.new(
127
+ authority.certificate_path,
128
+ authority.key_path
129
+ )
130
+ ```
131
+
132
+ Run `bake localhost:install` once to add the localhost CA to your system trust
133
+ store. You can still use `curl -k` or `unsecure: true` for quick local testing
134
+ without trusting the CA.
135
+
136
+ Quicsilver clients verify certificates by default. Disable verification only
137
+ for local development or tests:
138
+
139
+ ```ruby
140
+ client = Quicsilver::Client.new("localhost", 4433, unsecure: true)
141
+ ```
142
+
143
+ ### Browser Access
144
+
145
+ Any HTTP/3 client connects directly — no extra setup needed.
146
+
147
+ Browsers discover HTTP/3 via the `Alt-Svc` header from an existing HTTP/1.1
148
+ or HTTP/2 server. Run quicsilver alongside your regular Rails/Rack server on
149
+ the **same port** — TCP for HTTP/1.1+2, UDP for HTTP/3:
150
+
151
+ ```bash
152
+ # Your normal Rails server (HTTP/1.1 + HTTP/2 over TCP)
153
+ bin/rails server -p 3000
154
+
155
+ # Quicsilver (HTTP/3 over UDP, same port)
156
+ rackup -s quicsilver -p 3000
74
157
  ```
75
158
 
159
+ Add the `Alt-Svc` header so browsers discover HTTP/3. The port must match
160
+ the port quicsilver is listening on:
161
+
162
+ ```ruby
163
+ # config/application.rb
164
+ config.action_dispatch.default_headers["Alt-Svc"] = 'h3=":3000"; ma=86400'
165
+ ```
166
+
167
+ With a publicly-trusted certificate (e.g. Let's Encrypt), browsers upgrade
168
+ to HTTP/3 automatically via Alt-Svc — no special configuration needed.
169
+
170
+ ### Local Development
171
+
172
+ Both your TCP server and quicsilver need a TLS certificate trusted by
173
+ your system. Both servers **must use the same certificate**. The
174
+ `localhost` gem can generate local certificates — run `bake localhost:install`
175
+ to add the CA to your system trust store.
176
+
177
+ Chrome requires one additional flag for local development because it
178
+ does not allow QUIC connections with locally-trusted certificates:
179
+
180
+ ```bash
181
+ chrome --origin-to-force-quic-on=myapp.test:3000 https://myapp.test:3000
182
+ ```
183
+
184
+ This is not needed in production with publicly-trusted certificates.
185
+
76
186
  ## Configuration
77
187
 
188
+ Pass both certificate paths explicitly. Omitting either path raises a
189
+ configuration error.
190
+
78
191
  ```ruby
79
192
  config = Quicsilver::Transport::Configuration.new(
80
193
  "certificates/server.crt",
@@ -94,7 +207,7 @@ server.start
94
207
 
95
208
  ## Priorities
96
209
 
97
- Browsers send priority hints on requests. Quicsilver parses them and tells MsQuic to schedule high-priority streams first.
210
+ Browsers send priority hints on requests. Quicsilver parses them and schedules high-priority streams first.
98
211
 
99
212
  ```
100
213
  GET /style.css → priority: u=0 → sent first (highest urgency)
@@ -116,25 +229,25 @@ headers.trailer!
116
229
  headers.add("x-checksum", "abc123")
117
230
  ```
118
231
 
119
- ## Protocol-HTTP Mode
232
+ ## Falcon Middleware Mode
120
233
 
121
- For integration with [Falcon](https://github.com/socketry/falcon) and the protocol-http ecosystem:
234
+ Use Falcon's middleware stack (caching, content encoding) over HTTP/3. Quicsilver handles the transport, Falcon's middleware handles the request pipeline:
122
235
 
123
236
  ```ruby
124
237
  config = Quicsilver::Transport::Configuration.new(
125
238
  "certificates/server.crt",
126
239
  "certificates/server.key",
127
- mode: :protocol_http
240
+ mode: :falcon
128
241
  )
129
242
 
130
243
  server = Quicsilver::Server.new(4433, app: app, server_configuration: config)
131
244
  server.start
132
245
  ```
133
246
 
134
- | Mode | Body Handling | Use Case |
247
+ | Mode | App Interface | Use Case |
135
248
  |------|---------------|----------|
136
- | `:rack` (default) | Buffered | Standard Rack apps |
137
- | `:protocol_http` | Streaming | Falcon, protocol-http apps |
249
+ | `:rack` (default) | Rack env hash | Rails, Sinatra, any Rack app |
250
+ | `:falcon` | Protocol::HTTP::Request | Falcon middleware stack |
138
251
 
139
252
  ## Development
140
253
 
data/Rakefile CHANGED
@@ -2,13 +2,15 @@ require "bundler/setup"
2
2
  require "bundler/gem_tasks"
3
3
  require "rake/testtask"
4
4
  require "rake/extensiontask"
5
+ require_relative "ext/quicsilver/msquic_patch"
5
6
 
6
7
  Rake::ExtensionTask.new('quicsilver') do |ext|
7
8
  ext.lib_dir = 'lib/quicsilver'
8
9
  end
9
10
 
10
- # Ensure MsQuic is built before compiling the C extension
11
- task :compile => :build_msquic
11
+ # Keep `rake compile` useful in source checkouts, but let cibuildgem own
12
+ # extension compilation after the workflow has built and bundled MsQuic.
13
+ task :compile => [:build_msquic, :bundle_msquic] unless ENV["CIBUILDGEM"]
12
14
 
13
15
  # Copy MsQuic dylib next to the compiled extension for gem packaging
14
16
  task :bundle_msquic do
@@ -37,7 +39,8 @@ task :setup do
37
39
  end
38
40
 
39
41
  task :build_msquic => :setup do
40
- cmake_args = ['-B build', '-DCMAKE_BUILD_TYPE=Release']
42
+ MsquicPatch.apply!(File.expand_path("vendor/msquic", __dir__))
43
+ cmake_args = ['-B build', '-DCMAKE_BUILD_TYPE=Release', '-DQUIC_TLS_LIB=quictls', '-DQUIC_LINUX_IOURING_ENABLED=OFF']
41
44
  if RUBY_PLATFORM =~ /darwin/
42
45
  cmake_args << '-DCMAKE_EXE_LINKER_FLAGS="-framework CoreServices"'
43
46
  cmake_args << '-DCMAKE_SHARED_LINKER_FLAGS="-framework CoreServices"'
@@ -49,6 +52,7 @@ task :build_msquic => :setup do
49
52
  env = { 'PATH' => "/usr/bin:#{ENV['PATH']}" }
50
53
  sh env, "cd vendor/msquic && cmake #{cmake_args.join(' ')}"
51
54
  sh env, 'cd vendor/msquic && cmake --build build --config Release'
55
+ MsquicPatch.record_build!(File.expand_path("vendor/msquic", __dir__))
52
56
  end
53
57
 
54
58
  task :build => [:build_msquic, :compile]
@@ -56,12 +60,14 @@ task :build => [:build_msquic, :compile]
56
60
  Rake::TestTask.new(:test) do |t|
57
61
  t.libs << "test"
58
62
  t.libs << "lib"
63
+ t.ruby_opts << "-W0"
59
64
  t.test_files = FileList["test/**/*_test.rb"]
60
65
  end
61
66
 
62
67
  Rake::TestTask.new(:test_unit) do |t|
63
68
  t.libs << "test"
64
69
  t.libs << "lib"
70
+ t.ruby_opts << "-W0"
65
71
  t.test_files = FileList["test/**/*_test.rb"].reject { |f|
66
72
  f.include?("integration") || f.include?("stream_control") ||
67
73
  f =~ /quicsilver_test|event_loop_test/
@@ -71,6 +77,7 @@ end
71
77
  Rake::TestTask.new(:test_integration) do |t|
72
78
  t.libs << "test"
73
79
  t.libs << "lib"
80
+ t.ruby_opts << "-W0"
74
81
  t.test_files = FileList[
75
82
  "test/stream_control_integration_test.rb",
76
83
  "test/integration/**/*_test.rb",
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Quicsilver baseline benchmark with a single-file Rails app.
5
+ #
6
+ # HTTP/3 is always TLS, so the client connects by the name on the certificate
7
+ # and the server binds an address that name resolves to. Dialling 127.0.0.1
8
+ # against a certificate issued for localhost fails verification, and on macOS
9
+ # localhost resolves to ::1 first, so binding 127.0.0.1 is unreachable. Run
10
+ # `bake localhost:install` once so the localhost CA is trusted.
11
+ #
12
+ # Read Failed before Req/s. A server that sheds reports better latency than one
13
+ # that serves, because rejecting is quicker than answering.
14
+ #
15
+ # STREAMS is the only knob that loads the server. With one connection and one
16
+ # stream nothing is ever concurrent, so Req/s is just 1000 / p50.
17
+ #
18
+ # REQUESTS=2000 latency, one request at a time
19
+ # STREAMS=10 REQUESTS=2000 concurrent, inside capacity
20
+ # STREAMS=50 REQUESTS=5000 concurrent, above the advertised
21
+ # stream limit: QUIC makes the
22
+ # extra streams wait
23
+ # WORKERS=50 STREAMS=50 REQUESTS=5000 capacity matched to the load
24
+ # WORKLOAD=big REQUESTS=50 large bodies
25
+ # WORKLOAD=sleep SLEEP_SECONDS=0.1 REQUESTS=100 slow handler
26
+ #
27
+ # Capacity is WORKERS threads plus a queue of WORKERS * 4, and that total is
28
+ # what the server advertises as its stream limit. Ten requests is a warm-up,
29
+ # not a result: two runs of ten differed by 48% here.
30
+ #
31
+ # HOST name to connect to, must match the certificate (default localhost)
32
+ # ADDRESS address to bind (default ::1, what localhost resolves to first)
33
+
34
+ require 'bundler/inline'
35
+
36
+ gemfile do
37
+ source 'https://rubygems.org'
38
+ gem "rails"
39
+ gem "localhost"
40
+ gem "falcon"
41
+ gem "puma"
42
+ gem "protocol-http"
43
+ end
44
+
45
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
46
+
47
+ require "benchmark"
48
+ require "localhost/authority"
49
+ require "quicsilver"
50
+ require_relative "helpers"
51
+
52
+ REQUESTS = Integer(ENV.fetch("REQUESTS", "10"))
53
+ CONNECTIONS = Integer(ENV.fetch("CONNECTIONS", "1"))
54
+ STREAMS = Integer(ENV.fetch("STREAMS", "1"))
55
+ WORKERS = Integer(ENV.fetch("WORKERS", "5"))
56
+ WORKLOAD = ENV.fetch("WORKLOAD", "tiny")
57
+ SLEEP_SECONDS = Float(ENV.fetch("SLEEP_SECONDS", "0.1"))
58
+ HOST = ENV.fetch("HOST", "localhost")
59
+ PORT = Integer(ENV.fetch("PORT", Benchmarks.random_port.to_s))
60
+ PATH = Benchmarks.path_for(WORKLOAD)
61
+ APP = Benchmarks.rails_app(sleep_seconds: SLEEP_SECONDS, secret_key_base: "quicsilver-benchmark")
62
+
63
+ def start_server
64
+ authority = Localhost::Authority.fetch
65
+ config = Quicsilver::Transport::Configuration.new(authority.certificate_path, authority.key_path)
66
+ server = Quicsilver::Server.new(
67
+ PORT,
68
+ address: ENV.fetch("ADDRESS", "::1"),
69
+ app: APP,
70
+ server_configuration: config,
71
+ threads: WORKERS,
72
+ )
73
+
74
+ thread = Thread.new { server.start }
75
+ thread.abort_on_exception = true
76
+ wait_for_server(thread)
77
+ [server, thread]
78
+ end
79
+
80
+ def wait_for_server(thread)
81
+ deadline = Time.now + 10
82
+
83
+ while Time.now < deadline
84
+ abort "server exited while booting" unless thread.alive?
85
+
86
+ begin
87
+ client = Quicsilver::Client.new(HOST, PORT, connection_timeout: 500, request_timeout: 1)
88
+ client.open_connection
89
+ response = client.get("/")
90
+ return if response&.status == 200
91
+ warn "readiness check returned HTTP #{response&.status}: #{response&.body&.byteslice(0, 120).inspect}" if ENV["DEBUG"]
92
+ rescue StandardError => error
93
+ warn "readiness check failed: #{error.class}: #{error.message}" if ENV["DEBUG"]
94
+ sleep 0.1
95
+ ensure
96
+ client&.disconnect rescue nil
97
+ end
98
+ end
99
+
100
+ abort "server failed to boot: quicsilver://127.0.0.1:#{PORT}"
101
+ end
102
+
103
+ def stop_server(server, thread)
104
+ server&.stop rescue nil
105
+ thread&.join(5)
106
+ end
107
+
108
+ def start_request(connection, path)
109
+ connection.get(path) { |request| request }
110
+ end
111
+
112
+ def finish_request(request)
113
+ request.response
114
+ end
115
+
116
+ def request(connection, path)
117
+ finish_request(start_request(connection, path))
118
+ end
119
+
120
+ def open_connections
121
+ Array.new(CONNECTIONS) do
122
+ Quicsilver::Client.new(HOST, PORT, connection_timeout: 5000, request_timeout: 10).tap(&:open_connection)
123
+ end
124
+ end
125
+
126
+ def measure
127
+ connections = open_connections
128
+ # Untimed request per connection to warm Rails and establish steady connection state.
129
+ connections.each { |connection| request(connection, PATH) }
130
+
131
+ times = []
132
+ failed = 0
133
+
134
+ elapsed = Benchmark.realtime do
135
+ remaining = REQUESTS
136
+
137
+ while remaining.positive?
138
+ batch = []
139
+
140
+ connections.each do |connection|
141
+ STREAMS.times do
142
+ break if batch.length >= remaining
143
+
144
+ batch << [Benchmarks.now, start_request(connection, PATH)]
145
+ end
146
+ end
147
+
148
+ batch.each do |started_at, pending_request|
149
+ finish_request(pending_request)&.status == 200 ? times << (Benchmarks.now - started_at) : failed += 1
150
+ rescue StandardError
151
+ failed += 1
152
+ end
153
+
154
+ remaining -= batch.length
155
+ end
156
+ end
157
+
158
+ Benchmarks::Result.new(
159
+ requests: REQUESTS,
160
+ connections: CONNECTIONS,
161
+ streams: STREAMS,
162
+ times: times,
163
+ failed: failed,
164
+ elapsed: elapsed,
165
+ )
166
+ ensure
167
+ connections&.each { |connection| connection.disconnect rescue nil }
168
+ end
169
+
170
+ begin
171
+ server, thread = start_server
172
+ Benchmarks.print_result("Quicsilver Rails baseline", measure, path: PATH, workers: WORKERS)
173
+ ensure
174
+ stop_server(server, thread)
175
+ end