nnq 0.4.0 → 0.6.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.
data/lib/nnq/engine.rb CHANGED
@@ -35,33 +35,44 @@ module NNQ
35
35
  # @return [Integer] our SP protocol id (e.g. Protocols::PUSH_V0)
36
36
  attr_reader :protocol
37
37
 
38
+
38
39
  # @return [Options]
39
40
  attr_reader :options
40
41
 
42
+
43
+ # @return [Routing strategy]
44
+ attr_reader :routing
45
+
46
+
41
47
  # @return [Hash{NNQ::Connection => ConnectionLifecycle}]
42
48
  attr_reader :connections
43
49
 
50
+
44
51
  # @return [SocketLifecycle]
45
52
  attr_reader :lifecycle
46
53
 
54
+
47
55
  # @return [String, nil]
48
56
  attr_reader :last_endpoint
49
57
 
58
+
50
59
  # @return [Async::Condition] signaled when a new pipe is registered
51
60
  attr_reader :new_pipe
52
61
 
62
+
53
63
  # @return [Set<String>] endpoints we have called #connect on; used
54
64
  # to decide whether to schedule a reconnect after a connection
55
65
  # is lost.
56
66
  attr_reader :dialed
57
67
 
58
- # @return [Array<Async::Task>] transient tasks owned by the engine
59
- # (currently just background reconnect loops). Stopped at #close.
60
- attr_reader :tasks
61
-
62
68
 
63
69
  # @return [Async::Queue, nil] monitor event queue (set by Socket#monitor)
64
70
  attr_accessor :monitor_queue
71
+
72
+
73
+ # @return [Boolean] when true, {#emit_verbose_monitor_event} forwards
74
+ # per-message traces (:message_sent / :message_received) to the
75
+ # monitor queue. Set by {Socket#monitor} via its +verbose:+ kwarg.
65
76
  attr_accessor :verbose_monitor
66
77
 
67
78
 
@@ -80,7 +91,6 @@ module NNQ
80
91
  @monitor_queue = nil
81
92
  @verbose_monitor = false
82
93
  @dialed = Set.new
83
- @tasks = []
84
94
  @routing = yield(self)
85
95
  end
86
96
 
@@ -93,32 +103,51 @@ module NNQ
93
103
  end
94
104
 
95
105
 
96
- # Emits a verbose-only monitor event. Only forwarded when
97
- # {Socket#monitor} was called with +verbose: true+.
98
- def emit_verbose_monitor_event(type, **detail)
106
+ # Emits a :message_sent verbose event. Early-returns before
107
+ # allocating the detail hash so the hot send path pays nothing
108
+ # when verbose monitoring is off.
109
+ def emit_verbose_msg_sent(body)
99
110
  return unless @verbose_monitor
100
- emit_monitor_event(type, detail: detail)
111
+ emit_monitor_event(:message_sent, detail: { body: body })
101
112
  end
102
113
 
103
114
 
104
- # @return [Routing strategy]
105
- attr_reader :routing
115
+ # Emits a :message_received verbose event. Same early-return
116
+ # discipline as {#emit_verbose_msg_sent}.
117
+ def emit_verbose_msg_received(body)
118
+ return unless @verbose_monitor
119
+ emit_monitor_event(:message_received, detail: { body: body })
120
+ end
106
121
 
107
122
 
108
123
  # @return [Async::Task, nil]
109
- def parent_task = @lifecycle.parent_task
124
+ def parent_task
125
+ @lifecycle.parent_task
126
+ end
110
127
 
111
128
 
112
- def closed? = @lifecycle.closed?
129
+ # @return [Async::Barrier, nil]
130
+ def barrier
131
+ @lifecycle.barrier
132
+ end
133
+
134
+
135
+ def closed?
136
+ @lifecycle.closed?
137
+ end
113
138
 
114
139
 
115
140
  # @return [Async::Promise] resolves with the first connected peer
116
- def peer_connected = @lifecycle.peer_connected
141
+ def peer_connected
142
+ @lifecycle.peer_connected
143
+ end
117
144
 
118
145
 
119
146
  # @return [Async::Promise] resolves when all peers have disconnected
120
147
  # (edge-triggered, after at least one peer connected)
121
- def all_peers_gone = @lifecycle.all_peers_gone
148
+ def all_peers_gone
149
+ @lifecycle.all_peers_gone
150
+ end
122
151
 
