quicsilver 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/cibuildgem.yaml +93 -0
- data/.gitignore +3 -1
- data/CHANGELOG.md +32 -0
- data/Gemfile.lock +20 -2
- data/README.md +92 -29
- data/Rakefile +67 -2
- data/benchmarks/concurrent.rb +2 -2
- data/benchmarks/rails.rb +3 -3
- data/benchmarks/throughput.rb +2 -2
- data/examples/README.md +44 -91
- data/examples/benchmark.rb +111 -0
- data/examples/connection_pool_demo.rb +47 -0
- data/examples/example_helper.rb +18 -0
- data/examples/falcon_middleware.rb +44 -0
- data/examples/feature_demo.rb +125 -0
- data/examples/grpc_style.rb +97 -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 +260 -0
- data/examples/simple_client_test.rb +2 -2
- data/examples/streaming_sse.rb +33 -0
- data/examples/trailers.rb +69 -0
- data/ext/quicsilver/extconf.rb +14 -0
- data/ext/quicsilver/quicsilver.c +39 -0
- data/lib/quicsilver/client/client.rb +138 -39
- data/lib/quicsilver/client/connection_pool.rb +106 -0
- data/lib/quicsilver/libmsquic.2.dylib +0 -0
- data/lib/quicsilver/protocol/adapter.rb +176 -0
- data/lib/quicsilver/protocol/control_stream_parser.rb +106 -0
- data/lib/quicsilver/protocol/frame_parser.rb +142 -0
- data/lib/quicsilver/protocol/frame_reader.rb +55 -0
- data/lib/quicsilver/protocol/frames.rb +18 -7
- data/lib/quicsilver/protocol/priority.rb +56 -0
- data/lib/quicsilver/protocol/qpack/encoder.rb +39 -1
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
- data/lib/quicsilver/protocol/request_parser.rb +28 -140
- data/lib/quicsilver/protocol/response_encoder.rb +27 -2
- data/lib/quicsilver/protocol/response_parser.rb +22 -130
- data/lib/quicsilver/protocol/stream_input.rb +98 -0
- data/lib/quicsilver/protocol/stream_output.rb +59 -0
- data/lib/quicsilver/quicsilver.bundle +0 -0
- data/lib/quicsilver/server/request_handler.rb +96 -44
- data/lib/quicsilver/server/server.rb +316 -42
- data/lib/quicsilver/transport/configuration.rb +10 -1
- data/lib/quicsilver/transport/connection.rb +92 -63
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +26 -3
- data/quicsilver.gemspec +10 -2
- metadata +69 -5
- data/examples/setup_certs.sh +0 -57
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example: Running a Rack app with protocol-http as the internal layer
|
|
5
|
+
#
|
|
6
|
+
# quicsilver uses protocol-http Request/Response internally.
|
|
7
|
+
# Rack apps are automatically wrapped — no code changes needed.
|
|
8
|
+
#
|
|
9
|
+
# Modes:
|
|
10
|
+
# :rack (default) — Rack app, wrapped with Protocol::Rack::Adapter
|
|
11
|
+
# :falcon — native protocol-http app, no wrapping
|
|
12
|
+
|
|
13
|
+
require_relative "example_helper"
|
|
14
|
+
|
|
15
|
+
# Any standard Rack app works
|
|
16
|
+
app = ->(env) {
|
|
17
|
+
puts "#{env['REQUEST_METHOD']} #{env['PATH_INFO']} #{env['SERVER_PROTOCOL']}"
|
|
18
|
+
|
|
19
|
+
body = "Hello from Quicsilver!\n" \
|
|
20
|
+
"Method: #{env['REQUEST_METHOD']}\n" \
|
|
21
|
+
"Path: #{env['PATH_INFO']}\n" \
|
|
22
|
+
"Protocol: #{env['SERVER_PROTOCOL']}\n"
|
|
23
|
+
|
|
24
|
+
[200, { "content-type" => "text/plain" }, [body]]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
server = Quicsilver::Server.new(4433, app: app, server_configuration: EXAMPLE_TLS_CONFIG)
|
|
28
|
+
|
|
29
|
+
puts "Starting Quicsilver on https://localhost:4433"
|
|
30
|
+
puts "Test with: curl --http3 -k https://localhost:4433/"
|
|
31
|
+
server.start
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "quicsilver"
|
|
3
|
+
require_relative "example_helper"
|
|
5
4
|
|
|
6
5
|
puts "🚀 Rack HTTP/3 Server Example"
|
|
7
6
|
puts "=" * 40
|
|
@@ -31,24 +30,13 @@ app = ->(env) {
|
|
|
31
30
|
end
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
server = Quicsilver::Server.new(4433, app: app)
|
|
33
|
+
server = Quicsilver::Server.new(4433, app: app, server_configuration: EXAMPLE_TLS_CONFIG)
|
|
36
34
|
|
|
37
|
-
puts "🔧 Starting server..."
|
|
38
|
-
server.start
|
|
39
|
-
|
|
40
|
-
puts "✅ Server is running on port 4433"
|
|
41
35
|
puts "📋 Try these requests:"
|
|
42
|
-
puts " curl --http3 -k https://
|
|
43
|
-
puts " curl --http3 -k https://
|
|
44
|
-
puts " curl --http3 -k https://
|
|
36
|
+
puts " curl --http3 -k https://localhost:4433/"
|
|
37
|
+
puts " curl --http3 -k https://localhost:4433/api/users"
|
|
38
|
+
puts " curl --http3 -k https://localhost:4433/api/status"
|
|
39
|
+
puts "⏳ Press Ctrl+C to stop."
|
|
40
|
+
puts
|
|
45
41
|
|
|
46
|
-
|
|
47
|
-
puts "⏳ Server is running. Press Ctrl+C to stop..."
|
|
48
|
-
begin
|
|
49
|
-
server.wait_for_connections
|
|
50
|
-
rescue Interrupt
|
|
51
|
-
puts "\n🛑 Stopping server..."
|
|
52
|
-
server.stop
|
|
53
|
-
puts "👋 Server stopped"
|
|
54
|
-
end
|
|
42
|
+
server.start
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# Tests all major quicsilver features against a running Rails app.
|
|
4
|
+
#
|
|
5
|
+
# Prerequisites:
|
|
6
|
+
# 1. Start the Rails app: cd blogz && bundle exec rackup -s quicsilver -p 4433
|
|
7
|
+
# 2. Run this script: cd quicsilver && bundle exec ruby examples/rails_feature_test.rb
|
|
8
|
+
#
|
|
9
|
+
# Tests: ping, streaming, priorities, echo, HEAD, multiplexing, connection pooling
|
|
10
|
+
|
|
11
|
+
require "bundler/setup"
|
|
12
|
+
require "quicsilver"
|
|
13
|
+
|
|
14
|
+
HOST = "localhost"
|
|
15
|
+
PORT = 4433
|
|
16
|
+
PASS = "✅"
|
|
17
|
+
FAIL = "❌"
|
|
18
|
+
|
|
19
|
+
def test(name)
|
|
20
|
+
result = yield
|
|
21
|
+
puts " #{PASS} #{name}"
|
|
22
|
+
result
|
|
23
|
+
rescue => e
|
|
24
|
+
puts " #{FAIL} #{name}: #{e.message}"
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
puts "🚀 Quicsilver Rails Feature Test"
|
|
29
|
+
puts " Target: https://#{HOST}:#{PORT}"
|
|
30
|
+
puts "=" * 60
|
|
31
|
+
|
|
32
|
+
# === 1. Basic connectivity ===
|
|
33
|
+
puts "\n1️⃣ Basic Connectivity"
|
|
34
|
+
puts "-" * 60
|
|
35
|
+
|
|
36
|
+
test("GET /h3/ping returns JSON over HTTP/3") do
|
|
37
|
+
response = Quicsilver::Client.get(HOST, PORT, "/h3/ping", unsecure: true)
|
|
38
|
+
raise "Expected 200, got #{response[:status]}" unless response[:status] == 200
|
|
39
|
+
raise "Expected JSON" unless response[:body].include?('"status":"ok"')
|
|
40
|
+
raise "Expected HTTP/3" unless response[:body].include?("HTTP/3")
|
|
41
|
+
puts " Response: #{response[:body]}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# === 2. Connection Pooling ===
|
|
45
|
+
puts "\n2️⃣ Connection Pooling"
|
|
46
|
+
puts "-" * 60
|
|
47
|
+
|
|
48
|
+
test("Multiple requests reuse the same connection") do
|
|
49
|
+
5.times do |i|
|
|
50
|
+
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
51
|
+
Quicsilver::Client.get(HOST, PORT, "/h3/ping", unsecure: true)
|
|
52
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
|
|
53
|
+
puts " Request #{i}: #{elapsed}ms (#{i == 0 ? 'new connection' : 'same connection'})"
|
|
54
|
+
end
|
|
55
|
+
puts " Pool: #{Quicsilver::Client.pool.size} connection(s)"
|
|
56
|
+
raise "Pool should have 1 connection" unless Quicsilver::Client.pool.size == 1
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# === 3. Streaming ===
|
|
60
|
+
puts "\n3️⃣ Streaming Response"
|
|
61
|
+
puts "-" * 60
|
|
62
|
+
|
|
63
|
+
test("GET /h3/stream returns chunked SSE data") do
|
|
64
|
+
response = Quicsilver::Client.get(HOST, PORT, "/h3/stream", unsecure: true)
|
|
65
|
+
raise "Expected 200" unless response[:status] == 200
|
|
66
|
+
chunks = response[:body].scan(/data: chunk \d+/)
|
|
67
|
+
raise "Expected 5 chunks, got #{chunks.size}" unless chunks.size == 5
|
|
68
|
+
puts " Received #{chunks.size} streamed chunks"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# === 4. POST with Body (Echo) ===
|
|
72
|
+
puts "\n4️⃣ POST with Body"
|
|
73
|
+
puts "-" * 60
|
|
74
|
+
|
|
75
|
+
test("POST /h3/echo returns the request body") do
|
|
76
|
+
body = '{"message": "Hello HTTP/3!"}'
|
|
77
|
+
response = Quicsilver::Client.post(HOST, PORT, "/h3/echo",
|
|
78
|
+
body: body,
|
|
79
|
+
headers: { "content-type" => "application/json" },
|
|
80
|
+
unsecure: true)
|
|
81
|
+
raise "Expected 200" unless response[:status] == 200
|
|
82
|
+
raise "Body not echoed" unless response[:body].include?("Hello HTTP/3!")
|
|
83
|
+
puts " Echoed: #{response[:body][0..80]}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# === 5. HEAD Request ===
|
|
87
|
+
puts "\n5️⃣ HEAD Request"
|
|
88
|
+
puts "-" * 60
|
|
89
|
+
|
|
90
|
+
test("HEAD /h3/head returns headers but no body") do
|
|
91
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
92
|
+
response = client.head("/h3/head")
|
|
93
|
+
raise "Expected 200" unless response[:status] == 200
|
|
94
|
+
raise "HEAD should have empty body" unless response[:body].nil? || response[:body].empty?
|
|
95
|
+
puts " Status: #{response[:status]}, body size: #{response[:body]&.bytesize || 0}"
|
|
96
|
+
client.disconnect
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# === 6. Multiplexing (Sequential on single connection) ===
|
|
100
|
+
puts "\n6️⃣ Multiplexing (10 requests, single connection)"
|
|
101
|
+
puts "-" * 60
|
|
102
|
+
|
|
103
|
+
test("10 sequential requests on one connection") do
|
|
104
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
105
|
+
t_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
106
|
+
10.times { client.get("/h3/ping") }
|
|
107
|
+
total = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t_start) * 1000).round(1)
|
|
108
|
+
puts " 10 requests in #{total}ms (single connection)"
|
|
109
|
+
client.disconnect
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# === 7. Priority Endpoints (CSS vs Image) ===
|
|
113
|
+
puts "\n7️⃣ Priority Endpoints"
|
|
114
|
+
puts "-" * 60
|
|
115
|
+
|
|
116
|
+
test("GET /h3/css returns CSS content") do
|
|
117
|
+
response = Quicsilver::Client.get(HOST, PORT, "/h3/css", unsecure: true)
|
|
118
|
+
raise "Expected 200" unless response[:status] == 200
|
|
119
|
+
puts " CSS: #{response[:body].bytesize} bytes"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
test("GET /h3/image returns large binary payload") do
|
|
123
|
+
response = Quicsilver::Client.get(HOST, PORT, "/h3/image", unsecure: true)
|
|
124
|
+
raise "Expected 200" unless response[:status] == 200
|
|
125
|
+
raise "Expected 50KB" unless response[:body].bytesize == 50_000
|
|
126
|
+
puts " Image: #{response[:body].bytesize} bytes"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# === 8. Slow Response (Delay) ===
|
|
130
|
+
puts "\n8️⃣ Slow Response"
|
|
131
|
+
puts "-" * 60
|
|
132
|
+
|
|
133
|
+
test("GET /h3/slow?delay=0.2 respects delay") do
|
|
134
|
+
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
135
|
+
response = Quicsilver::Client.get(HOST, PORT, "/h3/slow?delay=0.2", unsecure: true)
|
|
136
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
|
|
137
|
+
raise "Expected 200" unless response[:status] == 200
|
|
138
|
+
raise "Should take ~200ms, took #{elapsed}ms" unless elapsed > 150
|
|
139
|
+
puts " Delayed response in #{elapsed}ms"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# === 9. Concurrent Multiplexing ===
|
|
143
|
+
puts "\n9️⃣ Concurrent Multiplexing"
|
|
144
|
+
puts "-" * 60
|
|
145
|
+
|
|
146
|
+
test("4 concurrent requests on separate connections") do
|
|
147
|
+
threads = 4.times.map do |i|
|
|
148
|
+
Thread.new do
|
|
149
|
+
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
150
|
+
response = Quicsilver::Client.get(HOST, PORT, "/h3/slow?delay=0.2", unsecure: true)
|
|
151
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
|
|
152
|
+
[i, response[:status], elapsed]
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
results = threads.map(&:value).sort_by(&:first)
|
|
156
|
+
results.each { |i, status, elapsed| puts " Stream #{i}: #{status} — #{elapsed}ms" }
|
|
157
|
+
total = results.map { |_, _, e| e }.max
|
|
158
|
+
# All 4 should complete in roughly the same time (~200ms) not 4x (800ms)
|
|
159
|
+
raise "Expected concurrent, took #{total}ms" if total > 600
|
|
160
|
+
puts " All 4 concurrent in #{total}ms (not #{200 * 4}ms sequential)"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# === 10. Large Upload ===
|
|
164
|
+
puts "\n🔟 Large Upload"
|
|
165
|
+
puts "-" * 60
|
|
166
|
+
|
|
167
|
+
test("POST 100KB body") do
|
|
168
|
+
big_body = "x" * 100_000
|
|
169
|
+
response = Quicsilver::Client.post(HOST, PORT, "/h3/upload",
|
|
170
|
+
body: big_body,
|
|
171
|
+
headers: { "content-type" => "application/octet-stream" },
|
|
172
|
+
unsecure: true)
|
|
173
|
+
raise "Expected 200" unless response[:status] == 200
|
|
174
|
+
raise "Size mismatch" unless response[:body].include?('"received_bytes":100000')
|
|
175
|
+
puts " Uploaded 100KB, server received 100,000 bytes"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# === 11. Content-Length Validation ===
|
|
179
|
+
puts "\n1️⃣1️⃣ Content-Length Validation"
|
|
180
|
+
puts "-" * 60
|
|
181
|
+
|
|
182
|
+
test("POST with content-length header") do
|
|
183
|
+
body = "hello"
|
|
184
|
+
response = Quicsilver::Client.post(HOST, PORT, "/h3/upload",
|
|
185
|
+
body: body,
|
|
186
|
+
headers: { "content-type" => "text/plain" },
|
|
187
|
+
unsecure: true)
|
|
188
|
+
raise "Expected 200, got #{response[:status]}" unless response[:status] == 200
|
|
189
|
+
raise "Size mismatch" unless response[:body].include?('"received_bytes":5')
|
|
190
|
+
puts " Sent 5 bytes, server received 5 bytes"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# === 12. 0-RTT Reconnection ===
|
|
194
|
+
puts "\n1️⃣2️⃣ 0-RTT Reconnection"
|
|
195
|
+
puts "-" * 60
|
|
196
|
+
|
|
197
|
+
test("Two separate connections show handshake cost") do
|
|
198
|
+
Quicsilver::Client.close_pool
|
|
199
|
+
|
|
200
|
+
# First connection — full handshake
|
|
201
|
+
client1 = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
202
|
+
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
203
|
+
client1.get("/h3/ping")
|
|
204
|
+
first = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t1) * 1000).round(1)
|
|
205
|
+
client1.disconnect
|
|
206
|
+
|
|
207
|
+
sleep 0.1
|
|
208
|
+
|
|
209
|
+
# Second connection — new handshake (0-RTT depends on MsQuic session cache)
|
|
210
|
+
client2 = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
211
|
+
t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
212
|
+
client2.get("/h3/ping")
|
|
213
|
+
second = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t2) * 1000).round(1)
|
|
214
|
+
client2.disconnect
|
|
215
|
+
|
|
216
|
+
puts " First connection: #{first}ms"
|
|
217
|
+
puts " Second connection: #{second}ms"
|
|
218
|
+
puts " Pooling saves: ~#{first}ms per request by avoiding handshakes"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# === 13. GOAWAY Graceful Shutdown ===
|
|
222
|
+
puts "\n1️⃣3️⃣ GOAWAY (server keeps serving during test)"
|
|
223
|
+
puts "-" * 60
|
|
224
|
+
|
|
225
|
+
test("Server responds after many requests (no GOAWAY yet)") do
|
|
226
|
+
response = Quicsilver::Client.get(HOST, PORT, "/h3/ping", unsecure: true)
|
|
227
|
+
raise "Expected 200" unless response[:status] == 200
|
|
228
|
+
puts " Server still healthy after all tests"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# === 14. Rails CRUD ===
|
|
232
|
+
puts "\n1️⃣4️⃣ Rails CRUD"
|
|
233
|
+
puts "-" * 60
|
|
234
|
+
|
|
235
|
+
test("GET /posts.json returns posts array") do
|
|
236
|
+
response = Quicsilver::Client.get(HOST, PORT, "/posts.json", unsecure: true)
|
|
237
|
+
raise "Expected 200" unless response[:status] == 200
|
|
238
|
+
puts " Posts: #{response[:body][0..40]}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# === 15. HTTP Methods ===
|
|
242
|
+
puts "\n1️⃣5️⃣ HTTP Methods"
|
|
243
|
+
puts "-" * 60
|
|
244
|
+
|
|
245
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
246
|
+
%i[get post put patch delete head].each do |method|
|
|
247
|
+
test("#{method.to_s.upcase} /h3/ping") do
|
|
248
|
+
response = client.public_send(method, "/h3/ping")
|
|
249
|
+
body_size = response[:body]&.bytesize || 0
|
|
250
|
+
puts " #{response[:status]} (#{body_size} bytes)"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
client.disconnect
|
|
254
|
+
|
|
255
|
+
# === Summary ===
|
|
256
|
+
puts "\n" + "=" * 60
|
|
257
|
+
puts "🏁 Feature test complete!"
|
|
258
|
+
puts " Pool: #{Quicsilver::Client.pool.size} connection(s)"
|
|
259
|
+
Quicsilver::Client.close_pool
|
|
260
|
+
puts "👋 Done"
|
|
@@ -7,11 +7,11 @@ puts "🔌 Simple HTTP/3 Client Test"
|
|
|
7
7
|
puts "=" * 40
|
|
8
8
|
|
|
9
9
|
begin
|
|
10
|
-
client = Quicsilver::Client.new("
|
|
10
|
+
client = Quicsilver::Client.new("localhost", 4433, unsecure: true)
|
|
11
11
|
|
|
12
12
|
client.connect
|
|
13
13
|
|
|
14
|
-
response = client.get("/
|
|
14
|
+
response = client.get("/")
|
|
15
15
|
|
|
16
16
|
puts "Status: #{response[:status]}"
|
|
17
17
|
puts "Headers: #{response[:headers].inspect}"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# Server-Sent Events over HTTP/3.
|
|
4
|
+
#
|
|
5
|
+
# Demonstrates streaming responses — data arrives chunk by chunk,
|
|
6
|
+
# not buffered. Each chunk is sent as a separate HTTP/3 DATA frame.
|
|
7
|
+
#
|
|
8
|
+
# ruby examples/streaming_sse.rb
|
|
9
|
+
# curl --http3-only -k https://localhost:4433/
|
|
10
|
+
|
|
11
|
+
require_relative "example_helper"
|
|
12
|
+
|
|
13
|
+
app = ->(env) {
|
|
14
|
+
body = Enumerator.new do |y|
|
|
15
|
+
10.times do |i|
|
|
16
|
+
y << "data: {\"count\":#{i},\"time\":\"#{Time.now.iso8601}\"}\n\n"
|
|
17
|
+
sleep 0.2
|
|
18
|
+
end
|
|
19
|
+
y << "data: {\"done\":true}\n\n"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
[200, { "content-type" => "text/event-stream", "cache-control" => "no-cache" }, body]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
server = Quicsilver::Server.new(4433, app: app, server_configuration: EXAMPLE_TLS_CONFIG)
|
|
26
|
+
|
|
27
|
+
puts "📡 Streaming SSE over HTTP/3"
|
|
28
|
+
puts " https://localhost:4433"
|
|
29
|
+
puts " curl --http3-only -k https://localhost:4433/"
|
|
30
|
+
puts " Events arrive every 200ms — no buffering."
|
|
31
|
+
puts
|
|
32
|
+
|
|
33
|
+
server.start
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# HTTP/3 Trailers — send headers after the body.
|
|
4
|
+
#
|
|
5
|
+
# Trailers let the server report status after streaming is complete.
|
|
6
|
+
# Useful for checksums, streaming error status, and gRPC.
|
|
7
|
+
#
|
|
8
|
+
# ruby examples/trailers.rb
|
|
9
|
+
|
|
10
|
+
require_relative "example_helper"
|
|
11
|
+
|
|
12
|
+
PORT = 4433
|
|
13
|
+
HOST = "localhost"
|
|
14
|
+
|
|
15
|
+
app = ->(env) {
|
|
16
|
+
path = env["PATH_INFO"]
|
|
17
|
+
|
|
18
|
+
case path
|
|
19
|
+
when "/stream-with-checksum"
|
|
20
|
+
# Stream data, then send a checksum trailer
|
|
21
|
+
body = ["chunk1\n", "chunk2\n", "chunk3\n"]
|
|
22
|
+
checksum = Digest::SHA256.hexdigest(body.join)[0..7]
|
|
23
|
+
|
|
24
|
+
[200,
|
|
25
|
+
{ "content-type" => "text/plain", "trailer" => "x-checksum" },
|
|
26
|
+
body]
|
|
27
|
+
# Note: trailers via Rack need the protocol-rack convention
|
|
28
|
+
# (see autoresearch.ideas.md for the Samuel Williams discussion)
|
|
29
|
+
|
|
30
|
+
when "/grpc-style"
|
|
31
|
+
# gRPC-style response: status comes in trailers, not headers
|
|
32
|
+
body = ['{"result":"processed"}']
|
|
33
|
+
|
|
34
|
+
[200,
|
|
35
|
+
{ "content-type" => "application/json" },
|
|
36
|
+
body]
|
|
37
|
+
|
|
38
|
+
else
|
|
39
|
+
[200, { "content-type" => "text/plain" }, ["Try /stream-with-checksum or /grpc-style"]]
|
|
40
|
+
end
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
server = Quicsilver::Server.new(PORT, app: app, server_configuration: EXAMPLE_TLS_CONFIG)
|
|
44
|
+
server_thread = Thread.new { server.start }
|
|
45
|
+
sleep 0.3
|
|
46
|
+
|
|
47
|
+
puts "📎 HTTP/3 Trailers Demo"
|
|
48
|
+
puts "=" * 50
|
|
49
|
+
|
|
50
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
51
|
+
|
|
52
|
+
puts "\n Streaming with checksum trailer:"
|
|
53
|
+
response = client.get("/stream-with-checksum")
|
|
54
|
+
puts " Status: #{response[:status]}"
|
|
55
|
+
puts " Body: #{response[:body].inspect}"
|
|
56
|
+
|
|
57
|
+
puts "\n gRPC-style response:"
|
|
58
|
+
response = client.get("/grpc-style")
|
|
59
|
+
puts " Status: #{response[:status]}"
|
|
60
|
+
puts " Body: #{response[:body]}"
|
|
61
|
+
|
|
62
|
+
puts "\n Trailer support is built into the protocol layer."
|
|
63
|
+
puts " ResponseEncoder can send trailers after DATA frames."
|
|
64
|
+
puts " Full Rack integration needs protocol-rack update."
|
|
65
|
+
|
|
66
|
+
client.disconnect
|
|
67
|
+
server.stop
|
|
68
|
+
server_thread.join(2)
|
|
69
|
+
puts "\n✅ Done"
|
data/ext/quicsilver/extconf.rb
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
require 'mkmf'
|
|
2
2
|
|
|
3
|
+
# Skip compilation if precompiled binary is already present
|
|
4
|
+
ext_dir = File.expand_path('../../lib/quicsilver', __dir__)
|
|
5
|
+
if File.exist?(File.join(ext_dir, 'quicsilver.bundle')) || File.exist?(File.join(ext_dir, 'quicsilver.so'))
|
|
6
|
+
File.write('Makefile', "install:\n\t@echo 'Using precompiled binary'\n\nall:\n\t@echo 'Using precompiled binary'\n")
|
|
7
|
+
exit
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# On macOS, use Apple clang if available. Homebrew clang can't find
|
|
11
|
+
# system headers and produces broken binaries with MsQuic.
|
|
12
|
+
if RUBY_PLATFORM =~ /darwin/ && File.exist?("/usr/bin/clang")
|
|
13
|
+
RbConfig::CONFIG["CC"] = "/usr/bin/clang"
|
|
14
|
+
RbConfig::MAKEFILE_CONFIG["CC"] = "/usr/bin/clang"
|
|
15
|
+
end
|
|
16
|
+
|
|
3
17
|
# Find MSQUIC in the submodule
|
|
4
18
|
msquic_dir = File.expand_path('../../../vendor/msquic', __FILE__)
|
|
5
19
|
|
data/ext/quicsilver/quicsilver.c
CHANGED
|
@@ -79,6 +79,12 @@ typedef struct {
|
|
|
79
79
|
QUIC_STATUS error_status;
|
|
80
80
|
} StreamContext;
|
|
81
81
|
|
|
82
|
+
// Pending stream priorities — set from Ruby threads, applied on MsQuic event thread.
|
|
83
|
+
// Simple array-based storage (max 256 pending). Key = stream handle, value = priority + 1.
|
|
84
|
+
#define MAX_PENDING_PRIORITIES 256
|
|
85
|
+
static struct { HQUIC stream; uint16_t priority_plus_one; } PendingPriorities[MAX_PENDING_PRIORITIES];
|
|
86
|
+
static int PendingPriorityCount = 0;
|
|
87
|
+
|
|
82
88
|
// rb_protect wrapper — catches Ruby exceptions so they don't longjmp
|
|
83
89
|
// through MsQuic callback frames (which would corrupt MsQuic state).
|
|
84
90
|
// All Ruby object construction AND the funcall happen inside rb_protect.
|
|
@@ -273,6 +279,17 @@ StreamCallback(HQUIC Stream, void* Context, QUIC_STREAM_EVENT* Event)
|
|
|
273
279
|
return QUIC_STATUS_SUCCESS;
|
|
274
280
|
}
|
|
275
281
|
|
|
282
|
+
// Apply pending priority on the event loop thread (safe context for SetParam)
|
|
283
|
+
for (int i = 0; i < PendingPriorityCount; i++) {
|
|
284
|
+
if (PendingPriorities[i].stream == Stream) {
|
|
285
|
+
uint16_t priority = PendingPriorities[i].priority_plus_one - 1;
|
|
286
|
+
// Remove by swapping with last
|
|
287
|
+
PendingPriorities[i] = PendingPriorities[--PendingPriorityCount];
|
|
288
|
+
MsQuic->SetParam(Stream, QUIC_PARAM_STREAM_PRIORITY, sizeof(priority), &priority);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
276
293
|
switch (Event->Type) {
|
|
277
294
|
case QUIC_STREAM_EVENT_RECEIVE: {
|
|
278
295
|
int has_fin = (Event->RECEIVE.Flags & QUIC_RECEIVE_FLAG_FIN) != 0;
|
|
@@ -1215,6 +1232,27 @@ quicsilver_stream_reset(VALUE self, VALUE stream_handle, VALUE error_code)
|
|
|
1215
1232
|
return Qtrue;
|
|
1216
1233
|
}
|
|
1217
1234
|
|
|
1235
|
+
// Queue a stream priority change. Called from Ruby threads — just stores the
|
|
1236
|
+
// priority. The actual SetParam happens on the MsQuic event thread in StreamCallback.
|
|
1237
|
+
static VALUE
|
|
1238
|
+
quicsilver_set_stream_priority(VALUE self, VALUE stream_handle, VALUE priority)
|
|
1239
|
+
{
|
|
1240
|
+
if (MsQuic == NULL) return Qnil;
|
|
1241
|
+
|
|
1242
|
+
HQUIC Stream = (HQUIC)(uintptr_t)NUM2ULL(stream_handle);
|
|
1243
|
+
if (Stream == NULL) return Qnil;
|
|
1244
|
+
|
|
1245
|
+
if (PendingPriorityCount >= MAX_PENDING_PRIORITIES) return Qfalse;
|
|
1246
|
+
|
|
1247
|
+
uint16_t Priority = (uint16_t)NUM2UINT(priority);
|
|
1248
|
+
PendingPriorities[PendingPriorityCount].stream = Stream;
|
|
1249
|
+
PendingPriorities[PendingPriorityCount].priority_plus_one = Priority + 1;
|
|
1250
|
+
PendingPriorityCount++;
|
|
1251
|
+
|
|
1252
|
+
wake_event_loop();
|
|
1253
|
+
return Qtrue;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1218
1256
|
// Stop sending on a QUIC stream (STOP_SENDING frame - requests peer to stop)
|
|
1219
1257
|
static VALUE
|
|
1220
1258
|
quicsilver_stream_stop_sending(VALUE self, VALUE stream_handle, VALUE error_code)
|
|
@@ -1277,6 +1315,7 @@ Init_quicsilver(void)
|
|
|
1277
1315
|
rb_define_singleton_method(mQuicsilver, "send_stream", quicsilver_send_stream, 3);
|
|
1278
1316
|
rb_define_singleton_method(mQuicsilver, "stream_reset", quicsilver_stream_reset, 2);
|
|
1279
1317
|
rb_define_singleton_method(mQuicsilver, "stream_stop_sending", quicsilver_stream_stop_sending, 2);
|
|
1318
|
+
rb_define_singleton_method(mQuicsilver, "set_stream_priority", quicsilver_set_stream_priority, 2);
|
|
1280
1319
|
|
|
1281
1320
|
// Event processing (custom execution — app drives MsQuic)
|
|
1282
1321
|
rb_define_singleton_method(mQuicsilver, "poll", quicsilver_poll, 0);
|