nnq 0.7.0 → 0.8.1

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: c55340c77dffd3e4fdbc8419fb463e608a6e635adc577582e295b8af20fdc3e0
4
- data.tar.gz: 6bb76f07c3732a4e69ad9a8e93a6a852f5ca04aac40fedc0df346ec9a2d74297
3
+ metadata.gz: 19ad156b056b2948c31b8447aef51f07d6fde7838c7f448b13cf3ebcc0b84ccf
4
+ data.tar.gz: d781f021467df323ac687bd08247c503b018d2961d8499e3cce50504b7df70a8
5
5
  SHA512:
6
- metadata.gz: 1cb190f96ace0a94363ea6a0b24b9d8b563363fbb3fb84e0bb4927a8ef818949270f20dec984828405bd15068400ace40aa5a9017d52321e2f9e10aa5235d973
7
- data.tar.gz: 036a2a8203a7a26afad8d67fd23d9606d4947561ec0d8d31893b6efb116b62f623560afc6a4293fdbbd8846c39fa76aa9885eb47c9ab11900d552f5bc4377ecd
6
+ metadata.gz: f74b75020a0ae60b9caf974e92e85cf9fdbd56c7e0bb9e819a7ea0e783b0f7f2036112ed0698c723a699eb070bcac684ed354192c425d750c8e9e858c1d24561
7
+ data.tar.gz: 1d4e6ac56699c2ec8706c595f419ef8e39278e7aee4bc1d02fcf75ee31d91c167d182c74ab8176a3e41dbaaf029ca5b50d49fb0148bcc9d804278f229cb0049e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.1 — 2026-04-19
4
+
5
+ - **Fix close-race in `ConnectionLifecycle#tear_down!`.** The fd was
6
+ closed before sibling pumps were cancelled, which woke recv fibers
7
+ parked in `io_wait` with `IOError: stream closed in another thread`.
8
+ `@barrier.stop` now runs before `@conn.close`, so blocking reads
9
+ unwind via `Async::Stop` before the fd goes away.
10
+
11
+ ## 0.8.0 — 2026-04-19
12
+
13
+ - **Uniform frozen + `BINARY` message contract across transports.**
14
+ `Socket#coerce_binary` replaces the old `frozen_binary` + `.b.freeze`
15
+ copy on the hot send path. Every send method runs its body through
16
+ `coerce_binary`, which:
17
+ - coerces non-String bodies via `#to_str` (nil / `42` / `:foo` raise
18
+ `NoMethodError` instead of producing a zero-byte frame);
19
+ - re-tags unfrozen non-BINARY bodies to `Encoding::BINARY` in place —
20
+ a flag flip, no copy;
21
+ - freezes the body.
22
+
23
+ Receivers always see a frozen BINARY-tagged body: TCP/IPC get it via
24
+ the recv-pump freeze, inproc gets it via `Pipe#send_message`, which
25
+ only allocates for the pathological case of a frozen non-BINARY body
26
+ (the typical `# frozen_string_literal: true` UTF-8 literal). Bodies
27
+ returned by REP/REQ/SURVEYOR/RESPONDENT (cooked and raw) are frozen
28
+ by `parse_backtrace` and the REQ/SURVEYOR id-parsing paths. Mutation
29
+ bugs surface as `FrozenError` instead of silently corrupting a shared
30
+ reference on the inproc fast path. Inproc throughput pays ~20-30%
31
+ for the contract; TCP/IPC unaffected.
32
+
33
+ - **Benchmarks send fresh strings per iteration.** `BenchHelper.run`
34
+ passes an unfrozen `"x" * size` through to the burst closure; the
35
+ `measure` / `measure_roundtrip` bursts `.dup` it before each send.
36
+ More realistic than reusing one frozen payload and hitting every
37
+ fast path in `coerce_binary` + `Pipe#send_message`.
38
+
3
39
  ## 0.7.0 — 2026-04-18
4
40
 
5
41
  - **Inproc transport now uses a queue-based `Inproc::Pipe`** instead
@@ -20,15 +56,6 @@
20
56
  PAIR, SUB, REP, RESPONDENT, SURVEYOR, and the `*_raw` variants all