123
152
 
124
153
  # Called by ConnectionLifecycle teardown. Resolves `all_peers_gone`
@@ -129,7 +158,9 @@ module NNQ
129
158
 
130
159
 
131
160
  # @return [Boolean]
132
- def reconnect_enabled = @lifecycle.reconnect_enabled
161
+ def reconnect_enabled
162
+ @lifecycle.reconnect_enabled
163
+ end
133
164
 
134
165
 
135
166
  # Disables or re-enables automatic reconnect. nnq has no reconnect
@@ -159,7 +190,7 @@ module NNQ
159
190
  def bind(endpoint)
160
191
  transport = transport_for(endpoint)
161
192
  listener = transport.bind(endpoint, self)
162
- listener.start_accept_loop(@lifecycle.parent_task) do |io, framing = :tcp|
193
+ listener.start_accept_loop(@lifecycle.barrier) do |io, framing = :tcp|
163
194
  handle_accepted(io, endpoint: endpoint, framing: framing)
164
195
  end
165
196
  @listeners << listener
@@ -175,11 +206,12 @@ module NNQ
175
206
  def connect(endpoint)
176
207
  @dialed << endpoint
177
208
  @last_endpoint = endpoint
209
+
178
210
  if endpoint.start_with?("inproc://")
179
211
  transport_for(endpoint).connect(endpoint, self)
180
212
  else
181
213
  emit_monitor_event(:connect_delayed, endpoint: endpoint)
182
- Reconnect.schedule(endpoint, @options, @lifecycle.parent_task, self, delay: 0)
214
+ Reconnect.schedule(endpoint, @options, @lifecycle.barrier, self, delay: 0)
183
215
  end
184
216
  end
185
217
 
@@ -191,7 +223,7 @@ module NNQ
191
223
  return unless endpoint && @dialed.include?(endpoint)
192
224
  return unless @lifecycle.alive? && @lifecycle.reconnect_enabled
193
225
  return if endpoint.start_with?("inproc://")
194
- Reconnect.schedule(endpoint, @options, @lifecycle.parent_task, self)
226
+ Reconnect.schedule(endpoint, @options, @lifecycle.barrier, self)
195
227
  end
196
228
 
197
229
 
@@ -208,6 +240,7 @@ module NNQ
208
240
  lifecycle = ConnectionLifecycle.new(self, endpoint: endpoint, framing: framing)
209
241
  lifecycle.handshake!(io)
210
242
  spawn_recv_loop(lifecycle.conn) if @routing.respond_to?(:enqueue) && @connections.key?(lifecycle.conn)
243
+ lifecycle.start_supervisor!
211
244
  rescue ConnectionRejected
212
245
  # routing rejected this peer (e.g. PAIR already bonded) — lifecycle cleaned up
213
246
  rescue => e
@@ -220,16 +253,19 @@ module NNQ
220
253
  lifecycle = ConnectionLifecycle.new(self, endpoint: endpoint, framing: framing)
221
254
  lifecycle.handshake!(io)
222
255
  spawn_recv_loop(lifecycle.conn) if @routing.respond_to?(:enqueue) && @connections.key?(lifecycle.conn)
256
+ lifecycle.start_supervisor!
223
257
  rescue ConnectionRejected
224
258
  # unusual on connect side, but handled identically
225
259
  end
226
260
 
227
261
 
228
- # Spawns a task under the socket's parent task. Used by routing
229
- # strategies (e.g. PUSH send pump) to attach long-lived fibers to
230
- # the engine's lifecycle without going through transient: true.
231
- def spawn_task(annotation:, &block)
232
- @lifecycle.parent_task.async(annotation: annotation, &block)
262
+ # Spawns a task under the given parent barrier (defaults to the
263
+ # socket-level barrier). Used by routing strategies (e.g. PUSH send
264
+ # pump) to attach long-lived fibers to the engine's lifecycle. The
265
+ # parent barrier tracks every spawned task so teardown is a single
266
+ # barrier.stop call.
267
+ def spawn_task(annotation:, parent: @lifecycle.barrier, &block)
268
+ parent.async(annotation: annotation, &block)
233
269
  end
234
270
 
235
271
 
@@ -240,17 +276,22 @@ module NNQ
240
276
  # to abort with IOError.
241
277
  def close
242
278
  return unless @lifecycle.alive?
