quicsilver 0.3.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -1
  3. data/.github/workflows/cibuildgem.yaml +117 -0
  4. data/.gitignore +8 -1
  5. data/CHANGELOG.md +82 -0
  6. data/Gemfile.lock +27 -2
  7. data/README.md +205 -29
  8. data/Rakefile +75 -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/README.md +44 -91
  17. data/examples/benchmark.rb +191 -0
  18. data/examples/connection_pool_demo.rb +47 -0
  19. data/examples/example_helper.rb +69 -0
  20. data/examples/falcon_middleware.rb +44 -0
  21. data/examples/feature_demo.rb +125 -0
  22. data/examples/grpc_style.rb +97 -0
  23. data/examples/latency_aware_routing.rb +382 -0
  24. data/examples/lifecycle_drain.rb +264 -0
  25. data/examples/minimal_http3_server.rb +6 -18
  26. data/examples/priorities.rb +60 -0
  27. data/examples/protocol_http_server.rb +31 -0
  28. data/examples/rack_http3_server.rb +8 -20
  29. data/examples/rails_feature_test.rb +280 -0
  30. data/examples/server_stats.rb +117 -0
  31. data/examples/simple_client_test.rb +5 -5
  32. data/examples/streaming_sse.rb +33 -0
  33. data/examples/trailers.rb +69 -0
  34. data/examples/webtransport_smoke.rb +546 -0
  35. data/examples/worker_load_balancing.rb +425 -0
  36. data/ext/quicsilver/extconf.rb +111 -12
  37. data/ext/quicsilver/include/msquic.h +2226 -0
  38. data/ext/quicsilver/include/msquic_posix.h +696 -0
  39. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  40. data/ext/quicsilver/msquic_patch.rb +68 -0
  41. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  42. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  43. data/ext/quicsilver/quicsilver.c +1646 -189
  44. data/lib/quicsilver/client/client.rb +546 -88
  45. data/lib/quicsilver/client/connection_pool.rb +202 -0
  46. data/lib/quicsilver/client/request.rb +106 -14
  47. data/lib/quicsilver/errors.rb +39 -0
  48. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  49. data/lib/quicsilver/protocol/adapter.rb +196 -0
  50. data/lib/quicsilver/protocol/capsule.rb +38 -0
  51. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  52. data/lib/quicsilver/protocol/control_stream_parser.rb +117 -0
  53. data/lib/quicsilver/protocol/datagram.rb +40 -0
  54. data/lib/quicsilver/protocol/frame_parser.rb +141 -0
  55. data/lib/quicsilver/protocol/frame_reader.rb +84 -0
  56. data/lib/quicsilver/protocol/frames.rb +137 -10
  57. data/lib/quicsilver/protocol/priority.rb +65 -0
  58. data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
  59. data/lib/quicsilver/protocol/qpack/encoder.rb +49 -21
  60. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
  61. data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
  62. data/lib/quicsilver/protocol/request_encoder.rb +6 -3
  63. data/lib/quicsilver/protocol/request_parser.rb +43 -141
  64. data/lib/quicsilver/protocol/response_encoder.rb +40 -5
  65. data/lib/quicsilver/protocol/response_parser.rb +26 -128
  66. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  67. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  68. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  69. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  70. data/lib/quicsilver/rack/context.rb +33 -0
  71. data/lib/quicsilver/response.rb +40 -0
  72. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  73. data/lib/quicsilver/server/receive_queue.rb +106 -0
  74. data/lib/quicsilver/server/request_handler.rb +111 -48
  75. data/lib/quicsilver/server/request_registry.rb +18 -5
  76. data/lib/quicsilver/server/scheduler.rb +50 -0
  77. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  78. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  79. data/lib/quicsilver/server/server.rb +848 -104
  80. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  81. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  82. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  83. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  84. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  85. data/lib/quicsilver/transport/configuration.rb +113 -11
  86. data/lib/quicsilver/transport/connection.rb +376 -132
  87. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  88. data/lib/quicsilver/transport/event_loop.rb +36 -0
  89. data/lib/quicsilver/transport/inbound_stream.rb +21 -1
  90. data/lib/quicsilver/transport/stream.rb +18 -3
  91. data/lib/quicsilver/transport/stream_event.rb +25 -2
  92. data/lib/quicsilver/transport/stream_id.rb +26 -0
  93. data/lib/quicsilver/version.rb +1 -1
  94. data/lib/quicsilver.rb +45 -11
  95. data/lib/rackup/handler/quicsilver.rb +57 -38
  96. data/quicsilver.gemspec +17 -2
  97. metadata +120 -10
  98. data/benchmarks/concurrent.rb +0 -110
  99. data/benchmarks/quicsilver_server.rb +0 -46
  100. data/benchmarks/rails.rb +0 -170
  101. data/benchmarks/throughput.rb +0 -113
  102. data/examples/setup_certs.sh +0 -57
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36065332b2efe43090097b945882282ba770a6b6b5a54d16d7229bf8977dfb6e
4
- data.tar.gz: 22f94375fdaa43b465b6ff9b866985ca7dd06732ea5e0cbf98a94c7c92eb97df
3
+ metadata.gz: 9f3ec155af92a08642dd37953aa75728a021f9868c964c0240a61b98399f72be
4
+ data.tar.gz: 6c5effb85af855cfd60761fc7aef9bdf0cc6901df1c56b478d22557705c55925
5
5
  SHA512:
6
- metadata.gz: edcac1b797654202cf22ef471e3de5cd23a8409f0ded8d940652cb6194d14974f0c08f7118af1f76e1895dca0598ae646764687d0fabb0555317e67a3587f690
7
- data.tar.gz: a1521e2a828ba6bd394939a5c7b1034d1951f92329444f1adee5135db96df9703128b731c673c10c6309d104f238aa95589a9c9cfb581c6e050bc5ef6a261c8f
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.2"]
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
@@ -0,0 +1,117 @@
1
+ name: "Package and release gems with precompiled binaries"
2
+ on:
3
+ workflow_dispatch:
4
+ inputs:
5
+ release:
6
+ description: "If the whole build passes on all platforms, release the gems on RubyGems.org"
7
+ required: false
8
+ type: boolean
9
+ default: false
10
+ env:
11
+ CIBUILDGEM: 1
12
+ RELEASE: "true"
13
+ jobs:
14
+ compile:
15
+ timeout-minutes: 20
16
+ name: "Cross compile the gem on different ruby versions"
17
+ strategy:
18
+ matrix:
19
+ os: ["macos-latest", "ubuntu-22.04"]
20
+ runs-on: "${{ matrix.os }}"
21
+ steps:
22
+ - name: "Checkout code"
23
+ uses: "actions/checkout@v5"
24
+ with:
25
+ submodules: recursive
26
+ - name: "Setup Ruby"
27
+ uses: "ruby/setup-ruby@v1"
28
+ with:
29
+ ruby-version: "3.4"
30
+ bundler-cache: true
31
+ - name: "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/)'
40
+ - name: "Run cibuildgem"
41
+ uses: "shopify/cibuildgem/.github/actions/cibuildgem@main"
42
+ with:
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
58
+ test:
59
+ timeout-minutes: 20
60
+ name: "Run the test suite"
61
+ needs: compile
62
+ strategy:
63
+ matrix:
64
+ os: ["macos-latest", "ubuntu-22.04"]
65
+ rubies: ["3.4", "4.0"]
66
+ type: ["cross"]
67
+ runs-on: "${{ matrix.os }}"
68
+ steps:
69
+ - name: "Checkout code"
70
+ uses: "actions/checkout@v5"
71
+ - name: "Setup Ruby"
72
+ uses: "ruby/setup-ruby@v1"
73
+ with:
74
+ ruby-version: "${{ matrix.rubies }}"
75
+ bundler-cache: true
76
+ - name: "Run cibuildgem"
77
+ uses: "shopify/cibuildgem/.github/actions/cibuildgem@main"
78
+ with:
79
+ step: "test_${{ matrix.type }}"
80
+ install:
81
+ timeout-minutes: 5
82
+ name: "Verify the gem can be installed"
83
+ needs: test
84
+ strategy:
85
+ matrix:
86
+ os: ["macos-latest", "ubuntu-22.04"]
87
+ runs-on: "${{ matrix.os }}"
88
+ steps:
89
+ - name: "Setup Ruby"
90
+ uses: "ruby/setup-ruby@v1"
91
+ with:
92
+ ruby-version: "4.0.0"
93
+ - name: "Download platform gems"
94
+ uses: "actions/download-artifact@v5"
95
+ with:
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
99
+ release:
100
+ environment: release
101
+ permissions:
102
+ id-token: write
103
+ contents: read
104
+ timeout-minutes: 5
105
+ if: ${{ inputs.release }}
106
+ name: "Release all gems with RubyGems"
107
+ needs: install
108
+ runs-on: "ubuntu-latest"
109
+ steps:
110
+ - name: "Setup Ruby"
111
+ uses: "ruby/setup-ruby@v1"
112
+ with:
113
+ ruby-version: "4.0.0"
114
+ - name: "Run cibuildgem"
115
+ uses: "shopify/cibuildgem/.github/actions/cibuildgem@main"
116
+ with:
117
+ step: "release"
data/.gitignore CHANGED
@@ -10,5 +10,12 @@
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
- *.gem
19
+ *.gem
20
+ autoresearch.*
21
+ ext/quicsilver/Makefile
data/CHANGELOG.md CHANGED
@@ -5,6 +5,88 @@ 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
+
58
+ ## [0.4.0] - 2026-04-25
59
+
60
+ ### Added
61
+ - Client connection pool with automatic reuse (`Quicsilver::Client.get/post` class-level API)
62
+ - GREASE support (RFC 9297) — settings, frames, and unidirectional streams
63
+ - GOAWAY validation (RFC 9114 §7.2.6) — monotonically decreasing IDs, stream ID validation
64
+ - Trailer support (RFC 9114 §4.1) — parse and send trailing HEADERS frames
65
+ - Extensible Priorities (RFC 9218) — parse `priority` header, PRIORITY_UPDATE frames on control stream, MsQuic stream priority mapping
66
+ - FrameParser base class — unified frame walking, ordering, body accumulation, size limits
67
+ - FrameReader module — shared byte-level frame extraction for request/response/control streams
68
+ - Trailer wiring in Adapter and StreamOutput for protocol-http integration
69
+ - Informational 1xx responses (§4.1) — 103 Early Hints with `rack.early_hints` support for Rails
70
+ - Two-phase GOAWAY shutdown (§5.2) — server sends decreasing GOAWAY IDs during graceful shutdown
71
+ - Client processes server SETTINGS (§7.2.4) — parses peer's SETTINGS including MAX_FIELD_SECTION_SIZE
72
+ - Client processes server GOAWAY (§5.2) — tracks peer_goaway_id, blocks new requests, connection pool evicts draining connections
73
+ - MIT license in gemspec
74
+
75
+ ### Fixed
76
+ - QPACK prefix decoding — decode Required Insert Count and Delta Base as varints instead of hardcoded `offset = 2`
77
+ - Default decoder rejects payloads referencing the dynamic table
78
+ - Response parser now enforces `max_frame_payload_size` (was missing)
79
+ - Duplicate `frames` method in FrameParser
80
+ - Consistent `@headers` and `@trailers` initialization (`{}` not `nil`)
81
+ - extconf.rb — force Apple clang on macOS (Homebrew clang produces broken MsQuic binaries)
82
+
83
+ ### Changed
84
+ - RequestParser and ResponseParser inherit from FrameParser (reduced ~230 lines of duplication)
85
+ - `store_header`, `body`, `DEFAULT_DECODER`, `EMPTY_BODY`, `parse!` moved to FrameParser base class
86
+ - `@body_io` renamed to `@body` in ResponseParser for consistency
87
+ - ResponseEncoder accepts optional `trailers:` hash
88
+ - StreamOutput accepts `send_fin:` parameter for trailer support
89
+
8
90
  ## [0.3.0] - 2026-03-23
