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
@@ -1,186 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Quicsilver
4
- module HTTP3
5
- class ResponseEncoder
6
- def initialize(status, headers, body)
7
- @status = status
8
- @headers = headers
9
- @body = body
10
- end
11
-
12
- # Buffered encode - returns all frames at once (legacy)
13
- def encode
14
- frames = "".b
15
- frames << encode_headers_frame
16
- @body.each do |chunk|
17
- frames << encode_data_frame(chunk) unless chunk.empty?
18
- end
19
- @body.close if @body.respond_to?(:close)
20
- frames
21
- end
22
-
23
- # Streaming encode - yields frames as they're ready
24
- def stream_encode
25
- yield encode_headers_frame, false
26
-
27
- last_chunk = nil
28
- @body.each do |chunk|
29
- yield encode_data_frame(last_chunk), false if last_chunk && !last_chunk.empty?
30
- last_chunk = chunk
31
- end
32
-
33
- # Send final chunk with FIN=true
34
- if last_chunk && !last_chunk.empty?
35
- yield encode_data_frame(last_chunk), true
36
- else
37
- yield "".b, true # Empty frame to signal FIN
38
- end
39
-
40
- @body.close if @body.respond_to?(:close)
41
- end
42
-
43
- private
44
-
45
- def encode_headers_frame
46
- payload = encode_qpack_response
47
- frame_type = HTTP3.encode_varint(HTTP3::FRAME_HEADERS)
48
- frame_length = HTTP3.encode_varint(payload.bytesize)
49
- frame_type + frame_length + payload
50
- end
51
-
52
- def encode_data_frame(data)
53
- frame_type = HTTP3.encode_varint(HTTP3::FRAME_DATA)
54
- data_bytes = data.to_s.b
55
- frame_length = HTTP3.encode_varint(data_bytes.bytesize)
56
- frame_type + frame_length + data_bytes
57
- end
58
-
59
- def encode_qpack_response
60
- # QPACK prefix: Required Insert Count = 0, Delta Base = 0
61
- encoded = [0x00, 0x00].pack('C*')
62
-
63
- # :status pseudo-header - use indexed if possible
64
- encoded += encode_status(@status)
65
-
66
- # Regular headers
67
- @headers.each do |name, value|
68
- next if name.start_with?('rack.') # Skip Rack internals
69
-
70
- name = name.to_s.downcase
71
- value = value.to_s
72
-
73
- # Try to use indexed encoding for common headers
74
- index = find_static_index(name, value)
75
- if index
76
- # Pattern 1: Indexed Field Line (1Txxxxxx where T=1 for static)
77
- encoded += encode_indexed_field(index)
78
- else
79
- # Check if just the name exists in static table
80
- name_index = find_static_name_index(name)
81
- if name_index
82
- # Pattern 3: Literal with name reference (01NTxxxx)
83
- encoded += encode_literal_with_name_ref(name_index, value)
84
- else
85
- # Pattern 5: Literal with literal name (001NHxxx)
86
- encoded += encode_literal_with_literal_name(name, value)
87
- end
88
- end
89
- end
90
-
91
- encoded
92
- end
93
-
94
- private
95
-
96
- def encode_status(status)
97
- status_str = status.to_s
98
- # Map common status codes to static table indices
99
- index = case status_str
100
- when '200' then HTTP3::QPACK_STATUS_200 # 25
101
- when '404' then HTTP3::QPACK_STATUS_404 # 27
102
- when '500' then HTTP3::QPACK_STATUS_500 # 71
103
- when '400' then HTTP3::QPACK_STATUS_400 # 67
104
- when '100' then 63
105
- when '204' then 64
106
- when '304' then 26
107
- when '403' then 68
108
- else nil
109
- end
110
-
111
- if index
112
- # Indexed field line: 11 (pattern) + T (1=static) + index (prefix integer with N=6)
113
- encode_indexed_field_with_prefix(index)
114
- else
115
- # Literal with name reference - use index 25 (:status 200) for name
116
- name_ref = [0x40 | 25].pack('C')
117
- status_bytes = status_str.b
118
- length = [status_bytes.bytesize].pack('C')
119
- name_ref + length + status_bytes
120
- end
121
- end
122
-
123
- # Encode indexed field line using prefix integer encoding (RFC 7541)
124
- # Pattern: 11 + T(1 bit) + index as prefix integer with N=6
125
- def encode_indexed_field_with_prefix(index, prefix_bits: 6, pattern: 0xC0)
126
- max_prefix = (1 << prefix_bits) - 1 # 2^6 - 1 = 63
127
-
128
- if index < max_prefix
129
- # Fits in prefix bits
130
- [pattern | index].pack('C')
131
- else
132
- # Needs continuation bytes
133
- result = [pattern | max_prefix].pack('C') # First byte: all prefix bits set to 1
134
- remaining = index - max_prefix
135
-
136
- # Encode remaining value using 7-bit continuation bytes
137
- while remaining >= 128
138
- result += [(remaining & 0x7F) | 0x80].pack('C') # MSB=1 means more bytes
139
- remaining >>= 7
140
- end
141
- result += [remaining].pack('C') # Last byte: MSB=0
142
-
143
- result
144
- end
145
- end
146
-
147
- def find_static_index(name, value)
148
- HTTP3::STATIC_TABLE.each_with_index do |(tbl_name, tbl_value), idx|
149
- return idx if tbl_name == name && tbl_value == value
150
- end
151
- nil
152
- end
153
-
154
- def find_static_name_index(name)
155
- HTTP3::STATIC_TABLE.each_with_index do |(tbl_name, _), idx|
156
- return idx if tbl_name == name
157
- end
158
- nil
159
- end
160
-
161
- def encode_indexed_field(index)
162
- # Pattern 1: Indexed Field Line with prefix integer encoding
163
- encode_indexed_field_with_prefix(index)
164
- end
165
-
166
- def encode_literal_with_name_ref(name_index, value)
167
- # Pattern 3: Literal Field Line with Name Reference
168
- # 01 N T + 4-bit index (N=0, T=1 for static)
169
- prefix = 0x40 | name_index
170
- prefix_byte = [prefix].pack('C')
171
- value_bytes = value.to_s.b
172
- value_length = HTTP3.encode_varint(value_bytes.bytesize)
173
- prefix_byte + value_length + value_bytes
174
- end
175
-
176
- def encode_literal_with_literal_name(name, value)
177
- # Pattern 5: Literal Field Line with Literal Name
178
- # 001 N H + 3-bit name length (N=0, H=0 for no Huffman)
179
- prefix = 0x20 | name.bytesize
180
- name_bytes = name.to_s.b
181
- value_bytes = value.to_s.b
182
- [prefix].pack('C') + name_bytes + HTTP3.encode_varint(value_bytes.bytesize) + value_bytes
183
- end
184
- end
185
- end
186
- end
@@ -1,160 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'stringio'
4
-
5
- module Quicsilver
6
- module HTTP3
7
- class ResponseParser
8
- attr_reader :frames, :headers, :status
9
-
10
- def initialize(data)
11
- @data = data
12
- @frames = []
13
- @headers = {}
14
- @body_io = StringIO.new
15
- @status = nil
16
- end
17
-
18
- def body
19
- @body_io.rewind
20
- @body_io
21
- end
22
-
23
- def parse
24
- parse!
25
- end
26
-
27
- private
28
-
29
- def parse!
30
- buffer = @data.dup
31
- offset = 0
32
-
33
- while offset < buffer.bytesize
34
- break if buffer.bytesize - offset < 2
35
-
36
- type, type_len = HTTP3.decode_varint(buffer.bytes, offset)
37
- length, length_len = HTTP3.decode_varint(buffer.bytes, offset + type_len)
38
- header_len = type_len + length_len
39
-
40
- break if buffer.bytesize < offset + header_len + length
41
-
42
- payload = buffer[offset + header_len, length]
43
- @frames << { type: type, length: length, payload: payload }
44
-
45
- case type
46
- when 0x01 # HEADERS
47
- parse_headers(payload)
48
- when 0x00 # DATA
49
- @body_io.write(payload)
50
- end
51
-
52
- offset += header_len + length
53
- end
54
- end
55
-
56
- def parse_headers(payload)
57
- # Skip QPACK required insert count (1 byte) + delta base (1 byte)
58
- offset = 2
59
- return if payload.bytesize < offset
60
-
61
- while offset < payload.bytesize
62
- byte = payload.bytes[offset]
63
-
64
- # Pattern 1: Indexed Field Line (1Txxxxxx)
65
- if (byte & 0x80) == 0x80
66
- # Decode prefix integer with N=6 bits
67
- index, bytes_consumed = decode_prefix_integer(payload.bytes, offset, 6, 0xC0)
68
- offset += bytes_consumed
69
-
70
- field = decode_static_table_field(index)
71
- if field.is_a?(Hash)
72
- field.each do |name, value|
73
- if name == ":status"
74
- @status = value.to_i
75
- else
76
- @headers[name] = value
77
- end
78
- end
79
- end
80
- # Pattern 3: Literal with Name Reference (01NTxxxx)
81
- elsif (byte & 0xC0) == 0x40
82
- index = byte & 0x3F
83
- offset += 1
84
-
85
- entry = HTTP3::STATIC_TABLE[index] if index < HTTP3::STATIC_TABLE.size
86
- name = entry ? entry[0] : nil
87
-
88
- if name
89
- value_len, len_bytes = HTTP3.decode_varint(payload.bytes, offset)
90
- offset += len_bytes
91
- value = payload[offset, value_len]
92
- offset += value_len
93
-
94
- if name == ":status"
95
- @status = value.to_i
96
- else
97
- @headers[name] = value
98
- end
99
- end
100
- # Pattern 5: Literal with literal name (001NHxxx)
101
- elsif (byte & 0xE0) == 0x20
102
- name_len = byte & 0x1F
103
- offset += 1
104
- name = payload[offset, name_len]
105
- offset += name_len
106
-
107
- value_len, len_bytes = HTTP3.decode_varint(payload.bytes, offset)
108
- offset += len_bytes
109
- value = payload[offset, value_len]
110
- offset += value_len
111
-
112
- @headers[name] = value
113
- else
114
- break
115
- end
116
- end
117
- end
118
-
119
- def decode_static_table_field(index)
120
- return nil if index >= HTTP3::STATIC_TABLE.size
121
-
122
- name, value = HTTP3::STATIC_TABLE[index]
123
-
124
- if value.empty?
125
- name
126
- else
127
- {name => value}
128
- end
129
- end
130
-
131
- # Decode prefix integer (RFC 7541)
132
- # Returns [value, bytes_consumed]
133
- def decode_prefix_integer(bytes, offset, prefix_bits, pattern_mask)
134
- max_prefix = (1 << prefix_bits) - 1 # 2^N - 1
135
-
136
- first_byte = bytes[offset]
137
- value = first_byte & max_prefix
138
- bytes_consumed = 1
139
-
140
- # If all prefix bits are 1, value continues in next byte(s)
141
- if value == max_prefix
142
- multiplier = 1
143
- loop do
144
- return [value, bytes_consumed] if offset + bytes_consumed >= bytes.size
145
-
146
- next_byte = bytes[offset + bytes_consumed]
147
- bytes_consumed += 1
148
-
149
- value += (next_byte & 0x7F) * multiplier
150
- break if (next_byte & 0x80) == 0 # MSB=0 means last byte
151
-
152
- multiplier *= 128
153
- end
154
- end
155
-
156
- [value, bytes_consumed]
157
- end
158
- end
159
- end
160
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Quicsilver
4
- class ListenerData
5
- attr_reader :listener_handle, :context_handle, :started, :stopped, :failed, :configuration
6
-
7
- def initialize(listener_handle, context_handle)
8
- @listener_handle = listener_handle # The MSQUIC listener handle
9
- @context_handle = context_handle # The C context pointer
10
- # NOTE: Fetch this from the context handle, or improve return values from the C extension
11
- @started = false
12
- @stopped = false
13
- @failed = false
14
- @configuration = nil
15
- end
16
-
17
- def started?
18
- @started
19
- end
20
-
21
- def stopped?
22
- @stopped
23
- end
24
-
25
- def failed?
26
- @failed
27
- end
28
- end
29
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Quicsilver
4
- class QuicStream
5
- attr_reader :stream_id, :is_unidirectional, :buffer
6
- attr_accessor :stream_handle
7
-
8
- def initialize(stream_id, is_unidirectional: nil)
9
- @stream_id = stream_id
10
- @is_unidirectional = is_unidirectional.nil? ? !bidirectional? : is_unidirectional
11
- @buffer = StringIO.new.tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
12
- @stream_handle = nil
13
- end
14
-
15
- def bidirectional?
16
- (stream_id & 0x02) == 0
17
- end
18
-
19
- def ready_to_send?
20
- !stream_handle.nil?
21
- end
22
-
23
- def append_data(data)
24
- @buffer.write(data)
25
- end
26
-
27
- def data
28
- @buffer.string
29
- end
30
-
31
- def clear_buffer
32
- @buffer.truncate(0)
33
- @buffer.rewind
34
- end
35
- end
36
- end
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Quicsilver
4
- class RequestRegistry
5
- def initialize
6
- @requests = {}
7
- @mutex = Mutex.new
8
- end
9
-
10
- def track(stream_id, connection_handle, path:, method:, started_at: Time.now)
11
- @mutex.synchronize do
12
- @requests[stream_id] = {
13
- connection_handle: connection_handle,
14
- path: path,
15
- method: method,
16
- started_at: started_at
17
- }
18
- end
19
- end
20
-
21
- def complete(stream_id)
22
- @mutex.synchronize { @requests.delete(stream_id) }
23
- end
24
-
25
- def active_count
26
- @mutex.synchronize { @requests.size }
27
- end
28
-
29
- def active_requests
30
- @mutex.synchronize { @requests.dup }
31
- end
32
-
33
- def requests_older_than(seconds)
34
- cutoff = Time.now - seconds
35
- @mutex.synchronize do
36
- @requests.select { |_, r| r[:started_at] < cutoff }
37
- end
38
- end
39
-
40
- def empty?
41
- @mutex.synchronize { @requests.empty? }
42
- end
43
-
44
- def include?(stream_id)
45
- @mutex.synchronize { @requests.key?(stream_id) }
46
- end
47
- end
48
- end