279
+
243
280
  @lifecycle.start_closing!
244
281
  @listeners.each(&:stop)
245
- @tasks.each { |t| t.stop rescue nil }
246
- @tasks.clear
247
282
  drain_send_queue(@options.linger)
248
283
  @routing.close if @routing.respond_to?(:close)
284
+
249
285
  # Tear down each remaining connection via its lifecycle. The
250
286
  # collection mutates during iteration, so snapshot the values.
251
287
  @connections.values.each(&:close!)
288
+
289
+ # Cascade-cancel every remaining task (reconnect loops, accept
290
+ # loops, supervisors) in one shot.
291
+ @lifecycle.barrier&.stop
252
292
  @lifecycle.finish_closing!
253
293
  @new_pipe.signal
294
+
254
295
  # Unblock anyone waiting on peer_connected when the socket is
255
296
  # closed before a peer ever arrived.
256
297
  @lifecycle.peer_connected.resolve(nil) unless @lifecycle.peer_connected.resolved?
@@ -268,6 +309,7 @@ module NNQ
268
309
 
269
310
  private
270
311
 
312
+
271
313
  def close_monitor_queue
272
314
  return unless @monitor_queue
273
315
  @monitor_queue.enqueue(nil)
@@ -277,26 +319,31 @@ module NNQ
277
319
  def drain_send_queue(timeout)
278
320
  return unless @routing.respond_to?(:send_queue_drained?)
279
321
  return if @connections.empty?
322
+
280
323
  deadline = timeout ? Async::Clock.now + timeout : nil
324
+
281
325
  until @routing.send_queue_drained?
282
326
  break if deadline && (deadline - Async::Clock.now) <= 0
283
327
  sleep 0.001
284
328
  end
329
+ rescue Async::Stop
330
+ # Parent task is being cancelled — stop draining and let close
331
+ # proceed with the rest of teardown instead of propagating the
332
+ # cancellation out of the ensure path.
285
333
  end
286
334
 
287
335
 
288
336
  def spawn_recv_loop(conn)
289
- @lifecycle.parent_task.async(annotation: "nnq recv #{conn.endpoint}") do
337
+ @connections[conn].barrier.async(annotation: "nnq recv #{conn.endpoint}") do
290
338
  loop do
291
339
  body = conn.receive_message
292
- emit_verbose_monitor_event(:message_received, body: body)
340
+ emit_verbose_msg_received(body)
293
341
  @routing.enqueue(body, conn)
294
342
  rescue *CONNECTION_LOST, Async::Stop
295
343
  break
296
344
  end
297
- ensure
298
- handle_connection_lost(conn)
299
345
  end
300
346
  end
347
+
301
348
  end
302
349
  end
data/lib/nnq/error.rb CHANGED
@@ -1,10 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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
4
+ class Error < RuntimeError
5
+ end
6
+
7
+
8
+ class ClosedError < Error
9
+ end
10
+
11
+
12
+ class ProtocolError < Error
13
+ end
14
+
15
+
16
+ class TimeoutError < Error
17
+ end
18
+
19
+
20
+ class RequestCancelled < Error
21
+ end
22
+
23
+
24
+ class ConnectionRejected < Error
25
+ end
26
+
27
+
28
+ class TimedOut < Error
29
+ end
10
30
  end
@@ -11,6 +11,8 @@ module NNQ
11
11
  # @return [Hash, nil] extra context
12
12
  #
13
13
  MonitorEvent = Data.define(:type, :endpoint, :detail) do
14
- def initialize(type:, endpoint: nil, detail: nil) = super
14
+ def initialize(type:, endpoint: nil, detail: nil)
15
+ super
16
+ end
15
17
  end
16
18
  end
data/lib/nnq/options.rb CHANGED
@@ -18,14 +18,23 @@ module NNQ
18
18
  attr_accessor :reconnect_interval
19
19
  attr_accessor :max_message_size
20
20
  attr_accessor :send_hwm
21
+ attr_accessor :recv_hwm
22
+ attr_accessor :survey_time
21
23
 
22
- def initialize(linger: nil, send_hwm: DEFAULT_HWM)
24
+
25
+ # @param linger [Numeric] linger period in seconds on close
26
+ # (default Float::INFINITY = wait forever, matching libzmq).
27
+ # Pass 0 for immediate drop-on-close.
28
+ def initialize(linger: Float::INFINITY, send_hwm: DEFAULT_HWM, recv_hwm: DEFAULT_HWM)
23
29
  @linger = linger