9
91
 
10
92
  ### Added
data/Gemfile.lock CHANGED
@@ -1,9 +1,12 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- quicsilver (0.2.0)
4
+ quicsilver (0.5.0)
5
+ console
5
6
  localhost (~> 1.6)
6
7
  logger
8
+ protocol-http (~> 0.49)
9
+ protocol-rack (~> 0.22)
7
10
  rack (~> 3.0)
8
11
  rackup (~> 2.0)
9
12
 
@@ -11,18 +14,39 @@ GEM
11
14
  remote: https://rubygems.org/
12
15
  specs:
13
16
  benchmark-ips (2.14.0)
17
+ cibuildgem (0.3.0)
18
+ prism
19
+ rake-compiler
20
+ thor
21
+ console (1.34.3)
22
+ fiber-annotation
23
+ fiber-local (~> 1.1)
24
+ json
25
+ fiber-annotation (0.2.0)
26
+ fiber-local (1.1.0)
27
+ fiber-storage
28
+ fiber-storage (1.0.1)
29
+ io-stream (0.11.1)
30
+ json (2.15.2)
14
31
  localhost (1.6.0)
15
32
  logger (1.7.0)
16
33
  minitest (5.27.0)
17
34
  minitest-focus (1.4.0)
18
35
  minitest (>= 4, < 6)
