omq 0.22.1 → 0.24.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -0
  3. data/README.md +17 -21
  4. data/lib/omq/channel.rb +35 -0
  5. data/lib/omq/client_server.rb +72 -0
  6. data/lib/omq/constants.rb +68 -0
  7. data/lib/omq/engine/connection_lifecycle.rb +22 -8
  8. data/lib/omq/engine/heartbeat.rb +3 -4
  9. data/lib/omq/engine/maintenance.rb +4 -5
  10. data/lib/omq/engine/reconnect.rb +12 -11
  11. data/lib/omq/engine/recv_pump.rb +10 -10
  12. data/lib/omq/engine/socket_lifecycle.rb +26 -9
  13. data/lib/omq/engine.rb +202 -90
  14. data/lib/omq/peer.rb +49 -0
  15. data/lib/omq/pub_sub.rb +2 -2
  16. data/lib/omq/radio_dish.rb +122 -0
  17. data/lib/omq/reactor.rb +14 -5
  18. data/lib/omq/readable.rb +5 -1
  19. data/lib/omq/routing/channel.rb +110 -0
  20. data/lib/omq/routing/client.rb +70 -0
  21. data/lib/omq/routing/conn_send_pump.rb +5 -8
  22. data/lib/omq/routing/dealer.rb +3 -15
  23. data/lib/omq/routing/dish.rb +94 -0
  24. data/lib/omq/routing/fan_out.rb +12 -16
  25. data/lib/omq/routing/gather.rb +60 -0
  26. data/lib/omq/routing/pair.rb +7 -26
  27. data/lib/omq/routing/peer.rb +95 -0
  28. data/lib/omq/routing/pub.rb +2 -13
  29. data/lib/omq/routing/pull.rb +3 -15
  30. data/lib/omq/routing/push.rb +4 -13
  31. data/lib/omq/routing/radio.rb +187 -0
  32. data/lib/omq/routing/rep.rb +5 -19
  33. data/lib/omq/routing/req.rb +6 -18
  34. data/lib/omq/routing/round_robin.rb +15 -19
  35. data/lib/omq/routing/router.rb +5 -19
  36. data/lib/omq/routing/scatter.rb +76 -0
  37. data/lib/omq/routing/server.rb +90 -0
  38. data/lib/omq/routing/sub.rb +3 -15
  39. data/lib/omq/routing/xpub.rb +2 -13
  40. data/lib/omq/routing/xsub.rb +8 -25
  41. data/lib/omq/scatter_gather.rb +56 -0
  42. data/lib/omq/socket.rb +8 -23
  43. data/lib/omq/transport/inproc/{direct_pipe.rb → pipe.rb} +26 -24
  44. data/lib/omq/transport/inproc.rb +22 -14
  45. data/lib/omq/transport/ipc.rb +41 -13
  46. data/lib/omq/transport/tcp.rb +59 -23
  47. data/lib/omq/transport/udp.rb +281 -0
  48. data/lib/omq/version.rb +1 -1
  49. data/lib/omq/writable.rb +11 -42
  50. data/lib/omq.rb +9 -64
  51. metadata +17 -3
  52. data/lib/omq/monitor_event.rb +0 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f58b9e0c8c49bcdfb8dd80ec5eda6a6b6e5b09f04a2a7851001449449ba448fd
4
- data.tar.gz: 0f6cbd451adc8b1d1cde0771d6535c5959f09e4b3f0442164b0544964755795e
3
+ metadata.gz: ed87cc3a3243100b7977fd58f102b87918de8568cf8739642afc6377bb3f76e5
4
+ data.tar.gz: f01b30844ae48ffe26ec3934fef780521937a7611e1d00c87b333136fa5641ac
5
5
  SHA512:
6
- metadata.gz: d7d13f97eeacf998f1e0137bdbc5dd4a471195ff628fd7da97720a019e30c2a9c200fab761c38943bbbada056bf81880e6ca2dd87d3936f150f0fa22566cebe5
7
- data.tar.gz: f97931ecb4893ecd0155c37ea18793c9414bd57de6918496ef7af18bad714720aa2e146531788956265d6f85bc3e18f6dd403f71df8683913d322c94a6dd1bdb
6
+ metadata.gz: 853d3171298de868ad3de28fe284c7970ddf196203bbb217fc91fa62cebb9d39c1d278b53d4259d0dc505b09457624ea68f4a9a8a0e654850c9266139a1fa05e
7
+ data.tar.gz: ad8a2cec518ceebac32055aeab7fd159d982421e9983670ec28182094f4fe2338f89cd7a1609d02cd8720f01c121159fec7e2489b8c816f9f16adf9d950c34c4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,167 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.24.0 — 2026-04-18
4
+
5
+ ### Changed
6
+
7
+ - **Caller owns message parts.** `Writable#send` no longer deep-freezes or
8
+ binary-coerces the caller's input. The contract is now libzmq-style:
9
+ don't mutate parts after sending. `#receive` likewise returns mutable
10
+ arrays of mutable strings. This removes a full-payload allocation per
11
+ message (`.b.freeze`) on the send path and a per-frame freeze on the
12
+ receive path.
13
+
14
+ - **No more implicit `#to_s` / nil coercion.** Passing a non-string part
15
+ (e.g. Integer, Symbol, nil) will raise `NoMethodError` at the wire layer
16
+ instead of being silently converted. The `EMPTY_PART` constant is gone.
17
+
18
+ - **Reactor fast path for `#send` / `#receive`.** When the socket was
19
+ bound/connected from an Async fiber, hot-path I/O skips `Reactor.run`
20
+ entirely and calls the engine directly (with an `Async::Task#with_timeout`
21
+ wrapper only when a timeout is configured). The shared IO thread is used
22
+ only when the socket was created from a non-Async thread.
23
+
24
+ ### Performance
25
+
26
+ Combined effect of caller-owns-data + Reactor fast path on inproc:
27
+
28
+ - PUSH/PULL inproc 1-peer: **+105% to +128%** msg/s across payload sizes
29
+ - PUSH/PULL inproc 3-peer: **+63% to +111%** msg/s
30
+ - PUSH/PULL ipc: +5% to +17%
31
+ - TCP numbers unchanged (OS/syscall-dominated)
32
+
33
+ ### Removed
34
+
35
+ - `Writable#freeze_message` and `#frozen_binary` private helpers.
36
+ - `Writable::EMPTY_PART` constant.
37
+
38
+
39
+ ## 0.23.1 — 2026-04-18
40
+
41
+ ### Fixed
42
+
43
+ - **SCATTER double-tracked each peer.** `Routing::Scatter#connection_added`
44
+ appended to `@connections` and then called `add_round_robin_send_connection`,
45
+ which appends again — so every connected peer had two entries in the list.
46
+ `#connection_removed` deleted only one on disconnect, leaving a stale entry
47
+ behind. Fixed by dropping the duplicate append.
48
+
49
+
50
+ ## 0.23.0 — 2026-04-17
51
+
52
+ ### Added
53
+
54
+ - **Draft socket types now ship with `omq` itself.** `OMQ::CLIENT`/`SERVER`,
55
+ `OMQ::RADIO`/`DISH`, `OMQ::SCATTER`/`GATHER`, `OMQ::CHANNEL`, and
56
+ `OMQ::PEER` are back in OMQ. They were previously distributed as separate
57
+ `omq-rfc-*` gems, which was a PITA to maintain. Their source is now part of
58
+ `omq`. They are **not** loaded by `require "omq"` — opt in with one of:
59
+
60
+ ```ruby
61
+ require "omq/client_server"
62
+ require "omq/radio_dish" # also registers the udp:// transport
63
+ require "omq/scatter_gather"
64
+ require "omq/channel"
65
+ require "omq/peer"
66
+ ```
67
+
68
+ These requires must run at process startup (before any socket is bound
69
+ or connected), since the underlying registries (`Routing`,
70
+ `Engine.transports`) freeze on first use. The five `omq-rfc-*` gems are
71
+ superseded and will not receive further releases. Per-pattern docs live
72
+ under [`doc/socket-types/`](doc/socket-types/).
73
+
74
+
75
+ ### Changed
76
+
77
+ - **`Socket#bind` / `#connect` now return a `URI`** (the resolved endpoint).
78
+ `#bind` returns the listener's resolved URI — for `tcp://host:0` this
79
+ carries the auto-selected port via `uri.port`. `#connect` returns the
80
+ parsed input URI. The `last_tcp_port` and `last_endpoint` accessors are
81
+ removed; callers should capture the URI from `#bind` instead. Note: stdlib
82
+ `URI.parse` is lossy on abstract IPC endpoints (`ipc://@name`) — the `@`
83
+ is parsed as userinfo and dropped on `to_s`. For abstract IPC, use the
84
+ input string for connect rather than re-serializing the URI.
85
+ `Socket#inspect` now shows `bound=[...]` (the listener endpoints) instead
86
+ of `last_endpoint=...`.
87
+
88
+ - **Transport interface: `.bind`/`.connect` replaced by `.listener`/`.dialer`
89
+ factory methods** returning stateful `Listener`/`Dialer` objects. The
90
+ engine now stores a per-endpoint `@dialers` map (was a `@dialed` Set)
91
+ and a `@listeners` hash keyed by endpoint (was an Array). Reconnect
92
+ calls `dialer.connect` directly — no transport lookup or option replay
93
+ on every retry. `Transport::Inproc` keeps its synchronous `.connect`
94
+ fast-path; only TCP/IPC gain `Dialer` classes.
95
+
96
+ - **`Engine#bind` / `#connect` accept transport-specific kwargs** via
97
+ `**opts`, forwarded to the transport's `.listener` / `.dialer`. Socket
98
+ `#bind` / `#connect` pass them through. Enables per-connection
99
+ transport configuration (e.g., TLS context) without polluting
100
+ `Options`.
101
+
102
+ - **`ConnectionLifecycle#ready!` calls `transport_obj.wrap_connection(conn)`
103
+ if defined** — hook for transports that need to wrap the buffered
104
+ stream after handshake (e.g., TLS).
105
+
106
+ - **Transports self-register in `Engine.transports`.** Each transport
107
+ file (`tcp`, `ipc`, `inproc`) now adds its own scheme entry at load
108
+ time. `lib/omq.rb` requires transports after `engine.rb` so the
109
+ `Engine` constant is available. External transport plugins follow
110
+ the same pattern.
111
+
112
+ - **`Engine` gains delegate methods that hide internal layout** from
113
+ callers: `#subscribe`, `#unsubscribe`, `#subscriber_joined` forward
114
+ to the routing strategy; `#record_disconnect_reason(conn, error)`
115
+ wraps the `@connections` lookup; `Inproc::DirectPipe#wire_direct_recv`
116
+ replaces two separate attribute setters previously poked from the
117
+ recv pump. Callers no longer chain through `engine.routing.*` or
118
+ `engine.connections[conn]`.
119
+
120
+ - **`SocketLifecycle#resolve_all_peers_gone_if_empty` renamed to
121
+ `#maybe_resolve_all_peers_gone`.** The composite `unless` was split
122
+ into two early-returns for readability. A new `#force_close!` handles
123
+ `Engine#stop`'s crash path, collapsing two `@lifecycle.*` calls into
124
+ one.
125
+
126
+ - **Module-level constants consolidated into `lib/omq/constants.rb`.**
127
+ `MonitorEvent`, `DEBUG`, `SocketDeadError`, `CONNECTION_LOST`,
128
+ `CONNECTION_FAILED`, and `OMQ.freeze_for_ractors!` now live in one
129
+ file. `lib/omq/monitor_event.rb` is deleted; `lib/omq.rb` just
130
+ requires `omq/constants`.
131
+
132
+ ### Removed
133
+
134
+ - **`Engine#tasks` array** (and every `@tasks << ...` append site)
135
+ deleted. `Async::Barrier` already tracks every spawned task and
136
+ exposes `#size`, `#empty?`, and `#stop`. `Heartbeat.start`,
137
+ `Maintenance.start`, and `Reconnect#run` drop their `tasks`
138
+ parameter. Teardown collapses to `@lifecycle.barrier&.stop`.
139
+
140
+ - **Routing strategies and TCP listener drop their `@tasks` arrays
141
+ too.** Same `Async::Barrier` rollout applied to every routing
142
+ strategy and `Transport::TCP::Listener`. Per-connection pumps
143
+ (send/recv/reaper/group/subscription listener) ride the
144
+ per-connection lifecycle barrier; Radio's socket-level send pump
145
+ rides `engine.barrier` via a new `parent:` kwarg on
146
+ `Engine#spawn_pump_task`. The redundant `@conn_send_tasks` hashes
147
+ in RoundRobin, FanOut, Rep, Router, Peer, and Server are gone, as
148
+ are all routing-strategy `#stop` methods and the matching
149
+ `routing.stop rescue nil` calls in `Engine#close`/`#stop`.
150
+ `ConnSendPump.start` drops its `tasks` parameter. Channel's send
151
+ pump moves from loose `spawn_pump_task` to `spawn_conn_pump_task`,
152
+ so its disconnect rescue is now centralized in `Engine`. Net: 24
153
+ files, −340/+121.
154
+
155
+ ### Fixed
156
+
157
+ - **`bench/report.rb` preserves chronological run order.** Named run IDs
158
+ (e.g. `baseline-append`) previously sorted alphabetically after ISO
159
+ timestamps, hiding the most recent run. Now uses insertion order.
160
+
161
+ - **`zmtp_30_compat_test` waits for XSUB connection** before sending
162
+ `SUBSCRIBE`, removing a race where the subscribe arrived before the
163
+ handshake completed.
164
+
3
165
  ## 0.22.1 — 2026-04-16