24
30
  @read_timeout = nil
25
31
  @write_timeout = nil
26
32
  @reconnect_interval = 0.1
27
33
  @max_message_size = nil
28
34
  @send_hwm = send_hwm
35
+ @recv_hwm = recv_hwm
36
+ @survey_time = 1.0
29
37
  end
38
+
30
39
  end
31
40
  end
data/lib/nnq/pair.rb CHANGED
@@ -8,8 +8,9 @@ module NNQ
8
8
  # peer. First peer to connect wins; subsequent peers are dropped
9
9
  # until the current one disconnects. No SP header on the wire.
10
10
  #
11
- class PAIR < Socket
11
+ class PAIR0 < Socket
12
12
  def send(body)
13
+ body = frozen_binary(body)
13
14
  Reactor.run { @engine.routing.send(body) }
14
15
  end
15
16
 
@@ -21,6 +22,7 @@ module NNQ
21
22
 
22
23
  private
23
24
 
25
+
24
26
  def protocol
25
27
  Protocol::SP::Protocols::PAIR_V0
26
28
  end
@@ -30,4 +32,7 @@ module NNQ
30
32
  Routing::Pair.new(engine)
31
33
  end
32
34
  end
35
+
36
+
37
+ PAIR = PAIR0
33
38
  end
data/lib/nnq/pub_sub.rb CHANGED
@@ -10,14 +10,16 @@ module NNQ
10
10
  # a slow peer drops messages instead of blocking fast peers.
11
11
  # Defaults to listening.
12
12
  #
13
- class PUB < Socket
13
+ class PUB0 < Socket
14
14
  def send(body)
15
+ body = frozen_binary(body)
15
16
  Reactor.run { @engine.routing.send(body) }
16
17
  end
17
18
 
18
19
 
19
20
  private
20
21
 
22
+
21
23
  def protocol
22
24
  Protocol::SP::Protocols::PUB_V0
23
25
  end
@@ -34,7 +36,7 @@ module NNQ
34
36
  # are delivered — matching nng (unlike pre-4.x ZeroMQ). Defaults
35
37
  # to dialing.
36
38
  #
37
- class SUB < Socket
39
+ class SUB0 < Socket
38
40
  # Subscribes to +prefix+. Bytes-level match. The empty string
39
41
  # matches everything.
40
42
  #
@@ -60,6 +62,7 @@ module NNQ
60
62
 
61
63
  private
62
64
 
65
+
63
66
  def protocol
64
67
  Protocol::SP::Protocols::SUB_V0
65
68
  end
@@ -69,4 +72,8 @@ module NNQ
69
72
  Routing::Sub.new
70
73
  end
71
74
  end
75
+
76
+
77
+ PUB = PUB0
78
+ SUB = SUB0
72
79
  end
data/lib/nnq/push_pull.rb CHANGED
@@ -9,14 +9,16 @@ module NNQ
9
9
  # bounded send queue (`send_hwm`); per-peer send pumps work-steal from
10
10
  # it. Defaults to dialing.
11
11
  #
12
- class PUSH < Socket
12
+ class PUSH0 < Socket
13
13
  def send(body)
14
+ body = frozen_binary(body)
14
15
  Reactor.run { @engine.routing.send(body) }
15
16
  end
16
17
 
17
18
 
18
19
  private
19
20
 
21
+
20
22
  def protocol
21
23
  Protocol::SP::Protocols::PUSH_V0
22
24
  end
@@ -32,7 +34,7 @@ module NNQ
32
34
  # from all live PUSH peers into one unbounded receive queue. Defaults
33
35
  # to listening.
34
36
  #
35
- class PULL < Socket
37
+ class PULL0 < Socket
36
38
  def receive
37
39
  Reactor.run do
38
40
  if (timeout = @engine.options.read_timeout)
@@ -46,6 +48,7 @@ module NNQ
46
48
 
47
49
  private
48
50
 
51
+
49
52
  def protocol
50
53
  Protocol::SP::Protocols::PULL_V0
51
54
  end
@@ -55,4 +58,8 @@ module NNQ
55
58
  Routing::Pull.new
56
59
  end
57
60
  end
61
+
62
+
63
+ PUSH = PUSH0
64
+ PULL = PULL0
58
65
  end