21
57
  implement the hook; REQ (promise-based) stays on the fiber path.
22
58
  Cuts three fiber hops to one on the steady-state recv path.
23
-
24
- Inproc PUSH/PULL single-peer throughput (Ruby 4.0.2):
25
-
26
- | Size | Before (no JIT) | After (no JIT) | After (+YJIT) |
27
- |---|---|---|---|
28
- | 128 B | 122k msg/s | 350k msg/s | 1,226k msg/s |
29
- | 2 KiB | 87k msg/s | 360k msg/s | 1,458k msg/s |
30
- | 32 KiB | 21k msg/s | 261k msg/s | 887k msg/s |
31
-
32
59
  - **Routing pumps shed their `@pump_tasks` bookkeeping.** `bus`, `pub`,
33
60
  `surveyor`, and `surveyor_raw` no longer track per-connection pump
34
61
  tasks in a hash. Pumps are spawned under
data/lib/nnq/bus.rb CHANGED
@@ -13,7 +13,7 @@ module NNQ
13
13
  #
14
14
  class BUS0 < Socket
15
15
  def send(body)
16
- body = frozen_binary(body)
16
+ body = coerce_binary(body)
17
17
  Reactor.run { @engine.routing.send(body) }
18
18
  end
19
19
 
@@ -151,6 +151,11 @@ module NNQ
151
151
  def tear_down!(reconnect: false)
152
152
  return if @state == :closed
153
153
  transition!(:closed)
154
+ # Cancel sibling pumps BEFORE closing the fd. If we close first,
155
+ # any pump still parked in io_wait wakes up with
156
+ # `IOError: stream closed in another thread`. The caller is the
157
+ # supervisor task, which is NOT in the barrier — no self-stop.
158
+ @barrier.stop
154
159
  if @conn
155
160
  @engine.connections.delete(@conn)
156
161
  @engine.routing.connection_removed(@conn) if @engine.routing.respond_to?(:connection_removed)
@@ -159,10 +164,6 @@ module NNQ
159
164
  @engine.resolve_all_peers_gone_if_empty
160
165
  end
161
166
  @engine.maybe_reconnect(@endpoint) if reconnect
162
- # Cancel every sibling pump of this connection. The caller is
163
- # the supervisor task, which is NOT in the barrier — so there
164
- # is no self-stop risk.
165
- @barrier.stop
166
167
  end
167
168
 
168
169
 
data/lib/nnq/engine.rb CHANGED
@@ -382,7 +382,7 @@ module NNQ
382
382
 
383
383
  @connections[conn].barrier.async(annotation: "nnq recv #{conn.endpoint}") do
384
384
  loop do
385
- body = conn.receive_message
385
+ body = conn.receive_message.freeze
386
386
  if @verbose_monitor
387
387
  preview = @routing.respond_to?(:preview_body) ? @routing.preview_body(body) : body
388
388
  emit_verbose_msg_received(preview)
data/lib/nnq/pair.rb CHANGED
@@ -10,7 +10,7 @@ module NNQ
10
10
  #
11
11
  class PAIR0 < Socket
12
12
  def send(body)
13
- body = frozen_binary(body)
13
+ body = coerce_binary(body)
14
14
  Reactor.run { @engine.routing.send(body) }
15
15
  end
16
16
 
data/lib/nnq/pub_sub.rb CHANGED
@@ -12,7 +12,7 @@ module NNQ
12
12
  #
13
13
  class PUB0 < Socket
14
14
  def send(body)
15
- body = frozen_binary(body)
15
+ body = coerce_binary(body)
16
16
  Reactor.run { @engine.routing.send(body) }
17
17
  end
18
18
 
data/lib/nnq/push_pull.rb CHANGED
@@ -11,7 +11,7 @@ module NNQ
11
11
  #
12
12
  class PUSH0 < Socket
13
13
  def send(body)
14
- body = frozen_binary(body)
14
+ body = coerce_binary(body)
15
15
  Reactor.run { @engine.routing.send(body) }
16
16
  end
17
17
 
data/lib/nnq/req_rep.rb CHANGED
@@ -18,7 +18,7 @@ module NNQ
18
18
  # raw mode — use {#send} / {#receive} there.
19
19
  def send_request(body)
