omq 0.10.0 → 0.11.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: 3a81ea3e22e2133f508016129454fca72393068b259400e3d47aaf92d0948136
4
- data.tar.gz: 71839df55e8ae9edb9db9710b77c5449853cde909c60867958a46e8350f27ead
3
+ metadata.gz: b736b6b715f5c4f330d21be278450600b2642239b8bea9e85957f516e89211ed
4
+ data.tar.gz: 34c057a32055e7333e4299e85c0fee9e798b43ea34d021f237ba6778338b65f1
5
5
  SHA512:
6
- metadata.gz: 9e9d603fb6f44b53626e37d88da5dea1297f932bfbc7b3af0b32e5ab4ebdc6c2151f4b7482bca70d5060d31ffc49ba3c771603b6ed1b4dd17ed51733339de67d
7
- data.tar.gz: b40525791795b8e040a4a275a628c1cdca42eab43fcabfab76407de97f669ff9c1b21d05490509c0e0f5cd0b53505c7f85281343b2c312bdb89fd3eee7d4af75
6
+ metadata.gz: e95a6df6d4eb56ac1fc0586f61cfdb77d0afe9de620d3e540ffbae1256c2fe6526556485215eda3de20c8f34a332f51a68e5bb075087665049c0219bb07f6960
7
+ data.tar.gz: 8b5c8d38b7523e9c6ab30e17c21b0cf4adb2086e1bdde90298f62e42f4b8f2336cdd95c47293c3fb24fa41f376aed8a2ec6d1121fc56d41aa83d4afdc289d707
data/CHANGELOG.md CHANGED
@@ -1,6 +1,68 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.11.0
4
+
5
+ ### Added
6
+
7
+ - **`backend:` kwarg** — all socket types accept `backend: :ffi` to use
8
+ the libzmq FFI backend (via the [omq-ffi](https://github.com/paddor/omq-ffi)
9
+ gem). Default is `:ruby` (pure Ruby ZMTP). Enables interop testing and
10
+ access to libzmq-specific features without changing the socket API.
11
+ - **TLS transport (`tls+tcp://`)** — TLS v1.3 on top of TCP using Ruby's
12
+ stdlib `openssl`. Set `socket.tls_context` to an `OpenSSL::SSL::SSLContext`
13
+ before bind/connect. Per-socket (not per-endpoint), frozen on first use.
14
+ SNI set automatically from the endpoint hostname. Bad TLS handshakes are
15
+ dropped without killing the accept loop. `OpenSSL::SSL::SSLError` added
16
+ to `CONNECTION_LOST` for automatic reconnection on TLS failures.
17
+ Accompanied by a draft RFC (`rfc/zmtp-tls.md`) defining the transport
18
+ mapping for ZMTP 3.1 over TLS.
19
+ - **PUB/RADIO fan-out pre-encoding** — ZMTP frames are encoded once per
20
+ message and written as raw wire bytes to all non-CURVE subscribers.
21
+ Eliminates redundant `Frame.new` + `#to_wire` calls during fan-out.
22
+ CURVE connections (which encrypt at the ZMTP level) still encode
23
+ per-connection. TLS, NULL, and PLAIN all benefit since TLS encrypts
24
+ below ZMTP. Requires protocol-zmtp `Frame.encode_message` and
25
+ `Connection#write_wire`.
26
+ - **CURVE benchmarks** — all per-pattern benchmarks now include CURVE
27
+ (via rbnacl) alongside inproc, ipc, tcp, and tls transports.
28
+ - **Engine `connection_wrapper` hook** — optional proc on Engine that wraps
29
+ new connections (both inproc and tcp/ipc) at creation time. Used by the
30
+ omq-ractor gem for per-connection serialization (Marshal for tcp/ipc,
31
+ `Ractor.make_shareable` for inproc).
32
+ - **Queue-style interface** — readable sockets gain `#dequeue(timeout:)`,
33
+ `#pop`, `#wait`, and `#each`; writable sockets gain `#enqueue` and
34
+ `#push`. Inspired by `Async::Queue`. `#wait` blocks indefinitely
35
+ (ignores `read_timeout`); `#each` returns gracefully on timeout.
36
+ - **Recv pump fairness** — each connection yields to the fiber scheduler
37
+ after 64 messages or 1 MB (whichever comes first). Prevents a fast or
38
+ large-message connection from starving slower peers when the consumer
39
+ keeps up. Byte counting gracefully handles non-string messages (e.g.
40
+ deserialized objects from connection wrappers).
41
+ - **Per-pattern benchmark suite** — `bench/{push_pull,req_rep,router_dealer,dealer_dealer,pub_sub,pair}/omq.rb`
42
+ with shared helpers (`bench_helper.rb`) and UnicodePlot braille line
43
+ charts (`plot.rb`). Each benchmark measures throughput (msg/s) and
44
+ bandwidth (MB/s) across transports (inproc, ipc, tcp, tls, curve),
45
+ message sizes (64 B–64 KB), and peer counts (1, 3). Plots are written to per-directory
46
+ `README.md` files for easy diffing across versions.
47
+
48
+ ### Changed
49
+
50
+ - **SUB/XSUB `prefix:` kwarg renamed to `subscribe:`** — aligns with
51
+ ZeroMQ conventions. `subscribe: nil` (no subscription) remains the
52
+ default; pass `subscribe: ''` to subscribe to everything, or
53
+ `subscribe: 'topic.'` for a prefix filter.
54
+ - **Scenario benchmarks moved to `bench/scenarios/`** — broker,
55
+ draft_types, flush_batching, hwm_backpressure, large_messages,
56
+ multiframe, pubsub_fanout, ractors_vs_async, ractors_vs_fork,
57
+ reconnect_storm, and reqrep_throughput moved from `bench/` top level.
58
+
59
+ ### Removed
60
+
61
+ - **Old flat benchmarks** — `bench/throughput.rb`, `bench/latency.rb`,
62
+ `bench/pipeline_mbps.rb`, `bench/run_all.sh` replaced by per-pattern
63
+ benchmarks.
64
+ - **`bench/cli/`** — CLI-specific benchmarks (fib pipeline, latency,
65
+ throughput shell scripts) moved to the omq-cli repository.
4
66
 
5
67
  ## 0.10.0 — 2026-04-01
6
68
 
data/README.md CHANGED
@@ -9,9 +9,9 @@
9
9
 
10
10
  OMQ builds ZeroMQ socket patterns on top of [protocol-zmtp](https://github.com/paddor/protocol-zmtp) (a pure Ruby [ZMTP 3.1](https://rfc.zeromq.org/spec/23/) codec) using [Async](https://github.com/socketry/async) fibers. It speaks native ZeroMQ on the wire and interoperates with libzmq, pyzmq, CZMQ, and everything else in the ZMQ ecosystem.
11
11
 
12
- > **234k msg/s** inproc | **49k msg/s** ipc | **36k msg/s** tcp
12
+ > **980k msg/s** inproc | **38k msg/s** ipc | **31k msg/s** tcp
13
13
  >
14
- > **12 µs** inproc latency | **51 µs** ipc | **62 µs** tcp
14
+ > **10 µs** inproc latency | **71 µs** ipc | **82 µs** tcp
15
15
  >
16
16
  > Ruby 4.0 + YJIT on a Linux VM — see [`bench/`](bench/) for full results
17
17
 
@@ -30,7 +30,7 @@ See [GETTING_STARTED.md](GETTING_STARTED.md) for a ~30 min walkthrough of all ma
30
30
  ## Highlights
31
31
 
32
32
  - **Zero dependencies on C** — no extensions, no FFI, no libzmq. `gem install` just works everywhere
33
- - **Fast** — YJIT-optimized hot paths, batched sends, 234k msg/s inproc with 12 µs latency
33
+ - **Fast** — YJIT-optimized hot paths, batched sends, recv prefetching, direct-pipe inproc bypass. 980k msg/s inproc, 10 µs latency
34
34
  - **[`omq` CLI](https://github.com/paddor/omq-cli)** — `gem install omq-cli` for a command-line tool with Ruby eval, Ractor parallelism, and script handlers
35
35
  - **Every socket pattern** — req/rep, pub/sub, push/pull, dealer/router, xpub/xsub, pair, and all draft types
36
36
  - **Every transport** — tcp, ipc (Unix domain sockets), inproc (in-process queues)
@@ -125,6 +125,26 @@ pull.close
125
125
 
126
126
  The IO thread runs all pumps, reconnection, and heartbeating in the background. When you're inside an `Async` block, OMQ uses the existing reactor instead.
127
127
 
128
+ ### Queue Interface
129
+
130
+ All sockets expose an `Async::Queue`-inspired interface:
131
+
132
+ | Async::Queue | OMQ Socket | Notes |
133
+ |---|---|---|
134
+ | `enqueue(item)` / `push(item)` | `enqueue(msg)` / `push(msg)` | Also: `send(msg)`, `<< msg` |
135
+ | `dequeue(timeout:)` / `pop(timeout:)` | `dequeue(timeout:)` / `pop(timeout:)` | Defaults to socket's `read_timeout` |
136
+ | `wait` | `wait` | Blocks indefinitely (ignores `read_timeout`) |
137
+ | `each` | `each` | Yields messages; returns on close or timeout |
138
+
139
+ ```ruby
140
+ pull = OMQ::PULL.bind('inproc://work')
141
+
142
+ # iterate messages like a queue
143
+ pull.each do |msg|
144
+ puts msg.first
145
+ end
146
+ ```
147
+
128
148
  ## Socket Types
129
149
 
130
150
  All sockets are thread-safe. Default HWM is 1000 messages per socket. Classes live under `OMQ::` (alias: `ØMQ`).
@@ -163,6 +183,11 @@ echo "hello" | omq req -c tcp://localhost:5555
163
183
 
164
184
  See the [omq-cli README](https://github.com/paddor/omq-cli) for full documentation.
165
185
 
186
+ ## Companion Gems
187
+
188
+ - **[omq-ffi](https://github.com/paddor/omq-ffi)** — libzmq FFI backend. Same OMQ socket API, but backed by libzmq instead of the pure Ruby ZMTP stack. Useful for interop testing and when you need libzmq-specific features. Requires libzmq installed.
189
+ - **[omq-ractor](https://github.com/paddor/omq-ractor)** — bridge OMQ sockets into Ruby Ractors for true parallel processing across cores. I/O stays on the main Ractor, worker Ractors do pure computation.
190
+
166
191
  ## Development
167
192
 
168
193
  ```sh
data/lib/omq/channel.rb CHANGED
@@ -6,8 +6,8 @@ module OMQ
6
6
  include Writable
7
7
  include SingleFrame
8
8
 
9
- def initialize(endpoints = nil, linger: 0)
10
- _init_engine(:CHANNEL, linger: linger)
9
+ def initialize(endpoints = nil, linger: 0, backend: nil)
10
+ _init_engine(:CHANNEL, linger: linger, backend: backend)
11
11
  _attach(endpoints, default: :connect)
12
12
  end
13
13
  end
@@ -6,8 +6,8 @@ module OMQ
6
6
  include Writable
7
7
  include SingleFrame
8
8
 
9
- def initialize(endpoints = nil, linger: 0)
10
- _init_engine(:CLIENT, linger: linger)
9
+ def initialize(endpoints = nil, linger: 0, backend: nil)
10
+ _init_engine(:CLIENT, linger: linger, backend: backend)
11
11
  _attach(endpoints, default: :connect)
12
12
  end
13
13
  end
@@ -17,8 +17,8 @@ module OMQ
17
17
  include Writable
18
18
  include SingleFrame
19
19
 
20
- def initialize(endpoints = nil, linger: 0)
21
- _init_engine(:SERVER, linger: linger)
20
+ def initialize(endpoints = nil, linger: 0, backend: nil)
21
+ _init_engine(:SERVER, linger: linger, backend: backend)
22
22
  _attach(endpoints, default: :bind)
23
23
  end
24
24
 
data/lib/omq/engine.rb CHANGED
@@ -64,6 +64,11 @@ module OMQ
64
64
 
65
65
  attr_writer :reconnect_enabled
66
66
 
67
+ # Optional proc that wraps new connections (e.g. for serialization).
68
+ # Called with the raw connection; must return the (possibly wrapped) connection.
69
+ #
70
+ attr_accessor :connection_wrapper
71
+
67
72
 
68
73
  # Spawns an inproc reconnect retry task under @parent_task.
69
74
  #
@@ -184,6 +189,7 @@ module OMQ
184
189
  # @return [void]
185
190
  #
186
191
  def connection_ready(pipe, endpoint: nil)
192
+ pipe = @connection_wrapper.call(pipe) if @connection_wrapper
187
193
  @connections << pipe
188
194
  @connection_endpoints[pipe] = endpoint if endpoint
189
195
  @routing.connection_added(pipe)
@@ -262,6 +268,13 @@ module OMQ
262
268
  # @yield [msg] optional per-message transform
263
269
  # @return [#stop, nil] pump task handle, or nil for DirectPipe bypass
264
270
  #
271
+ # Fairness limits for the recv pump. Yield to the scheduler
272
+ # after reading this many messages or bytes from one connection,
273
+ # whichever comes first. Prevents a fast or large-message
274
+ # connection from starving slower peers.
275
+ RECV_FAIRNESS_MESSAGES = 64
276
+ RECV_FAIRNESS_BYTES = 1 << 20 # 1 MB
277
+
265
278
  def start_recv_pump(conn, recv_queue, &transform)
266
279
  if conn.is_a?(Transport::Inproc::DirectPipe) && conn.peer
267
280
  conn.peer.direct_recv_queue = recv_queue
@@ -270,11 +283,18 @@ module OMQ
270
283
  end
271
284
 
272
285
  if transform
273
- @parent_task.async(transient: true, annotation: "recv pump") do
286
+ @parent_task.async(transient: true, annotation: "recv pump") do |task|
274
287
  loop do
275
- msg = conn.receive_message
276
- msg = transform.call(msg).freeze
277
- recv_queue.enqueue(msg)
288
+ count = 0
289
+ bytes = 0
290
+ while count < RECV_FAIRNESS_MESSAGES && bytes < RECV_FAIRNESS_BYTES
291
+ msg = conn.receive_message
292
+ msg = transform.call(msg).freeze
293
+ recv_queue.enqueue(msg)
294
+ count += 1
295
+ bytes += msg.is_a?(Array) && msg.first.is_a?(String) ? msg.sum(&:bytesize) : 0
296
+ end
297
+ task.yield
278
298
  end
279
299
  rescue Async::Stop
280
300
  rescue Protocol::ZMTP::Error, *CONNECTION_LOST
@@ -283,9 +303,17 @@ module OMQ
283
303
  signal_fatal_error(error)
284
304
  end
285
305
  else
286
- @parent_task.async(transient: true, annotation: "recv pump") do
306
+ @parent_task.async(transient: true, annotation: "recv pump") do |task|
287
307
  loop do
288
- recv_queue.enqueue(conn.receive_message)
308
+ count = 0
309
+ bytes = 0
310
+ while count < RECV_FAIRNESS_MESSAGES && bytes < RECV_FAIRNESS_BYTES
311
+ msg = conn.receive_message
312
+ recv_queue.enqueue(msg)
313
+ count += 1
314
+ bytes += msg.is_a?(Array) && msg.first.is_a?(String) ? msg.sum(&:bytesize) : 0
315
+ end
316
+ task.yield
289
317
  end
290
318
  rescue Async::Stop
291
319
  rescue Protocol::ZMTP::Error, *CONNECTION_LOST
@@ -485,6 +513,7 @@ module OMQ
485
513
  )
486
514
  conn.handshake!
487
515
  start_heartbeat(conn)
516
+ conn = @connection_wrapper.call(conn) if @connection_wrapper
488
517
  @connections << conn
489
518
  @connection_endpoints[conn] = endpoint if endpoint
490
519
  @connection_promises[conn] = done if done
@@ -572,24 +601,31 @@ module OMQ
572
601
  # resolution failures during reconnect are retried with backoff.
573
602
  #
574
603
  def validate_endpoint!(endpoint)
575
- return unless endpoint.start_with?("tcp://")
576
- host = URI.parse(endpoint.sub("tcp://", "http://")).hostname
604
+ case endpoint
605
+ when /\Atcp:\/\//
606
+ host = URI.parse(endpoint.sub("tcp://", "http://")).hostname
607
+ when /\Atls\+tcp:\/\//
608
+ host = URI.parse("http://#{endpoint.delete_prefix("tls+tcp://")}").hostname
609
+ else
610
+ return
611
+ end
577
612
  Addrinfo.getaddrinfo(host, nil, nil, :STREAM) if host
578
613
  end
579
614
 
580
615
 
581
616
  def transport_for(endpoint)
582
617
  case endpoint
583
- when /\Atcp:\/\// then Transport::TCP
584
- when /\Aipc:\/\// then Transport::IPC
585
- when /\Ainproc:\/\// then Transport::Inproc
618
+ when /\Atls\+tcp:\/\// then Transport::TLS
619
+ when /\Atcp:\/\// then Transport::TCP
620
+ when /\Aipc:\/\// then Transport::IPC
621
+ when /\Ainproc:\/\// then Transport::Inproc
586
622
  else raise ArgumentError, "unsupported transport: #{endpoint}"
587
623
  end
588
624
  end
589
625
 
590
626
 
591
627
  def extract_tcp_port(endpoint)
592
- return nil unless endpoint&.start_with?("tcp://")
628
+ return nil unless endpoint&.start_with?("tcp://") || endpoint&.start_with?("tls+tcp://")
593
629
  port = endpoint.split(":").last.to_i
594
630
  port.positive? ? port : nil
595
631
  end
@@ -602,6 +638,31 @@ module OMQ
602
638
  #
603
639
  def start_accept_loops(listener)
604
640
  case listener
641
+ when Transport::TLS::Listener
642
+ tasks = listener.servers.map do |server|
643
+ @parent_task.async(transient: true, annotation: "tls accept #{listener.endpoint}") do
644
+ loop do
645
+ client = server.accept
646
+ Async::Task.current.defer_stop do
647
+ ssl = OpenSSL::SSL::SSLSocket.new(client, listener.ssl_context)
648
+ ssl.sync_close = true
649
+ ssl.accept
650
+ handle_accepted(IO::Stream::Buffered.wrap(ssl), endpoint: listener.endpoint)
651
+ rescue OpenSSL::SSL::SSLError
652
+ # Bad certificate, protocol mismatch, etc. — drop this
653
+ # connection but keep the accept loop running.
654
+ ssl&.close rescue nil
655
+ end
656
+ end
657
+ rescue Async::Stop
658
+ rescue IOError
659
+ # server closed
660
+ ensure
661
+ server.close rescue nil
662
+ end
663
+ end
664
+ listener.accept_tasks = tasks
665
+
605
666
  when Transport::TCP::Listener
606
667
  tasks = listener.servers.map do |server|
607
668
  @parent_task.async(transient: true, annotation: "tcp accept #{listener.endpoint}") do
data/lib/omq/options.rb CHANGED
@@ -26,6 +26,7 @@ module OMQ
26
26
  @max_message_size = nil # bytes, nil = unlimited
27
27
  @conflate = false
28
28
  @mechanism = Protocol::ZMTP::Mechanism::Null.new
29
+ @tls_context = nil # OpenSSL::SSL::SSLContext for tls+tcp://
29
30
  end
30
31
 
31
32
  attr_accessor :send_hwm, :recv_hwm,
@@ -35,7 +36,8 @@ module OMQ
35
36
  :reconnect_interval,
36
37
  :heartbeat_interval, :heartbeat_ttl, :heartbeat_timeout,
37
38
  :max_message_size,
38
- :mechanism
39
+ :mechanism,
40
+ :tls_context
39
41
 
40
42
  alias_method :router_mandatory?, :router_mandatory
41
43
  alias_method :recv_timeout, :read_timeout
data/lib/omq/pair.rb CHANGED
@@ -5,8 +5,8 @@ module OMQ
5
5
  include Readable
6
6
  include Writable
7
7
 
8
- def initialize(endpoints = nil, linger: 0)
9
- _init_engine(:PAIR, linger: linger)
8
+ def initialize(endpoints = nil, linger: 0, backend: nil)
9
+ _init_engine(:PAIR, linger: linger, backend: backend)
10
10
  _attach(endpoints, default: :connect)
11
11
  end
12
12
  end
data/lib/omq/peer.rb CHANGED
@@ -6,8 +6,8 @@ module OMQ
6
6
  include Writable
7
7
  include SingleFrame
8
8
 
9
- def initialize(endpoints = nil, linger: 0)
10
- _init_engine(:PEER, linger: linger)
9
+ def initialize(endpoints = nil, linger: 0, backend: nil)
10
+ _init_engine(:PEER, linger: linger, backend: backend)
11
11
  _attach(endpoints, default: :connect)
12
12
  end
13
13
 
data/lib/omq/pub_sub.rb CHANGED
@@ -4,8 +4,8 @@ module OMQ
4
4
  class PUB < Socket
5
5
  include Writable
6
6
 
7
- def initialize(endpoints = nil, linger: 0, conflate: false)
8
- _init_engine(:PUB, linger: linger, conflate: conflate)
7
+ def initialize(endpoints = nil, linger: 0, conflate: false, backend: nil)
8
+ _init_engine(:PUB, linger: linger, conflate: conflate, backend: backend)
9
9
  _attach(endpoints, default: :bind)
10
10
  end
11
11
  end
@@ -21,13 +21,13 @@ module OMQ
21
21
 
22
22
  # @param endpoints [String, nil]
23
23
  # @param linger [Integer]
24
- # @param prefix [String, nil] subscription prefix; +nil+ (default)
24
+ # @param subscribe [String, nil] subscription prefix; +nil+ (default)
25
25
  # means no subscription — call {#subscribe} explicitly.
26
26
  #
27
- def initialize(endpoints = nil, linger: 0, prefix: nil)
28
- _init_engine(:SUB, linger: linger)
27
+ def initialize(endpoints = nil, linger: 0, subscribe: nil, backend: nil)
28
+ _init_engine(:SUB, linger: linger, backend: backend)
29
29
  _attach(endpoints, default: :connect)
30
- subscribe(prefix) unless prefix.nil?
30
+ self.subscribe(subscribe) unless subscribe.nil?
31
31
  end
32
32
 
33
33
  # Subscribes to a topic prefix.
@@ -53,8 +53,8 @@ module OMQ
53
53
  include Readable
54
54
  include Writable
55
55
 
56
- def initialize(endpoints = nil, linger: 0)
57
- _init_engine(:XPUB, linger: linger)
56
+ def initialize(endpoints = nil, linger: 0, backend: nil)
57
+ _init_engine(:XPUB, linger: linger, backend: backend)
58
58
  _attach(endpoints, default: :bind)
59
59
  end
60
60
  end
@@ -65,13 +65,13 @@ module OMQ
65
65
 
66
66
  # @param endpoints [String, nil]
67
67
  # @param linger [Integer]
68
- # @param prefix [String, nil] subscription prefix; +nil+ (default)
68
+ # @param subscribe [String, nil] subscription prefix; +nil+ (default)
69
69
  # means no subscription — send a subscribe frame explicitly.
70
70
  #
71
- def initialize(endpoints = nil, linger: 0, prefix: nil)
72
- _init_engine(:XSUB, linger: linger)
71
+ def initialize(endpoints = nil, linger: 0, subscribe: nil, backend: nil)
72
+ _init_engine(:XSUB, linger: linger, backend: backend)
73
73
  _attach(endpoints, default: :connect)
74
- send("\x01#{prefix}".b) unless prefix.nil?
74
+ send("\x01#{subscribe}".b) unless subscribe.nil?
75
75
  end
76
76
  end
77
77
  end
data/lib/omq/push_pull.rb CHANGED
@@ -4,8 +4,8 @@ module OMQ
4
4
  class PUSH < Socket
5
5
  include Writable
6
6
 
7
- def initialize(endpoints = nil, linger: 0, send_hwm: nil, send_timeout: nil)
8
- _init_engine(:PUSH, linger: linger, send_hwm: send_hwm, send_timeout: send_timeout)
7
+ def initialize(endpoints = nil, linger: 0, send_hwm: nil, send_timeout: nil, backend: nil)
8
+ _init_engine(:PUSH, linger: linger, send_hwm: send_hwm, send_timeout: send_timeout, backend: backend)
9
9
  _attach(endpoints, default: :connect)
10
10
  end
11
11
  end
@@ -13,8 +13,8 @@ module OMQ
13
13
  class PULL < Socket
14
14
  include Readable
15
15
 
16
- def initialize(endpoints = nil, linger: 0, recv_hwm: nil, recv_timeout: nil)
17
- _init_engine(:PULL, linger: linger, recv_hwm: recv_hwm, recv_timeout: recv_timeout)
16
+ def initialize(endpoints = nil, linger: 0, recv_hwm: nil, recv_timeout: nil, backend: nil)
17
+ _init_engine(:PULL, linger: linger, recv_hwm: recv_hwm, recv_timeout: recv_timeout, backend: backend)
18
18
  _attach(endpoints, default: :bind)
19
19
  end
20
20
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ # Async::Queue-compatible read interface.
5
+ #
6
+ # Automatically included by {Readable}. Provides #dequeue, #pop,
7
+ # #wait, and #each so sockets can be used where an Async::Queue
8
+ # is expected.
9
+ #
10
+ module QueueReadable
11
+ # Dequeues the next message.
12
+ #
13
+ # @param timeout [Numeric, nil] timeout in seconds (overrides
14
+ # the socket's +read_timeout+ for this call)
15
+ # @return [Array<String>] message parts
16
+ # @raise [IO::TimeoutError] if timeout exceeded
17
+ #
18
+ def dequeue(timeout: @options.read_timeout)
19
+ msg = @recv_mutex.synchronize { @recv_buffer.shift }
20
+ return msg if msg
21
+
22
+ batch = Reactor.run { with_timeout(timeout) { @engine.dequeue_recv_batch(Readable::RECV_BATCH_SIZE) } }
23
+ msg = batch.shift
24
+ @recv_mutex.synchronize { @recv_buffer.concat(batch) } unless batch.empty?
25
+ msg
26
+ end
27
+
28
+ alias_method :pop, :dequeue
29
+
30
+ # Waits for the next message indefinitely (ignores read_timeout).
31
+ #
32
+ # @return [Array<String>] message parts
33
+ #
34
+ def wait
35
+ dequeue(timeout: nil)
36
+ end
37
+
38
+ # Yields each received message until the socket is closed or
39
+ # a receive timeout expires.
40
+ #
41
+ # @yield [Array<String>] message parts
42
+ # @return [void]
43
+ #
44
+ def each
45
+ while (msg = receive)
46
+ yield msg
47
+ end
48
+ rescue IO::TimeoutError
49
+ nil
50
+ end
51
+ end
52
+
53
+
54
+ # Async::Queue-compatible write interface.
55
+ #
56
+ # Automatically included by {Writable}. Provides #enqueue, #push,
57
+ # and #signal so sockets can be used where an Async::Queue is
58
+ # expected.
59
+ #
60
+ module QueueWritable
61
+ # Enqueues one or more messages for sending.
62
+ #
63
+ # @param messages [String, Array<String>]
64
+ # @return [self]
65
+ #
66
+ def enqueue(*messages)
67
+ messages.each { |msg| send(msg) }
68
+ self
69
+ end
70
+
71
+ alias_method :push, :enqueue
72
+ end
73
+ end
@@ -4,8 +4,8 @@ module OMQ
4
4
  class RADIO < Socket
5
5
  include Writable
6
6
 
7
- def initialize(endpoints = nil, linger: 0, conflate: false)
8
- _init_engine(:RADIO, linger: linger, conflate: conflate)
7
+ def initialize(endpoints = nil, linger: 0, conflate: false, backend: nil)
8
+ _init_engine(:RADIO, linger: linger, conflate: conflate, backend: backend)
9
9
  _attach(endpoints, default: :bind)
10
10
  end
11
11
 
@@ -47,8 +47,8 @@ module OMQ
47
47
  class DISH < Socket
48
48
  include Readable
49
49
 
50
- def initialize(endpoints = nil, linger: 0, group: nil)
51
- _init_engine(:DISH, linger: linger)
50
+ def initialize(endpoints = nil, linger: 0, group: nil, backend: nil)
51
+ _init_engine(:DISH, linger: linger, backend: backend)
52
52
  _attach(endpoints, default: :connect)
53
53
  join(group) if group
54
54
  end
data/lib/omq/readable.rb CHANGED
@@ -6,6 +6,8 @@ module OMQ
6
6
  # Pure Ruby Readable mixin. Dequeues messages from the engine's recv queue.
7
7
  #
8
8
  module Readable
9
+ include QueueReadable
10
+
9
11
  # Maximum messages to prefetch from the recv queue per drain.
10
12
  RECV_BATCH_SIZE = 64
11
13
 
data/lib/omq/req_rep.rb CHANGED
@@ -5,8 +5,8 @@ module OMQ
5
5
  include Readable
6
6
  include Writable
7
7
 
8
- def initialize(endpoints = nil, linger: 0)
9
- _init_engine(:REQ, linger: linger)
8
+ def initialize(endpoints = nil, linger: 0, backend: nil)
9
+ _init_engine(:REQ, linger: linger, backend: backend)
10
10
  _attach(endpoints, default: :connect)
11
11
  end
12
12
  end
@@ -15,8 +15,8 @@ module OMQ
15
15
  include Readable
16
16
  include Writable
17
17
 
18
- def initialize(endpoints = nil, linger: 0)
19
- _init_engine(:REP, linger: linger)
18
+ def initialize(endpoints = nil, linger: 0, backend: nil)
19
+ _init_engine(:REP, linger: linger, backend: backend)
20
20
  _attach(endpoints, default: :bind)
21
21
  end
22
22
  end
@@ -5,8 +5,8 @@ module OMQ
5
5
  include Readable
6
6
  include Writable
7
7
 
8
- def initialize(endpoints = nil, linger: 0)
9
- _init_engine(:DEALER, linger: linger)
8
+ def initialize(endpoints = nil, linger: 0, backend: nil)
9
+ _init_engine(:DEALER, linger: linger, backend: backend)
10
10
  _attach(endpoints, default: :connect)
11
11
  end
12
12
  end
@@ -17,8 +17,8 @@ module OMQ
17
17
  include Readable
18
18
  include Writable
19
19
 
20
- def initialize(endpoints = nil, linger: 0)
21
- _init_engine(:ROUTER, linger: linger)
20
+ def initialize(endpoints = nil, linger: 0, backend: nil)
21
+ _init_engine(:ROUTER, linger: linger, backend: backend)
22
22
  _attach(endpoints, default: :bind)
23
23
  end
24
24
 
@@ -91,11 +91,20 @@ module OMQ
91
91
  end
92
92
  else
93
93
  batch.each do |parts|
94
- topic = parts.first || EMPTY_BINARY
94
+ topic = parts.first || EMPTY_BINARY
95
+ wire_bytes = nil
96
+
95
97
  @connections.each do |conn|
96
98
  next unless subscribed?(conn, topic)
97
99
  begin
98
- conn.write_message(parts)
100
+ if conn.respond_to?(:curve?) && conn.curve?
101
+ conn.write_message(parts)
102
+ elsif conn.respond_to?(:write_wire)
103
+ wire_bytes ||= Protocol::ZMTP::Codec::Frame.encode_message(parts)
104
+ conn.write_wire(wire_bytes)
105
+ else
106
+ conn.write_message(parts)
107
+ end
99
108
  @written << conn
100
109
  rescue *CONNECTION_LOST
101
110
  end
@@ -98,12 +98,22 @@ module OMQ
98
98
  end
99
99
  else
100
100
  batch.each do |parts|
101
- group = parts[0]
102
- body = parts[1] || EMPTY_BINARY
101
+ group = parts[0]
102
+ body = parts[1] || EMPTY_BINARY
103
+ msg = [group, body]
104
+ wire_bytes = nil
105
+
103
106
  @connections.each do |conn|
104
107
  next unless @groups[conn]&.include?(group)
105
108
  begin
106
- conn.write_message([group, body])
109
+ if conn.respond_to?(:curve?) && conn.curve?
110
+ conn.write_message(msg)
111
+ elsif conn.respond_to?(:write_wire)
112
+ wire_bytes ||= Protocol::ZMTP::Codec::Frame.encode_message(msg)
113
+ conn.write_wire(wire_bytes)
114
+ else
115
+ conn.write_message(msg)
116
+ end
107
117
  @written << conn
108
118
  rescue *CONNECTION_LOST
109
119
  end
@@ -5,8 +5,8 @@ module OMQ
5
5
  include Writable
6
6
  include SingleFrame
7
7
 
8
- def initialize(endpoints = nil, linger: 0, send_hwm: nil, send_timeout: nil)
9
- _init_engine(:SCATTER, linger: linger, send_hwm: send_hwm, send_timeout: send_timeout)
8
+ def initialize(endpoints = nil, linger: 0, send_hwm: nil, send_timeout: nil, backend: nil)
9
+ _init_engine(:SCATTER, linger: linger, send_hwm: send_hwm, send_timeout: send_timeout, backend: backend)
10
10
  _attach(endpoints, default: :connect)
11
11
  end
12
12
  end
@@ -15,8 +15,8 @@ module OMQ
15
15
  include Readable
16
16
  include SingleFrame
17
17
 
18
- def initialize(endpoints = nil, linger: 0, recv_hwm: nil, recv_timeout: nil)
19
- _init_engine(:GATHER, linger: linger, recv_hwm: recv_hwm, recv_timeout: recv_timeout)
18
+ def initialize(endpoints = nil, linger: 0, recv_hwm: nil, recv_timeout: nil, backend: nil)
19
+ _init_engine(:GATHER, linger: linger, recv_hwm: recv_hwm, recv_timeout: recv_timeout, backend: backend)
20
20
  _attach(endpoints, default: :bind)
21
21
  end
22
22
  end
data/lib/omq/socket.rb CHANGED
@@ -36,7 +36,8 @@ module OMQ
36
36
  :heartbeat_ttl, :heartbeat_ttl=,
37
37
  :heartbeat_timeout, :heartbeat_timeout=,
38
38
  :max_message_size, :max_message_size=,
39
- :mechanism, :mechanism=
39
+ :mechanism, :mechanism=,
40
+ :tls_context, :tls_context=
40
41
 
41
42
 
42
43
  # Creates a new socket and binds it to the given endpoint.
@@ -224,7 +225,8 @@ module OMQ
224
225
  # @param linger [Integer]
225
226
  #
226
227
  def _init_engine(socket_type, linger:, send_hwm: nil, recv_hwm: nil,
227
- send_timeout: nil, recv_timeout: nil, conflate: false)
228
+ send_timeout: nil, recv_timeout: nil, conflate: false,
229
+ backend: nil)
228
230
  @options = Options.new(linger: linger)
229
231
  @options.send_hwm = send_hwm if send_hwm
230
232
  @options.recv_hwm = recv_hwm if recv_hwm
@@ -233,7 +235,11 @@ module OMQ
233
235
  @options.conflate = conflate
234
236
  @recv_buffer = []
235
237
  @recv_mutex = Mutex.new
236
- @engine = Engine.new(socket_type, @options)
238
+ @engine = case backend
239
+ when nil, :ruby then Engine.new(socket_type, @options)
240
+ when :ffi then FFI::Engine.new(socket_type, @options)
241
+ else raise ArgumentError, "unknown backend: #{backend}"
242
+ end
237
243
  end
238
244
  end
239
245
  end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "uri"
5
+ require "openssl"
6
+ require "io/stream"
7
+
8
+ module OMQ
9
+ module Transport
10
+ # TLS transport — TLS v1.3 on top of TCP.
11
+ #
12
+ # Requires the socket's +tls_context+ to be set to an
13
+ # +OpenSSL::SSL::SSLContext+ before bind or connect.
14
+ #
15
+ module TLS
16
+ TLS_PREFIX = "tls+tcp://"
17
+
18
+ class << self
19
+ # Binds a TLS server.
20
+ #
21
+ # @param endpoint [String] e.g. "tls+tcp://127.0.0.1:5555" or "tls+tcp://*:0"
22
+ # @param engine [Engine]
23
+ # @return [Listener]
24
+ #
25
+ def bind(endpoint, engine)
26
+ ctx = require_context!(engine)
27
+ host, port = parse_endpoint(endpoint)
28
+ host = "0.0.0.0" if host == "*"
29
+
30
+ addrs = Addrinfo.getaddrinfo(host, port, nil, :STREAM, nil, ::Socket::AI_PASSIVE)
31
+ raise ::Socket::ResolutionError, "no addresses for #{host}" if addrs.empty?
32
+
33
+ servers = []
34
+ actual_port = nil
35
+
36
+ addrs.each do |addr|
37
+ server = TCPServer.new(addr.ip_address, actual_port || port)
38
+ actual_port ||= server.local_address.ip_port
39
+ servers << server
40
+ end
41
+
42
+ host_part = host.include?(":") ? "[#{host}]" : host
43
+ resolved = "#{TLS_PREFIX}#{host_part}:#{actual_port}"
44
+ Listener.new(resolved, servers, actual_port, ctx)
45
+ end
46
+
47
+ # Connects to a TLS endpoint.
48
+ #
49
+ # @param endpoint [String] e.g. "tls+tcp://127.0.0.1:5555"
50
+ # @param engine [Engine]
51
+ # @return [void]
52
+ #
53
+ def connect(endpoint, engine)
54
+ ctx = require_context!(engine)
55
+ host, port = parse_endpoint(endpoint)
56
+ tcp_sock = TCPSocket.new(host, port)
57
+
58
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp_sock, ctx)
59
+ ssl.sync_close = true
60
+ ssl.hostname = host
61
+ ssl.connect
62
+
63
+ engine.handle_connected(IO::Stream::Buffered.wrap(ssl), endpoint: endpoint)
64
+ rescue
65
+ tcp_sock&.close unless ssl&.sync_close
66
+ raise
67
+ end
68
+
69
+ private
70
+
71
+ # Validates and freezes the TLS context from engine options.
72
+ #
73
+ # The context SHOULD have min_version set to TLS1_3_VERSION.
74
+ # We cannot validate this because SSLContext#min_version is
75
+ # write-only in Ruby's OpenSSL binding.
76
+ #
77
+ # @return [OpenSSL::SSL::SSLContext]
78
+ # @raise [ArgumentError] if no context is set
79
+ #
80
+ def require_context!(engine)
81
+ ctx = engine.options.tls_context
82
+ raise ArgumentError, "tls_context must be set for tls+tcp:// endpoints" unless ctx
83
+ ctx.freeze unless ctx.frozen?
84
+ ctx
85
+ end
86
+
87
+ # Parses a tls+tcp:// endpoint URI into host and port.
88
+ #
89
+ # @param endpoint [String]
90
+ # @return [Array(String, Integer)]
91
+ #
92
+ def parse_endpoint(endpoint)
93
+ uri = URI.parse("http://#{endpoint.delete_prefix(TLS_PREFIX)}")
94
+ [uri.hostname, uri.port]
95
+ end
96
+ end
97
+
98
+ # A bound TLS listener.
99
+ #
100
+ class Listener
101
+ # @return [String] resolved endpoint with actual port
102
+ attr_reader :endpoint
103
+
104
+ # @return [Integer] bound port
105
+ attr_reader :port
106
+
107
+ # @return [Array<TCPServer>] bound server sockets
108
+ attr_reader :servers
109
+
110
+ # @return [OpenSSL::SSL::SSLContext]
111
+ attr_reader :ssl_context
112
+
113
+
114
+ # @param endpoint [String] resolved endpoint URI
115
+ # @param servers [Array<TCPServer>]
116
+ # @param port [Integer] bound port number
117
+ # @param ssl_context [OpenSSL::SSL::SSLContext]
118
+ #
119
+ def initialize(endpoint, servers, port, ssl_context)
120
+ @endpoint = endpoint
121
+ @servers = servers
122
+ @port = port
123
+ @ssl_context = ssl_context
124
+ @tasks = []
125
+ end
126
+
127
+
128
+ # Registers accept loop tasks owned by the engine.
129
+ #
130
+ # @param tasks [Array<Async::Task>]
131
+ #
132
+ def accept_tasks=(tasks)
133
+ @tasks = tasks
134
+ end
135
+
136
+
137
+ # Stops the listener.
138
+ #
139
+ def stop
140
+ @tasks.each(&:stop)
141
+ @servers.each { |s| s.close rescue nil }
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
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.10.0"
4
+ VERSION = "0.11.0"
5
5
  end
data/lib/omq/writable.rb CHANGED
@@ -6,6 +6,7 @@ module OMQ
6
6
  # Pure Ruby Writable mixin. Enqueues messages to the engine's send path.
7
7
  #
8
8
  module Writable
9
+ include QueueWritable
9
10
  # Sends a message.
10
11
  #
11
12
  # @param message [String, Array<String>] message parts
data/lib/omq.rb CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  require "protocol/zmtp"
10
10
  require "io/stream"
11
+ require "openssl"
11
12
 
12
13
  require_relative "omq/version"
13
14
 
@@ -26,6 +27,7 @@ module OMQ
26
27
  Errno::ECONNABORTED,
27
28
  Errno::ENOTCONN,
28
29
  IO::Stream::ConnectionResetError,
30
+ OpenSSL::SSL::SSLError,
29
31
  ].freeze
30
32
 
31
33
  # Errors raised when a peer cannot be reached.
@@ -42,6 +44,7 @@ end
42
44
  # Transport
43
45
  require_relative "omq/transport/inproc"
44
46
  require_relative "omq/transport/tcp"
47
+ require_relative "omq/transport/tls"
45
48
  require_relative "omq/transport/ipc"
46
49
 
47
50
  # Core
@@ -71,6 +74,7 @@ require_relative "omq/routing/dish"
71
74
  require_relative "omq/routing/peer"
72
75
  require_relative "omq/single_frame"
73
76
  require_relative "omq/engine"
77
+ require_relative "omq/queue_interface"
74
78
  require_relative "omq/readable"
75
79
  require_relative "omq/writable"
76
80
 
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.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -73,6 +73,7 @@ files:
73
73
  - lib/omq/peer.rb
74
74
  - lib/omq/pub_sub.rb
75
75
  - lib/omq/push_pull.rb
76
+ - lib/omq/queue_interface.rb
76
77
  - lib/omq/radio_dish.rb
77
78
  - lib/omq/reactor.rb
78
79
  - lib/omq/readable.rb
@@ -106,6 +107,7 @@ files:
106
107
  - lib/omq/transport/inproc.rb
107
108
  - lib/omq/transport/ipc.rb
108
109
  - lib/omq/transport/tcp.rb
110
+ - lib/omq/transport/tls.rb
109
111
  - lib/omq/version.rb
110
112
  - lib/omq/writable.rb
111
113
  homepage: https://github.com/zeromq/omq