data/lib/nnq/reactor.rb CHANGED
@@ -22,11 +22,14 @@ module NNQ
22
22
  @root_task = nil
23
23
  @work_queue = nil
24
24
 
25
+
25
26
  class << self
26
27
  def root_task
27
28
  return @root_task if @root_task
29
+
28
30
  @mutex.synchronize do
29
31
  return @root_task if @root_task
32
+
30
33
  ready = Thread::Queue.new
31
34
  @work_queue = Async::Queue.new
32
35
  @thread = Thread.new { run_reactor(ready) }
@@ -34,6 +37,7 @@ module NNQ
34
37
  @root_task = ready.pop
35
38
  at_exit { stop! }
36
39
  end
40
+
37
41
  @root_task
38
42
  end
39
43
 
@@ -42,12 +46,10 @@ module NNQ
42
46
  if Async::Task.current?
43
47
  yield
44
48
  else
45
- result = Thread::Queue.new
49
+ result = Async::Promise.new
46
50
  root_task # ensure started
47
51
  @work_queue.push([block, result])
48
- status, value = result.pop
49
- raise value if status == :error
50
- value
52
+ result.wait
51
53
  end
52
54
  end
53
55
 
@@ -61,23 +63,22 @@ module NNQ
61
63
  @work_queue = nil
62
64
  end
63
65
 
66
+
64
67
  private
65
68
 
69
+
66
70
  def run_reactor(ready)
67
71
  Async do |task|
68
72
  ready.push(task)
73
+
69
74
  loop do
70
- item = @work_queue.dequeue
71
- break if item.nil?
75
+ item = @work_queue.dequeue or break
72
76
  block, result = item
73
- task.async do
74
- result.push([:ok, block.call])
75
- rescue => e
76
- result.push([:error, e])
77
- end
77
+ task.async { result.fulfill { block.call } }
78
78
  end
79
79
  end
80
80
  end
81
+
81
82
  end
82
83
  end
83
84
  end
data/lib/nnq/req_rep.rb CHANGED
@@ -3,58 +3,106 @@
3
3
  require_relative "socket"
4
4
  require_relative "routing/req"
5
5
  require_relative "routing/rep"
6
+ require_relative "routing/req_raw"
7
+ require_relative "routing/rep_raw"
6
8
 
7
9
  module NNQ
8
- # REQ (nng req0): client side of request/reply. Single in-flight
9
- # request per socket. #send_request blocks until the matching reply
10
- # comes back.
10
+ # REQ (nng req0): client side of request/reply. Cooked mode keeps a
11
+ # single in-flight request and matches replies by id; raw mode bypasses
12
+ # the state machine entirely and delivers replies as
13
+ # `[pipe, header, body]` tuples so the app can correlate verbatim.
11
14
  #
12
- class REQ < Socket
13
- # Sends +body+ as a request, blocks until the matching reply
14
- # arrives. Returns the reply body (without the id header).
15
+ class REQ0 < Socket
16
+ # Cooked: sends +body+ as a request, blocks until the matching reply
17
+ # arrives. Returns the reply body (without the id header). Raises in
18
+ # raw mode — use {#send} / {#receive} there.
15
19
  def send_request(body)
20
+ raise Error, "REQ#send_request not available in raw mode" if raw?
21
+ body = frozen_binary(body)
16
22
  Reactor.run { @engine.routing.send_request(body) }
17
23
  end
18
24
 
19
25
 
26
+ # Raw: round-robins +body+ to the next connected peer with
27
+ # +header+ (typically `[id | 0x80000000].pack("N")`) written
28
+ # verbatim between the SP length prefix and the body. Raises in
29
+ # cooked mode.
30
+ def send(body, header:)
31
+ raise Error, "REQ#send not available in cooked mode" unless raw?
32
+ body = frozen_binary(body)
33
+ Reactor.run { @engine.routing.send(body, header: header) }
34
+ end
35
+
36
+
37
+ # Raw: blocks until the next reply arrives, returns
38
+ # `[pipe, header, body]`. Raises in cooked mode.
39
+ def receive
40
+ raise Error, "REQ#receive not available in cooked mode" unless raw?
41
+ Reactor.run { @engine.routing.receive }
42
+ end
43
+
44
+
20
45
  private
21
46
 
47
+
22
48
  def protocol