20
20
  raise Error, "REQ#send_request not available in raw mode" if raw?
21
- body = frozen_binary(body)
21
+ body = coerce_binary(body)
22
22
  Reactor.run { @engine.routing.send_request(body) }
23
23
  end
24
24
 
@@ -29,7 +29,7 @@ module NNQ
29
29
  # cooked mode.
30
30
  def send(body, header:)
31
31
  raise Error, "REQ#send not available in cooked mode" unless raw?
32
- body = frozen_binary(body)
32
+ body = coerce_binary(body)
33
33
  Reactor.run { @engine.routing.send(body, header: header) }
34
34
  end
35
35
 
@@ -74,7 +74,7 @@ module NNQ
74
74
  # came from. Raises in raw mode.
75
75
  def send_reply(body)
76
76
  raise Error, "REP#send_reply not available in raw mode" if raw?
77
- body = frozen_binary(body)
77
+ body = coerce_binary(body)
78
78
  Reactor.run { @engine.routing.send_reply(body) }
79
79
  end
80
80
 
@@ -84,7 +84,7 @@ module NNQ
84
84
  # tuple). Silent drop if +to+ is closed. Raises in cooked mode.
85
85
  def send(body, to:, header:)
86
86
  raise Error, "REP#send not available in cooked mode" unless raw?
87
- body = frozen_binary(body)
87
+ body = coerce_binary(body)
88
88
  Reactor.run { @engine.routing.send(body, to: to, header: header) }
89
89
  end
90
90
 
@@ -27,7 +27,7 @@ module NNQ
27
27
  hops += 1
28
28
 
29
29
  if word.getbyte(0) & 0x80 != 0
30
- return [body.byteslice(0, offset), body.byteslice(offset..)]
30
+ return [body.byteslice(0, offset).freeze, body.byteslice(offset..).freeze]
31
31
  end
32
32
  end
33
33
 
@@ -71,7 +71,7 @@ module NNQ
71
71
  def enqueue(body, _conn)
72
72
  return if body.bytesize < 4
73
73
  id = body.unpack1("N")
74
- payload = body.byteslice(4..)
74
+ payload = body.byteslice(4..).freeze
75
75
 
76
76
  @mutex.synchronize do
77
77
  if @outstanding && @outstanding[0] == id
@@ -67,7 +67,7 @@ module NNQ
67
67
  return if body.bytesize < 4
68
68
 
69
69
  id = body.unpack1("N")
70
- payload = body.byteslice(4..)
70
+ payload = body.byteslice(4..).freeze
71
71
 
72
72
  @mutex.synchronize do
73
73
  return unless @current_id == id
@@ -84,7 +84,7 @@ module NNQ
84
84
  transform = lambda do |body|
85
85
  next nil if body.bytesize < 4
86
86
  id = body.unpack1("N")
87
- payload = body.byteslice(4..)
87
+ payload = body.byteslice(4..).freeze
88
88
  match = mutex.synchronize { @current_id == id }
89
89
  match ? payload : nil
90
90
  end
data/lib/nnq/socket.rb CHANGED
@@ -135,16 +135,21 @@ module NNQ
135
135
  end
136
136
 
137
137
 
138
- # Coerces +body+ to a frozen binary string. Called by every send
139
- # method so a caller can't mutate the string after it's been
140
- # enqueued (the body sits in a send queue or per-peer queue until
141
- # the pump writes it, and an unfrozen caller-owned buffer could be
142
- # appended to mid-flight).
138
+ # Coerces +body+ to a frozen `Encoding::BINARY`-tagged String and
139
+ # returns it. Every send method runs its body through this so the
140
+ # receiver sees a uniform frozen+BINARY contract across transports
141
+ # (mutation bugs raise `FrozenError` instead of silently corrupting
142
+ # a shared reference on the inproc fast path).
143
143
  #
