mfp 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: 3525ca738b1b2808085c13bc5695f1c5828a15997dec33262b7d3ae70f327a22
4
+ data.tar.gz: 70b05bc2f1dcf64fb5dd1ccc504f0e48ab5f41f1dfe1eeff2db15cc147712284
5
+ SHA512:
6
+ metadata.gz: 90a6de0a911c51be9e2c8786109f3670b630c4a1c8f159a0e6d3de5e31ea76d1979ef809d94cae30731244a8b7969c885991c34fa4685513a17731e1e9fafe82
7
+ data.tar.gz: 17ddea6ff55b8c351be36918935e4f7ae15c8a86756d1836c0dc06272b62e72ba209f91562ddb4b93e90b9214b7c38e1bd458189c2cb53013fd67ed7880b2978
data/.editorconfig ADDED
@@ -0,0 +1,10 @@
1
+ root = true
2
+
3
+ [*]
4
+ end_of_line = lf
5
+ insert_final_newline = true
6
+
7
+ [*.rb]
8
+ charset = utf-8
9
+ indent_style = space
10
+ indent_size = 2
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ ruby-4.0.6
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "mfp" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["hey@vito.io"](mailto:"hey@vito.io").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Vito Sartori
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # Mfp
2
+
3
+ TODO: Delete this and the text below, and describe your gem
4
+
5
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/mfp`. To experiment with that code, run `bin/console` for an interactive prompt.
6
+
7
+ ## Installation
8
+
9
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ ```bash
14
+ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
15
+ ```
16
+
17
+ If bundler is not being used to manage dependencies, install the gem by executing:
18
+
19
+ ```bash
20
+ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/mfp. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/mfp/blob/master/CODE_OF_CONDUCT.md).
36
+
37
+ ## License
38
+
39
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
40
+
41
+ ## Code of Conduct
42
+
43
+ Everyone interacting in the Mfp project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/mfp/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/lib/mfp/client.rb ADDED
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mfp
4
+ class Client
5
+ Settings = Data.define(
6
+ :ca_certs,
7
+ :key,
8
+ :cert,
9
+ :insecure,
10
+ :application,
11
+ :hostname,
12
+ :disable_compression,
13
+ :idle_timeout_secs,
14
+ :ping_timeout_secs,
15
+ :logger,
16
+ )
17
+
18
+ DEFAULT_OPTS = {
19
+ ca_certs: [],
20
+ key: nil,
21
+ cert: nil,
22
+ insecure: false,
23
+ application: nil,
24
+ hostname: Socket.gethostname,
25
+ disable_compression: false,
26
+ idle_timeout_secs: 30,
27
+ ping_timeout_secs: 10,
28
+ logger: Logrb.noop,
29
+ }.freeze
30
+
31
+ attr_reader :max_streams, :max_payload
32
+
33
+ def initialize(host, port, call_sign, **)
34
+ raise InvalidCallSignLengthError, "call_sign must have 4 bytes" if call_sign.bytesize != 4
35
+
36
+ # State
37
+
38
+ @running = true
39
+ @to_write = Async::Queue.new
40
+ @to_read = Async::Queue.new
41
+ @call_sign = call_sign
42
+ @max_streams = nil
43
+ # this is set initially to zero so we can transmit the HELLO payload.
44
+ # Once the handshake is completed, this value will be replaced with
45
+ # whatever the server returns to us.
46
+ @max_payload = 0
47
+ @inner_tasks = []
48
+
49
+ # Networking
50
+
51
+ @host = host
52
+ @port = port
53
+ @settings = Settings.new(**DEFAULT_OPTS.dup, **)
54
+
55
+ @logger = @settings.logger
56
+
57
+ @endpoint = if (@settings.cert && @settings.key) || !@settings.ca_certs.empty? || @settings.insecure
58
+ @logger.debug("Starting TLS client", hostname: host, port:)
59
+ ctx = Mfp.default_tls_context
60
+ ctx.cert = OpenSSL::X509::Certificate.new(@settings.cert) if @settings.cert
61
+ ctx.key = OpenSSL::PKey.read(@settings.key) if @settings.key
62
+ ctx.verify_mode = @settings.insecure ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
63
+ store = OpenSSL::X509::Store.new
64
+ store.set_default_paths
65
+ @settings.ca_certs.each do |raw|
66
+ store.add_cert(OpenSSL::X509::Certificate.new(raw))
67
+ end
68
+ ctx.cert_store = store
69
+ IO::Endpoint.ssl(host, port, ssl_context: ctx)
70
+ else
71
+ @logger.debug("Starting plain client", hostname: host, port:)
72
+ IO::Endpoint.tcp(host, port)
73
+ end
74
+
75
+ @compressor = Compressor.new(:identity)
76
+ @ping_handler = PingHandler.new(
77
+ idle_timeout: @settings.idle_timeout_secs,
78
+ ping_timeout: @settings.ping_timeout_secs,
79
+ )
80
+ end
81
+
82
+ def start(task)
83
+ @task = task.async do |t|
84
+ @socket = @endpoint.connect
85
+ @inner_tasks << t.async { handle_writes }
86
+ setup
87
+ end
88
+
89
+ begin
90
+ @task.wait
91
+ rescue StandardError => e
92
+ stop_inner_tasks!
93
+ raise Mfp::Error, "handshake failed: #{e.message}"
94
+ end
95
+
96
+ @inner_tasks << task.async { handle_reads }
97
+ @inner_tasks << task.async { @ping_handler.start(it) }
98
+ @inner_tasks << task.async { handle_pings }
99
+ end
100
+
101
+ def close
102
+ return unless @running
103
+
104
+ abort!(reason: CONNECTION_EVENT_CODE_DISCONNECTED)
105
+ nil
106
+ end
107
+
108
+ def recv = @to_read.dequeue
109
+
110
+ def send_stream(id, msg, flags: 0)
111
+ send(Proto::Frame.new(
112
+ stream_id: id,
113
+ kind: Proto::MessageKind::STREAM,
114
+ flags:,
115
+ payload: msg,
116
+ length: msg&.bytesize || 0,
117
+ ))
118
+ end
119
+
120
+ def send_control(id, msg, flags: 0)
121
+ send(Proto::Frame.new(
122
+ stream_id: id,
123
+ kind: Proto::MessageKind::CONTROL,
124
+ flags:,
125
+ payload: msg,
126
+ length: msg&.bytesize || 0,
127
+ ))
128
+ end
129
+
130
+ private
131
+
132
+ def terminate_error(code)
133
+ send(Proto::Error.new(code, terminal: true).encode(0))
134
+ rescue StandardError
135
+ # noop
136
+ ensure
137
+ abort!(reason: CONNECTION_EVENT_CODE_BROKEN)
138
+ end
139
+
140
+ def send(frame)
141
+ size = frame.payload&.bytesize || 0
142
+ raise PayloadMustBeLessThan4GBError if size >= 0xFFFFFFFF
143
+ raise PayloadTooLargeError if !@max_payload.zero? && size > @max_payload
144
+ raise ClientClosedError unless @running
145
+
146
+ out = OutgoingMessage.new(frame)
147
+ @logger.debug("Send frame", frame: frame.inspect)
148
+ err = Async::Task.current.defer_cancel do
149
+ @to_write.enqueue out
150
+ out.wait
151
+ end
152
+ raise err if err
153
+ end
154
+
155
+ def setup
156
+ caps = 0
157
+ unless @settings.disable_compression
158
+ caps |= Proto::Capability::COMPRESSION_BROTLI
159
+ caps |= Proto::Capability::COMPRESSION_GZIP
160
+ end
161
+ hello = Proto::Hello.new(
162
+ version_major: 1,
163
+ version_minor: 0,
164
+ capabilities: caps,
165
+ application: @settings.application,
166
+ host: @settings.hostname,
167
+ )
168
+ fr = hello.encode(0)
169
+
170
+ send(fr)
171
+ fr = read_frame(1 << 20)
172
+
173
+ case fr.kind
174
+ when Proto::MessageKind::HELLO_RESPONSE
175
+ resp = Proto::HelloResponse.read(fr)
176
+ if resp.capabilities == Proto::Capability::COMPRESSION_BROTLI
177
+ @compressor = Compressor.new(:brotli)
178
+ elsif resp.capabilities == Proto::Capability::COMPRESSION_GZIP
179
+ @compressor = Compressor.new(:gzip)
180
+ end
181
+ @max_payload = resp.max_payload || 0
182
+ @max_streams = resp.max_streams || 0
183
+
184
+ when Proto::MessageKind::ERROR
185
+ msg = Proto::Error.read(fr)
186
+ debug = ""
187
+ debug = ", debug: #{Base64.encode64(msg.debug)}" unless msg.debug.nil?
188
+ raise Mfp::Error, "Error returned from server: #{msg.code}#{debug}"
189
+
190
+ else
191
+ raise Mfp::Error, "Unexpected message kind after HELLO"
192
+
193
+ end
194
+ end
195
+
196
+ def handle_writes
197
+ while @running
198
+ out = @to_write.dequeue
199
+ return if out.nil?
200
+
201
+ unless @running
202
+ out.complete(ClientClosedError.new)
203
+ drain_writes_closing
204
+ return
205
+ end
206
+
207
+ fr = out.frame
208
+ fr.call_sign = @call_sign
209
+ size = fr.payload&.bytesize || 0
210
+ if !size.zero? && fr.kind == Proto::MessageKind::STREAM && @compressor.kind != :identity
211
+ buf = @compressor.compress(fr.payload)
212
+ fr.length = buf.bytesize
213
+ fr.payload = buf
214
+ else
215
+ fr.length = size
216
+ end
217
+
218
+ msg = fr.encode
219
+
220
+ begin
221
+ @socket.write(msg)
222
+ rescue StandardError => e
223
+ out.complete(e)
224
+ next
225
+ end
226
+
227
+ out.complete(nil)
228
+ end
229
+ end
230
+
231
+ def handle_reads
232
+ while @running
233
+ fr = nil
234
+ begin
235
+ fr = read_frame
236
+ rescue StandardError => e
237
+ @logger.error("Failed reading frame", e) if @running
238
+ abort!(reason: CONNECTION_EVENT_CODE_BROKEN)
239
+ return
240
+ end
241
+
242
+ @ping_handler.notify_received
243
+
244
+ case fr.kind
245
+ when Proto::MessageKind::ERROR
246
+ begin
247
+ err = Proto::Error.read(fr)
248
+ rescue StandardError => e
249
+ @logger.error("Failed parsing error message", e)
250
+ abort!(reason: CONNECTION_EVENT_CODE_BROKEN)
251
+ return
252
+ end
253
+
254
+ ev = ErrorEvent.new(
255
+ stream_id: fr.stream_id,
256
+ code: err.code,
257
+ terminal: err.terminal,
258
+ debug: err.debug,
259
+ )
260
+ @to_read.enqueue(ev) if @running
261
+
262
+ when Proto::MessageKind::STREAM
263
+ if @compressor.kind != :identity && fr.length.positive?
264
+ begin
265
+ payload = @compressor.decompress(fr.payload)
266
+ fr.payload = payload
267
+ rescue StandardError => e
268
+ @logger.error("Failed decompressing message", e)
269
+ terminate_error(Proto::ErrorKind::COMPRESSION_ERROR)
270
+ return
271
+ end
272
+ end
273
+
274
+ ev = StreamEvent.new(
275
+ stream_id: fr.stream_id,
276
+ payload: fr.payload,
277
+ )
278
+ @to_read.enqueue(ev) if @running
279
+
280
+ when Proto::MessageKind::CONTROL
281
+ ev = ControlEvent.new(
282
+ stream_id: fr.stream_id,
283
+ payload: fr.payload,
284
+ )
285
+ @to_read.enqueue(ev) if @running
286
+
287
+ when Proto::MessageKind::PING
288
+ unless fr.stream_id.zero?
289
+ terminate_error(Proto::ErrorKind::PROTOCOL_ERROR)
290
+ return
291
+ end
292
+
293
+ if fr.length != 8
294
+ terminate_error(Proto::ErrorKind::FRAME_SIZE_ERROR)
295
+ return
296
+ end
297
+
298
+ begin
299
+ handle_ping(fr)
300
+ rescue StandardError
301
+ terminate_error(Proto::ErrorKind::INTERNAL_ERROR)
302
+ end
303
+ else
304
+ @logger.warn("Received unexpected message kind during runtime", kind: fr.kind)
305
+ abort!(reason: CONNECTION_EVENT_CODE_BROKEN)
306
+ return
307
+ end
308
+ end
309
+ end
310
+
311
+ def drain_writes_closing
312
+ loop do
313
+ out = @to_write.dequeue
314
+ return if out.nil?
315
+
316
+ out.complete(ClientClosedError.new)
317
+ end
318
+ end
319
+
320
+ def read_frame(max_len = 0)
321
+ max_len = @max_payload if max_len.zero?
322
+ max_len = 0 if max_len.nil?
323
+
324
+ fr = Proto::Frame.read(@socket, max_len, @call_sign)
325
+ @logger.debug("Read frame", frame: fr.inspect)
326
+ fr
327
+ end
328
+
329
+ def handle_ping(fr)
330
+ png = nil
331
+ begin
332
+ png = Proto::Ping.read(fr)
333
+ rescue StandardError
334
+ terminate_error(Proto::ErrorKind::PROTOCOL_ERROR)
335
+ return
336
+ end
337
+
338
+ if png.ack
339
+ @ping_handler.notify_pong(png.payload)
340
+ return
341
+ end
342
+
343
+ resp = Proto::Ping.new(ack: true, payload: png.payload)
344
+ send(resp.encode(0))
345
+ end
346
+
347
+ def handle_pings
348
+ while @running
349
+ ev = @ping_handler.recv
350
+ return if ev.nil?
351
+
352
+ case ev[:kind]
353
+ when :request
354
+ png = Proto::Ping.new(
355
+ ack: false,
356
+ payload: ev[:cookie],
357
+ )
358
+ begin
359
+ send(png.encode(0))
360
+ rescue StandardError => e
361
+ @logger.error("Failed sending ping", e)
362
+ abort!(reason: CONNECTION_EVENT_CODE_BROKEN)
363
+ return
364
+ end
365
+ when :timeout
366
+ @logger.warn("Timed out waiting for ping acknowledgement. Aborting connection...")
367
+ abort!(reason: CONNECTION_EVENT_CODE_TIMED_OUT)
368
+ return
369
+ end
370
+ end
371
+ end
372
+
373
+ def abort!(reason: nil)
374
+ return unless @running
375
+
376
+ @running = false
377
+ @ping_handler&.stop
378
+ @to_write.close
379
+ @to_read.enqueue ConnectionEvent.new(code: reason)
380
+ @to_read.close
381
+ @task&.stop
382
+ @socket&.close
383
+ end
384
+
385
+ def stop_inner_tasks!
386
+ @inner_tasks.each(&:stop)
387
+ @socket&.close
388
+ end
389
+ end
390
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mfp
4
+ class Compressor
5
+ attr_reader :kind
6
+
7
+ def initialize(algo)
8
+ @kind = algo
9
+ case algo
10
+ when :identity
11
+ nil
12
+ when :gzip
13
+ @compress = Zlib.method(:gzip)
14
+ @decompress = Zlib.method(:gunzip)
15
+ when :brotli
16
+ @compress = Brotli.method(:deflate)
17
+ @decompress = Brotli.method(:inflate)
18
+ else
19
+ raise ArgumentError, "Unknown compression algorithm #{algo}"
20
+ end
21
+ end
22
+
23
+ def compress(data)
24
+ return data unless @compress
25
+
26
+ @compress.call(data)
27
+ end
28
+
29
+ def decompress(data)
30
+ return data unless @decompress
31
+
32
+ @decompress.call(data)
33
+ end
34
+ end
35
+ end
data/lib/mfp/enum.rb ADDED
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mfp
4
+ class Enum
5
+ class Value
6
+ include Comparable
7
+
8
+ attr_reader :name, :value
9
+
10
+ def initialize(owner, name, value)
11
+ @owner = owner
12
+ @name = name.to_sym
13
+ @value = Integer(value)
14
+ freeze
15
+ end
16
+
17
+ # implicit integer coercion
18
+ def to_int = @value
19
+ def to_i = @value
20
+ def to_s = @value.to_s
21
+ def to_json(*_a) = @value.to_json
22
+
23
+ # ==, case/when, hashing, ordering
24
+ def ==(other) = @value == coerced(other)
25
+ def ===(other) = @value == coerced(other)
26
+ alias eql? ==
27
+ def hash = @value.hash
28
+
29
+ def <=>(other)
30
+ c = coerced(other)
31
+ c.nil? ? nil : @value <=> c
32
+ end
33
+
34
+ # arithmetic when the Value is on the right
35
+ def coerce(other) = [other, @value]
36
+
37
+ # everything else Integer can do
38
+ def method_missing(name, *, &)
39
+ @value.respond_to?(name) ? @value.public_send(name, *, &) : super
40
+ end
41
+
42
+ def respond_to_missing?(name, include_private = false)
43
+ @value.respond_to?(name, include_private) || super
44
+ end
45
+
46
+ def inspect = @name.to_s
47
+
48
+ private
49
+
50
+ def coerced(other)
51
+ return other.value if other.is_a?(Value)
52
+ return other if other.is_a?(Integer)
53
+
54
+ other.to_int if other.respond_to?(:to_int)
55
+ end
56
+ end
57
+
58
+ class << self
59
+ include Enumerable
60
+
61
+ def define(name, value)
62
+ name = name.to_sym
63
+ raise ArgumentError, "#{name} already defined on #{self}" if const_defined?(name, false)
64
+
65
+ instance = Value.new(self, name, value)
66
+ const_set(name, instance)
67
+ registry[name] = instance
68
+ instance
69
+ end
70
+
71
+ def [](value) = registry.values.find { |v| v.value == value }
72
+ def named(name) = registry[name.to_sym]
73
+ def values = registry.values
74
+ def names = registry.keys
75
+ def each(&) = values.each(&)
76
+ def max = values.max
77
+
78
+ private
79
+
80
+ def registry = (@registry ||= {})
81
+ end
82
+ end
83
+ end
data/lib/mfp/error.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mfp
4
+ class Error < StandardError; end
5
+ class PayloadTooLargeError < Error; end
6
+ class ClientClosedError < Error; end
7
+ class ConnectionClosedError < Error; end
8
+ class InvalidCallSignLengthError < Error; end
9
+ class PayloadMustBeLessThan4GBError < Error; end
10
+ class InvalidCallSignError < Error; end
11
+
12
+ class ParserError < Error
13
+ attr_reader :error_code
14
+
15
+ def initialize(value)
16
+ @error_code = value
17
+ super(value.inspect)
18
+ end
19
+ end
20
+ end
data/lib/mfp/event.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mfp
4
+ # StreamEvent is delivered when a STREAM frame is received on a connection.
5
+ # Payload contains the raw frame payload, already decompressed if compression
6
+ # was negotiated. StreamID is always non-zero.
7
+ StreamEvent = Data.define(:stream_id, :payload)
8
+
9
+ # ControlEvent is delivered when a CONTROL frame is received on a connection.
10
+ # Payload is opaque to MFP — its structure is defined by the upper-layer
11
+ # protocol. StreamID may be zero for connection-scoped signals.
12
+ ControlEvent = Data.define(:stream_id, :payload)
13
+
14
+ # ErrorEvent is delivered when a stream-level ERROR frame is received.
15
+ # Connection-level errors are handled internally by MFP and never forwarded.
16
+ # Terminal indicates whether the stream has been terminated by this error.
17
+ ErrorEvent = Data.define(:stream_id, :code, :terminal, :debug)
18
+
19
+ ConnectionEvent = Data.define(:code)
20
+
21
+ CONNECTION_EVENT_CODE_INVALID = 0
22
+ CONNECTION_EVENT_CODE_DISCONNECTED = 1
23
+ CONNECTION_EVENT_CODE_BROKEN = 2
24
+ CONNECTION_EVENT_CODE_TIMED_OUT = 3
25
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mfp
4
+ class OutgoingMessage
5
+ attr_reader :frame
6
+
7
+ def initialize(frame)
8
+ @frame = frame
9
+ @result = Async::Queue.new
10
+ end
11
+
12
+ def wait = @result.dequeue
13
+ def complete(error) = @result.enqueue(error)
14
+ end
15
+ end