omq-backend-rust 0.1.7 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b22c5a39d466ddff0ea811bfadff93fe58e64c3ea08bb213f61419e5efd13cc6
4
- data.tar.gz: '09ef0d2306516c3809a957f61e234360d9f2bbe34b0389cf874c8d5cdc87abc8'
3
+ metadata.gz: 4f12671673afe657015b59cf65873132c88c5cbb15cf34962b54318fb944310c
4
+ data.tar.gz: 3b7de77e6e01dc461fa0aa969870472442a2f9b3b4657fa5b35ca1fe90bd8940
5
5
  SHA512:
6
- metadata.gz: bae7c0181f01e714d3fbbb11546bb84dc6642d77661734e5d5eaa5467af153a5437b0a9af1ff0d1b8f894609928d7952a83bcb492ef299e327253acdc6a1f85d
7
- data.tar.gz: b787de3962b49bf2c784596f8fd82901e0323e74270915ba051359eb73a46022389726cea92b1a6364c7780eaf296a457d8355d90507503a5690f4de4de8410b
6
+ metadata.gz: 77dda34fcf69ef4d048d74276b1ae5cd455b23086de0a826f751862014e0d048dd8f27ccc2042f5332ff91a5410f11243f687450ca0093e58ab19377b64ec9dd
7
+ data.tar.gz: 2536562047da85d828e732a03c5a551a85b2b0774821f3ada32f890ba48f4453696c0243ef5e66248cbdee763623d1cf0f9b45bb74ac5e9aedba6aab014558ed
data/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.0] - 2026-08-26
6
+
7
+ ### Added
8
+
9
+ - Added JRuby support through OMQ.java Maven artifacts for Linux x86_64,
10
+ macOS aarch64, macOS x86_64, and Windows x86_64.
11
+
12
+ ### Changed
13
+
14
+ - Replaced the private MRI/TruffleRuby native extension with the first-class
15
+ `omq-rs` binding.
16
+
17
+ ## [0.2.0] - 2026-08-08
18
+
19
+ ### Added
20
+
21
+ - Added TruffleRuby support for the Rust backend.
22
+ - Added a shared fallback file descriptor watcher for Rubies without a
23
+ native `Fiber.scheduler`.
24
+ - Added TruffleRuby CI coverage for the Rust backend.
25
+
26
+ ### Changed
27
+
28
+ - Updated to `omq-tokio` 0.21.1 and `omq-proto` 0.25.1.
29
+ - Replaced Magnus with a small `rb-sys` helper layer that uses public
30
+ Ruby C API calls.
31
+
32
+ ### Fixed
33
+
34
+ - Raised Ruby exceptions through public C API entry points for
35
+ TruffleRuby compatibility.
36
+ - Rejected invalid negative numeric options before they can overflow Rust
37
+ sizes or panic on invalid durations.
38
+
5
39
  ## [0.1.7] - 2026-08-02
6
40
 
7
41
  ### Added
