nnq 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1d4408e132c91a9ebf74a96a8c14f6004c5e05c4b9abdc959a798b98f7adbf32
4
+ data.tar.gz: ebc0be58942030dcd326664ddde9286d948e074c953ec06f9c238a59b6f3b02a
5
+ SHA512:
6
+ metadata.gz: dc12b86758f90e1c36aba100962fc89d8cabbc93d72e9e28d4f7973a90cb1b9fab1ab38908cd3988f1b264f3bff2de34818043e5026cdc5ad655312e374fd150
7
+ data.tar.gz: 7679994454867fa4fcc35d3c16e7c49ffe275ba9cdaf66fbcd428e3fef8b0e89df440918232b0968e38448033198be6f91616aac678ed65bd9c5158a9cda4702
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-04-09
4
+
5
+ - `NNQ::PUB` / `NNQ::SUB` with local prefix filtering (pub0/sub0).
6
+ - `NNQ::PAIR` (pair0) — first-pipe-wins exclusive bidirectional channel.
7
+ - `NNQ::REQ` / `NNQ::REP` (req0/rep0) cooked-mode request/reply.
8
+ - `NNQ::Engine::ConnectionLifecycle` — per-connection state machine
9
+ (`new → handshaking → ready → closed`) consolidating registration,
10
+ teardown ordering, and idempotent loss handling.
11
+ - `NNQ::Engine::SocketLifecycle` — socket-level state machine
12
+ (`new → open → closing → closed`) owning the parent task capture and
13
+ close sequencing.
14
+ - `NNQ::ConnectionRejected` — raised by routing strategies (e.g. PAIR's
15
+ second peer) to reject a just-handshook connection without exposing
16
+ it to pumps.
17
+
18
+ ## 0.1.0 — 2026-04-09
19
+
20
+ Initial Phase 1 slice (push0/pull0 over TCP). Requires Ruby >= 4.0.
21
+
22
+ - `NNQ::Send::Staging` — opportunistic-batching, HWM-free send path.
23
+ - `NNQ::Connection`, `NNQ::Engine`, `NNQ::Socket`.
24
+ - `NNQ::PUSH` / `NNQ::PULL` with `NNQ::Routing::Push` (round-robin send)
25
+ and `NNQ::Routing::Pull` (unbounded fair-queue receive).
26
+ - `NNQ::Transport::TCP`.
27
+ - `NNQ::Reactor` — per-process fallback IO thread for non-Async callers.
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright (c) 2026, Patrik Wenger
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any
4
+ purpose with or without fee is hereby granted, provided that the above
5
+ copyright notice and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
12
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
13
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # NNQ — pure Ruby NNG on Async
2
+
3
+ [![CI](https://github.com/paddor/nnq/actions/workflows/ci.yml/badge.svg)](https://github.com/paddor/nnq/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/nnq?color=e9573f)](https://rubygems.org/gems/nnq)
5
+ [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE)
6
+ [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%204.0-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
7
+
8
+ NNQ is a pure-Ruby implementation of nanomsg's Scalability Protocols
9
+ (SP), wire-compatible with libnng. It is the nng-philosophy sibling of
10
+ [omq](https://github.com/paddor/omq) (pure-Ruby ZeroMQ).
11
+
12
+ Status: pre-alpha. v0.0.1 implements push0/pull0 over TCP only. See
13
+ [PLAN.md](PLAN.md) for the design and roadmap.
14
+
15
+ ## Why a pure-Ruby NNG?
16
+
17
+ - **No native deps.** Same stack as omq: `async`, `io-stream`,
18
+ `protocol-sp`. No C extension, no FFI.
19
+ - **Faster than libnng** for the multi-fiber case (target: 10–25×).
20
+ libnng's per-message aio model leaves all the throughput of write
21
+ coalescing on the table.
22
+ - **Async-native.** Wrap in `Async{}`, no background thread for users
23
+ who already run a reactor.
24
+
25
+ ## Quickstart
26
+
27
+ ```ruby
28
+ require "nnq"
29
+ require "async"
30
+
31
+ Async do
32
+ pull = NNQ::PULL.bind("tcp://127.0.0.1:5570")
33
+ push = NNQ::PUSH.connect("tcp://127.0.0.1:5570")
34
+
35
+ push.send("hello")
36
+ puts pull.receive # => "hello"
37
+
38
+ push.close
39
+ pull.close
40
+ end
41
+ ```
42
+
43
+ ## The "no HWM" philosophy
44
+
45
+ NNQ has no high-water mark. Backpressure comes only from the kernel
46
+ socket buffer. Concurrent senders are coalesced into one writev syscall
47
+ by `NNQ::Send::Staging`, which keeps a tiny in-memory aggregation point
48
+ that exists only during the moments when the drainer fiber is parked
49
+ on `wait_writable`. See `lib/nnq/send/staging.rb` for the full
50
+ discussion.
51
+
52
+ ## Cancellation
53
+
54
+ `Async::Task` is the aio. To cancel an in-flight send, stop the task
55
+ that called `send`. The semantics match libnng's
56
+ `nng_aio_cancel` exactly: best-effort, may lose the race if the message
57
+ is already on the wire.
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "protocol/sp"
4
+
5
+ module NNQ
6
+ # Per-pipe state: thin wrapper around Protocol::SP::Connection.
7
+ #
8
+ # Owns no fibers itself — recv loop and send pump are spawned by
9
+ # the Engine and routing strategy respectively.
10
+ #
11
+ class Connection
12
+ # @return [Protocol::SP::Connection]
13
+ attr_reader :sp
14
+
15
+ # @return [String, nil] endpoint URI we connected to / accepted from
16
+ attr_reader :endpoint
17
+
18
+ # @param sp [Protocol::SP::Connection] handshake-completed SP connection
19
+ # @param endpoint [String, nil]
20
+ def initialize(sp, endpoint: nil)
21
+ @sp = sp
22
+ @endpoint = endpoint
23
+ @closed = false
24
+ end
25
+
26
+
27
+ # @return [Integer] peer protocol id (e.g. Protocols::PULL_V0)
28
+ def peer_protocol = @sp.peer_protocol
29
+
30
+
31
+ # Writes one message into the SP connection's send buffer (no flush).
32
+ #
33
+ # @param body [String]
34
+ # @return [void]
35
+ def write_message(body)
36
+ raise ClosedError, "connection closed" if @closed
37
+ @sp.write_message(body)
38
+ end
39
+
40
+
41
+ # Writes a batch of bodies under a single SP mutex acquisition.
42
+ # Used by the work-stealing send pump hot path.
43
+ #
44
+ # @param bodies [Array<String>]
45
+ # @return [void]
46
+ def write_messages(bodies)
47
+ raise ClosedError, "connection closed" if @closed
48
+ @sp.write_messages(bodies)
49
+ end
50
+
51
+
52
+ # Writes one message AND flushes immediately. Used by REQ/REP where
53
+ # each call is request-paced and there's nothing to batch.
54
+ #
55
+ # @param body [String]
56
+ # @return [void]
57
+ def send_message(body)
58
+ raise ClosedError, "connection closed" if @closed
59
+ @sp.send_message(body)
60
+ end
61
+
62
+
63
+ # Flushes the SP connection's send buffer to the socket.
64
+ #
65
+ # @return [void]
66
+ def flush
67
+ @sp.flush
68
+ end
69
+
70
+
71
+ # Reads one message body off the wire. Blocks the calling fiber.
72
+ #
73
+ # @return [String]
74
+ def receive_message
75
+ @sp.receive_message
76
+ end
77
+
78
+
79
+ # @return [Boolean]
80
+ def closed? = @closed
81
+
82
+
83
+ # Closes the underlying SP connection. Safe to call twice.
84
+ def close
85
+ return if @closed
86
+ @closed = true
87
+ @sp.close
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "protocol/sp"
4
+ require_relative "../connection"
5
+
6
+ module NNQ
7
+ class Engine
8
+ # Owns the full arc of one connection: handshake → ready → closed.
9
+ #
10
+ # Centralizes the ordering of side effects (routing registration,
11
+ # teardown) so the sequence lives in one place instead of being
12
+ # scattered across Engine, the accept/connect paths, and the
13
+ # recv/send pumps.
14
+ #
15
+ # State machine:
16
+ #
17
+ # new → handshaking → ready → closed
18
+ #
19
+ # #lost! and #close! are idempotent — the state guard ensures side
20
+ # effects run exactly once even if multiple pumps race to report a
21
+ # lost connection.
22
+ #
23
+ class ConnectionLifecycle
24
+ class InvalidTransition < RuntimeError; end
25
+
26
+ STATES = %i[new handshaking ready closed].freeze
27
+
28
+ TRANSITIONS = {
29
+ new: %i[handshaking ready closed].freeze,
30
+ handshaking: %i[ready closed].freeze,
31
+ ready: %i[closed].freeze,
32
+ closed: [].freeze,
33
+ }.freeze
34
+
35
+
36
+ # @return [NNQ::Connection, nil]
37
+ attr_reader :conn
38
+
39
+ # @return [String, nil]
40
+ attr_reader :endpoint
41
+
42
+ # @return [Symbol]
43
+ attr_reader :state
44
+
45
+
46
+ # @param engine [Engine]
47
+ # @param endpoint [String, nil]
48
+ # @param framing [Symbol] :tcp or :ipc
49
+ def initialize(engine, endpoint:, framing:)
50
+ @engine = engine
51
+ @endpoint = endpoint
52
+ @framing = framing
53
+ @state = :new
54
+ @conn = nil
55
+ end
56
+
57
+
58
+ # Performs the SP handshake, wraps the result in NNQ::Connection,
59
+ # registers with the engine and routing, and transitions to :ready.
60
+ #
61
+ # @param io [#read, #write, #close]
62
+ # @return [NNQ::Connection]
63
+ def handshake!(io)
64
+ transition!(:handshaking)
65
+ sp = Protocol::SP::Connection.new(
66
+ io,
67
+ protocol: @engine.protocol,
68
+ max_message_size: @engine.options.max_message_size,
69
+ framing: @framing,
70
+ )
71
+ sp.handshake!
72
+ ready!(NNQ::Connection.new(sp, endpoint: @endpoint))
73
+ @conn
74
+ rescue
75
+ io.close rescue nil
76
+ transition!(:closed) unless @state == :closed
77
+ raise
78
+ end
79
+
80
+
81
+ # Transitions to :closed, removing the connection from the engine
82
+ # and notifying the routing strategy. Idempotent.
83
+ def lost!
84
+ tear_down!
85
+ end
86
+
87
+
88
+ # Alias for lost!. Kept as a separate method for parity with OMQ,
89
+ # where the distinction drives reconnect scheduling. nnq has no
90
+ # reconnect yet, so the two behave identically.
91
+ def close!
92
+ tear_down!
93
+ end
94
+
95
+
96
+ private
97
+
98
+ def ready!(conn)
99
+ @conn = conn
100
+ @engine.connections[conn] = self
101
+ transition!(:ready)
102
+ begin
103
+ @engine.routing.connection_added(conn) if @engine.routing.respond_to?(:connection_added)
104
+ rescue ConnectionRejected
105
+ tear_down!
106
+ raise
107
+ end
108
+ @engine.new_pipe.signal
109
+ end
110
+
111
+
112
+ def tear_down!
113
+ return if @state == :closed
114
+ transition!(:closed)
115
+ if @conn
116
+ @engine.connections.delete(@conn)
117
+ @engine.routing.connection_removed(@conn) if @engine.routing.respond_to?(:connection_removed)
118
+ @conn.close rescue nil
119
+ end
120
+ end
121
+
122
+
123
+ def transition!(new_state)
124
+ allowed = TRANSITIONS[@state]
125
+ unless allowed&.include?(new_state)
126
+ raise InvalidTransition, "#{@state} → #{new_state}"
127
+ end
128
+ @state = new_state
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ class Engine
5
+ # Owns the socket-level state: `:new → :open → :closing → :closed`
6
+ # and the captured parent task for the socket's task tree.
7
+ #
8
+ # Engine delegates state queries here and uses it to coordinate the
9
+ # ordering of close-time side effects. Mirrors OMQ's SocketLifecycle
10
+ # without the heartbeat/mechanism/monitor machinery nnq doesn't need.
11
+ #
12
+ class SocketLifecycle
13
+ class InvalidTransition < RuntimeError; end
14
+
15
+ STATES = %i[new open closing closed].freeze
16
+
17
+ TRANSITIONS = {
18
+ new: %i[open closed].freeze,
19
+ open: %i[closing closed].freeze,
20
+ closing: %i[closed].freeze,
21
+ closed: [].freeze,
22
+ }.freeze
23
+
24
+
25
+ # @return [Symbol]
26
+ attr_reader :state
27
+
28
+ # @return [Async::Task, nil] root of the socket's task tree
29
+ attr_reader :parent_task
30
+
31
+ # @return [Boolean] true if parent_task is the shared Reactor thread
32
+ attr_reader :on_io_thread
33
+
34
+
35
+ def initialize
36
+ @state = :new
37
+ @parent_task = nil
38
+ @on_io_thread = false
39
+ end
40
+
41
+
42
+ def open? = @state == :open
43
+ def closing? = @state == :closing
44
+ def closed? = @state == :closed
45
+ def alive? = @state == :new || @state == :open
46
+
47
+
48
+ # Captures +task+ as this socket's task tree root. Transitions
49
+ # `:new → :open`. Idempotent: second call is a no-op.
50
+ #
51
+ # @param task [Async::Task]
52
+ # @param on_io_thread [Boolean] true when +task+ is the shared
53
+ # NNQ::Reactor root task (vs. the caller's own Async task)
54
+ # @return [Boolean] true on first-time capture, false if already captured
55
+ def capture_parent_task(task, on_io_thread:)
56
+ return false if @parent_task
57
+ @parent_task = task
58
+ @on_io_thread = on_io_thread
59
+ transition!(:open)
60
+ true
61
+ end
62
+
63
+
64
+ # Transitions `:open → :closing`.
65
+ def start_closing!
66
+ transition!(:closing)
67
+ end
68
+
69
+
70
+ # Transitions `:closing → :closed` (or `:new → :closed` for
71
+ # never-opened sockets).
72
+ def finish_closing!
73
+ transition!(:closed)
74
+ end
75
+
76
+
77
+ private
78
+
79
+ def transition!(new_state)
80
+ allowed = TRANSITIONS[@state]
81
+ unless allowed&.include?(new_state)
82
+ raise InvalidTransition, "#{@state} → #{new_state}"
83
+ end
84
+ @state = new_state
85
+ end
86
+ end
87
+ end
88
+ end
data/lib/nnq/engine.rb ADDED
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/clock"
5
+ require "protocol/sp"
6
+ require_relative "error"
7
+ require_relative "connection"
8
+ require_relative "reactor"
9
+ require_relative "engine/socket_lifecycle"
10
+ require_relative "engine/connection_lifecycle"
11
+ require_relative "transport/tcp"
12
+ require_relative "transport/ipc"
13
+ require_relative "transport/inproc"
14
+
15
+ module NNQ
16
+ # Per-socket orchestrator. Owns the listener set, the connection map
17
+ # (keyed on NNQ::Connection, with per-connection ConnectionLifecycle
18
+ # values), the transport registry, and the socket-level state machine
19
+ # via {SocketLifecycle}.
20
+ #
21
+ # Mirrors OMQ's Engine in shape but is much smaller because there's
22
+ # no HWM bookkeeping, no mechanisms, no heartbeat, no monitor queue.
23
+ #
24
+ class Engine
25
+ TRANSPORTS = {
26
+ "tcp" => Transport::TCP,
27
+ "ipc" => Transport::IPC,
28
+ "inproc" => Transport::Inproc,
29
+ }
30
+
31
+
32
+ # @return [Integer] our SP protocol id (e.g. Protocols::PUSH_V0)
33
+ attr_reader :protocol
34
+
35
+ # @return [Options]
36
+ attr_reader :options
37
+
38
+ # @return [Hash{NNQ::Connection => ConnectionLifecycle}]
39
+ attr_reader :connections
40
+
41
+ # @return [SocketLifecycle]
42
+ attr_reader :lifecycle
43
+
44
+ # @return [String, nil]
45
+ attr_reader :last_endpoint
46
+
47
+ # @return [Async::Condition] signaled when a new pipe is registered
48
+ attr_reader :new_pipe
49
+
50
+
51
+ # @param protocol [Integer] our SP protocol id (e.g. Protocols::PUSH_V0)
52
+ # @param options [Options]
53
+ # @yieldparam engine [Engine] used by the caller to build a routing
54
+ # strategy with access to the engine's connection map
55
+ def initialize(protocol:, options:)
56
+ @protocol = protocol
57
+ @options = options
58
+ @connections = {}
59
+ @listeners = []
60
+ @lifecycle = SocketLifecycle.new
61
+ @last_endpoint = nil
62
+ @new_pipe = Async::Condition.new
63
+ @routing = yield(self)
64
+ end
65
+
66
+
67
+ # @return [Routing strategy]
68
+ attr_reader :routing
69
+
70
+
71
+ # @return [Async::Task, nil]
72
+ def parent_task = @lifecycle.parent_task
73
+
74
+
75
+ def closed? = @lifecycle.closed?
76
+
77
+
78
+ # Stores the parent Async task that long-lived NNQ fibers will
79
+ # attach to. The caller (Socket) is responsible for picking the
80
+ # right one (the user's current task, or Reactor.root_task).
81
+ def capture_parent_task(task)
82
+ on_io_thread = task.equal?(Reactor.root_task)
83
+ @lifecycle.capture_parent_task(task, on_io_thread: on_io_thread)
84
+ end
85
+
86
+
87
+ # Binds to +endpoint+. Synchronous: errors propagate.
88
+ def bind(endpoint)
89
+ transport = transport_for(endpoint)
90
+ listener = transport.bind(endpoint, self)
91
+ listener.start_accept_loop(@lifecycle.parent_task) do |io, framing = :tcp|
92
+ handle_accepted(io, endpoint: endpoint, framing: framing)
93
+ end
94
+ @listeners << listener
95
+ @last_endpoint = listener.endpoint
96
+ end
97
+
98
+
99
+ # Connects to +endpoint+. Synchronous on first attempt; reconnect
100
+ # is wired in Phase 1.1.
101
+ def connect(endpoint)
102
+ transport = transport_for(endpoint)
103
+ transport.connect(endpoint, self)
104
+ @last_endpoint = endpoint
105
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
106
+ raise Error, "could not connect to #{endpoint}: #{e.class}: #{e.message}"
107
+ end
108
+
109
+
110
+ # Called by transports for each accepted client connection.
111
+ def handle_accepted(io, endpoint:, framing: :tcp)
112
+ lifecycle = ConnectionLifecycle.new(self, endpoint: endpoint, framing: framing)
113
+ lifecycle.handshake!(io)
114
+ spawn_recv_loop(lifecycle.conn) if @routing.respond_to?(:enqueue) && @connections.key?(lifecycle.conn)
115
+ rescue ConnectionRejected
116
+ # routing rejected this peer (e.g. PAIR already bonded) — lifecycle cleaned up
117
+ rescue => e
118
+ warn("nnq: handshake failed for #{endpoint}: #{e.class}: #{e.message}") if $DEBUG
119
+ end
120
+
121
+
122
+ # Called by transports for each dialed connection.
123
+ def handle_connected(io, endpoint:, framing: :tcp)
124
+ lifecycle = ConnectionLifecycle.new(self, endpoint: endpoint, framing: framing)
125
+ lifecycle.handshake!(io)
126
+ spawn_recv_loop(lifecycle.conn) if @routing.respond_to?(:enqueue) && @connections.key?(lifecycle.conn)
127
+ rescue ConnectionRejected
128
+ # unusual on connect side, but handled identically
129
+ end
130
+
131
+
132
+ # Spawns a task under the socket's parent task. Used by routing
133
+ # strategies (e.g. PUSH send pump) to attach long-lived fibers to
134
+ # the engine's lifecycle without going through transient: true.
135
+ def spawn_task(annotation:, &block)
136
+ @lifecycle.parent_task.async(annotation: annotation, &block)
137
+ end
138
+
139
+
140
+ # Closes the engine: stops listeners, drains the send queue subject
141
+ # to linger, stops routing pumps (which by now are parked on the
142
+ # empty queue), then tears down every connection's lifecycle. Order
143
+ # matters — closing connections first would force mid-flush pumps
144
+ # to abort with IOError.
145
+ def close
146
+ return unless @lifecycle.alive?
147
+ @lifecycle.start_closing!
148
+ @listeners.each(&:stop)
149
+ drain_send_queue(@options.linger)
150
+ @routing.close if @routing.respond_to?(:close)
151
+ # Tear down each remaining connection via its lifecycle. The
152
+ # collection mutates during iteration, so snapshot the values.
153
+ @connections.values.each(&:close!)
154
+ @lifecycle.finish_closing!
155
+ @new_pipe.signal
156
+ end
157
+
158
+
159
+ # Called by routing pumps (or the recv loop) when their connection
160
+ # has died. Idempotent via the lifecycle state guard.
161
+ def handle_connection_lost(conn)
162
+ @connections[conn]&.lost!
163
+ end
164
+
165
+
166
+ private
167
+
168
+ def drain_send_queue(timeout)
169
+ return unless @routing.respond_to?(:send_queue_drained?)
170
+ return if @connections.empty?
171
+ deadline = timeout ? Async::Clock.now + timeout : nil
172
+ until @routing.send_queue_drained?
173
+ break if deadline && (deadline - Async::Clock.now) <= 0
174
+ sleep 0.001
175
+ end
176
+ end
177
+
178
+
179
+ def spawn_recv_loop(conn)
180
+ @lifecycle.parent_task.async(annotation: "nnq recv #{conn.endpoint}") do
181
+ loop do
182
+ body = conn.receive_message
183
+ @routing.enqueue(body, conn)
184
+ rescue EOFError, IOError, Errno::ECONNRESET, Async::Stop
185
+ break
186
+ end
187
+ ensure
188
+ handle_connection_lost(conn)
189
+ end
190
+ end
191
+
192
+
193
+ def transport_for(endpoint)
194
+ scheme = endpoint[/\A([a-z+]+):\/\//i, 1] or raise Error, "no scheme: #{endpoint}"
195
+ TRANSPORTS[scheme] or raise Error, "unsupported transport: #{scheme}"
196
+ end
197
+ end
198
+ end
data/lib/nnq/error.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ class Error < RuntimeError; end
5
+ class ClosedError < Error; end
6
+ class ProtocolError < Error; end
7
+ class TimeoutError < Error; end
8
+ class RequestCancelled < Error; end
9
+ class ConnectionRejected < Error; end
10
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ # Per-socket configuration. Deliberately tiny — no conflate, no heartbeat
5
+ # command (nng uses TCP keepalive instead). New options should match nng
6
+ # socket option names where they exist (`recv_max_size`, `reconnect_time`,
7
+ # `send_buf`, etc.).
8
+ #
9
+ # `send_hwm` bounds *one shared queue per socket*, not per peer. See
10
+ # DESIGN.md "Per-socket HWM".
11
+ #
12
+ class Options
13
+ DEFAULT_HWM = 1000
14
+
15
+ attr_accessor :linger
16
+ attr_accessor :read_timeout
17
+ attr_accessor :write_timeout
18
+ attr_accessor :reconnect_interval
19
+ attr_accessor :max_message_size
20
+ attr_accessor :send_hwm
21
+
22
+ def initialize(linger: nil, send_hwm: DEFAULT_HWM)
23
+ @linger = linger
24
+ @read_timeout = nil
25
+ @write_timeout = nil
26
+ @reconnect_interval = 0.1
27
+ @max_message_size = nil
28
+ @send_hwm = send_hwm
29
+ end
30
+ end
31
+ end
data/lib/nnq/pair.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "socket"
4
+ require_relative "routing/pair"
5
+
6
+ module NNQ
7
+ # PAIR (nng pair0): exclusive bidirectional channel with a single
8
+ # peer. First peer to connect wins; subsequent peers are dropped
9
+ # until the current one disconnects. No SP header on the wire.
10
+ #
11
+ class PAIR < Socket
12
+ def send(body)
13
+ Reactor.run { @engine.routing.send(body) }
14
+ end
15
+
16
+
17
+ def receive
18
+ Reactor.run { @engine.routing.receive }
19
+ end
20
+
21
+
22
+ private
23
+
24
+ def protocol
25
+ Protocol::SP::Protocols::PAIR_V0
26
+ end
27
+
28
+
29
+ def build_routing(engine)
30
+ Routing::Pair.new(engine)
31
+ end
32
+ end
33
+ end