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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -1
- data/.github/workflows/cibuildgem.yaml +117 -0
- data/.gitignore +8 -1
- data/CHANGELOG.md +82 -0
- data/Gemfile.lock +27 -2
- data/README.md +205 -29
- data/Rakefile +75 -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/README.md +44 -91
- data/examples/benchmark.rb +191 -0
- data/examples/connection_pool_demo.rb +47 -0
- data/examples/example_helper.rb +69 -0
- data/examples/falcon_middleware.rb +44 -0
- data/examples/feature_demo.rb +125 -0
- data/examples/grpc_style.rb +97 -0
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/minimal_http3_server.rb +6 -18
- data/examples/priorities.rb +60 -0
- data/examples/protocol_http_server.rb +31 -0
- data/examples/rack_http3_server.rb +8 -20
- data/examples/rails_feature_test.rb +280 -0
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +5 -5
- data/examples/streaming_sse.rb +33 -0
- data/examples/trailers.rb +69 -0
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +111 -12
- 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 +1646 -189
- data/lib/quicsilver/client/client.rb +546 -88
- data/lib/quicsilver/client/connection_pool.rb +202 -0
- 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 +196 -0
- 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 +117 -0
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +141 -0
- data/lib/quicsilver/protocol/frame_reader.rb +84 -0
- data/lib/quicsilver/protocol/frames.rb +137 -10
- data/lib/quicsilver/protocol/priority.rb +65 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
- data/lib/quicsilver/protocol/qpack/encoder.rb +49 -21
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
- 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 +43 -141
- data/lib/quicsilver/protocol/response_encoder.rb +40 -5
- data/lib/quicsilver/protocol/response_parser.rb +26 -128
- data/lib/quicsilver/protocol/stream_input.rb +98 -0
- data/lib/quicsilver/protocol/stream_output.rb +59 -0
- 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 +111 -48
- 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 +848 -104
- 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 +113 -11
- data/lib/quicsilver/transport/connection.rb +376 -132
- 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 +45 -11
- data/lib/rackup/handler/quicsilver.rb +57 -38
- data/quicsilver.gemspec +17 -2
- metadata +120 -10
- 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/examples/setup_certs.sh +0 -57
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
|
|
@@ -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
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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::
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|