data/README.md CHANGED
@@ -6,12 +6,16 @@
6
6
  [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%203.3-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
7
7
 
8
8
  Rust-backed engine for [OMQ](https://github.com/zeromq/omq.rb). Same socket API,
9
- but networking runs on a [Tokio](https://tokio.rs/) runtime inside a native
10
- extension compiled via [rb_sys](https://github.com/oxidize-rb/rb-sys).
9
+ but networking runs through the first-class
10
+ [omq-rs](https://github.com/paddor/omq.rs/tree/main/bindings/ruby) binding on
11
+ MRI and TruffleRuby, or through OMQ.java on JRuby.
11
12
 
12
13
  ## Install
13
14
 
14
- Requires a Rust toolchain (stable) at gem install time.
15
+ The `omq-rs` dependency compiles OMQ.rs during installation on MRI and
16
+ TruffleRuby. JRuby uses OMQ.java on Java 25 or newer. The Java gem selects the
17
+ matching OMQ.java artifact on Linux x86_64, macOS aarch64, macOS x86_64, and
18
+ Windows x86_64.
15
19
 
16
20
  ```ruby
17
21
  # Gemfile
@@ -73,8 +77,8 @@ the first `bind` or `connect` for a Rust-backed socket.
73
77
  ## Development
74
78
 
75
79
  ```sh
76
- OMQ_DEV=1 bundle install
77
- OMQ_DEV=1 bundle exec rake
80
+ OMQ_RS_PATH=/path/to/omq.rs/bindings/ruby bundle install
81
+ OMQ_RS_PATH=/path/to/omq.rs/bindings/ruby bundle exec rake
78
82
  ```
79
83
 
80
84
  ## License
@@ -1,16 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "omq"
4
- require_relative "../rust/omq_backend_rust"
5
4
  require_relative "../rust/version"
6
- require_relative "../rust/engine"
7
5
 
8
- module OMQ
9
- module Rust
10
- @io_threads = 1
6
+ if RUBY_ENGINE == "jruby"
7
+ require_relative "../rust/java/engine"
8
+ else
9
+ require "omq/rs"
10
+ require_relative "../rust/engine"
11
+ end
12
+
13
+ if RUBY_ENGINE == "jruby"
14
+ module OMQ
15
+ module Rust
16
+ @io_threads = 1
11
17
 
12
- class << self
13
- attr_accessor :io_threads
18
+ class << self
19
+ attr_accessor :io_threads
20
+ end
14
21
  end
15
22
  end
16
23
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "async"
4
+ require_relative "fd_watcher"
4
5
 
5
6
  module OMQ
6
7
  module Rust
@@ -14,20 +15,22 @@ module OMQ
14
15
 
15
16
 
16
17
  def initialize(socket_type, options)
17
- @socket_type = socket_type
18
- @options = options
19
- @peer_connected = Async::Promise.new
20
- @all_peers_gone = Async::Promise.new
21
- @subscriber_joined = Async::Promise.new
22
- @connections = {}
23
- @closed = false
24
- @parent_task = nil
25
- @on_io_thread = false
26
- @materialized = false
27
- @recv_sentinels = 0
28
- @compression_options = {}
29
-
30
- @native = Native::RustSocket.new(socket_type.to_s)
18
+ @socket_type = socket_type
19
+ @options = options
20
+ @peer_connected = Async::Promise.new
21
+ @all_peers_gone = Async::Promise.new
22
+ @subscriber_joined = Async::Promise.new
23
+ @connections = {}
24
+ @closed = false
25
+ @parent_task = nil
26
+ @on_io_thread = false
27
+ @materialized = false
28
+ @recv_sentinels = 0
29
+ @compression_options = {}
30
+ @socket = nil
31
+ @monitor_task = nil
32
+ @monitor_watching = false
33
+ @had_peer = false
31
34
 
32
35
  @routing = RoutingStub.new(self)
33
36
  end
@@ -40,6 +43,8 @@ module OMQ
40
43
  @parent_task = parent
41
44
  elsif Async::Task.current?
42
45
  @parent_task = Async::Task.current
46
+ elsif !Reactor.native_fiber_scheduler?
47
+ @parent_task = nil
43
48
  else
44
49
  @parent_task = Reactor.root_task
45
50
  @on_io_thread = true
@@ -52,7 +57,7 @@ module OMQ
52
57
  capture_parent_task(parent: parent)
53
58
  apply_endpoint_options!(opts)
54
59
  ensure_materialized
55
- resolved = @native.bind(endpoint)
60
+ resolved = @socket.bind(endpoint)
56
61
  URI.parse(resolved)
57
62
  end
58
63
 
@@ -61,54 +66,39 @@ module OMQ
61
66
  capture_parent_task(parent: parent)
62
67
  apply_endpoint_options!(opts)
63
68
  ensure_materialized
64
- @native.connect(endpoint)
69
+ @socket.connect(endpoint)
65
70
  URI.parse(endpoint)
66
71
  end
67
72
 
68
73
 
69
74
  def disconnect(endpoint)
70
- @native.disconnect(endpoint)
75
+ @socket.disconnect(endpoint)
71
76
  end
72
77
 
73
78
 
74
79
  def unbind(endpoint)
75
- @native.unbind(endpoint)
80
+ @socket.unbind(endpoint)
76
81
  end
77
82
 
78
83
 
79
84
  def enqueue_send(parts)
80
85
  ensure_materialized
81
- result = @native.enqueue_send(parts)
82
- return if result == :ok
83
-
84
- @send_signal_r ||= IO.for_fd(@native.send_fd, autoclose: false)
85
- loop do
86
- result = @native.enqueue_send(parts)
87
- return if result == :ok
88
-
89
- @send_signal_r.wait_readable
90
- @send_signal_r.read_nonblock(256, exception: false)
91
- end
86
+ @socket.send(normalize_outgoing(parts))
87
+ nil
92
88
  end
93
89
 
94
90
 
95
91
  def dequeue_recv
96
92
  ensure_materialized
97
93
 
98
- if @recv_batch && !@recv_batch.empty?
99
- return @recv_batch.shift
100
- end
101
-
102
- msg = try_recv_batch
94
+ msg = try_recv
103
95
  return msg if msg
104
96
 
105
97
  return take_recv_sentinel if @recv_sentinels.positive?
106
98
 
107
99
  loop do
108
- @recv_signal_r.wait_readable
109
- @recv_signal_r.read_nonblock(256, exception: false)
110
-
111
- msg = try_recv_batch
100
+ @socket.wait_readable
101
+ msg = try_recv
112
102
  return msg if msg
113
103
 
114
104
  return take_recv_sentinel if @recv_sentinels.positive?
@@ -118,7 +108,7 @@ module OMQ
118
108
 
119
109
  def dequeue_recv_sentinel
120
110
  @recv_sentinels += 1
121
- @native.wake_recv if @materialized
111
+ @socket.wake_recv if @materialized
122
112
  nil
123
113
  end
124
114
 
@@ -130,7 +120,9 @@ module OMQ
130
120
  @peer_connected.resolve(nil) unless @peer_connected.resolved?
131
121
  @all_peers_gone.resolve(nil) unless @all_peers_gone.resolved?
132
122
  @subscriber_joined.resolve(nil) unless @subscriber_joined.resolved?
133
- @native.close
123
+ FdWatcher.unwatch_owner(self) unless Reactor.native_fiber_scheduler?
124
+ @socket&.close
125
+ @monitor_task&.stop
134
126
  end
135
127
 
136
128
 
@@ -173,19 +165,10 @@ module OMQ
173
165
  return if @materialized
174
166
 
175
167
  capture_parent_task unless @parent_task
176
- Native.send(:io_threads=, OMQ::Rust.io_threads)
177
- @native.set_options(extract_options)
178
- @native.materialize
179
- @recv_signal_r = IO.for_fd(@native.recv_fd, autoclose: false)
180
- @materialized = true
181
-
182
- @routing.replay_pending(@native)
183
-
184
- spawn_lifecycle_watcher(@native.peer_connected_fd, @peer_connected)
185
- spawn_lifecycle_watcher(@native.all_peers_gone_fd, @all_peers_gone)
186
- spawn_lifecycle_watcher(@native.subscriber_joined_fd, @subscriber_joined)
187
-
188
- start_monitor_forwarder if @monitor_queue
168
+ @socket = OMQ::Rust.socket(@socket_type, **extract_options.transform_keys(&:to_sym))
169
+ @materialized = true
170
+ @routing.replay_pending(@socket)
171
+ start_monitor_forwarder
189
172
  end
190
173
 
191
174
 
@@ -195,55 +178,82 @@ module OMQ
195
178
  end
196
179
 
197
180
 
198
- def try_recv_batch
199
- batch = @native.try_recv_batch
200
- return unless batch
181
+ def try_recv
182
+ message = @socket.try_recv
183
+ return unless message
184
+ return message unless @socket_type.to_sym == :SERVER
201
185
 
202
- msg = batch.shift
203
- @recv_batch = batch unless batch.empty?
204
- msg
186
+ routing_id, *parts = message
187
+ [[routing_id].pack("N"), *parts]
205
188
  end
206
189
 
207
190
 
208
- def spawn_lifecycle_watcher(fd, promise)
209
- io = IO.for_fd(fd, autoclose: false)
210
- @parent_task.async(transient: true) do
211
- io.wait_readable
212
- promise.resolve(true) unless promise.resolved? || @closed
213
- rescue IOError, Errno::EBADF
191
+ def normalize_outgoing(parts)
192
+ return parts unless @socket_type.to_sym == :SERVER
193
+
194
+ routing_id, *body = parts
195
+ unless routing_id.is_a?(String) && routing_id.bytesize == 4
196
+ raise ArgumentError, "SERVER routing ID must be a 4-byte String"
214
197
  end
198
+ [routing_id.unpack1("N"), *body]
215
199
  end
216
200
 
217
201
 
218
202
  def start_monitor_forwarder
219
- monitor_io = IO.for_fd(@native.monitor_fd, autoclose: false)
220
- @parent_task.async(transient: true, annotation: "rust-monitor") do
221
- loop do
222
- monitor_io.wait_readable
223
- monitor_io.read_nonblock(256, exception: false)
224
- while (data = @native.try_recv_monitor)
225
- track_connection_event(data)
226
- @monitor_queue.enqueue(MonitorEvent.new(**data))
203
+ return if @monitor_task || @monitor_watching
204
+
205
+ if Reactor.native_fiber_scheduler?
206
+ @monitor_task = @parent_task.async(transient: true, annotation: "rust-monitor") do
207
+ monitor_loop
208
+ end
209
+ else
210
+ @monitor_watching = true
211
+ FdWatcher.watch_loop(@socket.monitor_fd, owner: self) do |io|
212
+ io.read_nonblock(256, exception: false)
213
+ while (data = @socket.try_monitor_event)
214
+ handle_monitor_event(data)
227
215
  end
216
+ @socket.monitor_fd unless @closed
228
217
  end
229
218
  end
230
219
  end
231
220
 
232
221
 
233
- def track_connection_event(data)
234
- detail = data[:detail] || {}
235
- connection_id = detail[:connection_id]
222
+ def monitor_loop
223
+ until @closed
224
+ data = @socket.monitor.recv
225
+ break unless data
226
+
227
+ handle_monitor_event(data)
228
+ end
229
+ rescue IOError, Errno::EBADF
230
+ raise unless @closed
231
+ end
232
+
233
+
234
+ def handle_monitor_event(data)
235
+ type = data.fetch(:event)
236
+ endpoint = data[:endpoint]
237
+ connection_id = data[:connection_id]
236
238
 
237
- case data[:type]
239
+ case type
238
240
  when :handshake_succeeded
241
+ @had_peer = true
239
242
  @connections[connection_id || Object.new] = true
243
+ @peer_connected.resolve(true) unless @peer_connected.resolved?
240
244
  when :disconnected
241
- if connection_id
242
- @connections.delete(connection_id)
243
- else
244
- @connections.shift
245
+ connection_id ? @connections.delete(connection_id) : @connections.shift
246
+ if @had_peer && @connections.empty? && !@all_peers_gone.resolved?
247
+ @all_peers_gone.resolve(true)
245
248
  end
249
+ when :subscribe_received
250
+ @subscriber_joined.resolve(true) unless @subscriber_joined.resolved?
246
251
  end
252
+
253
+ return unless @monitor_queue
254
+
255
+ detail = data.except(:event, :endpoint)
256
+ @monitor_queue.enqueue(MonitorEvent.new(type:, endpoint:, detail: detail.empty? ? nil : detail))
247
257
  end
248
258
 
249
259
 
@@ -404,9 +414,9 @@ module OMQ
404
414
 
405
415
 
406
416
  def subscribe(prefix)
407
- native = @engine.instance_variable_get(:@native)
417
+ socket = @engine.instance_variable_get(:@socket)
408
418
  if @engine.instance_variable_get(:@materialized)
409
- native.subscribe(prefix.b)
419
+ socket.subscribe(prefix.b)
410
420
  else
411
421
  @pending_subscribe << prefix.b
412
422
  end
@@ -414,14 +424,14 @@ module OMQ
414
424
 
415
425
 
416
426
  def unsubscribe(prefix)
417
- @engine.instance_variable_get(:@native).unsubscribe(prefix.b)
427
+ @engine.instance_variable_get(:@socket).unsubscribe(prefix.b)
418
428
  end
419
429
 
420
430
 
421
431
  def join(group)
422
- native = @engine.instance_variable_get(:@native)
432
+ socket = @engine.instance_variable_get(:@socket)
423
433
  if @engine.instance_variable_get(:@materialized)
424
- native.join(group)
434
+ socket.join(group)
425
435
  else
426
436
  @pending_join << group
427
437
  end
@@ -429,14 +439,14 @@ module OMQ
429
439
 
430
440
 
431
441
  def leave(group)
432
- @engine.instance_variable_get(:@native).leave(group)
442
+ @engine.instance_variable_get(:@socket).leave(group)
433
443
  end
434
444
 
435
445
 
436
- def replay_pending(native)
437
- @pending_subscribe.each { |p| native.subscribe(p) }
446
+ def replay_pending(socket)
447
+ @pending_subscribe.each { |p| socket.subscribe(p) }
438
448
  @pending_subscribe.clear
439
- @pending_join.each { |g| native.join(g) }
449
+ @pending_join.each { |g| socket.join(g) }
440
450
  @pending_join.clear
441
451
  end
442
452
  end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Rust
5
+ class FdWatcher
6
+ Watch = Struct.new(:id, :io, :owner, :once, :callback, keyword_init: true)
7
+
8
+ class << self
9
+ def watch_once(fd, owner:, &block)
10
+ watch(fd, owner: owner, once: true, &block)
11
+ end
12
+
13
+
14
+ def watch_loop(fd, owner:, &block)
15
+ watch(fd, owner: owner, once: false, &block)
16
+ end
17
+
18
+
19
+ def unwatch_owner(owner)
20
+ removed = nil
21
+
22
+ mutex.synchronize do
23
+ reset_after_fork
24
+ removed = watchers.values.select { |watch| watch.owner.equal?(owner) }
25
+ removed.each { |watch| watchers.delete(watch.id) }
26
+ end
27
+
28
+ removed.each { |watch| close_io(watch.io) }
29
+ wake
30
+ end
31
+
32
+
33
+ private
34
+
35
+
36
+ def watch(fd, owner:, once:, &block)
37
+ raise ArgumentError, "block required" unless block
38
+
39
+ io = IO.for_fd(fd, autoclose: false)
40
+ mutex.synchronize do
41
+ reset_after_fork
42
+ ensure_started
43
+ id = next_id
44
+ watchers[id] = Watch.new(id: id, io: io, owner: owner, once: once, callback: block)
45
+ id
46
+ end
47
+ rescue StandardError
48
+ close_io(io)
49
+ raise
50
+ ensure
51
+ wake if io
52
+ end
53
+
54
+
55
+ def reset_after_fork
56
+ pid = Process.pid
57
+ return if @pid == pid
58
+
59
+ watchers.each_value { |watch| close_io(watch.io) }
60
+ close_io(@wake_r)
61
+ close_io(@wake_w)
62
+ @watchers = {}
63
+ @wake_r = nil
64
+ @wake_w = nil
65
+ @thread = nil
66
+ @next_id = 0
67
+ @pid = pid
68
+ end
69
+
70
+
71
+ def ensure_started
72
+ return if @thread&.alive?
73
+
74
+ @wake_r, @wake_w = IO.pipe
75
+ @thread = Thread.new do
76
+ Thread.current.name = "omq-rust-watch" if Thread.current.respond_to?(:name=)
77
+ run
78
+ end
79
+ end
80
+
81
+
82
+ def run
83
+ loop do
84
+ wake_io, current = snapshot
85
+ readable = [wake_io, *current.map(&:io)].reject(&:closed?)
86
+ ready = IO.select(readable)&.first || []
87
+
88
+ drain_wake(wake_io) if ready.include?(wake_io)
89
+
90
+ current.each do |watch|
91
+ next unless ready.include?(watch.io)
92
+
93
+ dispatch(watch)
94
+ end
95
+ rescue IOError, SystemCallError
96
+ sweep_closed
97
+ end
98
+ end
99
+
100
+
101
+ def dispatch(watch)
102
+ active = watch.once ? delete_if_current(watch) : current?(watch)
103
+ return unless active
104
+
105
+ watch.callback.call(watch.io)
106
+ rescue StandardError
107
+ delete_if_current(watch)
108
+ ensure
109
+ close_io(watch.io) if watch.once
110
+ end
111
+
112
+
113
+ def snapshot
114
+ mutex.synchronize { [@wake_r, watchers.values] }
115
+ end
116
+
117
+
118
+ def current?(watch)
119
+ mutex.synchronize { watchers[watch.id].equal?(watch) }
120
+ end
121
+
122
+
123
+ def delete_if_current(watch)
124
+ mutex.synchronize do
125
+ if watchers[watch.id].equal?(watch)
126
+ watchers.delete(watch.id)
127
+ true
128
+ else
129
+ false
130
+ end
131
+ end
132
+ end
133
+
134
+
135
+ def sweep_closed
136
+ removed = nil
137
+ mutex.synchronize do
138
+ removed = watchers.values.select { |watch| watch.io.closed? }
139
+ removed.each { |watch| watchers.delete(watch.id) }
140
+ end
141
+ removed.each { |watch| close_io(watch.io) }
142
+ end
143
+
144
+
145
+ def drain_wake(io)
146
+ loop do
147
+ result = io.read_nonblock(256, exception: false)
148
+ break if result == :wait_readable || result.nil? || result.empty?
149
+ end
150
+ end
151
+
152
+
153
+ def wake
154
+ return unless @wake_w && !@wake_w.closed?
155
+
156
+ @wake_w.write_nonblock(".", exception: false)
157
+ rescue IOError, SystemCallError
158
+ end
159
+
160
+
161
+ def close_io(io)
162
+ io.close if io && !io.closed?
163
+ rescue IOError, SystemCallError
164
+ end
165
+
166
+
167
+ def watchers
168
+ @watchers ||= {}
169
+ end
170
+
171
+
172
+ def mutex
173
+ @mutex ||= Mutex.new
174
+ end
175
+
176
+
177
+ def next_id
178
+ @next_id ||= 0
179
+ @next_id += 1
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end