4
166
 
5
167
  ### Changed
data/README.md CHANGED
@@ -20,7 +20,7 @@ live in the same process, on the same machine, or across the network.
20
20
  Reconnects, queuing, and back-pressure are handled for you; you write the
21
21
  interesting part.
22
22
 
23
- New to ZeroMQ? Start with [GETTING_STARTED.md](GETTING_STARTED.md) — a ~30 min
23
+ New to ZeroMQ? Start with [GETTING_STARTED.md](doc/GETTING_STARTED.md) — a ~30 min
24
24
  walkthrough of every major pattern with working code.
25
25
 
26
26
  ## Highlights
@@ -45,7 +45,7 @@ walkthrough of every major pattern with working code.
45
45
  connect, peers come and go. ZeroMQ reconnects automatically and queued
46
46
  messages drain when peers arrive
47
47
 
48
- For architecture internals, see [DESIGN.md](DESIGN.md).
48
+ For architecture internals, see [DESIGN.md](doc/DESIGN.md).
49
49
 
50
50
  ## Install
51
51
 
@@ -176,20 +176,22 @@ wire. Classes live under `OMQ::` (alias: `ØMQ`).
176
176
  > **Work-stealing, not round-robin.** Outbound load balancing uses one shared
177
177
  > send queue per socket drained by N racing pump fibers, so a slow peer can't