23
49
  Protocol::SP::Protocols::REQ_V0
24
50
  end
25
51
 
26
52
 
27
53
  def build_routing(engine)
28
- Routing::Req.new(engine)
54
+ raw? ? Routing::ReqRaw.new(engine) : Routing::Req.new(engine)
29
55
  end
30
56
  end
31
57
 
32
58
 
33
- # REP (nng rep0): server side of request/reply. Strict alternation
34
- # of #receive then #send_reply, per request.
59
+ # REP (nng rep0): server side of request/reply. Cooked mode strictly
60
+ # alternates #receive / #send_reply and stashes the backtrace
61
+ # internally; raw mode exposes the backtrace as an opaque +header+ and
62
+ # the originating pipe as a live Connection, so the app can drive the
63
+ # reply protocol itself (e.g. proxy/device use cases).
35
64
  #
36
- class REP < Socket
37
- # Blocks until the next request arrives. Returns the request body.
65
+ class REP0 < Socket
66
+ # Cooked: returns the next request body. Raw: returns
67
+ # `[pipe, header, body]`.
38
68
  def receive
39
69
  Reactor.run { @engine.routing.receive }
40
70
  end
41
71
 
42
72
 
43
- # Routes +body+ back to the pipe the most recent #receive came from.
73
+ # Cooked: routes +body+ back to the pipe the most recent #receive
74
+ # came from. Raises in raw mode.
44
75
  def send_reply(body)
76
+ raise Error, "REP#send_reply not available in raw mode" if raw?
77
+ body = frozen_binary(body)
45
78
  Reactor.run { @engine.routing.send_reply(body) }
46
79
  end
47
80
 
48
81
 
82
+ # Raw: writes +body+ with +header+ (the opaque backtrace handed out
83
+ # by a prior #receive) back to +to+ (the Connection from the same
84
+ # tuple). Silent drop if +to+ is closed. Raises in cooked mode.
85
+ def send(body, to:, header:)
86
+ raise Error, "REP#send not available in cooked mode" unless raw?
87
+ body = frozen_binary(body)
88
+ Reactor.run { @engine.routing.send(body, to: to, header: header) }
89
+ end
90
+
91
+
49
92
  private
50
93
 
94
+
51
95
  def protocol
52
96
  Protocol::SP::Protocols::REP_V0
53
97
  end
54
98
 
55
99
 
56
100
  def build_routing(engine)
57
- Routing::Rep.new(engine)
101
+ raw? ? Routing::RepRaw.new(engine) : Routing::Rep.new(engine)
58
102
  end
59
103
  end
104
+
105
+
106
+ REQ = REQ0
107
+ REP = REP0
60
108
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ module Routing
5
+ # Shared backtrace parsing for SP protocols that use the
6
+ # request-id / hop-stack wire format (REQ/REP, SURVEYOR/RESPONDENT).
7
+ #
8
+ # Wire format: one or more 4-byte big-endian words. The terminal
9
+ # word (request or survey id) has its high bit set (0x80). Preceding
10
+ # words (hop ids added by devices) have the high bit clear.
11
+ #
12
+ module Backtrace
13
+ MAX_HOPS = 8 # nng's default ttl
14
+
15
+ # Reads 4-byte BE words off the front of +body+, stopping at the
16
+ # first one whose top byte has its high bit set. Returns
17
+ # [backtrace_bytes, remaining_payload] or nil on malformed input.
18
+ def parse_backtrace(body)
19
+ offset = 0
20
+ hops = 0
21
+
22
+ while hops < MAX_HOPS
23
+ return nil if body.bytesize - offset < 4
24
+
25
+ word = body.byteslice(offset, 4)
26
+ offset += 4
27
+ hops += 1
28
+
29
+ if word.getbyte(0) & 0x80 != 0
30
+ return [body.byteslice(0, offset), body.byteslice(offset..)]
31
+ end
32
+ end
33
+
34
+ nil # exceeded TTL without finding terminator
35
+ end
36
+
37
+
38
+ # Raw-mode TTL check: returns true if +header+ contains at least
39
+ # MAX_HOPS 4-byte words (i.e. forwarding it would push total hops
40
+ # over the cap). Cheap: just bytesize arithmetic.
41
+ def self.too_many_hops?(header)
42
+ header.bytesize >= MAX_HOPS * 4
43
+ end
44
+
45
+ end
46
+ end
47
+ end