solid-resp-ractor 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5a7e50be310bd21524ea7a372f0f47b4d463d78ab6710df53a807433fa64c096
4
+ data.tar.gz: 88310b01fef02d445ac6a7c1c7d5b097bdcb68c80b8657c65dff638b1d9b090a
5
+ SHA512:
6
+ metadata.gz: 0fe96b665160e1ea048e3fd18a1f6dc43a09423e7aceb6e5a27898590edfcbdc39ae798235cac25b5ec33a64e685dd7ccef4c8147c77823eda426aced1cd9522
7
+ data.tar.gz: 211d591e7acc734020c4641dd33915f7991ac55aa3bddcf674a5001eab4d1f0c91e2959e9f3beb036da17b971b970ec9dc1c4af4d11e787e279e947cf9036ee0
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ ## [0.1.0] - 2026-09-29
6
+
7
+ ### Added
8
+
9
+ - Dependency-free RESP2/RESP3 stream reader.
10
+ - Immutable command encoder with injectable argument conversion.
11
+ - Injectable byte sources, selectors, clocks, error mappers, and value
12
+ handlers.
13
+ - Compatible and typed RESP3 handling modes.
14
+ - Configurable limits for blob size, collection cardinality, nesting depth,
15
+ and line length.
16
+ - Bounded stress coverage for large pipelines, fragmented streams, malformed
17
+ input, and parallel Ractors.
18
+ - Ractor-shareable default encoder with no global mutable state.
19
+
20
+ [0.1.0]: https://github.com/nicolasva/solid-resp-ractor/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicolas Vandenbogaerde
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,262 @@
1
+ # Solid RESP Ractor
2
+
3
+ `solid-resp-ractor` is a dependency-free RESP2/RESP3 codec for Ruby. It keeps
4
+ protocol parsing independent from Redis client behavior and provides explicit
5
+ extension points for transports, event loops, error hierarchies, value
6
+ representation, and command argument conversion.
7
+
8
+ The gem has no global mutable state. Immutable encoder instances can be shared
9
+ between Ractors; readers remain local to the Ractor that owns their IO.
10
+
11
+ ## Installation
12
+
13
+ Add to your Gemfile:
14
+
15
+ ```ruby
16
+ gem "solid-resp-ractor"
17
+ ```
18
+
19
+ Then run:
20
+
21
+ ```sh
22
+ bundle install
23
+ ```
24
+
25
+ ## Encoding commands
26
+
27
+ ```ruby
28
+ require "solid_resp_ractor"
29
+
30
+ SolidRespRactor.encode(["SET", "key", "value"])
31
+ # => "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n"
32
+ ```
33
+
34
+ Arrays are expanded by one level, which supports APIs that group command
35
+ arguments:
36
+
37
+ ```ruby
38
+ SolidRespRactor.encode(["MSET", ["first", 1], ["second", 2]])
39
+ ```
40
+
41
+ Use an immutable custom encoder for domain-specific values:
42
+
43
+ ```ruby
44
+ argument_encoder = Object.new
45
+
46
+ def argument_encoder.call(value)
47
+ value.respond_to?(:to_wire) ? value.to_wire : value.to_s
48
+ end
49
+
50
+ argument_encoder.freeze
51
+
52
+ encoder = SolidRespRactor::Encoder.new(argument_encoder: argument_encoder)
53
+ encoder.encode(["SET", "key", custom_value])
54
+ ```
55
+
56
+ Set `expand_arrays: false` when arrays represent individual values rather than
57
+ argument groups.
58
+
59
+ ## Reusable codec configurations
60
+
61
+ `Codec` groups all extension points into one immutable object:
62
+
63
+ ```ruby
64
+ codec = SolidRespRactor::Codec.new(
65
+ encoder: custom_encoder,
66
+ handler: SolidRespRactor::Handlers::Typed,
67
+ error_mapper: error_mapper,
68
+ limits: SolidRespRactor::Limits.new(max_blob_size: 64 * 1024 * 1024),
69
+ chunk_size: 32_768,
70
+ )
71
+
72
+ payload = codec.encode(["PING"])
73
+ reader = codec.reader(socket, read_timeout: 1.0)
74
+ ```
75
+
76
+ `SolidRespRactor::DEFAULT_CODEC` is Ractor-shareable. A custom codec is also
77
+ shareable when all injected collaborators are shareable.
78
+
79
+ ## Reading RESP streams
80
+
81
+ The default reader accepts blocking IO objects such as `StringIO`, as well as
82
+ non-blocking TCP, Unix, and TLS-compatible streams:
83
+
84
+ ```ruby
85
+ socket = TCPSocket.new("127.0.0.1", 6379)
86
+ reader = SolidRespRactor.reader(socket, read_timeout: 1.0)
87
+
88
+ socket.write(SolidRespRactor.encode(["PING"]))
89
+ reader.read
90
+ # => "PONG"
91
+ ```
92
+
93
+ Use `exception: false` for pipelines or proxies that need error values:
94
+
95
+ ```ruby
96
+ result = reader.read(exception: false)
97
+
98
+ if result.is_a?(SolidRespRactor::ResponseError)
99
+ warn result.message
100
+ end
101
+ ```
102
+
103
+ Nested errors are raised only after the complete aggregate has been consumed,
104
+ so the next frame remains synchronized.
105
+
106
+ ## RESP3 types
107
+
108
+ The compatible handler returns ordinary Ruby values and intentionally unwraps
109
+ RESP3 sets, pushes, verbatim strings, and attributes:
110
+
111
+ ```ruby
112
+ reader = SolidRespRactor::Reader.new(io)
113
+ ```
114
+
115
+ Use the typed handler when those distinctions matter:
116
+
117
+ ```ruby
118
+ reader = SolidRespRactor::Reader.new(
119
+ io,
120
+ handler: SolidRespRactor::Handlers::Typed,
121
+ )
122
+
123
+ case (value = reader.read)
124
+ when SolidRespRactor::Types::Push
125
+ process_push(value.value)
126
+ when SolidRespRactor::Types::Attribute
127
+ process(value.value, metadata: value.attributes)
128
+ end
129
+ ```
130
+
131
+ Typed values include:
132
+
133
+ - `SolidRespRactor::Types::Set`
134
+ - `SolidRespRactor::Types::Push`
135
+ - `SolidRespRactor::Types::Verbatim`
136
+ - `SolidRespRactor::Types::Attribute`
137
+
138
+ RESP3 streamed blob strings, arrays, maps, and sets are supported as well as
139
+ special double values (`inf`, `-inf`, and `nan`).
140
+
141
+ ## Resource limits
142
+
143
+ The default immutable limits protect generic protocol consumers from
144
+ unbounded allocations and nesting:
145
+
146
+ | Limit | Default |
147
+ |---|---:|
148
+ | Blob or chunked string | 512 MiB |
149
+ | Collection cardinality | 1,000,000 |
150
+ | Nesting depth | 128 |
151
+ | Line length | 64 KiB |
152
+
153
+ Provide stricter limits for untrusted peers:
154
+
155
+ ```ruby
156
+ limits = SolidRespRactor::Limits.new(
157
+ max_blob_size: 8 * 1024 * 1024,
158
+ max_collection_size: 10_000,
159
+ max_nesting_depth: 32,
160
+ max_line_size: 8 * 1024,
161
+ )
162
+
163
+ codec = SolidRespRactor::Codec.new(limits: limits)
164
+ ```
165
+
166
+ ## Extension points
167
+
168
+ ### Custom error mapping
169
+
170
+ Map RESP error frames into an application's existing exception hierarchy:
171
+
172
+ ```ruby
173
+ error_mapper = lambda do |message, blob:|
174
+ MyProtocolError.new(message)
175
+ end
176
+
177
+ reader = SolidRespRactor::Reader.new(io, error_mapper: error_mapper)
178
+ ```
179
+
180
+ The mapper must return an `Exception`.
181
+
182
+ ### Custom value handling
183
+
184
+ Every decoded value can be transformed:
185
+
186
+ ```ruby
187
+ handler = lambda do |type, value|
188
+ Event.new(type:, value:)
189
+ end
190
+
191
+ reader = SolidRespRactor::Reader.new(io, handler: handler)
192
+ ```
193
+
194
+ ### Custom byte sources
195
+
196
+ A source only needs:
197
+
198
+ ```ruby
199
+ class Source
200
+ def read(timeout:)
201
+ # Return a non-empty String, or nil at EOF.
202
+ end
203
+
204
+ def wait_readable(timeout)
205
+ # Optional polling API.
206
+ end
207
+ end
208
+
209
+ reader = SolidRespRactor::Reader.new(source: Source.new)
210
+ ```
211
+
212
+ This allows integration with event loops, in-memory transports, framed
213
+ protocols, instrumentation, or test fixtures without changing the parser.
214
+
215
+ The built-in `Sources::IO` also accepts custom `selector` and `clock` objects.
216
+
217
+ ## Timeouts and blocking commands
218
+
219
+ `read_timeout: nil` waits indefinitely. Temporarily override a timeout without
220
+ rebuilding the reader:
221
+
222
+ ```ruby
223
+ reader.with_timeout(nil) do
224
+ reader.read
225
+ end
226
+ ```
227
+
228
+ `wait_readable(timeout)` polls without consuming bytes, which is useful for
229
+ subscriptions and event-driven clients.
230
+
231
+ ## Ractor usage
232
+
233
+ The default encoder is shareable:
234
+
235
+ ```ruby
236
+ encoder = SolidRespRactor::DEFAULT_ENCODER
237
+
238
+ ractor = Ractor.new(encoder) do |shared_encoder|
239
+ socket = TCPSocket.new("127.0.0.1", 6379)
240
+ reader = SolidRespRactor::Reader.new(socket, read_timeout: 1.0)
241
+
242
+ socket.write(shared_encoder.encode(["PING"]))
243
+ reader.read
244
+ ensure
245
+ socket&.close
246
+ end
247
+
248
+ ractor.take
249
+ ```
250
+
251
+ Create sockets and readers inside their owning Ractor. Custom handlers,
252
+ encoders, selectors, and clocks must themselves be Ractor-shareable if they
253
+ are passed between Ractors.
254
+
255
+ ## Development
256
+
257
+ ```sh
258
+ bundle install
259
+ bundle exec rake
260
+ ```
261
+
262
+ The default task runs the tests and builds the gem.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRespRactor
4
+ class Codec
5
+ attr_reader :encoder, :handler, :error_mapper, :limits, :chunk_size,
6
+ :selector, :clock
7
+
8
+ def initialize(
9
+ encoder: DEFAULT_ENCODER,
10
+ handler: Handlers::Compatible,
11
+ error_mapper: ErrorMappers::Default,
12
+ limits: Limits::DEFAULT,
13
+ chunk_size: 16_384,
14
+ selector: Selectors::IOSelect,
15
+ clock: Clocks::Monotonic
16
+ )
17
+ @encoder = encoder
18
+ @handler = handler
19
+ @error_mapper = error_mapper
20
+ @limits = limits
21
+ @chunk_size = Integer(chunk_size)
22
+ raise ArgumentError, "chunk_size must be positive" unless @chunk_size.positive?
23
+
24
+ @selector = selector
25
+ @clock = clock
26
+ freeze
27
+ end
28
+
29
+ def encode(command)
30
+ @encoder.encode(command)
31
+ end
32
+
33
+ def reader(io = nil, source: nil, read_timeout: nil)
34
+ Reader.new(
35
+ io,
36
+ source: source,
37
+ read_timeout: read_timeout,
38
+ handler: @handler,
39
+ error_mapper: @error_mapper,
40
+ limits: @limits,
41
+ chunk_size: @chunk_size,
42
+ selector: @selector,
43
+ clock: @clock,
44
+ )
45
+ end
46
+ end
47
+
48
+ DEFAULT_CODEC = Codec.new
49
+ Ractor.make_shareable(DEFAULT_CODEC) if defined?(Ractor)
50
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRespRactor
4
+ module ArgumentEncoders
5
+ module Default
6
+ module_function
7
+
8
+ def call(value)
9
+ case value
10
+ when String then value
11
+ when Symbol, Integer, Float then value.to_s
12
+ when true then "1"
13
+ when false then "0"
14
+ when nil then ""
15
+ else value.to_s
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ class Encoder
22
+ CRLF = "\r\n"
23
+
24
+ attr_reader :argument_encoder
25
+
26
+ def initialize(argument_encoder: ArgumentEncoders::Default, expand_arrays: true)
27
+ @argument_encoder = argument_encoder
28
+ @expand_arrays = expand_arrays
29
+ freeze
30
+ end
31
+
32
+ def encode(command)
33
+ length = command.sum { |argument| expanded?(argument) ? argument.length : 1 }
34
+ raise ArgumentError, "RESP command cannot be empty" if length.zero?
35
+
36
+ command.each_with_object(+"*#{length}#{CRLF}") do |argument, buffer|
37
+ if expanded?(argument)
38
+ argument.each { |value| append_argument(buffer, value) }
39
+ else
40
+ append_argument(buffer, argument)
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def expanded?(argument)
48
+ @expand_arrays && argument.is_a?(Array)
49
+ end
50
+
51
+ def append_argument(buffer, argument)
52
+ value = @argument_encoder.call(argument)
53
+ unless value.is_a?(String)
54
+ raise TypeError, "argument encoder must return a String, got #{value.class}"
55
+ end
56
+
57
+ buffer << "$#{value.bytesize}#{CRLF}#{value}#{CRLF}"
58
+ end
59
+ end
60
+
61
+ DEFAULT_ENCODER = Encoder.new
62
+ Ractor.make_shareable(DEFAULT_ENCODER) if defined?(Ractor)
63
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRespRactor
4
+ class Error < StandardError; end
5
+ class ConnectionError < Error; end
6
+ class TimeoutError < ConnectionError; end
7
+ class ProtocolError < Error; end
8
+ class ResponseError < Error; end
9
+ class AuthenticationError < ResponseError; end
10
+
11
+ module ErrorMappers
12
+ module Default
13
+ module_function
14
+
15
+ def call(message, blob:)
16
+ error_class = if message.start_with?("NOAUTH", "WRONGPASS")
17
+ AuthenticationError
18
+ else
19
+ ResponseError
20
+ end
21
+ error_class.new(message)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRespRactor
4
+ class Limits
5
+ attr_reader :max_blob_size, :max_collection_size, :max_nesting_depth,
6
+ :max_line_size
7
+
8
+ def initialize(
9
+ max_blob_size: 512 * 1024 * 1024,
10
+ max_collection_size: 1_000_000,
11
+ max_nesting_depth: 128,
12
+ max_line_size: 64 * 1024
13
+ )
14
+ @max_blob_size = positive_integer(max_blob_size, :max_blob_size)
15
+ @max_collection_size = positive_integer(max_collection_size, :max_collection_size)
16
+ @max_nesting_depth = positive_integer(max_nesting_depth, :max_nesting_depth)
17
+ @max_line_size = positive_integer(max_line_size, :max_line_size)
18
+ freeze
19
+ end
20
+
21
+ private
22
+
23
+ def positive_integer(value, name)
24
+ value = Integer(value)
25
+ raise ArgumentError, "#{name} must be positive" unless value.positive?
26
+
27
+ value
28
+ end
29
+
30
+ DEFAULT = new
31
+ Ractor.make_shareable(DEFAULT) if defined?(Ractor)
32
+ end
33
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRespRactor
4
+ class Reader
5
+ CRLF = "\r\n"
6
+
7
+ attr_reader :source
8
+
9
+ def initialize(
10
+ io = nil,
11
+ source: nil,
12
+ read_timeout: nil,
13
+ handler: Handlers::Compatible,
14
+ error_mapper: ErrorMappers::Default,
15
+ limits: Limits::DEFAULT,
16
+ chunk_size: 16_384,
17
+ selector: Selectors::IOSelect,
18
+ clock: Clocks::Monotonic
19
+ )
20
+ if io && source
21
+ raise ArgumentError, "pass either io or source, not both"
22
+ end
23
+ raise ArgumentError, "io or source is required" unless io || source
24
+
25
+ @source = source || Sources::IO.new(
26
+ io,
27
+ chunk_size: chunk_size,
28
+ selector: selector,
29
+ clock: clock,
30
+ )
31
+ @read_timeout = read_timeout
32
+ @handler = handler
33
+ @error_mapper = error_mapper
34
+ @limits = limits
35
+ @buffer = +""
36
+ @offset = 0
37
+ @read_depth = 0
38
+ @captured_error = nil
39
+ end
40
+
41
+ def with_timeout(timeout)
42
+ previous = @read_timeout
43
+ @read_timeout = timeout
44
+ yield
45
+ ensure
46
+ @read_timeout = previous
47
+ end
48
+
49
+ def wait_readable(timeout)
50
+ return true if available_bytes.positive?
51
+ return true unless @source.respond_to?(:wait_readable)
52
+
53
+ @source.wait_readable(timeout)
54
+ end
55
+
56
+ def read(exception: true)
57
+ top_level = @read_depth.zero?
58
+ @captured_error = nil if top_level
59
+ @read_depth += 1
60
+ if @read_depth > @limits.max_nesting_depth
61
+ raise ProtocolError, "RESP nesting exceeds #{@limits.max_nesting_depth}"
62
+ end
63
+ value = read_type(read_bytes(1))
64
+ raise @captured_error if top_level && exception && @captured_error
65
+
66
+ value
67
+ rescue EOFError
68
+ raise ConnectionError, "RESP stream closed"
69
+ ensure
70
+ @read_depth -= 1
71
+ end
72
+
73
+ private
74
+
75
+ def read_type(type)
76
+ case type
77
+ when "+" then handle(:simple_string, read_line)
78
+ when "-" then error_response(read_line, blob: false)
79
+ when ":" then handle(:integer, parse_integer(read_line))
80
+ when "$" then read_blob
81
+ when "*" then read_array
82
+ when "_" then read_null
83
+ when "#" then read_boolean
84
+ when "," then handle(:double, parse_float(read_line))
85
+ when "(" then handle(:big_number, parse_integer(read_line))
86
+ when "%" then read_map
87
+ when "~" then read_collection(:set)
88
+ when ">" then read_collection(:push)
89
+ when "=" then read_verbatim
90
+ when "!" then error_response(read_sized_string(streamed: true), blob: true)
91
+ when "|" then read_attribute
92
+ else raise ProtocolError, "Unknown RESP type byte: #{type.inspect}"
93
+ end
94
+ end
95
+
96
+ def read_blob
97
+ length = read_length(nullable: true, streamed: true)
98
+ return handle(:blob_string, nil) unless length
99
+
100
+ validate_blob_length(length) unless length == :streamed
101
+ value = length == :streamed ? read_chunked_string : read_sized_value(length)
102
+ handle(:blob_string, value)
103
+ end
104
+
105
+ def read_array
106
+ length = read_length(nullable: true, streamed: true)
107
+ return handle(:array, nil) unless length
108
+
109
+ validate_collection_length(length) unless length == :streamed
110
+ value = if length == :streamed
111
+ read_streamed_collection
112
+ else
113
+ Array.new(length) { read(exception: false) }
114
+ end
115
+ handle(:array, value)
116
+ end
117
+
118
+ def read_collection(type)
119
+ length = read_length(streamed: true)
120
+ validate_collection_length(length) unless length == :streamed
121
+ value = if length == :streamed
122
+ read_streamed_collection
123
+ else
124
+ Array.new(length) { read(exception: false) }
125
+ end
126
+ handle(type, value)
127
+ end
128
+
129
+ def read_map
130
+ length = read_length(streamed: true)
131
+ validate_collection_length(length) unless length == :streamed
132
+ value = if length == :streamed
133
+ read_streamed_map
134
+ else
135
+ {}.tap do |result|
136
+ length.times { result[read(exception: false)] = read(exception: false) }
137
+ end
138
+ end
139
+ handle(:map, value)
140
+ end
141
+
142
+ def read_streamed_collection
143
+ values = []
144
+ loop do
145
+ type = read_bytes(1)
146
+ break if aggregate_end?(type)
147
+
148
+ values << read_type(type)
149
+ validate_collection_length(values.length)
150
+ end
151
+ values
152
+ end
153
+
154
+ def read_streamed_map
155
+ {}.tap do |result|
156
+ count = 0
157
+ loop do
158
+ type = read_bytes(1)
159
+ break if aggregate_end?(type)
160
+
161
+ key = read_type(type)
162
+ result[key] = read(exception: false)
163
+ count += 1
164
+ validate_collection_length(count)
165
+ end
166
+ end
167
+ end
168
+
169
+ def aggregate_end?(type)
170
+ return false unless type == "."
171
+
172
+ value = read_line
173
+ raise ProtocolError, "RESP aggregate terminator must not contain data" unless value.empty?
174
+
175
+ true
176
+ end
177
+
178
+ def read_attribute
179
+ attributes = read_map
180
+ value = read(exception: false)
181
+ handle(:attribute, [attributes, value])
182
+ end
183
+
184
+ def read_null
185
+ value = read_line
186
+ raise ProtocolError, "RESP null must not contain data" unless value.empty?
187
+
188
+ handle(:null, nil)
189
+ end
190
+
191
+ def read_boolean
192
+ case (value = read_line)
193
+ when "t" then handle(:boolean, true)
194
+ when "f" then handle(:boolean, false)
195
+ else raise ProtocolError, "Invalid RESP boolean: #{value.inspect}"
196
+ end
197
+ end
198
+
199
+ def read_verbatim
200
+ value = read_sized_string(streamed: true)
201
+ unless value.bytesize >= 4 && value.byteslice(3, 1) == ":"
202
+ raise ProtocolError, "Invalid RESP verbatim string"
203
+ end
204
+
205
+ handle(:verbatim, [value.byteslice(0, 3).freeze, value.byteslice(4..)])
206
+ end
207
+
208
+ def read_sized_string(streamed: false)
209
+ length = read_length(streamed: streamed)
210
+ validate_blob_length(length) unless length == :streamed
211
+ length == :streamed ? read_chunked_string : read_sized_value(length)
212
+ end
213
+
214
+ def read_chunked_string
215
+ buffer = +""
216
+ loop do
217
+ type = read_bytes(1)
218
+ raise ProtocolError, "Expected RESP chunk, got #{type.inspect}" unless type == ";"
219
+
220
+ length = read_length
221
+ break if length.zero?
222
+
223
+ validate_blob_length(buffer.bytesize + length)
224
+ buffer << read_sized_value(length)
225
+ end
226
+ buffer
227
+ end
228
+
229
+ def read_sized_value(length)
230
+ value = read_bytes(length)
231
+ actual = read_bytes(2)
232
+ raise ProtocolError, "Expected CRLF, got #{actual.inspect}" unless actual == CRLF
233
+
234
+ value
235
+ end
236
+
237
+ def read_length(nullable: false, streamed: false)
238
+ value = read_line
239
+ return :streamed if streamed && value == "?"
240
+
241
+ length = parse_integer(value)
242
+ return if nullable && length == -1
243
+ raise ProtocolError, "Invalid RESP length: #{length}" if length.negative?
244
+
245
+ length
246
+ end
247
+
248
+ def parse_integer(value)
249
+ Integer(value)
250
+ rescue ArgumentError
251
+ raise ProtocolError, "Invalid RESP integer: #{value.inspect}"
252
+ end
253
+
254
+ def parse_float(value)
255
+ return Float::INFINITY if value == "inf"
256
+ return -Float::INFINITY if value == "-inf"
257
+ return Float::NAN if value == "nan"
258
+
259
+ Float(value)
260
+ rescue ArgumentError
261
+ raise ProtocolError, "Invalid RESP double: #{value.inspect}"
262
+ end
263
+
264
+ def read_line
265
+ loop do
266
+ if (index = @buffer.index(CRLF, @offset))
267
+ line_length = index - @offset
268
+ if line_length > @limits.max_line_size
269
+ raise ProtocolError, "RESP line exceeds #{@limits.max_line_size} bytes"
270
+ end
271
+ value = @buffer.byteslice(@offset, line_length)
272
+ @offset = index + 2
273
+ clear_consumed_buffer
274
+ return value
275
+ end
276
+ if available_bytes > @limits.max_line_size
277
+ raise ProtocolError, "RESP line exceeds #{@limits.max_line_size} bytes"
278
+ end
279
+ fill_buffer
280
+ end
281
+ end
282
+
283
+ def read_bytes(length)
284
+ fill_buffer while available_bytes < length
285
+ value = @buffer.byteslice(@offset, length)
286
+ @offset += length
287
+ clear_consumed_buffer
288
+ value
289
+ end
290
+
291
+ def fill_buffer
292
+ compact_buffer
293
+ chunk = @source.read(timeout: @read_timeout)
294
+ raise EOFError if chunk.nil?
295
+ unless chunk.is_a?(String)
296
+ raise ProtocolError, "RESP source must return a String or nil, got #{chunk.class}"
297
+ end
298
+ raise ProtocolError, "RESP source returned an empty chunk" if chunk.empty?
299
+
300
+ @buffer << chunk
301
+ end
302
+
303
+ def available_bytes
304
+ @buffer.bytesize - @offset
305
+ end
306
+
307
+ def clear_consumed_buffer
308
+ return unless @offset == @buffer.bytesize
309
+
310
+ @buffer.clear
311
+ @offset = 0
312
+ end
313
+
314
+ def compact_buffer
315
+ return if @offset.zero?
316
+
317
+ @buffer = @buffer.byteslice(@offset..) || +""
318
+ @offset = 0
319
+ end
320
+
321
+ def error_response(message, blob:)
322
+ error = @error_mapper.call(message, blob: blob)
323
+ unless error.is_a?(Exception)
324
+ raise TypeError, "error mapper must return an Exception, got #{error.class}"
325
+ end
326
+ @captured_error ||= error
327
+ error
328
+ end
329
+
330
+ def validate_blob_length(length)
331
+ return if length <= @limits.max_blob_size
332
+
333
+ raise ProtocolError, "RESP blob exceeds #{@limits.max_blob_size} bytes"
334
+ end
335
+
336
+ def validate_collection_length(length)
337
+ return if length <= @limits.max_collection_size
338
+
339
+ raise ProtocolError,
340
+ "RESP collection exceeds #{@limits.max_collection_size} elements"
341
+ end
342
+
343
+ def handle(type, value)
344
+ @handler.call(type, value)
345
+ end
346
+ end
347
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRespRactor
4
+ module Clocks
5
+ module Monotonic
6
+ module_function
7
+
8
+ def now
9
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+ end
11
+ end
12
+ end
13
+
14
+ module Selectors
15
+ module IOSelect
16
+ module_function
17
+
18
+ def call(readers, writers, timeout)
19
+ IO.select(readers, writers, nil, timeout)
20
+ end
21
+ end
22
+ end
23
+
24
+ module Sources
25
+ class IO
26
+ def initialize(
27
+ io,
28
+ chunk_size: 16_384,
29
+ selector: Selectors::IOSelect,
30
+ clock: Clocks::Monotonic
31
+ )
32
+ @io = io
33
+ @chunk_size = Integer(chunk_size)
34
+ raise ArgumentError, "chunk_size must be positive" unless @chunk_size.positive?
35
+
36
+ @selector = selector
37
+ @clock = clock
38
+ end
39
+
40
+ def read(timeout:)
41
+ return blocking_read unless @io.respond_to?(:read_nonblock)
42
+
43
+ deadline = nil
44
+ loop do
45
+ chunk = @io.read_nonblock(@chunk_size, exception: false)
46
+ return chunk unless chunk == :wait_readable || chunk == :wait_writable
47
+
48
+ deadline ||= @clock.now + timeout if timeout
49
+ remaining = deadline && deadline - @clock.now
50
+ raise_timeout(timeout) if remaining && remaining <= 0
51
+
52
+ selectable = @io.respond_to?(:to_io) ? @io.to_io : @io
53
+ readers = chunk == :wait_readable ? [selectable] : nil
54
+ writers = chunk == :wait_writable ? [selectable] : nil
55
+ raise_timeout(timeout) unless @selector.call(readers, writers, remaining)
56
+ end
57
+ rescue IOError, SystemCallError => error
58
+ raise ConnectionError, error.message, cause: error
59
+ end
60
+
61
+ def wait_readable(timeout)
62
+ return true unless @io.respond_to?(:to_io)
63
+
64
+ !@selector.call([@io.to_io], nil, timeout).nil?
65
+ rescue IOError, SystemCallError => error
66
+ raise ConnectionError, error.message, cause: error
67
+ end
68
+
69
+ private
70
+
71
+ def blocking_read
72
+ @io.read(@chunk_size)
73
+ end
74
+
75
+ def raise_timeout(timeout)
76
+ raise TimeoutError, "RESP read timed out after #{timeout}s"
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRespRactor
4
+ module Types
5
+ class Collection
6
+ attr_reader :value
7
+
8
+ def initialize(value)
9
+ @value = value
10
+ freeze
11
+ end
12
+
13
+ def ==(other)
14
+ other.instance_of?(self.class) && other.value == value
15
+ end
16
+ end
17
+
18
+ class Set < Collection; end
19
+ class Push < Collection; end
20
+
21
+ class Verbatim
22
+ attr_reader :format, :data
23
+
24
+ def initialize(format, data)
25
+ @format = format.freeze
26
+ @data = data
27
+ freeze
28
+ end
29
+
30
+ def ==(other)
31
+ other.instance_of?(self.class) && other.format == format && other.data == data
32
+ end
33
+ end
34
+
35
+ class Attribute
36
+ attr_reader :attributes, :value
37
+
38
+ def initialize(attributes, value)
39
+ @attributes = attributes
40
+ @value = value
41
+ freeze
42
+ end
43
+
44
+ def ==(other)
45
+ other.instance_of?(self.class) &&
46
+ other.attributes == attributes &&
47
+ other.value == value
48
+ end
49
+ end
50
+ end
51
+
52
+ module Handlers
53
+ module Compatible
54
+ module_function
55
+
56
+ def call(type, value)
57
+ case type
58
+ when :verbatim then value.fetch(1)
59
+ when :attribute then value.fetch(1)
60
+ else value
61
+ end
62
+ end
63
+ end
64
+
65
+ module Typed
66
+ module_function
67
+
68
+ def call(type, value)
69
+ case type
70
+ when :set then Types::Set.new(value)
71
+ when :push then Types::Push.new(value)
72
+ when :verbatim then Types::Verbatim.new(*value)
73
+ when :attribute then Types::Attribute.new(*value)
74
+ else value
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRespRactor
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "solid_resp_ractor/version"
4
+ require_relative "solid_resp_ractor/errors"
5
+ require_relative "solid_resp_ractor/types"
6
+ require_relative "solid_resp_ractor/limits"
7
+ require_relative "solid_resp_ractor/encoder"
8
+ require_relative "solid_resp_ractor/source"
9
+ require_relative "solid_resp_ractor/reader"
10
+ require_relative "solid_resp_ractor/codec"
11
+
12
+ module SolidRespRactor
13
+ module_function
14
+
15
+ def encode(command, encoder: DEFAULT_ENCODER)
16
+ encoder.encode(command)
17
+ end
18
+
19
+ def reader(io = nil, source: nil, read_timeout: nil, codec: DEFAULT_CODEC)
20
+ codec.reader(io, source: source, read_timeout: read_timeout)
21
+ end
22
+ end
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: solid-resp-ractor
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nicolas Vandenbogaerde
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '5'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '7'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '5'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '7'
32
+ - !ruby/object:Gem::Dependency
33
+ name: rake
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '13.0'
39
+ type: :development
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '13.0'
46
+ description: Dependency-free RESP command encoding and stream decoding with injectable
47
+ sources, selectors, clocks, error mappers, and RESP3 type handlers.
48
+ executables: []
49
+ extensions: []
50
+ extra_rdoc_files: []
51
+ files:
52
+ - CHANGELOG.md
53
+ - LICENSE.txt
54
+ - README.md
55
+ - lib/solid_resp_ractor.rb
56
+ - lib/solid_resp_ractor/codec.rb
57
+ - lib/solid_resp_ractor/encoder.rb
58
+ - lib/solid_resp_ractor/errors.rb
59
+ - lib/solid_resp_ractor/limits.rb
60
+ - lib/solid_resp_ractor/reader.rb
61
+ - lib/solid_resp_ractor/source.rb
62
+ - lib/solid_resp_ractor/types.rb
63
+ - lib/solid_resp_ractor/version.rb
64
+ homepage: https://github.com/nicolasva/solid-resp-ractor
65
+ licenses:
66
+ - MIT
67
+ metadata:
68
+ rubygems_mfa_required: 'true'
69
+ source_code_uri: https://github.com/nicolasva/solid-resp-ractor
70
+ changelog_uri: https://github.com/nicolasva/solid-resp-ractor/blob/main/CHANGELOG.md
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '3.1'
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 4.0.20
86
+ specification_version: 4
87
+ summary: A modular, Ractor-friendly RESP2/RESP3 codec
88
+ test_files: []