36
+ prism (1.9.0)
37
+ protocol-http (0.60.0)
38
+ protocol-rack (0.22.1)
39
+ io-stream (>= 0.10)
40
+ protocol-http (~> 0.58)
41
+ rack (>= 1.0)
19
42
  rack (3.2.4)
20
43
  rackup (2.2.1)
21
44
  rack (>= 3)
22
- rake (13.3.1)
45
+ rake (13.4.2)
23
46
  rake-compiler (1.3.0)
24
47
  rake
25
48
  rake-compiler-dock (1.9.1)
49
+ thor (1.5.0)
26
50
 
27
51
  PLATFORMS
28
52
  arm64-darwin-23
@@ -31,6 +55,7 @@ PLATFORMS
31
55
  DEPENDENCIES
32
56
  benchmark-ips (~> 2.12)
33
57
  bundler (~> 2.0)
58
+ cibuildgem
34
59
  minitest (~> 5.0)
35
60
  minitest-focus (~> 1.3)
36
61
  quicsilver!
data/README.md CHANGED
@@ -1,13 +1,32 @@
1
1
  # Quicsilver
2
2
 
3
- HTTP/3 server for Ruby with Rack support.
3
+ HTTP/3 server and client for Ruby with Rack support.
4
4
 
5
- **Status:** Experimental (v0.2.0)
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
+
12
+ ## Features
13
+
14
+ - **HTTP/3 server** — serve any Rack app over QUIC/HTTP/3
15
+ - **HTTP/3 client** — make requests with automatic connection pooling
16
+ - **Rack integration** — `rackup -s quicsilver` works with Rails, Sinatra, any Rack app
17
+ - **Streaming** — dispatch on HEADERS, stream body chunks as they arrive
18
+ - **Extensible Priorities** (RFC 9218) — CSS before images, server respects client priority hints
19
+ - **Trailers** (RFC 9114 §4.1) — send/receive trailing headers after the body
20
+ - **GREASE** (RFC 9297) — extensibility testing on settings, frames, and streams
21
+ - **GOAWAY** (RFC 9114 §7.2.6) — graceful connection draining with validation
22
+ - **0-RTT** — fast reconnection with replay protection
23
+ - **Connection pooling** — client reuses connections automatically
24
+ - **Falcon middleware compatible** — use Falcon's caching and content encoding over HTTP/3
6
25
 
7
26
  ## Installation
8
27
 
9
28
  ```bash
10
- git clone <repository>
29
+ git clone https://github.com/hahmed/quicsilver
11
30
  cd quicsilver
12
31
  bundle install
13
32
  rake compile
@@ -19,20 +38,20 @@ rake compile
19
38
 
20
39
  ```ruby
21
40
  require "quicsilver"
41
+ require "localhost/authority"
22
42
 
23
43
  app = ->(env) {
24
- case env['PATH_INFO']
25
- when '/'
26
- [200, {'content-type' => 'text/plain'}, ["Hello HTTP/3!"]]
27
- when '/api/users'
28
- [200, {'content-type' => 'application/json'}, ['{"users": ["alice", "bob"]}']]
29
- else
30
- [404, {'content-type' => 'text/plain'}, ["Not Found"]]
31
- end
44
+ [200, {"content-type" => "text/plain"}, ["Hello HTTP/3!"]]
32
45
  }
33
46
 
34
- server = Quicsilver::Server.new(4433, app: app)
35
- server.start # Blocks until shutdown
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)
54
+ server.start
36
55
  ```
37
56
 
38
57
  ### Client
@@ -40,44 +59,201 @@ server.start # Blocks until shutdown
40
59
  ```ruby
41
60
  require "quicsilver"
42
61
 
