omq 0.24.0 → 0.27.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: ed87cc3a3243100b7977fd58f102b87918de8568cf8739642afc6377bb3f76e5
4
- data.tar.gz: f01b30844ae48ffe26ec3934fef780521937a7611e1d00c87b333136fa5641ac
3
+ metadata.gz: 304adcf07ab3967251a821578bb96376b7c080e2a4d0d16430768ba6855b3557
4
+ data.tar.gz: 26babe478d3f2f07f8d011b2484083bc1c327a9938eaae0ab199bcdc560eaf55
5
5
  SHA512:
6
- metadata.gz: 853d3171298de868ad3de28fe284c7970ddf196203bbb217fc91fa62cebb9d39c1d278b53d4259d0dc505b09457624ea68f4a9a8a0e654850c9266139a1fa05e
7
- data.tar.gz: ad8a2cec518ceebac32055aeab7fd159d982421e9983670ec28182094f4fe2338f89cd7a1609d02cd8720f01c121159fec7e2489b8c816f9f16adf9d950c34c4
6
+ metadata.gz: 873f2817de7d8fb063da6b425982493012b4eff08b3035cf86fbce948e3398de34ca319713dc4b1f56e4556250a82b23510a304e5f3a503aa01b367c5e6c6379
7
+ data.tar.gz: 18df1029acefe76806a1e188e077aad0023ec52a5ae63b31aac09fbe450aec68c4c4688b88b8ae77c19669a30f53c418b65f9a728eb377562a5c74da34e1a8fd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,111 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.27.0 — 2026-04-20
4
+
5
+ ### Added
6
+
7
+ - **Transport-supplied ZMTP Connection class.** Transport modules may
8
+ now define `.connection_class` to substitute their own
9
+ `Protocol::ZMTP::Connection`-shaped class. `ConnectionLifecycle`
10
+ reads it (with a `respond_to?` fallback to
11
+ `Protocol::ZMTP::Connection`) so existing transports — built-in or
12
+ third-party — keep working unchanged. Enables plugin transports
13
+ whose wire shape differs from ZMTP/3.1 (e.g. ZeroMQ-over-WebSocket
14
+ per RFC 45) to plug in without forking the engine.
15
+
16
+ ## 0.26.2 — 2026-04-20
17
+
18
+ ### Fixed
19
+
20
+ - **Ruby 3.3 compatibility.** Replaced bare `it` block references
21
+ with explicit block parameters in `engine/recv_pump.rb` and
22
+ `writable.rb`. Ruby 3.3 warned that `it` would change meaning in
23
+ 3.4; the explicit params work on both.
24
+
25
+ ## 0.26.1 — 2026-04-20
26
+
27
+ ### Fixed
28
+
29
+ - **Inproc Pipe: tolerate non-String parts.** The BINARY-encoding
30
+ upgrade introduced in 0.25 called `.encoding` on every frame,
31
+ crashing when plugins (e.g. omq-ractor's `ShareableConnection`)
32
+ carried arbitrary Ruby objects through inproc. Non-String parts
33
+ now pass through untouched; String parts still get the
34
+ frozen-string-literal → BINARY upgrade.
35
+
36
+ ## 0.26.0 — 2026-04-20
37
+
38
+ ### Added
39
+
40
+ - **FFI backend absorbed in-tree.** The libzmq-backed `OMQ::FFI::Engine`
41
+ (previously shipped as the separate `omq-ffi` gem) now lives in
42
+ `lib/omq/ffi/`. Load with `require "omq/ffi"` and select per socket
43
+ via `OMQ::PUSH.new(backend: :ffi)`. `lib/omq/socket.rb` also
44
+ lazy-requires `omq/ffi` on first `:ffi` use, so the explicit require
45
+ is optional.
46
+ - **Auto-running FFI interop tests.** `test/omq/ffi_test.rb` (FFI
47
+ backend) and `test/omq/interop_test.rb` (FFI ↔ pure Ruby wire
48
+ compatibility) now run as part of `rake test` whenever the `ffi` gem
49
+ and system libzmq are both available. They self-skip otherwise —
50
+ detection runs once in `test/test_helper.rb` as `OMQ_FFI_AVAILABLE`.
51
+
52
+ ### Notes
53
+
54
+ - `ffi` remains optional and is NOT a runtime dependency of `omq`.
55
+ Install it explicitly (`gem install ffi` + system libzmq 4.x) to use
56
+ the `:ffi` backend. The omq-ffi gem is superseded; existing pins to
57
+ `omq-ffi ~> 0.3` keep working via its own dependency on `omq ~> 0.23`.
58
+
59
+ ## 0.25.0 — 2026-04-20
60
+
61
+ ### Added
62
+
63
+ - **Recv-pump transforms can drop messages.** A `transform` block passed
64
+ to `Engine#start_recv_pump` may now return `nil` to discard the
65
+ message instead of enqueueing it to the application's recv queue.
66
+ The pump still counts the dropped message toward its per-connection
67
+ fairness caps (64 msgs / 1 MiB), so a duplicate flood can't starve
68
+ siblings. omq-qos 0.3.0 uses this at QoS >= 2 for dedup-set hits:
69
+ the transform ACKs the sender and returns `nil`.
70
+
71
+ ### Changed
72
+
73
+ - **Uniform frozen + BINARY contract on both sides of the wire —
74
+ restoring pre-0.24 behavior.** 0.24 dropped freezing from the
75
+ send/receive paths to chase throughput numbers, which left inproc
76
+ with an unsafe shared-reference contract (sender and receiver share
77
+ the same array and strings) and made the contract differ by
78
+ transport. Safety is back, minus the `.b` copy that was the
79
+ actually-expensive part of the old path. Invariants:
80
+
81
+ - `Writable#send` freezes every part (and the parts array, if one
82
+ was passed). Unfrozen non-BINARY parts are re-tagged to
83
+ `Encoding::BINARY` in place — a flag flip, no allocation.
84
+ - Receivers always get frozen `BINARY`-tagged parts. TCP/IPC get
85
+ this via byteslice on the wire + recv-pump freeze. Inproc gets
86
+ it via `Pipe#send_message`, which only allocates (one `.b` copy
87
+ per part) in the pathological case of a frozen non-BINARY part
88
+ — the typical `# frozen_string_literal: true` UTF-8 literal.
89
+
90
+ Mutation bugs surface as `FrozenError` instead of silently
91
+ corrupting a shared reference on inproc. Cost on inproc is ~20-30%
92
+ throughput; TCP/IPC unaffected.
93
+
94
+ - **String-like part coercion via `#to_str`.** Non-String parts are
95
+ coerced via `#to_str` (not `#to_s`) — an object must be explicitly
96
+ string-like to serialize. Passing `42`, `:foo`, or `nil` raises
97
+ `NoMethodError` instead of silently accepting a `#to_s`
98
+ representation or producing a zero-byte frame from a `nil`. Use
99
+ `""` to send an empty frame.
100
+
101
+ - **Inproc `needs_commands?` accepts nilable `options.qos`.** Core
102
+ `Options#qos` is still an Integer (default `0`), but omq-qos 0.3
103
+ stores either `nil` (QoS 0) or an `OMQ::QoS` instance (levels 1–3)
104
+ in that slot. The inproc transport's command-queue decision now
105
+ treats both Integer `0` and `nil` as disabled; any non-zero
106
+ Integer or non-nil object forces the command-queue path.
107
+
108
+
3
109
  ## 0.24.0 — 2026-04-18
4
110
 
5
111
  ### Changed
@@ -1044,9 +1150,10 @@ Combined effect of caller-owns-data + Reactor fast path on inproc:
1044
1150
  ### Added
1045
1151
 
1046
1152
  - **`backend:` kwarg** — all socket types accept `backend: :ffi` to use
1047
- the libzmq FFI backend (via the [omq-ffi](https://github.com/paddor/omq-ffi)
1048
- gem). Default is `:ruby` (pure Ruby ZMTP). Enables interop testing and
1049
- access to libzmq-specific features without changing the socket API.
1153
+ the libzmq FFI backend (then shipped separately as the `omq-ffi` gem;
1154
+ absorbed in-tree in 0.26.0). Default is `:ruby` (pure Ruby ZMTP).
1155
+ Enables interop testing and access to libzmq-specific features without
1156
+ changing the socket API.
1050
1157
  - **TLS transport (`tls+tcp://`)** — TLS v1.3 on top of TCP using Ruby's
1051
1158
  stdlib `openssl`. Set `socket.tls_context` to an `OpenSSL::SSL::SSLContext`
1052
1159
  before bind/connect. Per-socket (not per-endpoint), frozen on first use.
data/README.md CHANGED
@@ -5,9 +5,9 @@
5
5
  [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE)
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
- > **1.23M msg/s** inproc | **361k msg/s** ipc | **358k msg/s** tcp
8
+ > **1.64M msg/s** inproc | **294k msg/s** ipc | **308k msg/s** tcp
9
9
  >
10
- > **9.1 µs** inproc latency | **49 µs** ipc | **64 µs** tcp
10
+ > **8.7 µs** inproc latency | **51 µs** ipc | **64 µs** tcp
11
11
  >
12
12
  > Ruby 4.0 + YJIT on a Linux VM — see [`bench/`](bench/) for full results
13
13
 
@@ -162,6 +162,12 @@ All sockets are thread-safe. Default HWM is 1000 messages per socket.
162
162
  frames cause the connection to be dropped before the body is read from the
163
163
  wire. Classes live under `OMQ::` (alias: `ØMQ`).
164
164
 
165
+ **Received messages are frozen** across all transports (inproc, ipc, tcp).
166
+ The array returned by `#receive` and every part inside it is frozen —
167
+ mutating a received part raises `FrozenError` rather than silently
168
+ corrupting a shared reference on the inproc fast path. `dup` a part if
169
+ you need to mutate it.
170
+
165
171
  #### Standard (multipart messages)
166
172
 
167
173
  | Pattern | Send | Receive | When HWM full |
@@ -207,12 +213,23 @@ echo "hello" | omq req -c tcp://localhost:5555
207
213
 
208
214
  See the [omq-cli README](https://github.com/paddor/omq-cli) for full documentation.
209
215
 
216
+ ## Optional libzmq backend
217
+
218
+ OMQ ships with an optional libzmq FFI backend. Same socket API, but
219
+ backed by libzmq instead of the pure Ruby ZMTP stack. Useful when you
220
+ need libzmq-specific features or for verifying wire compatibility.
221
+
222
+ ```ruby
223
+ require "omq/ffi"
224
+ push = OMQ::PUSH.new(backend: :ffi)
225
+ ```
226
+
227
+ Requires the `ffi` gem and a system libzmq 4.x. `ffi` is not a runtime
228
+ dependency of `omq` — install it explicitly (`gem install ffi`) if you
229
+ want the `:ffi` backend.
230
+
210
231
  ## Companion Gems
211
232
 
212
- - **[omq-ffi](https://github.com/paddor/omq-ffi)** — libzmq FFI backend. Same
213
- OMQ socket API, but backed by libzmq instead of the pure Ruby ZMTP stack.
214
- Useful for interop testing and when you need libzmq-specific features.
215
- Requires libzmq installed.
216
233
  - **[omq-ractor](https://github.com/paddor/omq-ractor)** — bridge OMQ sockets
217
234
  into Ruby Ractors for true parallel processing across cores. I/O stays on the
218
235
  main Ractor, worker Ractors do pure computation.
@@ -244,7 +261,6 @@ the stack.
244
261
  git clone https://github.com/paddor/omq.git
245
262
  git clone https://github.com/paddor/protocol-zmtp.git
246
263
  git clone https://github.com/paddor/omq-zstd.git
247
- git clone https://github.com/paddor/omq-ffi.git
248
264
  git clone https://github.com/paddor/omq-ractor.git
249
265
  git clone https://github.com/paddor/nuckle.git
250
266
 
@@ -66,13 +66,19 @@ module OMQ
66
66
  # @param engine [Engine]
67
67
  # @param endpoint [String, nil]
68
68
  # @param done [Async::Promise, nil] resolved when connection is lost
69
+ # @param transport [Module, nil] transport module that produced +io+;
70
+ # queried for {.connection_class} so plugins (e.g. WebSocket) can
71
+ # substitute their own ZMTP-shaped connection class. Falls back to
72
+ # {Protocol::ZMTP::Connection} when nil or when the transport
73
+ # doesn't define +connection_class+.
69
74
  #
70
- def initialize(engine, endpoint: nil, done: nil)
71
- @engine = engine
72
- @endpoint = endpoint
73
- @done = done
74
- @state = :new
75
- @conn = nil
75
+ def initialize(engine, endpoint: nil, done: nil, transport: nil)
76
+ @engine = engine
77
+ @endpoint = endpoint
78
+ @done = done
79
+ @transport = transport
80
+ @state = :new
81
+ @conn = nil
76
82
 
77
83
  # Nest the per-connection barrier under the socket-level barrier
78
84
  # so every pump spawned via +@barrier.async+ is also tracked by
@@ -90,7 +96,8 @@ module OMQ
90
96
  #
91
97
  def handshake!(io, as_server:)
92
98
  transition!(:handshaking)
93
- conn = Protocol::ZMTP::Connection.new io,
99
+ conn_class = @transport.respond_to?(:connection_class) ? @transport.connection_class : Protocol::ZMTP::Connection
100
+ conn = conn_class.new io,
94
101
  socket_type: @engine.socket_type.to_s,
95
102
  identity: @engine.options.identity,
96
103
  as_server: as_server,
@@ -80,6 +80,12 @@ module OMQ
80
80
  # cross-Ractor transport). Kept separate from {#start_direct} so
81
81
  # YJIT sees a monomorphic transform.call site.
82
82
  #
83
+ # A transform that returns +nil+ drops the message — the recv pump
84
+ # still counts it toward fairness (so dup-floods can't starve
85
+ # siblings) but it is neither emitted nor enqueued to the
86
+ # application. omq-qos uses this at QoS >= 2 for dedup-set hits:
87
+ # the transform ACKs the sender and returns nil.
88
+ #
83
89
  # @param parent [Async::Task, Async::Barrier]
84
90
  # @param transform [Proc]
85
91
  # @return [Async::Task]
@@ -94,18 +100,11 @@ module OMQ
94
100
 
95
101
  while count < FAIRNESS_MESSAGES && bytes < FAIRNESS_BYTES
96
102
  msg = conn.receive_message
97
- msg = transform.call(msg)
98
-
99
- # Emit the verbose trace BEFORE enqueueing so the monitor
100
- # fiber is woken before the application fiber -- the
101
- # async scheduler is FIFO on the ready list, so this
102
- # preserves log-before-stdout ordering for -vvv traces.
103
- engine.emit_verbose_msg_received(conn, msg)
104
- recv_queue.enqueue(msg)
105
-
106
- count += 1
103
+ msg.each { |part| part.freeze }
104
+ msg.freeze
107
105
 
108
- # hot path
106
+ # hot path bytes — count before transform so dropped
107
+ # messages still contribute to the fairness cap.
109
108
  if count_bytes
110
109
  if msg.size == 1
111
110
  bytes += msg.first.bytesize
@@ -117,6 +116,17 @@ module OMQ
117
116
  end
118
117
  end
119
118
  end
119
+
120
+ count += 1
121
+ transformed = transform.call(msg)
122
+ next unless transformed
123
+
124
+ # Emit the verbose trace BEFORE enqueueing so the monitor
125
+ # fiber is woken before the application fiber -- the
126
+ # async scheduler is FIFO on the ready list, so this
127
+ # preserves log-before-stdout ordering for -vvv traces.
128
+ engine.emit_verbose_msg_received(conn, transformed)
129
+ recv_queue.enqueue(transformed)
120
130
  end
121
131
 
122
132
  task.yield
@@ -147,6 +157,8 @@ module OMQ
147
157
 
148
158
  while count < FAIRNESS_MESSAGES && bytes < FAIRNESS_BYTES
149
159
  msg = conn.receive_message
160
+ msg.each { |part| part.freeze }
161
+ msg.freeze
150
162
  engine.emit_verbose_msg_received(conn, msg)
151
163
  recv_queue.enqueue(msg)
152
164
 
data/lib/omq/engine.rb CHANGED
@@ -635,7 +635,8 @@ module OMQ
635
635
  def spawn_connection(io, as_server:, endpoint: nil)
636
636
  @lifecycle.barrier&.async(transient: true, annotation: "conn #{endpoint}") do
637
637
  done = Async::Promise.new
638
- lifecycle = ConnectionLifecycle.new(self, endpoint: endpoint, done: done)
638
+ transport = endpoint ? transport_for(endpoint) : nil
639
+ lifecycle = ConnectionLifecycle.new(self, endpoint: endpoint, done: done, transport: transport)
639
640
  lifecycle.handshake!(io, as_server: as_server)
640
641
  done.wait
641
642
  rescue Async::Stop, Async::Cancel
@@ -0,0 +1,646 @@
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
+
119
+ @zmq_socket = L.zmq_socket(OMQ::FFI.context, L::SOCKET_TYPES.fetch(@socket_type))
120
+ raise "zmq_socket failed: #{L.zmq_strerror(L.zmq_errno)}" if @zmq_socket.null?
121
+
122
+ apply_options
123
+
124
+ @routing = RoutingStub.new(self)
125
+
126
+ # Queues for cross-thread communication
127
+ @send_queue = Thread::Queue.new # main → io thread
128
+ @recv_queue = Thread::Queue.new # io thread → main
129
+ @cmd_queue = Thread::Queue.new # control commands → io thread
130
+
131
+ # Signal pipe: io thread → Async fiber (message received)
132
+ @recv_signal_r, @recv_signal_w = IO.pipe
133
+ # Wake pipe: main thread → io thread (send/cmd enqueued)
134
+ @wake_r, @wake_w = IO.pipe
135
+
136
+ @io_thread = nil
137
+ end
138
+
139
+
140
+ # --- Socket lifecycle ---
141
+
142
+ # Binds the socket to the given endpoint.
143
+ #
144
+ # @param endpoint [String] ZMQ endpoint URL (e.g. "tcp://*:5555")
145
+ # @return [URI::Generic] resolved endpoint URI (with auto-selected port for "tcp://host:0")
146
+ def bind(endpoint)
147
+ sync_identity
148
+ send_cmd(:bind, endpoint)
149
+ resolved = get_string_option(L::ZMQ_LAST_ENDPOINT)
150
+ @connections << :ffi
151
+ @peer_connected.resolve(:ffi) unless @peer_connected.resolved?
152
+ URI.parse(resolved)
153
+ end
154
+
155
+
156
+ # Connects the socket to the given endpoint.
157
+ #
158
+ # @param endpoint [String] ZMQ endpoint URL
159
+ # @return [URI::Generic] parsed endpoint URI
160
+ def connect(endpoint)
161
+ sync_identity
162
+ send_cmd(:connect, endpoint)
163
+ @connections << :ffi
164
+ @peer_connected.resolve(:ffi) unless @peer_connected.resolved?
165
+ URI.parse(endpoint)
166
+ end
167
+
168
+
169
+ # Disconnects from the given endpoint.
170
+ #
171
+ # @param endpoint [String] ZMQ endpoint URL
172
+ # @return [void]
173
+ def disconnect(endpoint)
174
+ send_cmd(:disconnect, endpoint)
175
+ end
176
+
177
+
178
+ # Unbinds from the given endpoint.
179
+ #
180
+ # @param endpoint [String] ZMQ endpoint URL
181
+ # @return [void]
182
+ def unbind(endpoint)
183
+ send_cmd(:unbind, endpoint)
184
+ end
185
+
186
+
187
+ # Subscribes to a topic prefix (SUB/XSUB). Delegates to the routing
188
+ # stub for API parity with the pure-Ruby Engine.
189
+ #
190
+ # @param prefix [String]
191
+ # @return [void]
192
+ def subscribe(prefix)
193
+ @routing.subscribe(prefix)
194
+ end
195
+
196
+
197
+ # Unsubscribes from a topic prefix (SUB/XSUB).
198
+ #
199
+ # @param prefix [String]
200
+ # @return [void]
201
+ def unsubscribe(prefix)
202
+ @routing.unsubscribe(prefix)
203
+ end
204
+
205
+
206
+ # @return [Async::Promise] resolved when a subscriber joins (PUB/XPUB).
207
+ def subscriber_joined
208
+ @routing.subscriber_joined
209
+ end
210
+
211
+
212
+ # Closes the socket and shuts down the I/O thread.
213
+ #
214
+ # Honors `options.linger`:
215
+ # nil → wait forever for Ruby-side queue to drain into libzmq
216
+ # and for libzmq's own LINGER to flush to the network
217
+ # 0 → drop anything not yet in libzmq's kernel buffers, close fast
218
+ # N → up to N seconds for drain + N + 1s grace for join
219
+ #
220
+ # @return [void]
221
+ def close
222
+ return if @closed
223
+ @closed = true
224
+ if @io_thread
225
+ @cmd_queue.push([:stop])
226
+ wake_io_thread
227
+ linger = @options.linger
228
+ if linger.nil?
229
+ @io_thread.join
230
+ elsif linger.zero?
231
+ @io_thread.join(0.5) # fast path: zmq_close is non-blocking with LINGER=0
232
+ else
233
+ @io_thread.join(linger + 1.0)
234
+ end
235
+ @io_thread.kill if @io_thread.alive? # hard stop if deadline exceeded
236
+ else
237
+ # IO thread never started — close socket directly
238
+ L.zmq_close(@zmq_socket)
239
+ end
240
+ @recv_signal_r&.close rescue nil
241
+ @recv_signal_w&.close rescue nil
242
+ @wake_r&.close rescue nil
243
+ @wake_w&.close rescue nil
244
+ end
245
+
246
+
247
+ # Captures the current Async task as the parent for I/O scheduling.
248
+ # +parent:+ is accepted for API compatibility with the pure-Ruby
249
+ # engine but has no effect: the FFI backend runs its own I/O
250
+ # thread and doesn't participate in the Async barrier tree.
251
+ #
252
+ # @return [void]
253
+ def capture_parent_task(parent: nil)
254
+ return if @parent_task
255
+ if parent
256
+ @parent_task = parent
257
+ elsif Async::Task.current?
258
+ @parent_task = Async::Task.current
259
+ else
260
+ @parent_task = Reactor.root_task
261
+ @on_io_thread = true
262
+ Reactor.track_linger(@options.linger)
263
+ end
264
+ end
265
+
266
+
267
+ # --- Send ---
268
+
269
+ # Enqueues a multipart message for sending via the I/O thread.
270
+ #
271
+ # @param parts [Array<String>] message frames
272
+ # @return [void]
273
+ def enqueue_send(parts)
274
+ ensure_io_thread
275
+ @send_queue.push(parts)
276
+ wake_io_thread
277
+ end
278
+
279
+
280
+ # --- Recv ---
281
+
282
+ # Dequeues the next received message, blocking until one is available.
283
+ #
284
+ # @return [Array<String>] multipart message
285
+ def dequeue_recv
286
+ ensure_io_thread
287
+ wait_for_message
288
+ end
289
+
290
+
291
+ # Pushes a nil sentinel into the recv queue to unblock a waiting consumer.
292
+ #
293
+ # @return [void]
294
+ def dequeue_recv_sentinel
295
+ @recv_queue.push(nil)
296
+ @recv_signal_w.write_nonblock(".", exception: false) rescue nil
297
+ end
298
+
299
+
300
+ # Send a control command to the I/O thread.
301
+ # @api private
302
+ #
303
+ def send_cmd(cmd, *args)
304
+ ensure_io_thread
305
+ result = Thread::Queue.new
306
+ @cmd_queue.push([cmd, args, result])
307
+ wake_io_thread
308
+ r = result.pop
309
+ raise r if r.is_a?(Exception)
310
+ r
311
+ end
312
+
313
+
314
+ # Wakes the I/O thread via the internal pipe.
315
+ #
316
+ # @return [void]
317
+ def wake_io_thread
318
+ @wake_w.write_nonblock(".", exception: false)
319
+ end
320
+
321
+ private
322
+
323
+ # Waits for a message from the I/O thread's recv queue.
324
+ # Uses the signal pipe so Async can yield the fiber.
325
+ #
326
+ def wait_for_message
327
+ loop do
328
+ begin
329
+ return @recv_queue.pop(true)
330
+ rescue ThreadError
331
+ # empty
332
+ end
333
+ @recv_signal_r.wait_readable
334
+ @recv_signal_r.read_nonblock(256, exception: false)
335
+ end
336
+ end
337
+
338
+
339
+ def ensure_io_thread
340
+ return if @io_thread
341
+ @io_thread = Thread.new { io_loop }
342
+ end
343
+
344
+
345
+ # The I/O loop runs on a dedicated thread. It owns the zmq_socket
346
+ # exclusively and processes commands, sends, and recvs.
347
+ #
348
+ def io_loop
349
+ zmq_fd_io = IO.for_fd(get_zmq_fd, autoclose: false)
350
+
351
+ loop do
352
+ drain_cmds or break
353
+ drain_sends
354
+ try_recv
355
+
356
+ # Block until ZMQ or wake pipe has activity.
357
+ IO.select([zmq_fd_io, @wake_r], nil, nil, 0.1)
358
+ @wake_r.read_nonblock(4096, exception: false)
359
+ end
360
+ rescue
361
+ # Thread exit
362
+ ensure
363
+ # Drain Ruby-side send queue into libzmq, bounded by linger deadline.
364
+ # Then re-apply current linger to libzmq (user may have changed it
365
+ # after apply_options ran in initialize) and zmq_close uses it to
366
+ # flush libzmq's own queue to TCP.
367
+ drain_sends_with_deadline(zmq_fd_io, shutdown_deadline) rescue nil
368
+ set_int_option(L::ZMQ_LINGER, Engine.linger_to_zmq_ms(@options.linger)) rescue nil
369
+ zmq_fd_io&.close rescue nil
370
+ L.zmq_close(@zmq_socket)
371
+ end
372
+
373
+
374
+ # Returns a monotonic deadline for the Ruby-side drain phase, or nil
375
+ # for infinite, or the current clock for "drop immediately".
376
+ #
377
+ def shutdown_deadline
378
+ linger = @options.linger
379
+ return nil if linger.nil?
380
+ now = Async::Clock.now
381
+ return now if linger.zero?
382
+ now + linger
383
+ end
384
+
385
+
386
+ # Retries drain_sends with IO.select until either the Ruby-side queue
387
+ # is empty or the deadline is hit. nil deadline = wait forever.
388
+ #
389
+ def drain_sends_with_deadline(zmq_fd_io, deadline)
390
+ loop do
391
+ drain_sends
392
+ break if @pending_send.nil? && @send_queue.empty?
393
+ if deadline
394
+ remaining = deadline - Async::Clock.now
395
+ break if remaining <= 0
396
+ IO.select([zmq_fd_io], nil, nil, [remaining, 0.1].min)
397
+ else
398
+ IO.select([zmq_fd_io], nil, nil, 0.1)
399
+ end
400
+ end
401
+ end
402
+
403
+
404
+ def zmq_has_events?
405
+ @events_buf ||= ::FFI::MemoryPointer.new(:int)
406
+ @events_len ||= ::FFI::MemoryPointer.new(:size_t).tap { |p| p.write(:size_t, ::FFI.type_size(:int)) }
407
+ L.zmq_getsockopt(@zmq_socket, L::ZMQ_EVENTS, @events_buf, @events_len)
408
+ @events_buf.read_int != 0
409
+ end
410
+
411
+
412
+ def drain_cmds
413
+ loop do
414
+ begin
415
+ cmd = @cmd_queue.pop(true)
416
+ rescue ThreadError
417
+ return true # queue empty, continue
418
+ end
419
+ return false unless process_cmd(cmd)
420
+ end
421
+ end
422
+
423
+
424
+ def process_cmd(cmd)
425
+ name, args, result = cmd
426
+ case name
427
+ when :stop
428
+ result&.push(nil)
429
+ return false
430
+ when :bind
431
+ rc = L.zmq_bind(@zmq_socket, args[0])
432
+ result&.push(rc >= 0 ? nil : syscall_error)
433
+ when :connect
434
+ rc = L.zmq_connect(@zmq_socket, args[0])
435
+ result&.push(rc >= 0 ? nil : syscall_error)
436
+ when :disconnect
437
+ rc = L.zmq_disconnect(@zmq_socket, args[0])
438
+ result&.push(rc >= 0 ? nil : syscall_error)
439
+ when :unbind
440
+ rc = L.zmq_unbind(@zmq_socket, args[0])
441
+ result&.push(rc >= 0 ? nil : syscall_error)
442
+ when :set_identity
443
+ set_bytes_option(L::ZMQ_IDENTITY, args[0])
444
+ result&.push(nil)
445
+ when :subscribe
446
+ set_bytes_option(L::ZMQ_SUBSCRIBE, args[0])
447
+ result&.push(nil)
448
+ when :unsubscribe
449
+ set_bytes_option(L::ZMQ_UNSUBSCRIBE, args[0])
450
+ result&.push(nil)
451
+ when :join
452
+ rc = L.respond_to?(:zmq_join) ? L.zmq_join(@zmq_socket, args[0]) : -1
453
+ result&.push(rc >= 0 ? nil : RuntimeError.new("zmq_join not available"))
454
+ when :leave
455
+ rc = L.respond_to?(:zmq_leave) ? L.zmq_leave(@zmq_socket, args[0]) : -1
456
+ result&.push(rc >= 0 ? nil : RuntimeError.new("zmq_leave not available"))
457
+ when :drain_send
458
+ # handled in drain_sends
459
+ result&.push(nil)
460
+ end
461
+ true
462
+ end
463
+
464
+
465
+ def try_recv
466
+ loop do
467
+ parts = recv_multipart_nonblock
468
+ break unless parts
469
+ @recv_queue.push(parts.freeze)
470
+ @recv_signal_w.write_nonblock(".", exception: false)
471
+ end
472
+ end
473
+
474
+
475
+ def drain_sends
476
+ @pending_send ||= nil
477
+ loop do
478
+ parts = @pending_send || begin
479
+ @send_queue.pop(true)
480
+ rescue ThreadError
481
+ break
482
+ end
483
+ if send_multipart_nonblock(parts)
484
+ @pending_send = nil
485
+ else
486
+ @pending_send = parts # retry next cycle (HWM reached)
487
+ break
488
+ end
489
+ end
490
+ end
491
+
492
+
493
+ # Returns true if fully sent, false if would block (HWM).
494
+ #
495
+ def send_multipart_nonblock(parts)
496
+ parts.each_with_index do |part, i|
497
+ flags = L::ZMQ_DONTWAIT
498
+ flags |= L::ZMQ_SNDMORE if i < parts.size - 1
499
+ msg = L.alloc_msg
500
+ L.zmq_msg_init_size(msg, part.bytesize)
501
+ L.zmq_msg_data(msg).write_bytes(part)
502
+ rc = L.zmq_msg_send(msg, @zmq_socket, flags)
503
+ if rc < 0
504
+ L.zmq_msg_close(msg)
505
+ return false # EAGAIN — would block
506
+ end
507
+ end
508
+ true
509
+ end
510
+
511
+
512
+ def recv_multipart_nonblock
513
+ parts = []
514
+ loop do
515
+ msg = L.alloc_msg
516
+ L.zmq_msg_init(msg)
517
+ rc = L.zmq_msg_recv(msg, @zmq_socket, L::ZMQ_DONTWAIT)
518
+ if rc < 0
519
+ L.zmq_msg_close(msg)
520
+ return parts.empty? ? nil : parts # EAGAIN = no more data
521
+ end
522
+
523
+ size = L.zmq_msg_size(msg)
524
+ data = L.zmq_msg_data(msg).read_bytes(size)
525
+ L.zmq_msg_close(msg)
526
+ parts << data.freeze
527
+
528
+ break unless rcvmore?
529
+ end
530
+ parts.empty? ? nil : parts
531
+ end
532
+
533
+
534
+ def rcvmore?
535
+ buf = ::FFI::MemoryPointer.new(:int)
536
+ len = ::FFI::MemoryPointer.new(:size_t)
537
+ len.write(:size_t, ::FFI.type_size(:int))
538
+ L.zmq_getsockopt(@zmq_socket, L::ZMQ_RCVMORE, buf, len)
539
+ buf.read_int != 0
540
+ end
541
+
542
+
543
+ def get_zmq_fd
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_FD, buf, len)
548
+ buf.read_int
549
+ end
550
+
551
+
552
+ # Re-syncs identity to libzmq (user may set it after construction).
553
+ #
554
+ def sync_identity
555
+ id = @options.identity
556
+ if id && !id.empty?
557
+ send_cmd(:set_identity, id)
558
+ end
559
+ end
560
+
561
+
562
+ def apply_options
563
+ set_int_option(L::ZMQ_SNDHWM, @options.send_hwm)
564
+ set_int_option(L::ZMQ_RCVHWM, @options.recv_hwm)
565
+ set_int_option(L::ZMQ_LINGER, Engine.linger_to_zmq_ms(@options.linger))
566
+ set_int_option(L::ZMQ_CONFLATE, @options.conflate ? 1 : 0)
567
+
568
+ if @options.identity && !@options.identity.empty?
569
+ set_bytes_option(L::ZMQ_IDENTITY, @options.identity)
570
+ end
571
+
572
+ if @options.max_message_size
573
+ set_int64_option(L::ZMQ_MAXMSGSIZE, @options.max_message_size)
574
+ end
575
+
576
+ if @options.reconnect_interval
577
+ ivl = @options.reconnect_interval
578
+ if ivl.is_a?(Range)
579
+ set_int_option(L::ZMQ_RECONNECT_IVL, (ivl.begin * 1000).to_i)
580
+ set_int_option(L::ZMQ_RECONNECT_IVL_MAX, (ivl.end * 1000).to_i)
581
+ else
582
+ set_int_option(L::ZMQ_RECONNECT_IVL, (ivl * 1000).to_i)
583
+ end
584
+ end
585
+
586
+ set_int_option(L::ZMQ_ROUTER_MANDATORY, 1) if @options.router_mandatory
587
+ end
588
+
589
+
590
+ def set_int_option(opt, value)
591
+ buf = ::FFI::MemoryPointer.new(:int)
592
+ buf.write_int(value)
593
+ L.zmq_setsockopt(@zmq_socket, opt, buf, ::FFI.type_size(:int))
594
+ end
595
+
596
+
597
+ def set_int64_option(opt, value)
598
+ buf = ::FFI::MemoryPointer.new(:int64)
599
+ buf.write_int64(value)
600
+ L.zmq_setsockopt(@zmq_socket, opt, buf, ::FFI.type_size(:int64))
601
+ end
602
+
603
+
604
+ def set_bytes_option(opt, value)
605
+ buf = ::FFI::MemoryPointer.from_string(value)
606
+ L.zmq_setsockopt(@zmq_socket, opt, buf, value.bytesize)
607
+ end
608
+
609
+
610
+ def get_string_option(opt)
611
+ buf = ::FFI::MemoryPointer.new(:char, 256)
612
+ len = ::FFI::MemoryPointer.new(:size_t)
613
+ len.write(:size_t, 256)
614
+ L.check!(L.zmq_getsockopt(@zmq_socket, opt, buf, len), "zmq_getsockopt")
615
+ buf.read_string(len.read(:size_t) - 1)
616
+ end
617
+
618
+
619
+ # Builds an Errno::XXX exception from the current zmq_errno so callers
620
+ # can rescue the same classes they would from the pure-Ruby backend
621
+ # (e.g. `Errno::EADDRINUSE`, `Errno::ECONNREFUSED`). Falls back to a
622
+ # plain SystemCallError when the errno is libzmq-specific.
623
+ #
624
+ def syscall_error
625
+ errno = L.zmq_errno
626
+ SystemCallError.new(L.zmq_strerror(errno), errno)
627
+ end
628
+
629
+
630
+ end
631
+
632
+
633
+ # Returns the shared ZMQ context (one per process, lazily initialized).
634
+ #
635
+ # @return [FFI::Pointer] zmq context pointer
636
+ def self.context
637
+ @context ||= Libzmq.zmq_ctx_new.tap do |ctx|
638
+ raise "zmq_ctx_new failed" if ctx.null?
639
+ at_exit do
640
+ Libzmq.zmq_ctx_shutdown(ctx) rescue nil
641
+ Libzmq.zmq_ctx_term(ctx) rescue nil
642
+ end
643
+ end
644
+ end
645
+ end
646
+ 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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load the FFI backend for OMQ.
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 "ffi/libzmq"
12
+ require_relative "ffi/engine"
data/lib/omq/socket.rb CHANGED
@@ -36,7 +36,7 @@ module OMQ
36
36
 
37
37
 
38
38
  # @return [Engine] the socket's engine. Exposed for peer tooling
39
- # (omq-cli, omq-ffi, omq-ractor) that needs to reach into the
39
+ # (omq-cli, omq-ractor) that needs to reach into the
40
40
  # socket's internals — not part of the stable user API.
41
41
  #
42
42
  attr_reader :engine
@@ -318,6 +318,7 @@ module OMQ
318
318
  when nil, :ruby
319
319
  Engine.new(socket_type, @options)
320
320
  when :ffi
321
+ require "omq/ffi" unless defined?(FFI::Engine)
321
322
  FFI::Engine.new(socket_type, @options)
322
323
  else
323
324
  raise ArgumentError, "unknown backend: #{backend}"
@@ -101,6 +101,19 @@ module OMQ
101
101
  def send_message(parts)
102
102
  raise IOError, "closed" if @closed
103
103
 
104
+ # Writable#send guarantees frozen parts, but a frozen non-BINARY
105
+ # part (e.g. a `# frozen_string_literal: true` literal) can't be
106
+ # re-tagged in place. Inproc receivers see the parts directly, so
107
+ # upgrade that one case to fresh BINARY copies to keep the
108
+ # receive contract uniform with TCP/IPC.
109
+ #
110
+ # Non-String parts pass through untouched — plugins like
111
+ # omq-ractor's ShareableConnection carry arbitrary Ruby objects
112
+ # over inproc.
113
+ if parts.any? { |p| p.is_a?(String) && p.encoding != Encoding::BINARY }
114
+ parts = parts.map { |p| !p.is_a?(String) || p.encoding == Encoding::BINARY ? p : p.b.freeze }.freeze
115
+ end
116
+
104
117
  if @direct_recv_queue
105
118
  @direct_recv_queue.enqueue(apply_transform(parts))
106
119
  elsif @send_queue
@@ -11,8 +11,8 @@ module OMQ
11
11
  # Both peers are Ruby backend sockets in the same process (native
12
12
  # ZMQ's inproc registry is separate and unreachable). Messages are
13
13
  # transferred as Ruby arrays — no ZMTP framing, no byte
14
- # serialization. String parts are frozen by Writable#send to
15
- # prevent shared mutable state without copying.
14
+ # serialization. Parts are already frozen by Writable#send, so the
15
+ # receiver sees the same immutable contract as ZMTP transports.
16
16
  #
17
17
  module Inproc
18
18
  Engine.transports["inproc"] = self
@@ -132,11 +132,23 @@ module OMQ
132
132
  #
133
133
  def needs_commands?(ce, se, ct, st)
134
134
  return true if COMMAND_TYPES.include?(ct) || COMMAND_TYPES.include?(st)
135
- return true if ce.options.qos >= 1 || se.options.qos >= 1
135
+ return true if qos_enabled?(ce.options) || qos_enabled?(se.options)
136
136
  false
137
137
  end
138
138
 
139
139
 
140
+ # QoS integration: core +Options#qos+ defaults to Integer +0+.
141
+ # When the omq-qos extension is loaded, +#qos+ holds either
142
+ # +nil+ (QoS 0) or an +OMQ::QoS+ instance (levels 1–3). Treat
143
+ # both Integer 0 and nil as disabled.
144
+ def qos_enabled?(options)
145
+ q = options.qos
146
+ return false if q.nil?
147
+ return q != 0 if q.is_a?(Integer)
148
+ true
149
+ end
150
+
151
+
140
152
  # Builds a bidirectional {Pipe} pair for client + server.
141
153
  # When +needs_cmds+ is false the pipes have no command queues
142
154
  # (fast path — all traffic bypasses Async::Queue entirely).
@@ -15,6 +15,15 @@ module OMQ
15
15
 
16
16
 
17
17
  class << self
18
+ # ZMTP connection class used for IPC-accepted/dialed peers.
19
+ #
20
+ # @return [Class]
21
+ #
22
+ def connection_class
23
+ Protocol::ZMTP::Connection
24
+ end
25
+
26
+
18
27
  # Creates a bound IPC listener.
19
28
  #
20
29
  # @param endpoint [String] e.g. "ipc:///tmp/my.sock" or "ipc://@abstract"
@@ -13,6 +13,15 @@ module OMQ
13
13
 
14
14
 
15
15
  class << self
16
+ # ZMTP connection class used for TCP-accepted/dialed peers.
17
+ #
18
+ # @return [Class]
19
+ #
20
+ def connection_class
21
+ Protocol::ZMTP::Connection
22
+ end
23
+
24
+
16
25
  # Creates a bound TCP listener.
17
26
  #
18
27
  # @param endpoint [String] e.g. "tcp://127.0.0.1:5555" or "tcp://*:0"
data/lib/omq/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OMQ
4
- VERSION = "0.24.0"
4
+ VERSION = "0.27.0"
5
5
  end
data/lib/omq/writable.rb CHANGED
@@ -11,18 +11,38 @@ module OMQ
11
11
 
12
12
  # Sends a message.
13
13
  #
14
- # Caller owns the message parts. Don't mutate them after sending — especially
15
- # with inproc transport or PUB fan-out, where a single reference can be shared
16
- # across peers and read later by the send pump.
14
+ # Parts must be String-like (respond to `#to_str`). Use an empty
15
+ # string to send an empty frame `nil` raises `NoMethodError` so
16
+ # accidental nils surface instead of silently producing a zero-byte
17
+ # frame. Invariants after `#send` returns:
17
18
  #
18
- # @param message [String, Array<String>] message parts
19
+ # * every part is a frozen String
20
+ # * unfrozen String parts are re-tagged to `Encoding::BINARY` in
21
+ # place (a flag flip, no copy)
22
+ # * the parts array (if the caller passed one) is frozen
23
+ #
24
+ # The receiver always gets frozen `BINARY`-tagged parts — on TCP/IPC
25
+ # via byteslice on the wire, on inproc via {Pipe#send_message} which
26
+ # duplicates the one pathological case (frozen non-BINARY parts) so
27
+ # the receiver sees BINARY like every other transport.
28
+ #
29
+ # @param message [String, #to_str, Array<String, #to_str>]
19
30
  # @return [self]
20
31
  # @raise [IO::TimeoutError] if write_timeout exceeded
32
+ # @raise [NoMethodError] if a part is not String-like
21
33
  #
22
34
  def send(message)
23
35
  parts = message.is_a?(Array) ? message : [message]
24
36
  raise ArgumentError, "message has no parts" if parts.empty?
25
37
 
38
+ parts = parts.map { |p| p.to_str } if parts.any? { |p| !p.is_a?(String) }
39
+
40
+ parts.each do |part|
41
+ part.force_encoding(Encoding::BINARY) unless part.frozen? || part.encoding == Encoding::BINARY
42
+ part.freeze
43
+ end
44
+ parts.freeze
45
+
26
46
  if @engine.on_io_thread?
27
47
  Reactor.run(timeout: @options.write_timeout) { @engine.enqueue_send(parts) }
28
48
  elsif (timeout = @options.write_timeout)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.24.0
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -76,6 +76,9 @@ files:
76
76
  - lib/omq/engine/reconnect.rb
77
77
  - lib/omq/engine/recv_pump.rb
78
78
  - lib/omq/engine/socket_lifecycle.rb
79
+ - lib/omq/ffi.rb
80
+ - lib/omq/ffi/engine.rb
81
+ - lib/omq/ffi/libzmq.rb
79
82
  - lib/omq/options.rb
80
83
  - lib/omq/pair.rb
81
84
  - lib/omq/peer.rb