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 +4 -4
- data/CHANGELOG.md +110 -3
- data/README.md +23 -7
- data/lib/omq/engine/connection_lifecycle.rb +14 -7
- data/lib/omq/engine/recv_pump.rb +23 -11
- data/lib/omq/engine.rb +2 -1
- data/lib/omq/ffi/engine.rb +646 -0
- data/lib/omq/ffi/libzmq.rb +134 -0
- data/lib/omq/ffi.rb +12 -0
- data/lib/omq/socket.rb +2 -1
- data/lib/omq/transport/inproc/pipe.rb +13 -0
- data/lib/omq/transport/inproc.rb +15 -3
- data/lib/omq/transport/ipc.rb +9 -0
- data/lib/omq/transport/tcp.rb +9 -0
- data/lib/omq/version.rb +1 -1
- data/lib/omq/writable.rb +24 -4
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 304adcf07ab3967251a821578bb96376b7c080e2a4d0d16430768ba6855b3557
|
|
4
|
+
data.tar.gz: 26babe478d3f2f07f8d011b2484083bc1c327a9938eaae0ab199bcdc560eaf55
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 (
|
|
1048
|
-
|
|
1049
|
-
access to libzmq-specific features without
|
|
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)
|
|
6
6
|
[](https://www.ruby-lang.org)
|
|
7
7
|
|
|
8
|
-
> **1.
|
|
8
|
+
> **1.64M msg/s** inproc | **294k msg/s** ipc | **308k msg/s** tcp
|
|
9
9
|
>
|
|
10
|
-
> **
|
|
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
|
|
72
|
-
@endpoint
|
|
73
|
-
@done
|
|
74
|
-
@
|
|
75
|
-
@
|
|
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
|
-
|
|
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,
|
data/lib/omq/engine/recv_pump.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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-
|
|
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
|
data/lib/omq/transport/inproc.rb
CHANGED
|
@@ -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.
|
|
15
|
-
#
|
|
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
|
|
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).
|
data/lib/omq/transport/ipc.rb
CHANGED
|
@@ -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"
|
data/lib/omq/transport/tcp.rb
CHANGED
|
@@ -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
data/lib/omq/writable.rb
CHANGED
|
@@ -11,18 +11,38 @@ module OMQ
|
|
|
11
11
|
|
|
12
12
|
# Sends a message.
|
|
13
13
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
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
|