omq-backend-libzmq 0.3.2

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: 963a9597aa1a5a4cd0b922dd332dcd34cabbf542072f9a985cb03b087df1c2f0
4
+ data.tar.gz: b76aa34f22c78f6639407562269f585c542f14cd940d7fa00619e028c646f746
5
+ SHA512:
6
+ metadata.gz: 21d10c3acef17a021ef16f641b7283d96e693ff1f816a9ffe99a05007ef2b82fe105e8f70920b636d43001b38a4fc09f05355407865b03d0729ded39da6bd5c9
7
+ data.tar.gz: 4cadea7bb6b143111b25c6995cdce2479d0d83bcab5ea463e08b356a7de8009d5eaa05548eab8b145185f2219a9fe1b4be4e75de9fc0843b94f870f8f6a53c3f
data/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025-2026, Patrik Wenger
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # omq-backend-libzmq
2
+
3
+ [![CI](https://github.com/zeromq/omq.rb/actions/workflows/ci.yml/badge.svg)](https://github.com/zeromq/omq.rb/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/omq-backend-libzmq?color=e9573f)](https://rubygems.org/gems/omq-backend-libzmq)
5
+ [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE)
6
+ [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%204.0-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
7
+
8
+ Use libzmq under the same OMQ socket API. Requires libzmq 4.x installed
9
+ on the system.
10
+
11
+ ```ruby
12
+ require "omq"
13
+ require "omq/backend/libzmq"
14
+
15
+ push = OMQ::PUSH.new(backend: :libzmq)
16
+ push.connect("tcp://127.0.0.1:5555")
17
+ push.send("hello from libzmq")
18
+ ```
19
+
20
+ The libzmq backend replaces the pure Ruby ZMTP stack with libzmq. The
21
+ socket API, options, and Async integration remain identical. A dedicated
22
+ I/O thread per socket handles all libzmq operations because libzmq
23
+ sockets are not thread-safe.
24
+
25
+ `require "omq/ffi"` and `backend: :ffi` remain supported for compatibility.
26
+
27
+ ## Interop
28
+
29
+ libzmq and native Ruby backends are wire-compatible. You can mix them
30
+ freely:
31
+
32
+ ```ruby
33
+ # native REP server
34
+ rep = OMQ::REP.bind("tcp://127.0.0.1:5555")
35
+
36
+ # libzmq REQ client
37
+ req = OMQ::REQ.new(backend: :libzmq)
38
+ req.connect("tcp://127.0.0.1:5555")
39
+ ```
40
+
41
+ ## Requirements
42
+
43
+ - Ruby >= 4.0
44
+ - libzmq 4.x (`libzmq5` / `libzmq3-dev` on Debian/Ubuntu)
45
+ - [omq](https://github.com/zeromq/omq.rb) >= 0.28
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "omq"
4
+ require_relative "../ffi/libzmq"
5
+ require_relative "../ffi/engine"
6
+
7
+ OMQ::Backend.register(:libzmq, OMQ::FFI::Engine)
8
+ OMQ::Backend.register(:ffi, OMQ::FFI::Engine)
@@ -0,0 +1,705 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "uri"
5
+
6
+ module OMQ
7
+ module FFI
8
+ # FFI Engine — wraps a libzmq socket to implement the OMQ Engine contract.
9
+ #
10
+ # A dedicated I/O thread owns the zmq_socket exclusively (libzmq sockets
11
+ # are not thread-safe). Send and recv flow through queues, with an IO pipe
12
+ # to wake the Async fiber scheduler.
13
+ #
14
+ class Engine
15
+ L = Libzmq
16
+
17
+
18
+ # @return [Options] socket options
19
+ attr_reader :options
20
+ # @return [Array] active connections
21
+ attr_reader :connections
22
+ # @return [RoutingStub] subscription/group routing interface
23
+ attr_reader :routing
24
+ # @return [Async::Promise] resolved when the first peer connects
25
+ attr_reader :peer_connected
26
+ # @return [Async::Promise] resolved when all peers have disconnected
27
+ attr_reader :all_peers_gone
28
+ # @return [Async::Task, nil] root of the engine's task tree
29
+ attr_reader :parent_task
30
+ # @return [Boolean] true when the engine's parent task lives on the
31
+ # shared {OMQ::Reactor} IO thread (i.e. not created under an
32
+ # Async task). Writable/Readable check this to pick the fast path.
33
+ attr_reader :on_io_thread
34
+ alias on_io_thread? on_io_thread
35
+ # @param value [Boolean] enables or disables automatic reconnection
36
+ attr_writer :reconnect_enabled
37
+ # @note Monitor events are not yet emitted by the FFI backend; these
38
+ # writers exist so Socket#monitor can attach without raising. Wiring
39
+ # libzmq's zmq_socket_monitor is a TODO.
40
+ attr_writer :monitor_queue, :verbose_monitor
41
+
42
+ # Routing stub that delegates subscribe/unsubscribe/join/leave to
43
+ # libzmq socket options via the I/O thread.
44
+ #
45
+ class RoutingStub
46
+ # @return [Async::Promise] resolved when a subscriber joins
47
+ attr_reader :subscriber_joined
48
+
49
+ # @param engine [Engine] the parent engine instance
50
+ def initialize(engine)
51
+ @engine = engine
52
+ @subscriber_joined = Async::Promise.new
53
+ end
54
+
55
+
56
+ # Subscribes to messages matching the given prefix.
57
+ #
58
+ # @param prefix [String] subscription prefix
59
+ # @return [void]
60
+ def subscribe(prefix)
61
+ @engine.send_cmd(:subscribe, prefix.b)
62
+ end
63
+
64
+
65
+ # Removes a subscription for the given prefix.
66
+ #
67
+ # @param prefix [String] subscription prefix to remove
68
+ # @return [void]
69
+ def unsubscribe(prefix)
70
+ @engine.send_cmd(:unsubscribe, prefix.b)
71
+ end
72
+
73
+
74
+ # Joins a DISH group for receiving RADIO messages.
75
+ #
76
+ # @param group [String] group name
77
+ # @return [void]
78
+ def join(group)
79
+ @engine.send_cmd(:join, group)
80
+ end
81
+
82
+
83
+ # Leaves a DISH group.
84
+ #
85
+ # @param group [String] group name
86
+ # @return [void]
87
+ def leave(group)
88
+ @engine.send_cmd(:leave, group)
89
+ end
90
+ end
91
+
92
+
93
+ # Maps an OMQ +linger+ value (seconds, or +nil+/+Float::INFINITY+
94
+ # for "wait forever") to libzmq's ZMQ_LINGER int milliseconds
95
+ # (-1 = infinite, 0 = drop, N = N ms).
96
+ #
97
+ # @param linger [Numeric, nil]
98
+ # @return [Integer]
99
+ #
100
+ def self.linger_to_zmq_ms(linger)
101
+ return -1 if linger.nil? || linger == Float::INFINITY
102
+ (linger * 1000).to_i
103
+ end
104
+
105
+
106
+ # @param socket_type [Symbol] e.g. :REQ, :PAIR
107
+ # @param options [Options]
108
+ #
109
+ def initialize(socket_type, options)
110
+ @socket_type = socket_type
111
+ @options = options
112
+ @peer_connected = Async::Promise.new
113
+ @all_peers_gone = Async::Promise.new
114
+ @connections = []
115
+ @closed = false
116
+ @parent_task = nil
117
+ @on_io_thread = false
118
+ @fatal_error = nil
119
+
120
+ @zmq_socket = L.zmq_socket(OMQ::FFI.context, L::SOCKET_TYPES.fetch(@socket_type))
121
+ raise "zmq_socket failed: #{L.zmq_strerror(L.zmq_errno)}" if @zmq_socket.null?
122
+
123
+ apply_options
124
+
125
+ @routing = RoutingStub.new(self)
126
+
127
+ # Queues for cross-thread communication
128
+ @send_queue = Thread::Queue.new # main → io thread
129
+ @recv_queue = Thread::Queue.new # io thread → main
130
+ @cmd_queue = Thread::Queue.new # control commands → io thread
131
+
132
+ # Signal pipe: io thread → Async fiber (message received)
133
+ @recv_signal_r, @recv_signal_w = IO.pipe
134
+ # Wake pipe: main thread → io thread (send/cmd enqueued)
135
+ @wake_r, @wake_w = IO.pipe
136
+
137
+ @io_thread = nil
138
+ end
139
+
140
+
141
+ # --- Socket lifecycle ---
142
+
143
+ # Binds the socket to the given endpoint.
144
+ #
145
+ # @param endpoint [String] ZMQ endpoint URL (e.g. "tcp://*:5555")
146
+ # @return [URI::Generic] resolved endpoint URI (with auto-selected port for "tcp://host:0")
147
+ def bind(endpoint)
148
+ sync_identity
149
+ send_cmd(:bind, endpoint)
150
+ resolved = get_string_option(L::ZMQ_LAST_ENDPOINT)
151
+ @connections << :libzmq
152
+ @peer_connected.resolve(:libzmq) unless @peer_connected.resolved?
153
+ URI.parse(resolved)
154
+ end
155
+
156
+
157
+ # Connects the socket to the given endpoint.
158
+ #
159
+ # @param endpoint [String] ZMQ endpoint URL
160
+ # @return [URI::Generic] parsed endpoint URI
161
+ def connect(endpoint)
162
+ sync_identity
163
+ send_cmd(:connect, endpoint)
164
+ @connections << :libzmq
165
+ @peer_connected.resolve(:libzmq) unless @peer_connected.resolved?
166
+ URI.parse(endpoint)
167
+ end
168
+
169
+
170
+ # Disconnects from the given endpoint.
171
+ #
172
+ # @param endpoint [String] ZMQ endpoint URL
173
+ # @return [void]
174
+ def disconnect(endpoint)
175
+ send_cmd(:disconnect, endpoint)
176
+ end
177
+
178
+
179
+ # Unbinds from the given endpoint.
180
+ #
181
+ # @param endpoint [String] ZMQ endpoint URL
182
+ # @return [void]
183
+ def unbind(endpoint)
184
+ send_cmd(:unbind, endpoint)
185
+ end
186
+
187
+
188
+ # Subscribes to a topic prefix (SUB/XSUB). Delegates to the routing
189
+ # stub for API parity with the pure-Ruby Engine.
190
+ #
191
+ # @param prefix [String]
192
+ # @return [void]
193
+ def subscribe(prefix)
194
+ @routing.subscribe(prefix)
195
+ end
196
+
197
+
198
+ # Unsubscribes from a topic prefix (SUB/XSUB).
199
+ #
200
+ # @param prefix [String]
201
+ # @return [void]
202
+ def unsubscribe(prefix)
203
+ @routing.unsubscribe(prefix)
204
+ end
205
+
206
+
207
+ # @return [Async::Promise] resolved when a subscriber joins (PUB/XPUB).
208
+ def subscriber_joined
209
+ @routing.subscriber_joined
210
+ end
211
+
212
+
213
+ # Closes the socket and shuts down the I/O thread.
214
+ #
215
+ # Honors `options.linger`:
216
+ # nil → wait forever for Ruby-side queue to drain into libzmq
217
+ # and for libzmq's own LINGER to flush to the network
218
+ # 0 → drop anything not yet in libzmq's kernel buffers, close fast
219
+ # N → up to N seconds for drain + N + 1s grace for join
220
+ #
221
+ # @return [void]
222
+ def close
223
+ return if @closed
224
+ @closed = true
225
+ if @io_thread
226
+ @cmd_queue.push([:stop])
227
+ wake_io_thread
228
+ linger = @options.linger
229
+ if linger.nil?
230
+ @io_thread.join
231
+ elsif linger.zero?
232
+ @io_thread.join(0.5) # fast path: zmq_close is non-blocking with LINGER=0
233
+ else
234
+ @io_thread.join(linger + 1.0)
235
+ end
236
+ @io_thread.kill if @io_thread.alive? # hard stop if deadline exceeded
237
+ else
238
+ # IO thread never started — close socket directly
239
+ L.zmq_close(@zmq_socket)
240
+ end
241
+ @recv_signal_r&.close rescue nil
242
+ @recv_signal_w&.close rescue nil
243
+ @wake_r&.close rescue nil
244
+ @wake_w&.close rescue nil
245
+ end
246
+
247
+
248
+ # Captures the current Async task as the parent for I/O scheduling.
249
+ # +parent:+ is accepted for API compatibility with the pure-Ruby
250
+ # engine but has no effect: the FFI backend runs its own I/O
251
+ # thread and doesn't participate in the Async barrier tree.
252
+ #
253
+ # @return [void]
254
+ def capture_parent_task(parent: nil)
255
+ return if @parent_task
256
+ if parent
257
+ @parent_task = parent
258
+ elsif Async::Task.current?
259
+ @parent_task = Async::Task.current
260
+ else
261
+ @parent_task = Reactor.root_task
262
+ @on_io_thread = true
263
+ Reactor.track_linger(@options.linger)
264
+ end
265
+ end
266
+
267
+
268
+ # --- Send ---
269
+
270
+ # Enqueues a multipart message for sending via the I/O thread.
271
+ #
272
+ # @param parts [Array<String>] message frames
273
+ # @return [void]
274
+ def enqueue_send(parts)
275
+ raise_if_dead!
276
+ ensure_io_thread
277
+ @send_queue.push(parts)
278
+ wake_io_thread
279
+ end
280
+
281
+
282
+ # --- Recv ---
283
+
284
+ # Dequeues the next received message, blocking until one is available.
285
+ #
286
+ # @return [Array<String>] multipart message
287
+ def dequeue_recv
288
+ raise_if_dead!
289
+ ensure_io_thread
290
+ msg = wait_for_message
291
+ raise_if_dead! if msg.nil?
292
+ msg
293
+ end
294
+
295
+
296
+ # Pushes a nil sentinel into the recv queue to unblock a waiting consumer.
297
+ #
298
+ # @return [void]
299
+ def dequeue_recv_sentinel
300
+ @recv_queue.push(nil)
301
+ @recv_signal_w.write_nonblock(".", exception: false) rescue nil
302
+ end
303
+
304
+
305
+ # Send a control command to the I/O thread.
306
+ # @api private
307
+ #
308
+ def send_cmd(cmd, *args)
309
+ raise_if_dead!
310
+ ensure_io_thread
311
+ result = Thread::Queue.new
312
+ @cmd_queue.push([cmd, args, result])
313
+ wake_io_thread
314
+ r = result.pop
315
+ raise r if r.is_a?(Exception)
316
+ r
317
+ end
318
+
319
+
320
+ # Wakes the I/O thread via the internal pipe.
321
+ #
322
+ # @return [void]
323
+ def wake_io_thread
324
+ @wake_w.write_nonblock(".", exception: false)
325
+ end
326
+
327
+ private
328
+
329
+ # Waits for a message from the I/O thread's recv queue.
330
+ # Uses the signal pipe so Async can yield the fiber.
331
+ #
332
+ def wait_for_message
333
+ loop do
334
+ begin
335
+ return @recv_queue.pop(true)
336
+ rescue ThreadError
337
+ # empty
338
+ end
339
+ @recv_signal_r.wait_readable
340
+ @recv_signal_r.read_nonblock(256, exception: false)
341
+ end
342
+ end
343
+
344
+
345
+ def ensure_io_thread
346
+ return if @io_thread
347
+ @io_thread = Thread.new { io_loop }
348
+ end
349
+
350
+
351
+ # The I/O loop runs on a dedicated thread. It owns the zmq_socket
352
+ # exclusively and processes commands, sends, and recvs.
353
+ #
354
+ def io_loop
355
+ zmq_fd_io = IO.for_fd(get_zmq_fd, autoclose: false)
356
+
357
+ loop do
358
+ drain_cmds or break
359
+ drain_sends
360
+ try_recv
361
+
362
+ # Block until ZMQ or wake pipe has activity.
363
+ IO.select([zmq_fd_io, @wake_r], nil, nil, 0.1)
364
+ @wake_r.read_nonblock(4096, exception: false)
365
+ end
366
+ rescue => error
367
+ signal_fatal_error(error)
368
+ ensure
369
+ # Drain Ruby-side send queue into libzmq, bounded by linger deadline.
370
+ # Then re-apply current linger to libzmq (user may have changed it
371
+ # after apply_options ran in initialize) and zmq_close uses it to
372
+ # flush libzmq's own queue to TCP.
373
+ drain_sends_with_deadline(zmq_fd_io, shutdown_deadline) rescue nil
374
+ set_int_option(L::ZMQ_LINGER, Engine.linger_to_zmq_ms(@options.linger)) rescue nil
375
+ zmq_fd_io&.close rescue nil
376
+ L.zmq_close(@zmq_socket)
377
+ end
378
+
379
+
380
+ # Returns a monotonic deadline for the Ruby-side drain phase, or nil
381
+ # for infinite, or the current clock for "drop immediately".
382
+ #
383
+ def shutdown_deadline
384
+ linger = @options.linger
385
+ return nil if linger.nil?
386
+ now = Async::Clock.now
387
+ return now if linger.zero?
388
+ now + linger
389
+ end
390
+
391
+
392
+ # Retries drain_sends with IO.select until either the Ruby-side queue
393
+ # is empty or the deadline is hit. nil deadline = wait forever.
394
+ #
395
+ def drain_sends_with_deadline(zmq_fd_io, deadline)
396
+ loop do
397
+ drain_sends
398
+ break if @pending_send.nil? && @send_queue.empty?
399
+ if deadline
400
+ remaining = deadline - Async::Clock.now
401
+ break if remaining <= 0
402
+ IO.select([zmq_fd_io], nil, nil, [remaining, 0.1].min)
403
+ else
404
+ IO.select([zmq_fd_io], nil, nil, 0.1)
405
+ end
406
+ end
407
+ end
408
+
409
+
410
+ def zmq_has_events?
411
+ @events_buf ||= ::FFI::MemoryPointer.new(:int)
412
+ @events_len ||= ::FFI::MemoryPointer.new(:size_t).tap { |p| p.write(:size_t, ::FFI.type_size(:int)) }
413
+ L.zmq_getsockopt(@zmq_socket, L::ZMQ_EVENTS, @events_buf, @events_len)
414
+ @events_buf.read_int != 0
415
+ end
416
+
417
+
418
+ def drain_cmds
419
+ loop do
420
+ begin
421
+ cmd = @cmd_queue.pop(true)
422
+ rescue ThreadError
423
+ return true # queue empty, continue
424
+ end
425
+ return false unless process_cmd(cmd)
426
+ end
427
+ end
428
+
429
+
430
+ def process_cmd(cmd)
431
+ name, args, result = cmd
432
+ case name
433
+ when :stop
434
+ result&.push(nil)
435
+ return false
436
+ when :bind
437
+ rc = L.zmq_bind(@zmq_socket, args[0])
438
+ result&.push(rc >= 0 ? nil : syscall_error)
439
+ when :connect
440
+ rc = L.zmq_connect(@zmq_socket, args[0])
441
+ result&.push(rc >= 0 ? nil : syscall_error)
442
+ when :disconnect
443
+ rc = L.zmq_disconnect(@zmq_socket, args[0])
444
+ result&.push(rc >= 0 ? nil : syscall_error)
445
+ when :unbind
446
+ rc = L.zmq_unbind(@zmq_socket, args[0])
447
+ result&.push(rc >= 0 ? nil : syscall_error)
448
+ when :set_identity
449
+ set_bytes_option(L::ZMQ_IDENTITY, args[0])
450
+ result&.push(nil)
451
+ when :subscribe
452
+ set_bytes_option(L::ZMQ_SUBSCRIBE, args[0])
453
+ result&.push(nil)
454
+ when :unsubscribe
455
+ set_bytes_option(L::ZMQ_UNSUBSCRIBE, args[0])
456
+ result&.push(nil)
457
+ when :join
458
+ rc = L.respond_to?(:zmq_join) ? L.zmq_join(@zmq_socket, args[0]) : -1
459
+ result&.push(rc >= 0 ? nil : RuntimeError.new("zmq_join not available"))
460
+ when :leave
461
+ rc = L.respond_to?(:zmq_leave) ? L.zmq_leave(@zmq_socket, args[0]) : -1
462
+ result&.push(rc >= 0 ? nil : RuntimeError.new("zmq_leave not available"))
463
+ when :drain_send
464
+ # handled in drain_sends
465
+ result&.push(nil)
466
+ end
467
+ true
468
+ rescue => error
469
+ result&.push(signal_fatal_error(error))
470
+ false
471
+ end
472
+
473
+
474
+ def try_recv
475
+ loop do
476
+ parts = recv_multipart_nonblock
477
+ break unless parts
478
+ @recv_queue.push(parts.freeze)
479
+ @recv_signal_w.write_nonblock(".", exception: false)
480
+ end
481
+ end
482
+
483
+
484
+ def drain_sends
485
+ @pending_send ||= nil
486
+ loop do
487
+ parts = @pending_send || begin
488
+ @send_queue.pop(true)
489
+ rescue ThreadError
490
+ break
491
+ end
492
+ if send_multipart_nonblock(parts)
493
+ @pending_send = nil
494
+ else
495
+ @pending_send = parts # retry next cycle (HWM reached)
496
+ break
497
+ end
498
+ end
499
+ end
500
+
501
+
502
+ # Returns true if fully sent, false if would block (HWM).
503
+ #
504
+ def send_multipart_nonblock(parts)
505
+ parts.each_with_index do |part, i|
506
+ flags = L::ZMQ_DONTWAIT
507
+ flags |= L::ZMQ_SNDMORE if i < parts.size - 1
508
+ msg = L.alloc_msg
509
+ L.zmq_msg_init_size(msg, part.bytesize)
510
+ L.zmq_msg_data(msg).write_bytes(part)
511
+ rc = L.zmq_msg_send(msg, @zmq_socket, flags)
512
+ if rc < 0
513
+ L.zmq_msg_close(msg)
514
+ return false # EAGAIN — would block
515
+ end
516
+ end
517
+ true
518
+ end
519
+
520
+
521
+ def recv_multipart_nonblock
522
+ parts = []
523
+ loop do
524
+ msg = L.alloc_msg
525
+ L.zmq_msg_init(msg)
526
+ rc = L.zmq_msg_recv(msg, @zmq_socket, L::ZMQ_DONTWAIT)
527
+ if rc < 0
528
+ L.zmq_msg_close(msg)
529
+ return parts.empty? ? nil : parts # EAGAIN = no more data
530
+ end
531
+
532
+ size = L.zmq_msg_size(msg)
533
+ data = L.zmq_msg_data(msg).read_bytes(size)
534
+ L.zmq_msg_close(msg)
535
+ parts << data.freeze
536
+
537
+ break unless rcvmore?
538
+ end
539
+ parts.empty? ? nil : parts
540
+ end
541
+
542
+
543
+ def rcvmore?
544
+ buf = ::FFI::MemoryPointer.new(:int)
545
+ len = ::FFI::MemoryPointer.new(:size_t)
546
+ len.write(:size_t, ::FFI.type_size(:int))
547
+ L.zmq_getsockopt(@zmq_socket, L::ZMQ_RCVMORE, buf, len)
548
+ buf.read_int != 0
549
+ end
550
+
551
+
552
+ def get_zmq_fd
553
+ buf = ::FFI::MemoryPointer.new(:int)
554
+ len = ::FFI::MemoryPointer.new(:size_t)
555
+ len.write(:size_t, ::FFI.type_size(:int))
556
+ L.zmq_getsockopt(@zmq_socket, L::ZMQ_FD, buf, len)
557
+ buf.read_int
558
+ end
559
+
560
+
561
+ def raise_if_dead!
562
+ raise @fatal_error if @fatal_error
563
+ end
564
+
565
+
566
+ # Bricks the socket after an unexpected I/O thread failure. Mirrors
567
+ # the pure-Ruby engine fatal path: future send/receive/command calls
568
+ # raise SocketDeadError, and blocked receivers or commands wake up.
569
+ def signal_fatal_error(error)
570
+ @fatal_error ||= build_fatal_error(error)
571
+
572
+ unblock_recv_waiters
573
+ fail_pending_cmds
574
+ @peer_connected.resolve(nil) unless @peer_connected.resolved?
575
+ @all_peers_gone.resolve(nil) unless @all_peers_gone.resolved?
576
+ @routing.subscriber_joined.resolve(nil) unless @routing.subscriber_joined.resolved?
577
+
578
+ @fatal_error
579
+ end
580
+
581
+
582
+ def unblock_recv_waiters
583
+ @recv_queue.push(nil)
584
+ @recv_signal_w.write_nonblock(".", exception: false) rescue nil
585
+ end
586
+
587
+
588
+ def fail_pending_cmds
589
+ loop do
590
+ _name, _args, result = @cmd_queue.pop(true)
591
+ result&.push(@fatal_error)
592
+ rescue ThreadError
593
+ break
594
+ end
595
+ end
596
+
597
+
598
+ def build_fatal_error(error)
599
+ return error if error.is_a?(SocketDeadError)
600
+
601
+ raise error
602
+ rescue
603
+ begin
604
+ raise SocketDeadError, "#{@socket_type} socket killed: #{error.message}"
605
+ rescue SocketDeadError => wrapped
606
+ wrapped
607
+ end
608
+ end
609
+
610
+
611
+ # Re-syncs identity to libzmq (user may set it after construction).
612
+ #
613
+ def sync_identity
614
+ id = @options.identity
615
+ if id && !id.empty?
616
+ send_cmd(:set_identity, id)
617
+ end
618
+ end
619
+
620
+
621
+ def apply_options
622
+ set_int_option(L::ZMQ_SNDHWM, @options.send_hwm)
623
+ set_int_option(L::ZMQ_RCVHWM, @options.recv_hwm)
624
+ set_int_option(L::ZMQ_LINGER, Engine.linger_to_zmq_ms(@options.linger))
625
+ set_int_option(L::ZMQ_CONFLATE, @options.conflate ? 1 : 0)
626
+
627
+ if @options.identity && !@options.identity.empty?
628
+ set_bytes_option(L::ZMQ_IDENTITY, @options.identity)
629
+ end
630
+
631
+ if @options.max_message_size
632
+ set_int64_option(L::ZMQ_MAXMSGSIZE, @options.max_message_size)
633
+ end
634
+
635
+ if @options.reconnect_interval
636
+ ivl = @options.reconnect_interval
637
+ if ivl.is_a?(Range)
638
+ set_int_option(L::ZMQ_RECONNECT_IVL, (ivl.begin * 1000).to_i)
639
+ set_int_option(L::ZMQ_RECONNECT_IVL_MAX, (ivl.end * 1000).to_i)
640
+ else
641
+ set_int_option(L::ZMQ_RECONNECT_IVL, (ivl * 1000).to_i)
642
+ end
643
+ end
644
+
645
+ set_int_option(L::ZMQ_ROUTER_MANDATORY, 1) if @options.router_mandatory
646
+ end
647
+
648
+
649
+ def set_int_option(opt, value)
650
+ buf = ::FFI::MemoryPointer.new(:int)
651
+ buf.write_int(value)
652
+ L.zmq_setsockopt(@zmq_socket, opt, buf, ::FFI.type_size(:int))
653
+ end
654
+
655
+
656
+ def set_int64_option(opt, value)
657
+ buf = ::FFI::MemoryPointer.new(:int64)
658
+ buf.write_int64(value)
659
+ L.zmq_setsockopt(@zmq_socket, opt, buf, ::FFI.type_size(:int64))
660
+ end
661
+
662
+
663
+ def set_bytes_option(opt, value)
664
+ buf = ::FFI::MemoryPointer.from_string(value)
665
+ L.zmq_setsockopt(@zmq_socket, opt, buf, value.bytesize)
666
+ end
667
+
668
+
669
+ def get_string_option(opt)
670
+ buf = ::FFI::MemoryPointer.new(:char, 256)
671
+ len = ::FFI::MemoryPointer.new(:size_t)
672
+ len.write(:size_t, 256)
673
+ L.check!(L.zmq_getsockopt(@zmq_socket, opt, buf, len), "zmq_getsockopt")
674
+ buf.read_string(len.read(:size_t) - 1)
675
+ end
676
+
677
+
678
+ # Builds an Errno::XXX exception from the current zmq_errno so callers
679
+ # can rescue the same classes they would from the pure-Ruby backend
680
+ # (e.g. `Errno::EADDRINUSE`, `Errno::ECONNREFUSED`). Falls back to a
681
+ # plain SystemCallError when the errno is libzmq-specific.
682
+ #
683
+ def syscall_error
684
+ errno = L.zmq_errno
685
+ SystemCallError.new(L.zmq_strerror(errno), errno)
686
+ end
687
+
688
+
689
+ end
690
+
691
+
692
+ # Returns the shared ZMQ context (one per process, lazily initialized).
693
+ #
694
+ # @return [FFI::Pointer] zmq context pointer
695
+ def self.context
696
+ @context ||= Libzmq.zmq_ctx_new.tap do |ctx|
697
+ raise "zmq_ctx_new failed" if ctx.null?
698
+ at_exit do
699
+ Libzmq.zmq_ctx_shutdown(ctx) rescue nil
700
+ Libzmq.zmq_ctx_term(ctx) rescue nil
701
+ end
702
+ end
703
+ end
704
+ end
705
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ module OMQ
6
+ module FFI
7
+ # Minimal libzmq FFI bindings — only what OMQ needs.
8
+ #
9
+ module Libzmq
10
+ extend ::FFI::Library
11
+ ffi_lib ["libzmq.so.5", "libzmq.5.dylib", "libzmq"]
12
+
13
+ # Context
14
+ attach_function :zmq_ctx_new, [], :pointer
15
+ attach_function :zmq_ctx_term, [:pointer], :int
16
+ attach_function :zmq_ctx_shutdown, [:pointer], :int
17
+
18
+ # Socket
19
+ attach_function :zmq_socket, [:pointer, :int], :pointer
20
+ attach_function :zmq_close, [:pointer], :int
21
+ attach_function :zmq_bind, [:pointer, :string], :int
22
+ attach_function :zmq_connect, [:pointer, :string], :int
23
+ attach_function :zmq_disconnect, [:pointer, :string], :int
24
+ attach_function :zmq_unbind, [:pointer, :string], :int
25
+
26
+ # Message
27
+ attach_function :zmq_msg_init, [:pointer], :int
28
+ attach_function :zmq_msg_init_size, [:pointer, :size_t], :int
29
+ attach_function :zmq_msg_data, [:pointer], :pointer
30
+ attach_function :zmq_msg_size, [:pointer], :size_t
31
+ attach_function :zmq_msg_close, [:pointer], :int
32
+ attach_function :zmq_msg_send, [:pointer, :pointer, :int], :int
33
+ attach_function :zmq_msg_recv, [:pointer, :pointer, :int], :int
34
+
35
+ # Socket options
36
+ attach_function :zmq_setsockopt, [:pointer, :int, :pointer, :size_t], :int
37
+ attach_function :zmq_getsockopt, [:pointer, :int, :pointer, :pointer], :int
38
+
39
+ # Group membership (RADIO/DISH) — draft API, may not be available
40
+ begin
41
+ attach_function :zmq_join, [:pointer, :string], :int
42
+ attach_function :zmq_leave, [:pointer, :string], :int
43
+ rescue ::FFI::NotFoundError
44
+ # libzmq built without ZMQ_BUILD_DRAFT_API
45
+ end
46
+
47
+
48
+ # Error
49
+ attach_function :zmq_errno, [], :int
50
+ attach_function :zmq_strerror, [:int], :string
51
+
52
+ # Socket types
53
+ ZMQ_PAIR = 0
54
+ ZMQ_PUB = 1
55
+ ZMQ_SUB = 2
56
+ ZMQ_REQ = 3
57
+ ZMQ_REP = 4
58
+ ZMQ_DEALER = 5
59
+ ZMQ_ROUTER = 6
60
+ ZMQ_PULL = 7
61
+ ZMQ_PUSH = 8
62
+ ZMQ_XPUB = 9
63
+ ZMQ_XSUB = 10
64
+ ZMQ_SERVER = 12
65
+ ZMQ_CLIENT = 13
66
+ ZMQ_RADIO = 14
67
+ ZMQ_DISH = 15
68
+ ZMQ_GATHER = 16
69
+ ZMQ_SCATTER = 17
70
+ ZMQ_PEER = 19
71
+ ZMQ_CHANNEL = 20
72
+
73
+
74
+ # Socket type name → constant
75
+ SOCKET_TYPES = {
76
+ PAIR: ZMQ_PAIR, PUB: ZMQ_PUB, SUB: ZMQ_SUB,
77
+ REQ: ZMQ_REQ, REP: ZMQ_REP,
78
+ DEALER: ZMQ_DEALER, ROUTER: ZMQ_ROUTER,
79
+ PULL: ZMQ_PULL, PUSH: ZMQ_PUSH,
80
+ XPUB: ZMQ_XPUB, XSUB: ZMQ_XSUB,
81
+ SERVER: ZMQ_SERVER, CLIENT: ZMQ_CLIENT,
82
+ RADIO: ZMQ_RADIO, DISH: ZMQ_DISH,
83
+ GATHER: ZMQ_GATHER, SCATTER: ZMQ_SCATTER,
84
+ PEER: ZMQ_PEER, CHANNEL: ZMQ_CHANNEL,
85
+ }.freeze
86
+
87
+ # Send/recv flags
88
+ ZMQ_DONTWAIT = 1
89
+ ZMQ_SNDMORE = 2
90
+
91
+
92
+ # Socket options
93
+ ZMQ_IDENTITY = 5
94
+ ZMQ_SUBSCRIBE = 6
95
+ ZMQ_UNSUBSCRIBE = 7
96
+ ZMQ_RCVMORE = 13
97
+ ZMQ_FD = 14
98
+ ZMQ_EVENTS = 15
99
+ ZMQ_LINGER = 17
100
+ ZMQ_SNDHWM = 23
101
+ ZMQ_RCVHWM = 24
102
+ ZMQ_RCVTIMEO = 27
103
+ ZMQ_SNDTIMEO = 28
104
+ ZMQ_MAXMSGSIZE = 22
105
+ ZMQ_LAST_ENDPOINT = 32
106
+ ZMQ_ROUTER_MANDATORY = 33
107
+ ZMQ_RECONNECT_IVL = 18
108
+ ZMQ_RECONNECT_IVL_MAX = 21
109
+ ZMQ_CONFLATE = 54
110
+
111
+
112
+ # zmq_msg_t is 64 bytes on all platforms
113
+ MSG_T_SIZE = 64
114
+
115
+
116
+ # Allocates a zmq_msg_t on the heap.
117
+ #
118
+ # @return [FFI::MemoryPointer]
119
+ #
120
+ def self.alloc_msg
121
+ ::FFI::MemoryPointer.new(MSG_T_SIZE)
122
+ end
123
+
124
+
125
+ # Raises an error with the current zmq_errno message.
126
+ #
127
+ def self.check!(rc, label = "zmq")
128
+ return rc if rc >= 0
129
+ errno = zmq_errno
130
+ raise "#{label}: #{zmq_strerror(errno)} (errno #{errno})"
131
+ end
132
+ end
133
+ end
134
+ end
data/lib/omq/ffi.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Compatibility load path for the libzmq backend.
4
+ #
5
+ # Usage:
6
+ # require "omq/ffi"
7
+ # push = OMQ::PUSH.new(backend: :ffi)
8
+ #
9
+ # Raises LoadError if libzmq is not installed.
10
+
11
+ require_relative "backend/libzmq"
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omq-backend-libzmq
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.2
5
+ platform: ruby
6
+ authors:
7
+ - Patrik Wenger
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: omq
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.28'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.28'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ffi
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ email:
41
+ - paddor@gmail.com
42
+ executables: []
43
+ extensions: []
44
+ extra_rdoc_files: []
45
+ files:
46
+ - LICENSE
47
+ - README.md
48
+ - lib/omq/backend/libzmq.rb
49
+ - lib/omq/ffi.rb
50
+ - lib/omq/ffi/engine.rb
51
+ - lib/omq/ffi/libzmq.rb
52
+ homepage: https://github.com/zeromq/omq.rb/tree/main/gems/omq-backend-libzmq
53
+ licenses:
54
+ - ISC
55
+ metadata: {}
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '4.0'
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubygems_version: 4.0.16
71
+ specification_version: 4
72
+ summary: libzmq backend for OMQ using FFI
73
+ test_files: []