144
- # Fast-path: already frozen + binary returned as-is.
145
- def frozen_binary(body)
146
- return body if body.frozen? && body.encoding == Encoding::BINARY
147
- body.b.freeze
144
+ # Fast-path: unfrozen non-BINARY strings are re-tagged in place
145
+ # (force_encoding is a flag flip, no copy). The pathological case
146
+ # of a frozen non-BINARY body (e.g. a `# frozen_string_literal: true`
147
+ # literal) can't be re-tagged in place — the inproc {Pipe} handles
148
+ # that with a copy so the receive contract stays uniform.
149
+ def coerce_binary(body)
150
+ body = body.to_str unless body.is_a?(String)
151
+ body.force_encoding(Encoding::BINARY) unless body.frozen? || body.encoding == Encoding::BINARY
152
+ body.freeze
148
153
  end
149
154
 
150
155
 
@@ -16,7 +16,7 @@ module NNQ
16
16
  # Cooked: broadcasts +body+ as a survey to all connected respondents.
17
17
  def send_survey(body)
18
18
  raise Error, "SURVEYOR#send_survey not available in raw mode" if raw?
19
- body = frozen_binary(body)
19
+ body = coerce_binary(body)
20
20
  Reactor.run { @engine.routing.send_survey(body) }
21
21
  end
22
22
 
@@ -26,7 +26,7 @@ module NNQ
26
26
  # protocol-sp header kwarg — no concat). Raises in cooked mode.
27
27
  def send(body, header:)
28
28
  raise Error, "SURVEYOR#send not available in cooked mode" unless raw?
29
- body = frozen_binary(body)
29
+ body = coerce_binary(body)
30
30
  Reactor.run { @engine.routing.send(body, header: header) }
31
31
  end
32
32
 
@@ -70,7 +70,7 @@ module NNQ
70
70
  # recent survey. Raises in raw mode.
71
71
  def send_reply(body)
72
72
  raise Error, "RESPONDENT#send_reply not available in raw mode" if raw?
73
- body = frozen_binary(body)
73
+ body = coerce_binary(body)
74
74
  Reactor.run { @engine.routing.send_reply(body) }
75
75
  end
76
76
 
@@ -78,7 +78,7 @@ module NNQ
78
78
  # Raw: writes +body+ with +header+ back to +to+. Raises in cooked mode.
79
79
  def send(body, to:, header:)
80
80
  raise Error, "RESPONDENT#send not available in cooked mode" unless raw?
81
- body = frozen_binary(body)
81
+ body = coerce_binary(body)
82
82
  Reactor.run { @engine.routing.send(body, to: to, header: header) }
83
83
  end
84
84
 
@@ -69,7 +69,14 @@ module NNQ
69
69
 
70
70
  def send_message(body, header: nil)
71
71
  raise ClosedError, "connection closed" if @closed
72
- wire = header ? header + body : body
72
+
73
+ # Socket#coerce_binary tags mutable bodies BINARY in place;
74
+ # the pathological case of a frozen non-BINARY body (e.g. a
75
+ # `# frozen_string_literal: true` literal) can't be re-tagged
76
+ # in place, so copy it here to keep the receiver contract
77
+ # uniform with TCP/IPC.
78
+ body = body.b.freeze if body.encoding != Encoding::BINARY
79
+ wire = header ? (header + body).freeze : body
73
80
 
74
81
  if (q = @direct_recv_queue)
75
82
  item = @direct_recv_transform ? @direct_recv_transform.call(wire) : wire
@@ -86,6 +93,10 @@ module NNQ
86
93
  def write_messages(bodies)
87
94
  raise ClosedError, "connection closed" if @closed
88
95
 
96
+ if bodies.any? { |b| b.encoding != Encoding::BINARY }
97
+ bodies = bodies.map { |b| b.encoding == Encoding::BINARY ? b : b.b.freeze }
98
+ end
99
+
89
100
  if (q = @direct_recv_queue)
90
101
  transform = @direct_recv_transform
91
102
  bodies.each do |body|
data/lib/nnq/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NNQ
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nnq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -41,16 +41,16 @@ dependencies:
41
41
  name: protocol-sp
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
- - - ">="
44
+ - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '0.3'
46
+ version: '0.4'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
- - - ">="
51
+ - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '0.3'
53
+ version: '0.4'
54
54
  description: Pure Ruby implementation of nanomsg's Scalability Protocols (SP) on top
55
55
  of async + io-stream. Per-socket HWM, opportunistic batching, wire-compatible with
56
56
  libnng over inproc/ipc/tcp.