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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -1
- data/.github/workflows/cibuildgem.yaml +29 -5
- data/.gitignore +5 -0
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +8 -1
- data/README.md +128 -15
- data/Rakefile +10 -3
- data/benchmarks/baseline.rb +175 -0
- data/benchmarks/compare.rb +398 -0
- data/benchmarks/helpers.rb +138 -64
- data/benchmarks/latency.rb +128 -0
- data/benchmarks/loss.rb +193 -0
- data/benchmarks/mobile.rb +125 -0
- data/docs/connection_identity.md +141 -0
- data/examples/benchmark.rb +80 -0
- data/examples/connection_pool_demo.rb +1 -1
- data/examples/example_helper.rb +51 -0
- data/examples/feature_demo.rb +7 -7
- data/examples/grpc_style.rb +2 -2
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/priorities.rb +2 -2
- data/examples/rails_feature_test.rb +92 -72
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +3 -3
- data/examples/trailers.rb +23 -23
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +100 -15
- data/ext/quicsilver/include/msquic.h +2226 -0
- data/ext/quicsilver/include/msquic_posix.h +696 -0
- data/ext/quicsilver/include/quic_sal_stub.h +314 -0
- data/ext/quicsilver/msquic_patch.rb +68 -0
- data/ext/quicsilver/patches/reliable-reset.patch +723 -0
- data/ext/quicsilver/patches/stream-final-size.patch +47 -0
- data/ext/quicsilver/quicsilver.c +1640 -222
- data/lib/quicsilver/client/client.rb +419 -60
- data/lib/quicsilver/client/connection_pool.rb +122 -26
- data/lib/quicsilver/client/request.rb +106 -14
- data/lib/quicsilver/errors.rb +39 -0
- data/lib/quicsilver/libmsquic.2.dylib +0 -0
- data/lib/quicsilver/protocol/adapter.rb +41 -21
- data/lib/quicsilver/protocol/capsule.rb +38 -0
- data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
- data/lib/quicsilver/protocol/control_stream_parser.rb +12 -1
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +5 -6
- data/lib/quicsilver/protocol/frame_reader.rb +31 -2
- data/lib/quicsilver/protocol/frames.rb +128 -12
- data/lib/quicsilver/protocol/priority.rb +9 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
- data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
- data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
- data/lib/quicsilver/protocol/request_encoder.rb +6 -3
- data/lib/quicsilver/protocol/request_parser.rb +16 -2
- data/lib/quicsilver/protocol/response_encoder.rb +18 -8
- data/lib/quicsilver/protocol/response_parser.rb +6 -0
- data/lib/quicsilver/protocol/stream_input.rb +3 -3
- data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
- data/lib/quicsilver/protocol/web_transport.rb +214 -0
- data/lib/quicsilver/rack/context.rb +33 -0
- data/lib/quicsilver/response.rb +40 -0
- data/lib/quicsilver/server/rack_adapter.rb +77 -0
- data/lib/quicsilver/server/receive_queue.rb +106 -0
- data/lib/quicsilver/server/request_handler.rb +24 -13
- data/lib/quicsilver/server/request_registry.rb +18 -5
- data/lib/quicsilver/server/scheduler.rb +50 -0
- data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
- data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
- data/lib/quicsilver/server/server.rb +655 -185
- data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
- data/lib/quicsilver/server/web_transport_manager.rb +281 -0
- data/lib/quicsilver/server/web_transport_registry.rb +40 -0
- data/lib/quicsilver/server/web_transport_session.rb +812 -0
- data/lib/quicsilver/server/web_transport_stream.rb +326 -0
- data/lib/quicsilver/transport/configuration.rb +103 -10
- data/lib/quicsilver/transport/connection.rb +302 -87
- data/lib/quicsilver/transport/connection_stats.rb +53 -0
- data/lib/quicsilver/transport/event_loop.rb +36 -0
- data/lib/quicsilver/transport/inbound_stream.rb +21 -1
- data/lib/quicsilver/transport/stream.rb +18 -3
- data/lib/quicsilver/transport/stream_event.rb +25 -2
- data/lib/quicsilver/transport/stream_id.rb +26 -0
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +22 -11
- data/lib/rackup/handler/quicsilver.rb +57 -38
- data/quicsilver.gemspec +10 -3
- metadata +54 -8
- data/benchmarks/concurrent.rb +0 -110
- data/benchmarks/quicsilver_server.rb +0 -46
- data/benchmarks/rails.rb +0 -170
- data/benchmarks/throughput.rb +0 -113
- data/lib/quicsilver/quicsilver.bundle +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f3ec155af92a08642dd37953aa75728a021f9868c964c0240a61b98399f72be
|
|
4
|
+
data.tar.gz: 6c5effb85af855cfd60761fc7aef9bdf0cc6901df1c56b478d22557705c55925
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 26490f0003dc3424abb07afaddd5fa9057d36a3cb2e974be2ea46b740069dc4114f95a46afa8d43e8f48c2d8dcc0a294187e6c9852806a97ae83f6841e01c9d9
|
|
7
|
+
data.tar.gz: 793d530b463494c8df0e6a0268686f0cd3588246300f1ef1763462f770949bbe0c04b20ef55599046486c3fb1383cf7207c9f317d8a0002a7c579b61f95d668a
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
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: "
|
|
72
|
-
uses: "
|
|
93
|
+
- name: "Download platform gems"
|
|
94
|
+
uses: "actions/download-artifact@v5"
|
|
73
95
|
with:
|
|
74
|
-
|
|
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
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
|
+
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
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
232
|
+
## Falcon Middleware Mode
|
|
120
233
|
|
|
121
|
-
|
|
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: :
|
|
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 |
|
|
247
|
+
| Mode | App Interface | Use Case |
|
|
135
248
|
|------|---------------|----------|
|
|
136
|
-
| `:rack` (default) |
|
|
137
|
-
| `:
|
|
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
|
-
#
|
|
11
|
-
|
|
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
|
-
|
|
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
|