43
- client = Quicsilver::Client.new("127.0.0.1", 4433, unsecure: true)
44
- client.connect
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, "/")
65
+ puts response[:status] # => 200
66
+ puts response[:body] # => "Hello HTTP/3!"
45
67
 
46
- response = client.get("/api/users")
47
- puts response[:body]
48
-
49
- response = client.post("/api/users", body: '{"name": "charlie"}')
68
+ # POST with body
69
+ response = Quicsilver::Client.post("localhost", 4433, "/api/users",
70
+ body: '{"name": "alice"}',
71
+ headers: { "content-type" => "application/json" })
50
72
 
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)
76
+ response = client.get("/")
51
77
  client.disconnect
52
78
  ```
53
79
 
54
- ## Usage with Rails
80
+ ### Rails
55
81
 
56
82
  ```bash
57
83
  rackup -s quicsilver -p 4433
58
84
  ```
59
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
+
96
+ ### curl
97
+
98
+ ```bash
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
157
+ ```
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
+
60
186
  ## Configuration
61
187
 
188
+ Pass both certificate paths explicitly. Omitting either path raises a
189
+ configuration error.
190
+
62
191
  ```ruby
63
- config = Quicsilver::ServerConfiguration.new("/path/to/cert.pem", "/path/to/key.pem",
64
- idle_timeout_ms: 10_000, # Connection idle timeout (ms)
65
- max_concurrent_requests: 100 # Max concurrent requests per connection
192
+ config = Quicsilver::Transport::Configuration.new(
193
+ "certificates/server.crt",
194
+ "certificates/server.key",
195
+ idle_timeout_ms: 10_000,
196
+ max_concurrent_requests: 100,
197
+ max_body_size: 10 * 1024 * 1024, # 10MB body limit (optional)
198
+ max_header_size: 64 * 1024, # 64KB header limit (optional)
199
+ max_header_count: 128, # Header count limit (optional)
200
+ stream_receive_window: 262_144, # 256KB per stream
201
+ connection_flow_control_window: 16_777_216 # 16MB per connection
66
202
  )
67
203
 
68
- server = Quicsilver::Server.new(4433,
69
- app: app,
70
- address: "0.0.0.0",
71
- server_configuration: config
204
+ server = Quicsilver::Server.new(4433, app: app, server_configuration: config)
205
+ server.start
206
+ ```
207
+
208
+ ## Priorities
209
+
210
+ Browsers send priority hints on requests. Quicsilver parses them and schedules high-priority streams first.
211
+
212
+ ```
213
+ GET /style.css → priority: u=0 → sent first (highest urgency)
214
+ GET /app.js → priority: u=1 → sent second
215
+ GET /hero.png → priority: u=5 → sent later
216
+ ```
217
+
218
+ No configuration needed — it works automatically.
219
+
220
+ ## Trailers
221
+
222
+ Send headers after the body — useful for checksums, streaming status, and gRPC.
223
+
224
+ ```ruby
225
+ # Trailers work with protocol-http's Headers#trailer! API
226
+ headers = Protocol::HTTP::Headers.new
227
+ headers.add("content-type", "text/plain")
228
+ headers.trailer!
229
+ headers.add("x-checksum", "abc123")
230
+ ```
231
+
232
+ ## Falcon Middleware Mode
233
+
234
+ Use Falcon's middleware stack (caching, content encoding) over HTTP/3. Quicsilver handles the transport, Falcon's middleware handles the request pipeline:
235
+
236
+ ```ruby
237
+ config = Quicsilver::Transport::Configuration.new(
238
+ "certificates/server.crt",
239
+ "certificates/server.key",
240
+ mode: :falcon
72
241
  )
242
+
243
+ server = Quicsilver::Server.new(4433, app: app, server_configuration: config)
244
+ server.start
73
245
  ```
74
246
 
247
+ | Mode | App Interface | Use Case |
248
+ |------|---------------|----------|
249
+ | `:rack` (default) | Rack env hash | Rails, Sinatra, any Rack app |
250
+ | `:falcon` | Protocol::HTTP::Request | Falcon middleware stack |
251
+
75
252
  ## Development
76
253
 
77
254
  ```bash
78
- rake compile # Build C extension
255
+ rake compile # Build C extension (macOS: uses Apple clang automatically)
79
256
  rake test # Run tests
80
- rake clean # Clean build artifacts
81
257
  ```
82
258
 
83
259
  ## License