178
178
  > stall the pipeline. Under tight bursts on small `n`, distribution isn't
179
- > strict RR. See [DESIGN.md](DESIGN.md#per-socket-hwm-not-per-connection) and
180
- > [Libzmq quirks](DESIGN.md#libzmq-quirks-omq-avoids) for the reasoning.
179
+ > strict RR. See [DESIGN.md](doc/DESIGN.md#per-socket-hwm-not-per-connection) and
180
+ > [Libzmq quirks](doc/DESIGN.md#libzmq-quirks-omq-avoids) for the reasoning.
181
181
 
182
182
  #### Draft (single-frame only)
183
183
 
184
- Each draft pattern lives in its own geminstall only the ones you use.
184
+ Bundled with `omq` but not loaded by `require "omq"` opt in with the matching
185
+ `require` line. See [`doc/socket-types/`](doc/socket-types/) for per-pattern
186
+ usage.
185
187
 
186
- | Pattern | Send | Receive | When HWM full | Gem |
187
- |---------|------|---------|---------------|-----|
188
- | **CLIENT** / **SERVER** | Work-stealing / routing-ID | Fair-queue | Block | [`omq-rfc-clientserver`](https://github.com/paddor/omq-rfc-clientserver) |
189
- | **RADIO** / **DISH** | Group fan-out | Group filter | Drop | [`omq-rfc-radiodish`](https://github.com/paddor/omq-rfc-radiodish) |
190
- | **SCATTER** / **GATHER** | Work-stealing | Fair-queue | Block | [`omq-rfc-scattergather`](https://github.com/paddor/omq-rfc-scattergather) |
191
- | **PEER** | Routing-ID | Fair-queue | Block | [`omq-rfc-p2p`](https://github.com/paddor/omq-rfc-p2p) |
192
- | **CHANNEL** | Exclusive 1-to-1 | Exclusive 1-to-1 | Block | [`omq-rfc-channel`](https://github.com/paddor/omq-rfc-channel) |
188
+ | Pattern | Send | Receive | When HWM full | Opt-in `require` |
189
+ |---------|------|---------|---------------|------------------|
190
+ | **CLIENT** / **SERVER** | Work-stealing / routing-ID | Fair-queue | Block | `require "omq/client_server"` |
191
+ | **RADIO** / **DISH** | Group fan-out | Group filter | Drop | `require "omq/radio_dish"` (also registers `udp://`) |
192
+ | **SCATTER** / **GATHER** | Work-stealing | Fair-queue | Block | `require "omq/scatter_gather"` |
193
+ | **PEER** | Routing-ID | Fair-queue | Block | `require "omq/peer"` |
194
+ | **CHANNEL** | Exclusive 1-to-1 | Exclusive 1-to-1 | Block | `require "omq/channel"` |
193
195
 
194
196
  ## CLI
195
197
 
@@ -220,7 +222,7 @@ See the [omq-cli README](https://github.com/paddor/omq-cli) for full documentati
220
222
  Optional plug-ins that extend the ZMTP wire protocol. Each is a separate gem;
221
223
  load the ones you need.
222
224
 
223
- - **[omq-rfc-zstd](https://github.com/paddor/omq-rfc-zstd)** — transparent
225
+ - **[omq-zstd](https://github.com/paddor/omq-zstd)** — transparent
224
226
  Zstandard compression on the wire, negotiated per peer via READY properties.
225
227
 
226
228
  ## Development
@@ -233,7 +235,7 @@ bundle exec rake
233
235
  ### Full development setup
234
236
 
235
237
  Set `OMQ_DEV=1` to tell Bundler to load sibling projects from source
236
- (protocol-zmtp, nuckle, omq-rfc-\*, etc.) instead of released gems.
238
+ (protocol-zmtp, omq-zstd ,nuckle, etc.) instead of released gems.
237
239
  This is required for running benchmarks and for testing changes across
238
240
  the stack.
239
241
 
@@ -241,13 +243,7 @@ the stack.
241
243
  # clone OMQ and its sibling repos into the same parent directory
242
244
  git clone https://github.com/paddor/omq.git
243
245
  git clone https://github.com/paddor/protocol-zmtp.git
244
- git clone https://github.com/paddor/omq-rfc-zstd.git
245
- git clone https://github.com/paddor/omq-rfc-clientserver.git
246
- git clone https://github.com/paddor/omq-rfc-radiodish.git
247
- git clone https://github.com/paddor/omq-rfc-scattergather.git
248
- git clone https://github.com/paddor/omq-rfc-channel.git
249
- git clone https://github.com/paddor/omq-rfc-p2p.git
250
- git clone https://github.com/paddor/omq-rfc-qos.git
246
+ git clone https://github.com/paddor/omq-zstd.git
251
247
  git clone https://github.com/paddor/omq-ffi.git
252
248
  git clone https://github.com/paddor/omq-ractor.git
253
249
  git clone https://github.com/paddor/nuckle.git
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OMQ CHANNEL socket type (ZeroMQ RFC 52).
4
+ #
5
+ # Not loaded by +require "omq"+; opt in with:
6
+ #
7
+ # require "omq/channel"
8
+
9
+ require "omq"
10
+ require_relative "routing/channel"
11
+
12
+ module OMQ
13
+ # Exclusive 1-to-1 bidirectional socket (ZeroMQ RFC 52).
14
+ #
15
+ # Allows exactly one peer connection. Both sides can send and receive.
16
+ class CHANNEL < Socket
17
+ include Readable
18
+ include Writable
19
+ include SingleFrame
20
+
21
+ # Creates a new CHANNEL socket.
22
+ #
23
+ # @param endpoints [String, Array<String>, nil] endpoint(s) to connect to
24
+ # @param linger [Numeric] linger period in seconds (Float::INFINITY = wait forever, 0 = drop)
25
+ # @param backend [Object, nil] optional transport backend
26
+ def initialize(endpoints = nil, linger: Float::INFINITY, backend: nil)
27
+ init_engine(:CHANNEL, backend: backend)
28
+ @options.linger = linger
29
+ attach_endpoints(endpoints, default: :connect)
30
+ end
31
+ end
32
+
33
+
34
+ Routing.register(:CHANNEL, Routing::Channel)
35
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OMQ CLIENT/SERVER socket types (ZeroMQ RFC 41).
4
+ #
5
+ # Not loaded by +require "omq"+; opt in with:
6
+ #
7
+ # require "omq/client_server"
8
+
9
+ require "omq"
10
+ require_relative "routing/client"
11
+ require_relative "routing/server"
12
+
13
+ module OMQ
14
+ # Asynchronous client socket for the CLIENT/SERVER pattern (ZeroMQ RFC 41).
15
+ #
16
+ # Round-robins outgoing messages across connected SERVER peers.
17
+ class CLIENT < Socket
18
+ include Readable
19
+ include Writable
20
+ include SingleFrame
21
+
22
+ # Creates a new CLIENT socket.
23
+ #
24
+ # @param endpoints [String, Array<String>, nil] endpoint(s) to connect to
25
+ # @param linger [Numeric] linger period in seconds (Float::INFINITY = wait forever, 0 = drop)
26
+ # @param backend [Object, nil] optional transport backend
27
+ def initialize(endpoints = nil, linger: Float::INFINITY, backend: nil)
28
+ init_engine(:CLIENT, backend: backend)
29
+ @options.linger = linger
30
+ attach_endpoints(endpoints, default: :connect)
31
+ end
32
+ end
33
+
34
+
35
+ # Asynchronous server socket for the CLIENT/SERVER pattern (ZeroMQ RFC 41).
36
+ #
37
+ # Assigns a 4-byte routing ID to each connected CLIENT and supports
38
+ # directed replies via #send_to.
39
+ class SERVER < Socket
40
+ include Readable
41
+ include Writable
42
+ include SingleFrame
43
+
44
+ # Creates a new SERVER socket.
45
+ #
46
+ # @param endpoints [String, Array<String>, nil] endpoint(s) to bind to
47
+ # @param linger [Numeric] linger period in seconds (Float::INFINITY = wait forever, 0 = drop)
48
+ # @param backend [Object, nil] optional transport backend
49
+ def initialize(endpoints = nil, linger: Float::INFINITY, backend: nil)
50
+ init_engine(:SERVER, backend: backend)
51
+ @options.linger = linger
52
+ attach_endpoints(endpoints, default: :bind)
53
+ end
54
+
55
+
56
+ # Sends a message to a specific peer by routing ID.
57
+ #
58
+ # @param routing_id [String] 4-byte routing ID
59
+ # @param message [String] message body
60
+ # @return [self]
61
+ #
62
+ def send_to(routing_id, message)
63
+ parts = [routing_id, message]
64
+ Reactor.run(timeout: @options.write_timeout) { @engine.enqueue_send(parts) }
65
+ self
66
+ end
67
+ end
68
+
69
+
70
+ Routing.register(:CLIENT, Routing::Client)
71
+ Routing.register(:SERVER, Routing::Server)
72
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "io/stream"
5
+
6
+ module OMQ
7
+ # When OMQ_DEBUG is set, silent rescue clauses print the exception
8
+ # to stderr so transport/engine bugs surface immediately.
9
+ DEBUG = !!ENV["OMQ_DEBUG"]
10
+
11
+
12
+ # Raised when an internal pump task crashes unexpectedly.
13
+ # The socket is no longer usable; the original error is available via #cause.
14
+ #
15
+ class SocketDeadError < RuntimeError
16
+ end
17
+
18
+
19
+ # Lifecycle event emitted by {Socket#monitor}.
20
+ #
21
+ # @!attribute [r] type
22
+ # @return [Symbol] event type (:listening, :connected, :disconnected, etc.)
23
+ # @!attribute [r] endpoint
24
+ # @return [String, nil] the endpoint involved
25
+ # @!attribute [r] detail
26
+ # @return [Hash, nil] extra context (e.g. { error: }, { interval: }, etc.)
27
+ #
28
+ MonitorEvent = Data.define(:type, :endpoint, :detail) do
29
+ def initialize(type:, endpoint: nil, detail: nil) = super
30
+ end
31
+
32
+
33
+ # Errors raised when a peer disconnects or resets the connection.
34
+ # Not frozen at load time — transport plugins append to this before
35
+ # the first bind/connect, which freezes both arrays.
36
+ CONNECTION_LOST = [
37
+ EOFError,
38
+ IOError,
39
+ Errno::EPIPE,
40
+ Errno::ECONNRESET,
41
+ Errno::ECONNABORTED,
42
+ Errno::ENOTCONN,
43
+ IO::Stream::ConnectionResetError,
44
+ ]
45
+
46
+
47
+ # Errors raised when a peer cannot be reached.
48
+ CONNECTION_FAILED = [
49
+ Errno::ECONNREFUSED,
50
+ Errno::ENOENT,
51
+ Errno::ETIMEDOUT,
52
+ Errno::EHOSTUNREACH,
53
+ Errno::ENETUNREACH,
54
+ Errno::EPROTOTYPE, # IPC: existing socket file is SOCK_DGRAM, not SOCK_STREAM
55
+ Socket::ResolutionError,
56
+ ]
57
+
58
+
59
+ # Freezes module-level state so OMQ sockets can be used inside Ractors.
60
+ # Call this once before spawning any Ractors that create OMQ sockets.
61
+ #
62
+ def self.freeze_for_ractors!
63
+ CONNECTION_LOST.freeze
64
+ CONNECTION_FAILED.freeze
65
+ Engine.transports.freeze
66
+ Routing.registry.freeze
67
+ end
68
+ end
@@ -5,7 +5,7 @@ module OMQ
5
5
  # Owns the full arc of *one* connection: handshake → ready → closed.
6
6
  #
7
7
  # Scope boundary: ConnectionLifecycle tracks a single peer link
8
- # (one ZMTP connection or one inproc DirectPipe). SocketLifecycle
8
+ # (one ZMTP connection or one inproc Pipe). SocketLifecycle
9
9
  # owns the socket-wide state above it — first-peer/last-peer
10
10
  # signaling, reconnect enable flag, the parent task tree, and the
11
11
  # open → closing → closed transitions that gate close-time drain.
@@ -43,7 +43,7 @@ module OMQ
43
43
  }.freeze
44
44
 
45
45
 
46
- # @return [Protocol::ZMTP::Connection, Transport::Inproc::DirectPipe, nil]
46
+ # @return [Protocol::ZMTP::Connection, Transport::Inproc::Pipe, nil]
47
47
  attr_reader :conn
48
48
 
49
49
 
@@ -101,7 +101,7 @@ module OMQ
101
101
  conn.handshake!
102
102
  end
103
103
 
104
- Heartbeat.start(@barrier, conn, @engine.options, @engine.tasks)
104
+ Heartbeat.start(@barrier, conn, @engine.options)
105
105
  ready!(conn)
106
106
  @conn
107
107
  rescue Protocol::ZMTP::Error, *CONNECTION_LOST, Async::TimeoutError => error
@@ -120,9 +120,9 @@ module OMQ
120
120
 
121
121
 
122
122
  # Registers an already-connected inproc pipe as :ready.
123
- # No handshake — inproc DirectPipe bypasses ZMTP entirely.
123
+ # No handshake — inproc Pipe bypasses ZMTP entirely.
124
124
  #
125
- # @param pipe [Transport::Inproc::DirectPipe]
125
+ # @param pipe [Transport::Inproc::Pipe]
126
126
  #
127
127
  def ready_direct!(pipe)
128
128
  ready!(pipe)
@@ -161,7 +161,17 @@ module OMQ
161
161
 
162
162
 
163
163
  def ready!(conn)
164
- conn = @engine.connection_wrapper.call(conn) if @engine.connection_wrapper
164
+ if @engine.connection_wrapper
165
+ conn = @engine.connection_wrapper.call(conn)
166
+ end
167
+
168
+ if @endpoint
169
+ transport_obj = @engine.transport_object_for(@endpoint)
170
+ if transport_obj.respond_to?(:wrap_connection)
171
+ conn = transport_obj.wrap_connection(conn)
172
+ end
173
+ end
174
+
165
175
  @conn = conn
166
176
  @engine.connections[@conn] = self
167
177
  @engine.emit_monitor_event(:handshake_succeeded, endpoint: @endpoint)
@@ -169,7 +179,7 @@ module OMQ
169
179
  @engine.peer_connected.resolve(@conn)
170
180
  transition!(:ready)
171
181
 
172
- # No supervisor if nothing to supervise: inproc DirectPipes
182
+ # No supervisor if nothing to supervise: inproc Pipes
173
183
  # wire the recv/send paths synchronously (no task-based pumps),
174
184
  # and isolated unit tests use a FakeEngine without pumps at all.
175
185
  # Waiting on an empty barrier returns immediately and would
@@ -205,6 +215,7 @@ module OMQ
205
215
 
206
216
  def tear_down!(reconnect:, reason: nil)
207
217
  return if @state == :closed
218
+
208
219
  transition!(:closed)
209
220
  @engine.connections.delete(@conn)
210
221
  @engine.routing.connection_removed(@conn) if @conn
@@ -212,7 +223,7 @@ module OMQ
212
223
  detail = reason ? { error: reason, reason: reason.message } : nil
213
224
  @engine.emit_monitor_event(:disconnected, endpoint: @endpoint, detail: detail)
214
225
  @done&.resolve(true)
215
- @engine.resolve_all_peers_gone_if_empty
226
+ @engine.maybe_resolve_all_peers_gone
216
227
  @engine.maybe_reconnect(@endpoint) if reconnect
217
228
 
218
229
  # Cancel every sibling pump of this connection. The caller is
@@ -236,11 +247,14 @@ module OMQ
236
247
 
237
248
  def transition!(new_state)
238
249
  allowed = TRANSITIONS[@state]
250
+
239
251
  unless allowed&.include?(new_state)
240
252
  raise InvalidTransition, "#{@state} → #{new_state}"
241
253
  end
254
+
242
255
  @state = new_state
243
256
  end
257
+
244
258
  end
245
259
  end
246
260
  end
@@ -9,11 +9,10 @@ module OMQ
9
9
  #
10
10
  module Heartbeat
11
11
  # @param parent [Async::Task, Async::Barrier] parent to spawn under
12
- # @param conn [Connection]
12
+ # @param conn [Protocol::ZMTP::Connection]
13
13
  # @param options [Options]
14
- # @param tasks [Array]
15
14
  #
16
- def self.start(parent, conn, options, tasks)
15
+ def self.start(parent, conn, options)
17
16
  interval = options.heartbeat_interval
18
17
  return unless interval
19
18
 
@@ -21,7 +20,7 @@ module OMQ
21
20
  timeout = options.heartbeat_timeout || interval
22
21
  conn.touch_heartbeat
23
22
 
24
- tasks << parent.async(transient: true, annotation: "heartbeat") do
23
+ parent.async(transient: true, annotation: "heartbeat") do
25
24
  loop do
26
25
  sleep interval
27
26
  conn.send_command(Protocol::ZMTP::Codec::Command.ping(ttl: ttl))
@@ -12,17 +12,15 @@ module OMQ
12
12
  module Maintenance
13
13
  # @param parent_task [Async::Task]
14
14
  # @param mechanism [#maintenance, nil]
15
- # @param tasks [Array<Async::Task>]
16
15
  #
17
- def self.start(parent_task, mechanism, tasks)
16
+ def self.start(parent_task, mechanism)
18
17
  return unless mechanism.respond_to?(:maintenance)
19
- spec = mechanism.maintenance
20
- return unless spec
18
+ spec = mechanism.maintenance or return spec
21
19
 
22
20
  interval = spec[:interval]
23
21
  callable = spec[:task]
24
22
 
25
- tasks << parent_task.async(transient: true, annotation: "mechanism maintenance") do
23
+ parent_task.async(transient: true, annotation: "mechanism maintenance") do
26
24
  Async::Loop.quantized(interval: interval) do
27
25
  callable.call
28
26
  end
@@ -30,6 +28,7 @@ module OMQ
30
28
  # clean shutdown
31
29
  end
32
30
  end
31
+
33
32
  end
34
33
  end
35
34
  end
@@ -8,25 +8,25 @@ module OMQ
8
8
  # or the engine is closed.
9
9
  #
10
10
  class Reconnect
11
- # @param endpoint [String]
11
+ # @param dialer [Transport::TCP::Dialer, etc.] stateful dialer factory
12
12
  # @param options [Options]
13
13
  # @param parent_task [Async::Task]
14
14
  # @param engine [Engine]
15
15
  # @param delay [Numeric, nil] initial delay (defaults to reconnect_interval)
16
16
  #
17
- def self.schedule(endpoint, options, parent_task, engine, delay: nil)
18
- new(engine, endpoint, options).run(parent_task, delay: delay)
17
+ def self.schedule(dialer, options, parent_task, engine, delay: nil)
18
+ new(engine, dialer, options).run(parent_task, delay: delay)
19
19
  end
20
20
 
21
21
 
22
22
  # @param engine [Engine]
23
- # @param endpoint [String]
23
+ # @param dialer [Transport::TCP::Dialer, etc.] stateful dialer factory
24
24
  # @param options [Options]
25
25
  #
26
- def initialize(engine, endpoint, options)
27
- @engine = engine
28
- @endpoint = endpoint
29
- @options = options
26
+ def initialize(engine, dialer, options)
27
+ @engine = engine
28
+ @dialer = dialer
29
+ @options = options
30
30
  end
31
31
 
32
32
 
@@ -37,7 +37,8 @@ module OMQ
37
37
  # @return [void]
38
38
  #
39
39
  def run(parent_task, delay: nil)
40
- @engine.tasks << parent_task.async(transient: true, annotation: "reconnect #{@endpoint}") do
40
+ endpoint = @dialer.endpoint
41
+ parent_task.async(transient: true, annotation: "reconnect #{endpoint}") do
41
42
  retry_loop(delay: delay)
42
43
  rescue Async::Stop
43
44
  rescue => error
@@ -57,12 +58,12 @@ module OMQ
57
58
  sleep quantized_wait(delay) if delay > 0
58
59
  break if @engine.closed?
59
60
  begin
60
- @engine.transport_for(@endpoint).connect(@endpoint, @engine)
61
+ @dialer.connect
61
62
  break
62
63
  rescue *CONNECTION_LOST, *CONNECTION_FAILED, Protocol::ZMTP::Error
63
64
  delay = next_delay(delay, max_delay)
64
65
  @engine.emit_monitor_event :connect_retried,
65
- endpoint: @endpoint, detail: { interval: delay }
66
+ endpoint: @dialer.endpoint, detail: { interval: delay }
66
67
  end
67
68
  end
68
69
  end