nats-pure 2.5.0 → 2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3dbc568f99c60cbe6a338d5aca7d9311e22624400cdb16e35b06d14c029815d8
4
- data.tar.gz: 0f5108f668ccf94894e827896dc0cdd7c3b3c0026130e2ed05c7808d933b931a
3
+ metadata.gz: 7d5c22758be6db2c05382a55c718c2c4f836a2c723f77820d4728bbf1538f30b
4
+ data.tar.gz: 1745fb8cfa406b598099779a027a3cc3dcc4cee28c22fd7c08e1b156b6ce4a5b
5
5
  SHA512:
6
- metadata.gz: bb75b0dba73b2f0bf83c591e6416d67bc8722f583a1170bf83fc40eb536b598439e701c7b32335d3d1050efbbe3f34f865e4a8d7259bcd35a9f5247348453281
7
- data.tar.gz: 8362a52a4309761638939795d66ef007f58856d19f3971110488ef3ee7b08f7e14077eaf237749571a744fa5c8eef0b36bb822e1a7ac8f9440503a9e6b245010
6
+ metadata.gz: 84b049092f60f34c3ca6b5a8ac2b4334a1a32dbaefd2659424f91db3472df105604bbddd4989793a56757a9cdee1c710db6b52fc02e40b95f64bc0b0eae21dd6
7
+ data.tar.gz: 38585616a5820faefe491c8c16d815bc52759e0cd2b7f77ab5b3a0227d3728720c4f1ac56d6d2af6354c2c53e54245ab48ce71c2a063efe3fe737e60e21c6af7
data/CHANGELOG.md CHANGED
@@ -1,3 +1,30 @@
1
1
  # Change log
2
2
 
3
3
  ## main
