quicsilver 0.2.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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +4 -5
  3. data/.github/workflows/cibuildgem.yaml +93 -0
  4. data/.gitignore +3 -1
  5. data/CHANGELOG.md +81 -0
  6. data/Gemfile.lock +26 -4
  7. data/README.md +95 -31
  8. data/Rakefile +95 -3
  9. data/benchmarks/components.rb +191 -0
  10. data/benchmarks/concurrent.rb +110 -0
  11. data/benchmarks/helpers.rb +88 -0
  12. data/benchmarks/quicsilver_server.rb +1 -1
  13. data/benchmarks/rails.rb +170 -0
  14. data/benchmarks/throughput.rb +113 -0
  15. data/examples/README.md +44 -91
  16. data/examples/benchmark.rb +111 -0
  17. data/examples/connection_pool_demo.rb +47 -0
  18. data/examples/example_helper.rb +18 -0
  19. data/examples/falcon_middleware.rb +44 -0
  20. data/examples/feature_demo.rb +125 -0
  21. data/examples/grpc_style.rb +97 -0
  22. data/examples/minimal_http3_server.rb +6 -18
  23. data/examples/priorities.rb +60 -0
  24. data/examples/protocol_http_server.rb +31 -0
  25. data/examples/rack_http3_server.rb +8 -20
  26. data/examples/rails_feature_test.rb +260 -0
  27. data/examples/simple_client_test.rb +2 -2
  28. data/examples/streaming_sse.rb +33 -0
  29. data/examples/trailers.rb +69 -0
  30. data/ext/quicsilver/extconf.rb +14 -0
  31. data/ext/quicsilver/quicsilver.c +568 -181
  32. data/lib/quicsilver/client/client.rb +349 -0
  33. data/lib/quicsilver/client/connection_pool.rb +106 -0
  34. data/lib/quicsilver/client/request.rb +98 -0
  35. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  36. data/lib/quicsilver/protocol/adapter.rb +176 -0
  37. data/lib/quicsilver/protocol/control_stream_parser.rb +106 -0
  38. data/lib/quicsilver/protocol/frame_parser.rb +142 -0
  39. data/lib/quicsilver/protocol/frame_reader.rb +55 -0
  40. data/lib/quicsilver/{http3.rb → protocol/frames.rb} +146 -30
  41. data/lib/quicsilver/protocol/priority.rb +56 -0
  42. data/lib/quicsilver/protocol/qpack/decoder.rb +165 -0
  43. data/lib/quicsilver/protocol/qpack/encoder.rb +227 -0
  44. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +140 -0
  45. data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
  46. data/lib/quicsilver/protocol/request_encoder.rb +47 -0
  47. data/lib/quicsilver/protocol/request_parser.rb +275 -0
  48. data/lib/quicsilver/protocol/response_encoder.rb +97 -0
  49. data/lib/quicsilver/protocol/response_parser.rb +141 -0
  50. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  51. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  52. data/lib/quicsilver/quicsilver.bundle +0 -0
  53. data/lib/quicsilver/server/listener_data.rb +14 -0
  54. data/lib/quicsilver/server/request_handler.rb +138 -0
  55. data/lib/quicsilver/server/request_registry.rb +50 -0
  56. data/lib/quicsilver/server/server.rb +610 -0
  57. data/lib/quicsilver/transport/configuration.rb +141 -0
  58. data/lib/quicsilver/transport/connection.rb +379 -0
  59. data/lib/quicsilver/transport/event_loop.rb +38 -0
  60. data/lib/quicsilver/transport/inbound_stream.rb +33 -0
  61. data/lib/quicsilver/transport/stream.rb +28 -0
  62. data/lib/quicsilver/transport/stream_event.rb +26 -0
  63. data/lib/quicsilver/version.rb +1 -1
  64. data/lib/quicsilver.rb +55 -14
  65. data/lib/rackup/handler/quicsilver.rb +1 -2
  66. data/quicsilver.gemspec +13 -3
  67. metadata +125 -21
  68. data/benchmarks/benchmark.rb +0 -68
  69. data/examples/setup_certs.sh +0 -57
  70. data/lib/quicsilver/client.rb +0 -261
  71. data/lib/quicsilver/connection.rb +0 -42
  72. data/lib/quicsilver/event_loop.rb +0 -38
  73. data/lib/quicsilver/http3/request_encoder.rb +0 -133
  74. data/lib/quicsilver/http3/request_parser.rb +0 -176
  75. data/lib/quicsilver/http3/response_encoder.rb +0 -186
  76. data/lib/quicsilver/http3/response_parser.rb +0 -160
  77. data/lib/quicsilver/listener_data.rb +0 -29
  78. data/lib/quicsilver/quic_stream.rb +0 -36
  79. data/lib/quicsilver/request_registry.rb +0 -48
  80. data/lib/quicsilver/server.rb +0 -355
  81. data/lib/quicsilver/server_configuration.rb +0 -78
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Transport
5
+ class Configuration
6
+ attr_reader :cert_file, :key_file, :idle_timeout_ms, :server_resumption_level, :max_concurrent_requests,
7
+ :max_unidirectional_streams, :stream_receive_window, :stream_receive_buffer, :connection_flow_control_window,
8
+ :pacing_enabled, :send_buffering_enabled, :initial_rtt_ms, :initial_window_packets, :max_ack_delay_ms,
9
+ :keep_alive_interval_ms, :congestion_control_algorithm, :migration_enabled,
10
+ :disconnect_timeout_ms, :handshake_idle_timeout_ms,
11
+ :max_body_size, :max_header_size, :max_header_count, :max_frame_payload_size,
12
+ :early_data_policy,
13
+ :mode
14
+
15
+ QUIC_SERVER_RESUME_AND_ZERORTT = 1
16
+ QUIC_SERVER_RESUME_ONLY = 2
17
+ QUIC_SERVER_RESUME_AND_REUSE = 3
18
+ QUIC_SERVER_RESUME_AND_REUSE_ZERORTT = 4
19
+
20
+ # Congestion control algorithms
21
+ CONGESTION_CONTROL_CUBIC = 0
22
+ CONGESTION_CONTROL_BBR = 1
23
+
24
+ DEFAULT_CERT_FILE = "certificates/server.crt"
25
+ DEFAULT_KEY_FILE = "certificates/server.key"
26
+ DEFAULT_ALPN = "h3"
27
+
28
+ # Flow control defaults — cross-referenced with quiche, quic-go, lsquic, RFC 9000
29
+ # See: https://github.com/microsoft/msquic/blob/main/docs/Settings.md
30
+ DEFAULT_STREAM_RECEIVE_WINDOW = 262_144 # 256KB (quiche/quic-go use 1MB, MsQuic default 64KB)
31
+ DEFAULT_STREAM_RECEIVE_BUFFER = 32_768 # 32KB (MsQuic default 4KB — too small for typical responses)
32
+ DEFAULT_CONNECTION_FLOW_CONTROL_WINDOW = 16_777_216 # 16MB - connection-wide flow control
33
+
34
+ # Throughput defaults
35
+ DEFAULT_PACING_ENABLED = true # RFC 9002: MUST pace or limit bursts
36
+ DEFAULT_SEND_BUFFERING_ENABLED = true # MsQuic recommended — coalesces small writes
37
+ DEFAULT_INITIAL_RTT_MS = 100 # MsQuic default 333ms is satellite-grade; 100ms matches Chromium
38
+ DEFAULT_INITIAL_WINDOW_PACKETS = 10 # Matches RFC 9002 recommendation
39
+ DEFAULT_MAX_ACK_DELAY_MS = 25 # Matches RFC 9000 default
40
+
41
+ # Connection management defaults
42
+ DEFAULT_KEEP_ALIVE_INTERVAL_MS = 0 # 0 = disabled. Set to 20000 for NAT traversal
43
+ DEFAULT_CONGESTION_CONTROL_ALGORITHM = CONGESTION_CONTROL_CUBIC # CUBIC (0) or BBR (1)
44
+ DEFAULT_MIGRATION_ENABLED = true # Client IP migration. Disable behind load balancers
45
+ DEFAULT_DISCONNECT_TIMEOUT_MS = 16_000 # How long to wait for ACK before path declared dead
46
+ DEFAULT_HANDSHAKE_IDLE_TIMEOUT_MS = 10_000 # Handshake timeout (separate from connection idle)
47
+
48
+ def initialize(cert_file = nil, key_file = nil, options = {})
49
+ @idle_timeout_ms = options.fetch(:idle_timeout_ms, 10000)
50
+ @server_resumption_level = options.fetch(:server_resumption_level, QUIC_SERVER_RESUME_AND_ZERORTT)
51
+ @max_concurrent_requests = options.fetch(:max_concurrent_requests, 100)
52
+ @max_unidirectional_streams = options.fetch(:max_unidirectional_streams, 10)
53
+ @alpn = options.fetch(:alpn, DEFAULT_ALPN)
54
+
55
+ # Flow control
56
+ @stream_receive_window = options.fetch(:stream_receive_window, DEFAULT_STREAM_RECEIVE_WINDOW)
57
+ @stream_receive_buffer = options.fetch(:stream_receive_buffer, DEFAULT_STREAM_RECEIVE_BUFFER)
58
+ @connection_flow_control_window = options.fetch(:connection_flow_control_window, DEFAULT_CONNECTION_FLOW_CONTROL_WINDOW)
59
+
60
+ # Throughput
61
+ @pacing_enabled = options.fetch(:pacing_enabled, DEFAULT_PACING_ENABLED)
62
+ @send_buffering_enabled = options.fetch(:send_buffering_enabled, DEFAULT_SEND_BUFFERING_ENABLED)
63
+ @initial_rtt_ms = options.fetch(:initial_rtt_ms, DEFAULT_INITIAL_RTT_MS)
64
+ @initial_window_packets = options.fetch(:initial_window_packets, DEFAULT_INITIAL_WINDOW_PACKETS)
65
+ @max_ack_delay_ms = options.fetch(:max_ack_delay_ms, DEFAULT_MAX_ACK_DELAY_MS)
66
+
67
+ # Connection management
68
+ @keep_alive_interval_ms = options.fetch(:keep_alive_interval_ms, DEFAULT_KEEP_ALIVE_INTERVAL_MS)
69
+ @congestion_control_algorithm = options.fetch(:congestion_control_algorithm, DEFAULT_CONGESTION_CONTROL_ALGORITHM)
70
+ @migration_enabled = options.fetch(:migration_enabled, DEFAULT_MIGRATION_ENABLED)
71
+ @disconnect_timeout_ms = options.fetch(:disconnect_timeout_ms, DEFAULT_DISCONNECT_TIMEOUT_MS)
72
+ @handshake_idle_timeout_ms = options.fetch(:handshake_idle_timeout_ms, DEFAULT_HANDSHAKE_IDLE_TIMEOUT_MS)
73
+
74
+ # HTTP/3 parser limits (nil = unlimited)
75
+ @max_body_size = options[:max_body_size]
76
+ @max_header_size = options[:max_header_size]
77
+ @max_header_count = options[:max_header_count]
78
+ @max_frame_payload_size = options[:max_frame_payload_size]
79
+
80
+ # 0-RTT early data policy (RFC 8470)
81
+ # :reject (default) — send 425 Too Early for unsafe methods on 0-RTT
82
+ # :allow — pass all 0-RTT requests to the Rack app with env["quicsilver.early_data"]
83
+ @early_data_policy = options.fetch(:early_data_policy, :reject)
84
+ unless %i[reject allow].include?(@early_data_policy)
85
+ raise ServerConfigurationError, "Invalid early_data_policy: #{@early_data_policy.inspect} (must be :reject or :allow)"
86
+ end
87
+
88
+ # Application interface mode:
89
+ # :rack (default) — app is a Rack app, auto-wrapped with Protocol::Rack::Adapter
90
+ # :falcon — app is a native protocol-http app, used directly
91
+ @mode = options.fetch(:mode, :rack)
92
+ unless %i[rack falcon].include?(@mode)
93
+ raise ServerConfigurationError, "Invalid mode: #{@mode.inspect} (must be :rack or :falcon)"
94
+ end
95
+
96
+ @cert_file = cert_file.nil? ? DEFAULT_CERT_FILE : cert_file
97
+ @key_file = key_file.nil? ? DEFAULT_KEY_FILE : key_file
98
+
99
+ unless File.exist?(@cert_file)
100
+ raise ServerConfigurationError, "Certificate file not found: #{@cert_file}"
101
+ end
102
+
103
+ unless File.exist?(@key_file)
104
+ raise ServerConfigurationError, "Key file not found: #{@key_file}"
105
+ end
106
+ end
107
+
108
+ # Common HTTP/3 ALPN Values:
109
+ # "h3" - HTTP/3 (most common)
110
+ # "h3-29" - HTTP/3 draft version 29
111
+ def alpn
112
+ @alpn
113
+ end
114
+
115
+ def to_h
116
+ {
117
+ cert_file: @cert_file,
118
+ key_file: @key_file,
119
+ idle_timeout_ms: @idle_timeout_ms,
120
+ server_resumption_level: @server_resumption_level,
121
+ max_concurrent_requests: @max_concurrent_requests,
122
+ max_unidirectional_streams: @max_unidirectional_streams,
123
+ alpn: alpn,
124
+ stream_receive_window: @stream_receive_window,
125
+ stream_receive_buffer: @stream_receive_buffer,
126
+ connection_flow_control_window: @connection_flow_control_window,
127
+ pacing_enabled: @pacing_enabled ? 1 : 0,
128
+ send_buffering_enabled: @send_buffering_enabled ? 1 : 0,
129
+ initial_rtt_ms: @initial_rtt_ms,
130
+ initial_window_packets: @initial_window_packets,
131
+ max_ack_delay_ms: @max_ack_delay_ms,
132
+ keep_alive_interval_ms: @keep_alive_interval_ms,
133
+ congestion_control_algorithm: @congestion_control_algorithm,
134
+ migration_enabled: @migration_enabled ? 1 : 0,
135
+ disconnect_timeout_ms: @disconnect_timeout_ms,
136
+ handshake_idle_timeout_ms: @handshake_idle_timeout_ms
137
+ }
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Transport
5
+ class Connection
6
+ include Protocol::ControlStreamParser
7
+
8
+ # MsQuic QUIC_STATUS_INVALID_STATE (POSIX errno ETOOMANYREFS = 0x59).
9
+ # Stream already shut down by peer — raised by StreamSend when the
10
+ # client has reset or closed the stream.
11
+ MSQUIC_INVALID_STATE = "0x59"
12
+
13
+ attr_reader :handle, :data, :streams
14
+ attr_reader :control_stream_id, :qpack_encoder_stream_id, :qpack_decoder_stream_id
15
+ attr_reader :server_control_stream
16
+ attr_reader :peer_goaway_id, :local_goaway_id
17
+ attr_reader :stream_priorities
18
+
19
+ def initialize(handle, data, max_header_size: nil)
20
+ @handle = handle
21
+ @data = data
22
+ @max_header_size = max_header_size
23
+ @streams = {}
24
+ @response_buffers = {}
25
+ @mutex = Mutex.new
26
+
27
+ # Client's control streams (received)
28
+ @control_stream_id = nil
29
+ @qpack_encoder_stream_id = nil
30
+ @qpack_decoder_stream_id = nil
31
+
32
+ # Server's control stream (sent)
33
+ @server_control_stream = nil
34
+
35
+ @settings = {}
36
+ @settings_received = false
37
+ @peer_goaway_id = nil
38
+ @local_goaway_id = nil
39
+ @stream_priorities = {}
40
+ end
41
+
42
+ # === Setup (called after connection established) ===
43
+
44
+ def setup_http3_streams
45
+ # Control stream (required)
46
+ @server_control_stream = open_stream(unidirectional: true)
47
+ @server_control_stream.send(Protocol.build_control_stream(max_field_section_size: @max_header_size))
48
+
49
+ # QPACK encoder/decoder streams
50
+ [0x02, 0x03].each do |type|
51
+ stream = open_stream(unidirectional: true)
52
+ stream.send([type].pack("C"))
53
+ end
54
+
55
+ # GREASE unidirectional stream (RFC 9297)
56
+ stream = open_stream(unidirectional: true)
57
+ stream.send(Protocol.encode_varint(Protocol.grease_id) + "GREASE".b)
58
+ end
59
+
60
+ # === Stream Management ===
61
+
62
+ def add_stream(stream)
63
+ @streams[stream.stream_id] = stream
64
+ end
65
+
66
+ def get_stream(stream_id)
67
+ @streams[stream_id]
68
+ end
69
+
70
+ def remove_stream(stream_id)
71
+ @streams.delete(stream_id)
72
+ end
73
+
74
+ def track_client_stream(stream_id)
75
+ @streams[stream_id] = true
76
+ end
77
+
78
+ # === Data Handling ===
79
+
80
+ def buffer_data(stream_id, data)
81
+ @mutex.synchronize do
82
+ (@response_buffers[stream_id] ||= "".b) << data
83
+ end
84
+ end
85
+
86
+ def complete_stream(stream_id, final_data)
87
+ @mutex.synchronize do
88
+ buffer = @response_buffers.delete(stream_id)
89
+ (buffer || "".b) + (final_data || "".b)
90
+ end
91
+ end
92
+
93
+ # === HTTP/3 Frames ===
94
+
95
+ def send_goaway(stream_id = nil)
96
+ return unless @server_control_stream
97
+
98
+ stream_id ||= last_client_stream_id
99
+ validate_goaway_id!(stream_id)
100
+
101
+ @server_control_stream.send(Protocol.build_goaway_frame(stream_id))
102
+ @local_goaway_id = stream_id
103
+ rescue ArgumentError
104
+ raise # Re-raise validation errors
105
+ rescue => e
106
+ Quicsilver.logger.error("Failed to send GOAWAY: #{e.message}")
107
+ end
108
+
109
+ # RFC 9114 §5.2: GOAWAY IDs MUST NOT increase from a previous value.
110
+ def validate_goaway_id!(stream_id)
111
+ if @local_goaway_id && stream_id > @local_goaway_id
112
+ raise ArgumentError, "GOAWAY stream ID #{stream_id} exceeds previous #{@local_goaway_id}"
113
+ end
114
+ end
115
+
116
+ # Send an informational (1xx) response before the final response.
117
+ # RFC 9114 §4.1: encoded as a HEADERS frame, no FIN.
118
+ def send_informational(stream, status, headers)
119
+ data = Protocol::ResponseEncoder.encode_informational(status, headers)
120
+ Quicsilver.send_stream(stream.stream_handle, data, false)
121
+ rescue RuntimeError => e
122
+ raise unless e.message.include?(MSQUIC_INVALID_STATE) || e.message.include?("StreamSend failed")
123
+ Quicsilver.logger.debug("Stream send failed (client likely reset): #{e.message}")
124
+ end
125
+
126
+ def send_response(stream, status, headers, body, head_request: false, trailers: nil)
127
+ body = [] if body.nil?
128
+ encoder = Protocol::ResponseEncoder.new(status, headers, body, head_request: head_request, trailers: trailers)
129
+
130
+ if body.respond_to?(:to_ary)
131
+ Quicsilver.send_stream(stream.stream_handle, encoder.encode, true)
132
+ else
133
+ encoder.stream_encode do |frame_data, fin|
134
+ Quicsilver.send_stream(stream.stream_handle, frame_data, fin) unless frame_data.empty? && !fin
135
+ end
136
+ end
137
+ rescue RuntimeError => e
138
+ # Stream may have been reset by client - this is expected
139
+ raise unless e.message.include?(MSQUIC_INVALID_STATE) || e.message.include?("StreamSend failed")
140
+ Quicsilver.logger.debug("Stream send failed (client likely reset): #{e.message}")
141
+ end
142
+
143
+ def send_error(stream, status, message)
144
+ body = ["#{status} #{message}"]
145
+ encoder = Protocol::ResponseEncoder.new(status, { "content-type" => "text/plain" }, body)
146
+ Quicsilver.send_stream(stream.stream_handle, encoder.encode, true)
147
+ rescue RuntimeError => e
148
+ # Stream may have been reset by client - this is expected
149
+ raise unless e.message.include?(MSQUIC_INVALID_STATE) || e.message.include?("StreamSend failed")
150
+ Quicsilver.logger.debug("Stream send failed (client likely reset): #{e.message}")
151
+ end
152
+
153
+ # === Control Stream Handling ===
154
+
155
+ # Process incoming data on a unidirectional stream incrementally.
156
+ # Called on each RECEIVE event — control streams never send FIN.
157
+ def receive_unidirectional_data(stream_id, data)
158
+ @mutex.synchronize do
159
+ (@response_buffers[stream_id] ||= "".b) << data
160
+ end
161
+
162
+ buf = @mutex.synchronize { @response_buffers[stream_id] || "".b }
163
+ return if buf.empty?
164
+
165
+ # First time seeing this stream: identify stream type
166
+ unless @uni_stream_types&.key?(stream_id)
167
+ @uni_stream_types ||= {}
168
+ stream_type, type_len = Protocol.decode_varint(buf.bytes, 0)
169
+ return if type_len == 0 # need more data
170
+
171
+ case stream_type
172
+ when 0x00 # Control stream
173
+ raise Protocol::FrameError, "Duplicate control stream" if @control_stream_id
174
+ @control_stream_id = stream_id
175
+ @uni_stream_types[stream_id] = :control
176
+ # Remove the stream type byte from the buffer
177
+ @mutex.synchronize { @response_buffers[stream_id] = (buf[type_len..] || "".b) }
178
+ when 0x01
179
+ raise Protocol::FrameError, "Client must not send push streams"
180
+ when 0x02 # QPACK encoder stream
181
+ raise Protocol::FrameError, "Duplicate QPACK encoder stream" if @qpack_encoder_stream_id
182
+ @qpack_encoder_stream_id = stream_id
183
+ @uni_stream_types[stream_id] = :qpack_encoder
184
+ @mutex.synchronize { @response_buffers[stream_id] = (buf[type_len..] || "".b) }
185
+ when 0x03 # QPACK decoder stream
186
+ raise Protocol::FrameError, "Duplicate QPACK decoder stream" if @qpack_decoder_stream_id
187
+ @qpack_decoder_stream_id = stream_id
188
+ @uni_stream_types[stream_id] = :qpack_decoder
189
+ @mutex.synchronize { @response_buffers[stream_id] = (buf[type_len..] || "".b) }
190
+ else
191
+ # Unknown unidirectional stream types MUST be ignored (RFC 9114 §6.2)
192
+ @uni_stream_types[stream_id] = :unknown
193
+ return
194
+ end
195
+
196
+ buf = @mutex.synchronize { @response_buffers[stream_id] || "".b }
197
+ end
198
+
199
+ stream_type = @uni_stream_types[stream_id]
200
+ return if buf.empty?
201
+
202
+ case stream_type
203
+ when :control
204
+ parse_control_frames(buf)
205
+ # Clear parsed data from buffer
206
+ @mutex.synchronize { @response_buffers[stream_id] = "".b }
207
+ when :qpack_encoder
208
+ validate_qpack_encoder_data(buf)
209
+ @mutex.synchronize { @response_buffers[stream_id] = "".b }
210
+ when :qpack_decoder
211
+ validate_qpack_decoder_data(buf)
212
+ @mutex.synchronize { @response_buffers[stream_id] = "".b }
213
+ end
214
+ end
215
+
216
+ def handle_unidirectional_stream(stream, fin: true)
217
+ stream_id = stream.stream_id
218
+
219
+ # Already known as critical stream — closure via FIN is an error
220
+ if fin && critical_stream?(stream_id)
221
+ raise Protocol::FrameError.new("Closure of critical stream", error_code: Protocol::H3_CLOSED_CRITICAL_STREAM)
222
+ end
223
+
224
+ data = stream.data
225
+ return if data.empty?
226
+
227
+ stream_type, type_len = Protocol.decode_varint(data.bytes, 0)
228
+ return if type_len == 0
229
+ payload = data[type_len..-1]
230
+
231
+ case stream_type
232
+ when 0x00
233
+ set_control_stream(stream_id, payload)
234
+ if fin
235
+ raise Protocol::FrameError.new("Closure of critical stream", error_code: Protocol::H3_CLOSED_CRITICAL_STREAM)
236
+ end
237
+ when 0x01
238
+ raise Protocol::FrameError, "Client must not send push streams"
239
+ when 0x02
240
+ raise Protocol::FrameError, "Duplicate QPACK encoder stream" if @qpack_encoder_stream_id
241
+ @qpack_encoder_stream_id = stream_id
242
+ if fin
243
+ raise Protocol::FrameError.new("Closure of critical stream", error_code: Protocol::H3_CLOSED_CRITICAL_STREAM)
244
+ end
245
+ when 0x03
246
+ raise Protocol::FrameError, "Duplicate QPACK decoder stream" if @qpack_decoder_stream_id
247
+ @qpack_decoder_stream_id = stream_id
248
+ if fin
249
+ raise Protocol::FrameError.new("Closure of critical stream", error_code: Protocol::H3_CLOSED_CRITICAL_STREAM)
250
+ end
251
+ end
252
+ end
253
+
254
+ def set_control_stream(stream_id, payload = nil)
255
+ raise Protocol::FrameError, "Duplicate control stream" if @control_stream_id
256
+ @control_stream_id = stream_id
257
+ parse_control_frames(payload) if payload && !payload.empty?
258
+ end
259
+
260
+ def settings
261
+ @settings
262
+ end
263
+
264
+ # Get the priority for a stream. Returns default Priority if not set.
265
+ def stream_priority(stream_id)
266
+ @stream_priorities[stream_id] || Protocol::Priority.new
267
+ end
268
+
269
+ # Apply priority to a QUIC stream via MsQuic.
270
+ # MsQuic: 0 = lowest, 0xFFFF = highest.
271
+ # HTTP urgency: 0 = highest, 7 = lowest.
272
+ # Maps urgency into evenly spaced bands across the uint16 range.
273
+ # The priority is queued and applied on the MsQuic event thread.
274
+ def apply_stream_priority(stream, priority)
275
+ handle = stream.respond_to?(:stream_handle) ? stream.stream_handle : nil
276
+ return unless handle
277
+ quic_priority = (7 - priority.urgency) * 0x2000
278
+ Quicsilver.set_stream_priority(handle, quic_priority)
279
+ rescue => e
280
+ Quicsilver.logger.debug("Failed to set stream priority: #{e.message}")
281
+ end
282
+
283
+ def critical_stream?(stream_id)
284
+ stream_id == @control_stream_id ||
285
+ stream_id == @qpack_encoder_stream_id ||
286
+ stream_id == @qpack_decoder_stream_id
287
+ end
288
+
289
+ # === Shutdown ===
290
+
291
+ def shutdown(error_code = 0)
292
+ send_goaway
293
+ Quicsilver.connection_shutdown(@handle, error_code, false)
294
+ end
295
+
296
+ private
297
+
298
+ def open_stream(unidirectional: false)
299
+ handle = Quicsilver.open_stream(@data, unidirectional)
300
+ Stream.new(handle)
301
+ end
302
+
303
+ def last_client_stream_id
304
+ @streams.keys.select { |id| (id & 0x02) == 0 }.max || 0
305
+ end
306
+
307
+ # Frame types forbidden on the control stream
308
+ FORBIDDEN_ON_CONTROL = [
309
+ 0x00, # DATA — request streams only
310
+ 0x01, # HEADERS — request streams only
311
+ 0x02, # HTTP/2 PRIORITY (reserved)
312
+ 0x05, # PUSH_PROMISE — request streams only
313
+ 0x06, # HTTP/2 PING (reserved)
314
+ 0x08, # HTTP/2 WINDOW_UPDATE (reserved)
315
+ 0x09, # HTTP/2 CONTINUATION (reserved)
316
+ ].freeze
317
+
318
+ def on_settings_received(settings)
319
+ @settings.merge!(settings)
320
+ end
321
+
322
+ def handle_control_frame(type, payload)
323
+ if FORBIDDEN_ON_CONTROL.include?(type)
324
+ raise Protocol::FrameError, "Frame type 0x#{type.to_s(16)} not allowed on control stream"
325
+ end
326
+
327
+ if type == Protocol::FRAME_PRIORITY_UPDATE
328
+ parse_priority_update(payload)
329
+ end
330
+ end
331
+
332
+ # RFC 9218 §7: Parse PRIORITY_UPDATE frame.
333
+ # Payload is a stream ID varint followed by a Priority Field Value string.
334
+ def parse_priority_update(payload)
335
+ stream_id, consumed = Protocol.decode_varint(payload.bytes, 0)
336
+ priority_value = payload[consumed..]
337
+ @stream_priorities[stream_id] = Protocol::Priority.parse(priority_value)
338
+ end
339
+
340
+ # RFC 9204 §4.1.3: Validate QPACK encoder stream instructions.
341
+ # We advertise QPACK_MAX_TABLE_CAPACITY = 0, so any Set Dynamic Table Capacity
342
+ # instruction with value > 0 is an error.
343
+ def validate_qpack_encoder_data(data)
344
+ return if data.empty?
345
+ byte = data.bytes[0]
346
+
347
+ # Set Dynamic Table Capacity (001xxxxx)
348
+ if (byte & 0xE0) == 0x20
349
+ capacity, _ = Protocol.decode_varint(data.bytes, 0)
350
+ capacity &= 0x1F # mask off the instruction prefix
351
+ # We advertised capacity 0, any non-zero is an error
352
+ raise Protocol::FrameError.new(
353
+ "Dynamic table capacity exceeds advertised maximum",
354
+ error_code: Protocol::QPACK_ENCODER_STREAM_ERROR
355
+ )
356
+ end
357
+ end
358
+
359
+ # RFC 9204 §4.4.3: Validate QPACK decoder stream instructions.
360
+ # Insert Count Increment of 0 is a decoder stream error.
361
+ def validate_qpack_decoder_data(data)
362
+ return if data.empty?
363
+ byte = data.bytes[0]
364
+
365
+ # Insert Count Increment (00xxxxxx)
366
+ if (byte & 0xC0) == 0x00
367
+ increment, _ = Protocol.decode_varint(data.bytes, 0)
368
+ increment &= 0x3F # mask off prefix bits
369
+ if increment == 0
370
+ raise Protocol::FrameError.new(
371
+ "Insert Count Increment of 0 on decoder stream",
372
+ error_code: Protocol::QPACK_DECODER_STREAM_ERROR
373
+ )
374
+ end
375
+ end
376
+ end
377
+ end
378
+ end
379
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Transport
5
+ class EventLoop
6
+ def initialize
7
+ @running = false
8
+ @thread = nil
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def start
13
+ @mutex.synchronize do
14
+ return if @running
15
+
16
+ @running = true
17
+ @thread = Thread.new do
18
+ Quicsilver.poll while @running
19
+ end
20
+ end
21
+ end
22
+
23
+ def stop
24
+ @running = false
25
+ Quicsilver.wake
26
+ @thread&.join(2)
27
+ end
28
+
29
+ def join
30
+ @thread&.join
31
+ end
32
+ end
33
+ end
34
+
35
+ def self.event_loop
36
+ @event_loop ||= Transport::EventLoop.new.tap(&:start)
37
+ end
38
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Transport
5
+ class InboundStream
6
+ attr_reader :stream_id, :is_unidirectional, :buffer
7
+ attr_accessor :stream_handle
8
+
9
+ def initialize(stream_id, is_unidirectional: nil)
10
+ @stream_id = stream_id
11
+ @is_unidirectional = is_unidirectional.nil? ? !bidirectional? : is_unidirectional
12
+ @buffer = StringIO.new.tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
13
+ @stream_handle = nil
14
+ end
15
+
16
+ def bidirectional?
17
+ (stream_id & 0x02) == 0
18
+ end
19
+
20
+ def writable?
21
+ !stream_handle.nil?
22
+ end
23
+
24
+ def append_data(data)
25
+ @buffer.write(data)
26
+ end
27
+
28
+ def data
29
+ @buffer.string
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Transport
5
+ # Wraps a QUIC stream opened by Ruby code (client requests, server control streams).
6
+ # Encapsulates the C handle — callers use send/reset/stop_sending methods instead
7
+ # of passing raw pointers to Quicsilver.send_stream etc.
8
+ class Stream
9
+ attr_reader :handle
10
+
11
+ def initialize(handle)
12
+ @handle = handle
13
+ end
14
+
15
+ def send(data, fin: false)
16
+ Quicsilver.send_stream(@handle, data, fin)
17
+ end
18
+
19
+ def reset(error_code = Protocol::H3_REQUEST_CANCELLED)
20
+ Quicsilver.stream_reset(@handle, error_code)
21
+ end
22
+
23
+ def stop_sending(error_code = Protocol::H3_REQUEST_CANCELLED)
24
+ Quicsilver.stream_stop_sending(@handle, error_code)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Transport
5
+ # Parses the binary data packed by the C extension for stream completion events.
6
+ # C packs events as:
7
+ # RECEIVE_FIN: [stream_handle(8)][payload...]
8
+ # STREAM_RESET: [stream_handle(8)][error_code(8)]
9
+ # STOP_SENDING: [stream_handle(8)][error_code(8)]
10
+ class StreamEvent
11
+ attr_reader :handle, :data, :error_code
12
+
13
+ def initialize(raw_data, event_type)
14
+ @handle = raw_data[0, 8].unpack1("Q")
15
+ remaining = raw_data[8..] || "".b
16
+
17
+ case event_type
18
+ when "RECEIVE_FIN"
19
+ @data = remaining
20
+ when "STREAM_RESET", "STOP_SENDING"
21
+ @error_code = remaining.unpack1("Q")
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,3 +1,3 @@
1
1
  module Quicsilver
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.0"
3
3
  end