4
+
5
+ ## v2.6.0
6
+
7
+ This release prepares nats-pure for nats-server 2.16, whose new JetStream
8
+ acknowledgement format breaks JetStream consumers and KV watchers in earlier
9
+ versions, and fixes a deadlock that could hang `Client#close`.
10
+
11
+ ### Changed
12
+
13
+ - With `reconnect: false`, losing the connection now closes the client, as nats.go does: the status becomes `CLOSED`, the close callback is called, and `publish` raises `NATS::IO::ConnectionClosedError`. It used to stay `DISCONNECTED` and keep accepting publishes that were never sent, because the close, started from the read loop, killed the read loop part way through. (#189)
14
+
15
+ ### Fixed
16
+
17
+ - JetStream: accept the 11-token acknowledgement subjects that nats-server 2.16 sends by default (the v2 format of ADR-15). Earlier versions fail to read the metadata of every JetStream message from such a server, which breaks pull and push consumers and KV watchers. (#185)
18
+ - JetStream: an unrecognized acknowledgement subject raises `NATS::JetStream::Error::NotJSMessage` instead of `NameError`, and subjects whose numeric fields do not parse are rejected. (#185)
19
+ - JetStream: message metadata timestamps keep their full nanosecond precision. (#185)
20
+ - KV: watchers track their position from message metadata, so they resume after a server restart with either acknowledgement format. (#185)
21
+ - `Client#close` could hang forever. It stopped the client's background threads with `Thread#exit`, and killing a thread that is waiting for the client lock can lose Ruby's mutex wakeup, leaving `close` asleep on an unlocked lock. The threads are now asked to stop and joined, on close and on reconnect. (#183, #189)
22
+ - A close while a reconnect was under way did not stick: the reconnect carried on, connected again and restarted the client, subscriptions included. (#189)
23
+ - A subscription with a concurrency limit could run its callbacks in parallel, and so out of order: an empty pending queue released the concurrency permit twice. (#186)
24
+ - A subscription could stop processing messages for good after a reconnect: shutting down the executor during dispatch killed the read loop and leaked the concurrency permit. (#182)
25
+ - WebSocket: protocol lines sharing a frame, such as the `PONG` and `INFO` that nats-server 2.12.2 sends together, were misparsed (#174, thanks @artyomb), and bytes that arrived with the reply to the connect handshake were lost. (#182, #186)
26
+ - JRuby: a refused connection raises `Errno::ECONNREFUSED`, as on MRI. (#182, #186)
27
+
28
+ ### Improved
29
+
30
+ - Tested against nats-server 2.14, 2.15 and the upcoming 2.16 (`main`), on Ruby 3.0 to 3.4 and JRuby. (#182, #188)
@@ -221,6 +221,10 @@ module NATS
221
221
  # Track whether connect has been already been called.
222
222
  @connect_called = false
223
223
 
224
+ # Bumped by every close, so that a reconnect started earlier can tell
225
+ # that the connection was closed meanwhile.
226
+ @close_generation = 0
227
+
224
228
  # New style request/response implementation.
225
229
  @resp_sub = nil
226
230
  @resp_map = nil
@@ -1293,6 +1297,11 @@ module NATS
1293
1297
  # Handles errors from reading, parsing the protocol or stale connection.
1294
1298
  # the lock should not be held entering this function.
1295
1299
  def process_op_error(e)
1300
+ # A background thread being stopped by close or a reconnect sees
1301
+ # errors from that; it must not start another close or reconnect,
1302
+ # which would wait for the thread that is stopping it.
1303
+ return if stopping?
1304
+
1296
1305
  should_bail = synchronize do
1297
1306
  connecting? || closed? || reconnecting?
1298
1307
  end
@@ -1320,23 +1329,29 @@ module NATS
1320
1329
 
1321
1330
  def initiate_reconnect
1322
1331
  @status = RECONNECTING
1323
- @io&.close
1332
+ old_io = @io
1324
1333
  @io = nil
1325
1334
 
1335
+ # Wake a read loop blocked on the socket by shutting it down, and
1336
+ # close the socket only once the loop has stopped: on JRuby a close
1337
+ # right after the shutdown can leave the read blocked for good.
1338
+ if @read_loop_thread&.alive? && @read_loop_thread != Thread.current
1339
+ old_io&.shutdown_read
1340
+ end
1341
+
1326
1342
  # TODO: Reconnecting pending buffer?
1327
1343
 
1344
+ generation = @close_generation
1345
+
1328
1346
  # Do reconnect under a different thread than the one
1329
1347
  # in which we got the error.
1330
1348
  Thread.new do
1331
- # Abort currently running reads in case they're around
1332
- # FIXME: There might be more graceful way here...
1333
- @read_loop_thread.exit if @read_loop_thread.alive?
1334
- @flusher_thread.exit if @flusher_thread.alive?
1335
- @ping_interval_thread.exit if @ping_interval_thread.alive?
1349
+ stop_threads!
1350
+ old_io&.close
1336
1351
 
1337
1352
  @subscription_executor.shutdown
1338
1353
 
1339
- attempt_reconnect
1354
+ attempt_reconnect(generation)
1340
1355
  rescue NATS::IO::NoServersError => e
1341
1356
  @last_err = e
1342
1357
  close
@@ -1346,6 +1361,8 @@ module NATS
1346
1361
  # Gathers data from the socket and sends it to the parser.
1347
1362
  def read_loop
1348
1363
  loop do
1364
+ return if stopping?
1365
+
1349
1366
  should_bail = synchronize do
1350
1367
  # FIXME: In case of reconnect as well?
1351
1368
  @status == CLOSED or @status == RECONNECTING
@@ -1361,6 +1378,9 @@ module NATS
1361
1378
  # FIXME: We do not really need a timeout here...
1362
1379
  retry
1363
1380
  rescue => e
1381
+ # Asked to stop: the error comes from the socket being shut down.
1382
+ return if stopping?
1383
+
1364
1384
  # In case of reading/parser errors, trigger
1365
1385
  # reconnection logic in case desired.
1366
1386
  process_op_error(e)
@@ -1373,6 +1393,7 @@ module NATS
1373
1393
  loop do
1374
1394
  # Blocks waiting for the flusher to be kicked...
1375
1395
  @flush_queue.pop
1396
+ return if stopping?
1376
1397
 
1377
1398
  should_bail = synchronize do
1378
1399
  (@status != CONNECTED && !draining?) || @status == CONNECTING
@@ -1411,9 +1432,9 @@ module NATS
1411
1432
  end
1412
1433
  end
1413
1434
 
1414
- def ping_interval_loop
1435
+ def ping_interval_loop(stop)
1415
1436
  loop do
1416
- sleep @options[:ping_interval]
1437
+ return if stop.wait(@options[:ping_interval])
1417
1438
 
1418
1439
  # Skip ping interval until connected
1419
1440
  next if !connected?
@@ -1486,8 +1507,11 @@ module NATS
1486
1507
  end
1487
1508
  end
1488
1509
 
1489
- # Reconnect logic, this is done while holding the lock.
1490
- def attempt_reconnect
1510
+ # Reconnect logic. generation is @close_generation from when the
1511
+ # reconnect started; a close since then cancels the reconnect.
1512
+ def attempt_reconnect(generation)
1513
+ return if closed_since?(generation)
1514
+
1491
1515
  @disconnect_cb&.call(@last_err)
1492
1516
 
1493
1517
  # Clear sticky error
@@ -1496,6 +1520,8 @@ module NATS
1496
1520
  # Do reconnect
1497
1521
  srv = nil
1498
1522
  begin
1523
+ return if closed_since?(generation)
1524
+
1499
1525
  srv = select_next_server
1500
1526
 
1501
1527
  # Set hostname to use for TLS hostname verification
@@ -1537,37 +1563,49 @@ module NATS
1537
1563
  retry
1538
1564
  end
1539
1565
 
1540
- # Clear pending flush calls and reset state before restarting loops
1541
- @flush_queue.clear
1542
- @pings_outstanding = 0
1543
- @pongs_received = 0
1566
+ # Under the lock, so that a concurrent close either cancels this or
1567
+ # finds the new connection in place and closes it.
1568
+ synchronize do
1569
+ if @close_generation != generation
1570
+ @io.close
1571
+ @io = nil
1572
+ return
1573
+ end
1544
1574
 
1545
- # Replay all subscriptions
1546
- @subs.each_pair do |sid, sub|
1547
- @io.write("SUB #{sub.subject} #{sub.queue} #{sid}#{CR_LF}")
1548
- end
1575
+ # Clear pending flush calls and reset state before restarting loops
1576
+ @flush_queue.clear
1577
+ @pings_outstanding = 0
1578
+ @pongs_received = 0
1549
1579
 
1550
- # Flush anything which was left pending, in case of errors during flush
1551
- # then we should raise error then retry the reconnect logic
1552
- cmds = []
1553
- cmds << @pending_queue.pop until @pending_queue.empty?
1554
- @io.write(cmds.join) unless cmds.empty?
1555
- @status = CONNECTED
1556
- @pending_size = 0
1580
+ # Replay all subscriptions
1581
+ @subs.each_pair do |sid, sub|
1582
+ @io.write("SUB #{sub.subject} #{sub.queue} #{sid}#{CR_LF}")
1583
+ end
1557
1584
 
1558
- # Reset parser state here to avoid unknown protocol errors
1559
- # on reconnect...
1560
- @parser.reset!
1585
+ # Flush anything which was left pending, in case of errors during flush
1586
+ # then we should raise error then retry the reconnect logic
1587
+ cmds = []
1588
+ cmds << @pending_queue.pop until @pending_queue.empty?
1589
+ @io.write(cmds.join) unless cmds.empty?
1590
+ @status = CONNECTED
1591
+ @pending_size = 0
1561
1592
 
1562
- # Now connected to NATS, and we can restart parser loop, flusher
1563
- # and ping interval
1564
- start_threads!
1593
+ # Reset parser state here to avoid unknown protocol errors
1594
+ # on reconnect...
1595
+ @parser.reset!
1596
+
1597
+ # Now connected to NATS, and we can restart parser loop, flusher
1598
+ # and ping interval
1599
+ start_threads!
1600
+ end
1565
1601
 
1566
- # Dispatch the reconnected callback while holding lock
1567
- # which we should have already
1568
1602
  @reconnect_cb&.call
1569
1603
  end
1570
1604
 
1605
+ def closed_since?(generation)
1606
+ synchronize { @close_generation != generation }
1607
+ end
1608
+
1571
1609
  def close_connection(conn_status, do_cbs = true)
1572
1610
  synchronize do
1573
1611
  @connect_called = false
@@ -1575,25 +1613,10 @@ module NATS
1575
1613
  @status = conn_status
1576
1614
  return
1577
1615
  end
1616
+ @close_generation += 1
1578
1617
  end
1579
1618
 
1580
- # Kick the flusher so it bails due to closed state
1581
- @flush_queue << :fallout if @flush_queue
1582
- Thread.pass
1583
-
1584
- # FIXME: More graceful way of handling the following?
1585
- # Ensure ping interval and flusher are not running anymore
1586
- if @ping_interval_thread&.alive?
1587
- @ping_interval_thread.exit
1588
- end
1589
-
1590
- if @flusher_thread&.alive?
1591
- @flusher_thread.exit
1592
- end
1593
-
1594
- if @read_loop_thread&.alive?
1595
- @read_loop_thread.exit
1596
- end
1619
+ stop_threads!
1597
1620
 
1598
1621
  @subscription_executor&.shutdown
1599
1622
  @subscription_executor&.wait_for_termination(options[:close_timeout])
@@ -1642,21 +1665,58 @@ module NATS
1642
1665
  end
1643
1666
  end
1644
1667
 
1668
+ # Asks the read loop, flusher and ping threads to stop, wakes them from
1669
+ # wherever they block, and waits for them to finish.
1670
+ #
1671
+ # They are never killed: Thread#exit on a thread that is waiting for
1672
+ # the client lock can swallow the lock's wakeup, so that the next
1673
+ # thread waiting for the lock sleeps forever on an unlocked lock (#183).
1674
+ def stop_threads!(timeout = NATS::IO::THREADS_STOP_TIMEOUT)
1675
+ threads = [@read_loop_thread, @flusher_thread, @ping_interval_thread].compact
1676
+ threads.each { |t| t[:nats_stop]&.stop! } # also wakes the ping thread
1677
+
1678
+ begin
1679
+ @flush_queue&.push(:fallout, true)
1680
+ rescue ThreadError
1681
+ # Queue is full, so the flusher is awake already.
1682
+ end
1683
+ # Wake the read loop if it is blocked on the socket. Only while it
1684
+ # runs: shutdown acts on the socket itself, which a forked child
1685
+ # (whose threads are gone) shares with the parent.
1686
+ if @read_loop_thread&.alive? && @read_loop_thread != Thread.current
1687
+ @io&.shutdown_read
1688
+ end
1689
+
1690
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
1691
+ threads.each do |t|
1692
+ next if t == Thread.current
1693
+
1694
+ t.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
1695
+ end
1696
+ end
1697
+
1698
+ def stopping?
1699
+ Thread.current[:nats_stop]&.stopped?
1700
+ end
1701
+
1702
+ def start_thread(name, stop, &block)
1703
+ thread = Thread.new(&block)
1704
+ thread[:nats_stop] = stop
1705
+ thread.name = name
1706
+ thread.abort_on_exception = true
1707
+ thread
1708
+ end
1709
+
1645
1710
  def start_threads!
1646
1711
  # Reading loop for gathering data
1647
- @read_loop_thread = Thread.new { read_loop }
1648
- @read_loop_thread.name = "nats:read_loop"
1649
- @read_loop_thread.abort_on_exception = true
1712
+ @read_loop_thread = start_thread("nats:read_loop", NATS::IO::StopSignal.new) { read_loop }
1650
1713
 
1651
1714
  # Flusher loop for sending commands
1652
- @flusher_thread = Thread.new { flusher_loop }
1653
- @flusher_thread.name = "nats:flusher_loop"
1654
- @flusher_thread.abort_on_exception = true
1715
+ @flusher_thread = start_thread("nats:flusher_loop", NATS::IO::StopSignal.new) { flusher_loop }
1655
1716
 
1656
1717
  # Ping interval handling for keeping alive the connection
1657
- @ping_interval_thread = Thread.new { ping_interval_loop }
1658
- @ping_interval_thread.name = "nats:ping_loop"
1659
- @ping_interval_thread.abort_on_exception = true
1718
+ ping_stop = NATS::IO::StopSignal.new
1719
+ @ping_interval_thread = start_thread("nats:ping_loop", ping_stop) { ping_interval_loop(ping_stop) }
1660
1720
 
1661
1721
  # Subscription handling thread pool
1662
1722
  @subscription_executor = Concurrent::ThreadPoolExecutor.new(
@@ -1889,6 +1949,44 @@ module NATS
1889
1949
  DEFAULT_DRAIN_TIMEOUT = 30
1890
1950
  DEFAULT_CLOSE_TIMEOUT = 30
1891
1951
 
1952
+ # How long to wait for the read loop, flusher and ping threads to
1953
+ # stop on close or reconnect.
1954
+ THREADS_STOP_TIMEOUT = 5
1955
+
1956
+ # Asks a background thread to stop, and lets it sleep until then.
1957
+ class StopSignal
1958
+ def initialize
1959
+ @mutex = Mutex.new
1960
+ @cond = ConditionVariable.new
1961
+ @stopped = false
1962
+ end
1963
+
1964
+ def stop!
1965
+ @mutex.synchronize do
1966
+ @stopped = true
1967
+ @cond.broadcast
1968
+ end
1969
+ end
1970
+
1971
+ def stopped?
1972
+ @stopped
1973
+ end
1974
+
1975
+ # Sleeps up to timeout seconds; returns whether stop! was called.
1976
+ def wait(timeout)
1977
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
1978
+ @mutex.synchronize do
1979
+ until @stopped
1980
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
1981
+ break if left <= 0
1982
+
1983
+ @cond.wait(@mutex, left)
1984
+ end
1985
+ @stopped
1986
+ end
1987
+ end
1988
+ end
1989
+
1892
1990
  # Default Pending Limits
1893
1991
  DEFAULT_SUB_PENDING_MSGS_LIMIT = 65536
1894
1992
  DEFAULT_SUB_PENDING_BYTES_LIMIT = 65536 * 1024
@@ -2004,6 +2102,15 @@ module NATS
2004
2102
  @socket.close
2005
2103
  end
2006
2104
 
2105
+ # Makes a read blocked on the socket in another thread return,
2106
+ # while writes keep working.
2107
+ def shutdown_read
2108
+ io = @socket.respond_to?(:to_io) ? @socket.to_io : @socket
2109
+ io.shutdown(::Socket::SHUT_RD)
2110
+ rescue IOError, SystemCallError
2111
+ # Already closed or not connected.
2112
+ end
2113
+
2007
2114
  def closed?
2008
2115
  @socket.closed?
2009
2116
  end
@@ -2030,6 +2137,12 @@ module NATS
2030
2137
  end
2031
2138
 
2032
2139
  sock
2140
+ rescue IOError => e
2141
+ # JRuby raises a plain IOError for a refused connection.
2142
+ if (RUBY_ENGINE == "jruby") && e.message.include?("Connection refused")
2143
+ raise Errno::ECONNREFUSED
2144
+ end
2145
+ raise
2033
2146
  end
2034
2147
  end
2035
2148
  end
@@ -231,7 +231,7 @@ module NATS
231
231
 
232
232
  data = req.to_json
233
233
  if params[:direct]
234
- if params[:subject] && !(params[:seq])
234
+ if params[:subject] && !params[:seq]
235
235
  # last_by_subject type request requires no payload.
236
236
  data = ""
237
237
  req_subject = "#{@prefix}.DIRECT.GET.#{stream_name}.#{params[:subject]}"
@@ -43,15 +43,19 @@ module NATS
43
43
  Timestamp = 9
44
44
  NumPending = 10
45
45
 
46
+ # Positions of fields that must be unsigned integers (ADR-15).
47
+ NumericTokens = [NumDelivered, StreamSeq, ConsumerSeq, Timestamp, NumPending].freeze
48
+ Digits = /\A\d+\z/
49
+
46
50
  # Subject without domain:
47
51
  # $JS.ACK.<stream>.<consumer>.<delivered>.<sseq>.<cseq>.<tm>.<pending>
48
52
  #
49
53
  V1TokenCounts = 9
50
54
 
51
- # Subject with domain:
52
- # $JS.ACK.<domain>.<account hash>.<stream>.<consumer>.<delivered>.<sseq>.<cseq>.<tm>.<pending>.<a token with a random value>
55
+ # Subject with domain, 11 or more tokens; the server may append more (ADR-15):
56
+ # $JS.ACK.<domain>.<account hash>.<stream>.<consumer>.<delivered>.<sseq>.<cseq>.<tm>.<pending>
53
57
  #
54
- V2TokenCounts = 12
58
+ V2TokenCounts = 11
55
59
 
56
60
  SequencePair = Struct.new(:stream, :consumer)
57
61
 
@@ -95,9 +95,9 @@ module NATS
95
95
  n = tokens.count
96
96
 
97
97
  if n < Ack::V1TokenCounts || ((n > Ack::V1TokenCounts) && (n < Ack::V2TokenCounts))
98
- raise NotJSMessage.new("nats: not a jetstream message")
98
+ raise JetStream::Error::NotJSMessage.new("nats: not a jetstream message")
99
99
  elsif tokens[0] != Ack::Prefix0 || tokens[1] != Ack::Prefix1
100
- raise NotJSMessage.new("nats: not a jetstream message")
100
+ raise JetStream::Error::NotJSMessage.new("nats: not a jetstream message")
101
101
  elsif n == Ack::V1TokenCounts
102
102
  tokens.insert(Ack::Domain, Ack::Empty)
103
103
  tokens.insert(Ack::AccHash, Ack::Empty)
@@ -105,6 +105,10 @@ module NATS
105
105
  tokens[Ack::Domain] = Ack::Empty
106
106
  end
107
107
 
108
+ unless Ack::NumericTokens.all? { |i| Ack::Digits.match?(tokens[i]) }
109
+ raise JetStream::Error::NotJSMessage.new("nats: not a jetstream message")
110
+ end
111
+
108
112
  Metadata.new(tokens)
109
113
  end
110
114
  end
@@ -27,7 +27,8 @@ module NATS
27
27
  @domain = opts[Ack::Domain]
28
28
  @num_delivered = opts[Ack::NumDelivered].to_i
29
29
  @num_pending = opts[Ack::NumPending].to_i
30
- @timestamp = Time.at((opts[Ack::Timestamp].to_i / 1_000_000_000.0))
30
+ nanos = opts[Ack::Timestamp].to_i
31
+ @timestamp = Time.at(nanos / 1_000_000_000, nanos % 1_000_000_000, :nsec)
31
32
  @stream = opts[Ack::Stream]
32
33
  @consumer = opts[Ack::Consumer]
33
34
  # TODO: Not exposed in Go client either right now.
@@ -305,7 +305,7 @@ module NATS
305
305
  # If attempting to bind, then this is a hard error.
306
306
  raise e if params[:stream] && !multi_filter
307
307
 
308
- config = if !(params[:config])
308
+ config = if !params[:config]
309
309
  JetStream::API::ConsumerConfig.new
310
310
  elsif params[:config].is_a?(JetStream::API::ConsumerConfig)
311
311
  params[:config]
data/lib/nats/io/kv.rb CHANGED
@@ -31,7 +31,7 @@ module NATS
31
31
  ROLLUP = "Nats-Rollup"
32
32
 
33
33
  VALID_BUCKET_RE = /\A[a-zA-Z0-9_-]+$/
34
- VALID_KEY_RE = /\A[-\/_=\.a-zA-Z0-9]+$/
34
+ VALID_KEY_RE = /\A[-\/_=.a-zA-Z0-9]+$/
35
35
 
36
36
  class << self
37
37
  def is_valid_key(key)
@@ -273,7 +273,7 @@ module NATS
273
273
  nc = @js.nc
274
274
  watcher = KeyWatcher.new(@js)
275
275
 
276
- deliver_policy = if !(params[:include_history])
276
+ deliver_policy = if !params[:include_history]
277
277
  "last_per_subject"
278
278
  end
279
279
 
@@ -327,15 +327,9 @@ module NATS
327
327
  meta = msg.metadata
328
328
  watcher.synchronize { watcher._active = true }
329
329
  # Track the sequences
330
- #
331
- # $JS.ACK.KV_TEST.CKRGrWpf.1.10.10.1739859923871837000.0
332
- #
333
- tokens = msg.reply.split(".")
334
- sseq = tokens[5]
335
- dseq = tokens[6]
336
330
  watcher.synchronize do
337
- watcher._dseq = dseq.to_i + 1
338
- watcher._sseq = sseq.to_i
331
+ watcher._dseq = meta.sequence.consumer + 1
332
+ watcher._sseq = meta.sequence.stream
339
333
  end
340
334
 
341
335
  # Keys() handling
@@ -465,6 +459,7 @@ module NATS
465
459
  class KeyWatcher
466
460
  include MonitorMixin
467
461
  include Enumerable
462
+
468
463
  attr_accessor :received, :pending, :_sub, :_updates, :_init_done, :_watcher_cond
469
464
  attr_accessor :_sseq, :_dseq, :_active, :_hb_task
470
465
 
@@ -17,7 +17,7 @@
17
17
  module NATS
18
18
  module Protocol
19
19
  MSG = /\AMSG\s+([^\s]+)\s+([^\s]+)\s+(([^\s]+)[^\S\r\n]+)?(\d+)\r\n/i
20
- HMSG = /\AHMSG\s+([^\s]+)\s+([^\s]+)\s+(([^\s]+)[^\S\r\n]+)?([\d]+)\s+(\d+)\r\n/i
20
+ HMSG = /\AHMSG\s+([^\s]+)\s+([^\s]+)\s+(([^\s]+)[^\S\r\n]+)?(\d+)\s+(\d+)\r\n/i
21
21
  OK = /\A\+OK\s*\r\n/i
22
22
  ERR = /\A-ERR\s+('.+')?\r\n/i
23
23
  PING = /\APING\s*\r\n/i
@@ -98,12 +98,12 @@ module NATS
98
98
  return unless @needed && @buf.bytesize >= (@needed + CR_LF_SIZE)
99
99
  if @header_needed
100
100
  hbuf = @buf.slice(0, @header_needed)
101
- payload = @buf.slice(@header_needed, (@needed - @header_needed))
101
+ payload = @buf.slice(@header_needed, @needed - @header_needed)
102
102
  @nc.send(:process_msg, @sub, @sid, @reply, payload, hbuf)
103
103
  else
104
104
  @nc.send(:process_msg, @sub, @sid, @reply, @buf.slice(0, @needed), nil)
105
105
  end
106
- @buf = @buf.slice((@needed + CR_LF_SIZE), @buf.bytesize)
106
+ @buf = @buf.slice(@needed + CR_LF_SIZE, @buf.bytesize)
107
107
 
108
108
  @sub = @sid = @reply = @needed = @header_needed = nil
109
109
  @parse_state = AWAITING_CONTROL_LINE
@@ -148,13 +148,20 @@ module NATS
148
148
  msg = pending_queue.pop(true)
149
149
  process(msg)
150
150
  rescue ThreadError # queue is empty
151
- concurrency_semaphore.release
151
+ # No release here: the ensure below releases the permit; a second
152
+ # release would grow the semaphore beyond processing_concurrency.
152
153
  ensure
153
154
  concurrency_semaphore.release
154
155
  [concurrency_semaphore.available_permits, pending_queue.size].min.times do
155
156
  enqueue_processing(executor)
156
157
  end
157
158
  end
159
+ rescue Concurrent::RejectedExecutionError
160
+ # The executor is being shut down (the connection is closing,
161
+ # draining or reconnecting). Release the permit so the
162
+ # subscription can process messages again after a reconnect;
163
+ # the message stays in pending_queue.
164
+ concurrency_semaphore.release
158
165
  end
159
166
  end
160
167
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright 2016-2025 The NATS Authors
3
+ # Copyright 2016-2026 The NATS Authors
4
4
  # Licensed under the Apache License, Version 2.0 (the "License");
5
5
  # you may not use this file except in compliance with the License.
6
6
  # You may obtain a copy of the License at
@@ -17,7 +17,7 @@
17
17
  module NATS
18
18
  module IO
19
19
  # VERSION is the version of the client announced on CONNECT to the server.
20
- VERSION = "2.5.0"
20
+ VERSION = "2.6.0"
21
21
 
22
22
  # LANG is the lang runtime of the client announced on CONNECT to the server.
23
23
  LANG = "#{RUBY_ENGINE}#{RUBY_VERSION}".freeze
@@ -47,23 +47,28 @@ module NATS
47
47
  end
48
48
 
49
49
  def read(max_bytes = MAX_SOCKET_READ_BYTES, deadline = nil)
50
- data = super
51
- @frame << data
52
- result = []
53
- while (msg = @frame.next)
54
- result << msg
50
+ # Hand over what read_line decoded beyond its last line first,
51
+ # otherwise those bytes are lost once the read loop takes over.
52
+ if @line_buf && !@line_buf.empty?
53
+ return @line_buf.slice!(0, @line_buf.bytesize)
55
54
  end
56
- result.join
55
+
56
+ read_frames(max_bytes, deadline)
57
57
  end
58
58
 
59
59
  def read_line(deadline = nil)
60
- data = super
61
- @frame << data
62
- result = []
63
- while (msg = @frame.next)
64
- result << msg
60
+ @line_buf ||= +""
61
+ loop do
62
+ if (idx = @line_buf =~ /\r?\n/)
63
+ return @line_buf.slice!(0, idx + Regexp.last_match(0).length)
64
+ end
65
+
66
+ # Pull more data from the wire; never from @line_buf itself,
67
+ # or a partial line would be fed back forever.
68
+ data = read_frames(MAX_SOCKET_READ_BYTES, deadline)
69
+ return nil unless data
70
+ @line_buf << data
65
71
  end
66
- result.join
67
72
  end
68
73
 
69
74
  def write(data, deadline = nil)
@@ -72,6 +77,21 @@ module NATS
72
77
  frame = ::WebSocket::Frame::Outgoing::Client.new(data: data, type: :binary, version: @handshake.version)
73
78
  super(frame.to_s)
74
79
  end
80
+
81
+ private
82
+
83
+ # Reads from the socket and returns the payloads of all complete
84
+ # frames decoded so far.
85
+ def read_frames(max_bytes, deadline)
86
+ data = Socket.instance_method(:read).bind_call(self, max_bytes, deadline)
87
+ @frame << data
88
+ [].tap do |parts|
89
+ while (msg = @frame.next)
90
+ payload = msg.respond_to?(:data) ? msg.data : msg
91
+ parts << payload if payload
92
+ end
93
+ end.join
94
+ end
75
95
  end
76
96
  end
77
97
  end
@@ -6,8 +6,8 @@ module NATS
6
6
  REGEX = {
7
7
  name: /[A-Za-z0-9\-_]+$/,
8
8
  version: /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,
9
- subject: /^[^ >]*[>]?$/,
10
- queue: /^[^ >]*[>]?$/
9
+ subject: /^[^ >]*>?$/,
10
+ queue: /^[^ >]*>?$/
11
11
  }.freeze
12
12
 
13
13
  class << self
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nats-pure
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.0
4
+ version: 2.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Waldemar Quevedo
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-02-21 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: concurrent-ruby
@@ -267,7 +266,6 @@ metadata:
267
266
  documentation_uri: https://github.com/nats-io/nats-pure.rb
268
267
  homepage_uri: https://github.com/nats-io/nats-pure.rb
269
268
  source_code_uri: https://github.com/nats-io/nats-pure.rb
270
- post_install_message:
271
269
  rdoc_options: []
272
270
  require_paths:
273
271
  - lib
@@ -282,8 +280,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
282
280
  - !ruby/object:Gem::Version
283
281
  version: '0'
284
282
  requirements: []
285
- rubygems_version: 3.5.11
286
- signing_key:
283
+ rubygems_version: 4.0.21
287
284
  specification_version: 4
288
285
  summary: NATS is an open-source, high-performance, lightweight cloud messaging system.
289
286
  test_files: []