nwc-ruby 0.2.0 → 0.2.2

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: 97f270975d59347524e31943926149e2d361833a5ab9dd7c580cf00281b5fec9
4
- data.tar.gz: 99b7a6b3a40c8c0f7ca30408f34afb653fab4c5afb8c69327e79be3a00afa663
3
+ metadata.gz: b847b05f148d03753548528e0f4483129cf59887e40329f28e7bbe31f0eda1cd
4
+ data.tar.gz: 9c99704df81a6e7cfa9c1bfede014b49270b15bf11a3bba8663b5d6f1f3500fa
5
5
  SHA512:
6
- metadata.gz: 96bc19c5c7ee1ac4794c76be6aa9e64b9aeddf7d89d5a4df88134104f07e65a40c1466efa54ffea9df546d55a92bb06c27e2ee1e8397d3c9459272bca6065526
7
- data.tar.gz: 20f46cf66670993db5c8fa351e2bc95b611c7ad994dd7d64495927ffac5347c8b9a1a8767cb19dd16a2deb2dea869086ae8ae606a13211f55f3546156526afd8
6
+ metadata.gz: 11e62747c2dfa88459d900a2e1f89e7a42df5420b88221508bf62df39b65ebe7f9937db357557b2ab3ee8b95be0b50b5062fa3667cf21002dbf607366f49675d
7
+ data.tar.gz: c0ff4c40f5d8fa68d3b12e6b7c745ff1ff5b19da5719c0998c8cc5db6098ee77917c2c914b0ca96dc9e9301ac4240595f7e472019e6001394c996e75ffdcff6a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.2] — 2026-04-21
11
+
12
+ ### Fixed
13
+
14
+ - Ctrl+C / SIGINT / SIGTERM now reliably exits the notification listener. The
15
+ previous trap handler called `@conn.close` directly, which deadlocks because
16
+ closing an SSL socket from inside a Ruby signal handler is unsafe
17
+ (`docker stop` was the only way out).
18
+ - Replaced the trap-based close with a self-pipe: the trap only flips `@stop`
19
+ and writes one byte to a pipe; an `Async` task reads from the pipe inside the
20
+ reactor and calls `top.stop` from the correct fiber context. This avoids two
21
+ further async-specific pitfalls discovered along the way — `Async::Task#stop`
22
+ raises `ThreadError: can't be called from trap context` (uses a Mutex), and
23
+ offloading to `Thread.new { task.stop }` fails with `NoMethodError` on
24
+ `Fiber.scheduler` because the reactor lives on a different thread.
25
+ - Added `rescue Interrupt, Async::Stop` at the top of the reconnect-loop rescue
26
+ chain in `RelayConnection#run!` so the stop signal is not swallowed by the
27
+ generic reconnect-on-error path.
28
+
29
+ ## [0.2.1] — 2026-04-20
30
+
31
+ ### Fixed
32
+
33
+ - Fixed all RuboCop offenses (CI now passes clean).
34
+ - Extracted `start_heartbeat` / `start_poll` from `run_one_connection` to reduce
35
+ method length and perceived complexity.
36
+ - Corrected README and CHANGELOG: transport uses 15 s ping keepalive (not 30 s
37
+ ping + 45 s pong deadline — the pong deadline was removed in 0.1.1 due to
38
+ async-websocket 0.30 API changes).
39
+ - Fixed string quoting, hash alignment, guard clause, and line length offenses
40
+ across Rakefile, `bin/nwc_test`, `client.rb`, `test_runner.rb`, and
41
+ `relay_connection.rb`.
42
+ - Excluded `lib/nwc-ruby.rb` from `Naming/FileName` (compatibility shim).
43
+
10
44
  ## [0.2.0] — 2026-04-20
11
45
 
12
46
  ### Changed
@@ -52,9 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
52
86
  `multi_pay_keysend`, `make_invoice`, `lookup_invoice`, `list_transactions`,
53
87
  `get_balance`, `get_info`, `sign_message`.
54
88
  - Notification listener (kinds 23196 and 23197) with dedupe by `payment_hash`.
55
- - Reliable long-running `Transport::RelayConnection`: RFC 6455 ping (30 s),
56
- pong deadline (45 s), forced recycle (5 min), capped exponential backoff,
57
- SIGTERM/SIGINT handling.
89
+ - Reliable long-running `Transport::RelayConnection`: RFC 6455 ping (15 s),
90
+ forced recycle (5 min), capped exponential backoff, SIGTERM/SIGINT handling.
58
91
  - `NwcRuby.test` and `NwcRuby.test_notifications` diagnostic methods, backed
59
92
  by `NwcRuby::TestRunner`. Announces read-only vs read+write, exercises every
60
93
  advertised method, pays a Lightning address if the code is read+write.
data/README.md CHANGED
@@ -34,8 +34,8 @@ heartbeats, zombie-TCP detection, reconnects, and backoff. You call methods.
34
34
  - **Both encryption schemes** — NIP-44 v2 when the wallet advertises it
35
35
  (validated against [paulmillr's test vectors][vectors]), NIP-04 fallback for
36
36
  wallets that haven't migrated.
37
- - **Bulletproof long-running transport** — 30 s ping, 45 s pong deadline, 5-min
38
- forced recycle, capped exponential backoff, clean SIGTERM handling. Built on
37
+ - **Bulletproof long-running transport** — 15 s ping keepalive, 5-min forced
38
+ recycle, capped exponential backoff, clean SIGTERM handling. Built on
39
39
  [async-websocket][aw] (no dead EventMachine dependency).
40
40
  - **Two diagnostic methods** — `NwcRuby.test` (info, read tests, write test
41
41
  if applicable) and `NwcRuby.test_notifications` (listen forever in a separate
@@ -211,10 +211,10 @@ end
211
211
  ```
212
212
 
213
213
  Under the hood, this subscribes to both kind 23196 (NIP-04) and kind 23197
214
- (NIP-44 v2), dedupes by `payment_hash`, sends a WebSocket ping every 30 seconds,
215
- reconnects with exponential backoff if the pong deadline is missed, and
216
- force-recycles the connection every 5 minutes as a belt-and-suspenders check
217
- against middleboxes that silently drop stale TCP streams.
214
+ (NIP-44 v2), dedupes by `payment_hash`, sends a WebSocket ping every 15 seconds
215
+ to keep middleboxes from idle-closing the socket, reconnects with capped
216
+ exponential backoff on failure, and force-recycles the connection every 5 minutes
217
+ as a belt-and-suspenders check against silently dead TCP streams.
218
218
 
219
219
  #### Resuming after a restart
220
220
 
@@ -143,7 +143,7 @@ module NwcRuby
143
143
  # @param kinds [Array<Integer>] notification kinds to listen for. Defaults
144
144
  # to both NIP-04 (23196) and NIP-44 v2 (23197). The listener dedupes by
145
145
  # `payment_hash`, so receiving both is safe.
146
- def subscribe_to_notifications(since: Time.now.to_i,
146
+ def subscribe_to_notifications(since: Time.now.to_i, # rubocop:disable Metrics/MethodLength
147
147
  kinds: [NIP47::Methods::KIND_NOTIFICATION_NIP04,
148
148
  NIP47::Methods::KIND_NOTIFICATION_NIP44],
149
149
  sub_id: "nwc-#{SecureRandom.hex(4)}",
@@ -168,7 +168,10 @@ module NwcRuby
168
168
  @notification_connection = conn
169
169
 
170
170
  conn.on_open do |c|
171
- @logger.info("[nwc] subscribing sub_id=#{sub_id} client_pubkey=#{@connection_string.client_pubkey} wallet_pubkey=#{@connection_string.wallet_pubkey} kinds=#{kinds.inspect} since=#{since}")
171
+ @logger.info(
172
+ "[nwc] subscribing sub_id=#{sub_id} client_pubkey=#{@connection_string.client_pubkey} " \
173
+ "wallet_pubkey=#{@connection_string.wallet_pubkey} kinds=#{kinds.inspect} since=#{since}"
174
+ )
172
175
  c.send_req(sub_id: sub_id, filters: filters)
173
176
  end
174
177
 
@@ -56,8 +56,8 @@ module NwcRuby
56
56
 
57
57
  if @pay_to_lightning_address
58
58
  if @info.read_only?
59
- warn!("pay_to_lightning_address was provided but this is a READ-ONLY code — it does not support pay_invoice.")
60
- warn!(" → Generate a read+write NWC code if you need to test outbound payments.")
59
+ warn!('pay_to_lightning_address was provided but this is a READ-ONLY code — it does not support pay_invoice.')
60
+ warn!(' → Generate a read+write NWC code if you need to test outbound payments.')
61
61
  @out.puts
62
62
  else
63
63
  run_write_tests
@@ -205,7 +205,10 @@ module NwcRuby
205
205
  try('lookup_invoice (payment_hash from previous step)', NIP47::Methods::LOOKUP_INVOICE) do
206
206
  result = @client.lookup_invoice(payment_hash: @test_payment_hash)
207
207
  @out.puts " #{DIM}state=#{result['state']} amount=#{result['amount']} msats#{CLR}"
208
- warn!("lookup_invoice: `state` is #{result['state'].inspect}, expected 'pending' for a fresh invoice (optional per NIP-47)") unless result['state'] == 'pending'
208
+ unless result['state'] == 'pending'
209
+ warn!("lookup_invoice: `state` is #{result['state'].inspect}, " \
210
+ "expected 'pending' for a fresh invoice (optional per NIP-47)")
211
+ end
209
212
  end
210
213
  end
211
214
 
@@ -252,8 +255,12 @@ module NwcRuby
252
255
  fail!('make_invoice: `payment_hash` is not a 64-char hex')
253
256
  end
254
257
  fail!('make_invoice: `amount` should echo 1000') unless result['amount'] == 1_000
255
- warn!("make_invoice: `type` is #{result['type'].inspect}, expected 'incoming' (optional per NIP-47)") unless result['type'] == 'incoming'
256
- warn!("make_invoice: `state` is #{result['state'].inspect}, expected 'pending' (optional per NIP-47)") unless result['state'] == 'pending'
258
+ unless result['type'] == 'incoming'
259
+ warn!("make_invoice: `type` is #{result['type'].inspect}, expected 'incoming' (optional per NIP-47)")
260
+ end
261
+ return if result['state'] == 'pending'
262
+
263
+ warn!("make_invoice: `state` is #{result['state'].inspect}, expected 'pending' (optional per NIP-47)")
257
264
  end
258
265
 
259
266
  # --- Lightning address resolver ----------------------------------------
@@ -63,14 +63,19 @@ module NwcRuby
63
63
 
64
64
  def stop!
65
65
  @stop = true
66
- # Close the websocket to unblock the read loop. The @stop flag
67
- # will cause the run loop to exit on its own. We avoid calling
68
- # @top_task.stop here because stop! may be called from a
69
- # different thread than the Async reactor.
70
- begin
71
- @conn&.close
72
- rescue StandardError
73
- nil
66
+ # Poke the signal pipe if available (works from any thread); the
67
+ # watcher task will call @top_task.stop from inside the reactor.
68
+ # If we're already inside the reactor thread/fiber, we can stop
69
+ # the top task directly.
70
+ if @signal_pipe_w
71
+ begin
72
+ @signal_pipe_w.write_nonblock('.')
73
+ rescue IO::WaitWritable, Errno::EPIPE, IOError
74
+ nil
75
+ end
76
+ else
77
+ task = @top_task
78
+ task&.stop
74
79
  end
75
80
  end
76
81
 
@@ -82,10 +87,15 @@ module NwcRuby
82
87
 
83
88
  Async do |top|
84
89
  @top_task = top
90
+ signal_watcher = start_signal_watcher(top)
85
91
  until @stop
86
92
  begin
87
93
  run_one_connection(top)
88
94
  backoff = 1
95
+ rescue Interrupt, Async::Stop
96
+ # Ctrl-C / SIGTERM / task.stop: exit cleanly.
97
+ @stop = true
98
+ break
89
99
  rescue StandardError => e
90
100
  break if @stop
91
101
 
@@ -97,7 +107,12 @@ module NwcRuby
97
107
  backoff *= 2
98
108
  end
99
109
  end
110
+ rescue Interrupt
111
+ # Signal arrived while not inside a connection; just exit.
112
+ @stop = true
100
113
  ensure
114
+ signal_watcher&.stop
115
+ close_signal_pipe
101
116
  @top_task = nil
102
117
  end
103
118
  end
@@ -135,39 +150,8 @@ module NwcRuby
135
150
 
136
151
  Async::WebSocket::Client.connect(endpoint) do |conn|
137
152
  @conn = conn
138
-
139
- heartbeat = top.async do
140
- loop do
141
- conn.send_ping
142
- conn.flush
143
- @logger.debug("[nwc] ping sent")
144
-
145
- sleep @ping_interval
146
- break if @stop
147
-
148
- if Async::Clock.now - opened_at > @recycle_interval
149
- raise TransportError, "recycle (#{@recycle_interval}s)"
150
- end
151
- end
152
- end
153
-
154
- # Some relays (e.g. strfry) store ephemeral events temporarily but
155
- # do not push them to active subscriptions. Periodic re-subscribe
156
- # forces the relay to return any newly-stored events.
157
- poll = if @poll_interval && @poll_cb
158
- top.async do
159
- loop do
160
- sleep @poll_interval
161
- break if @stop
162
-
163
- begin
164
- @poll_cb.call(self)
165
- rescue StandardError => e
166
- @logger.warn("[nwc] poll error: #{e.message}")
167
- end
168
- end
169
- end
170
- end
153
+ heartbeat = start_heartbeat(top, conn, opened_at)
154
+ poll = start_poll(top)
171
155
 
172
156
  @open_cb&.call(self)
173
157
  read_loop(conn)
@@ -183,6 +167,38 @@ module NwcRuby
183
167
  end
184
168
  end
185
169
 
170
+ def start_heartbeat(top, conn, opened_at)
171
+ top.async do
172
+ loop do
173
+ conn.send_ping
174
+ conn.flush
175
+ @logger.debug('[nwc] ping sent')
176
+
177
+ sleep @ping_interval
178
+ break if @stop
179
+
180
+ raise TransportError, "recycle (#{@recycle_interval}s)" if Async::Clock.now - opened_at > @recycle_interval
181
+ end
182
+ end
183
+ end
184
+
185
+ def start_poll(top)
186
+ return unless @poll_interval && @poll_cb
187
+
188
+ top.async do
189
+ loop do
190
+ sleep @poll_interval
191
+ break if @stop
192
+
193
+ begin
194
+ @poll_cb.call(self)
195
+ rescue StandardError => e
196
+ @logger.warn("[nwc] poll error: #{e.message}")
197
+ end
198
+ end
199
+ end
200
+ end
201
+
186
202
  def read_loop(conn)
187
203
  while (message = conn.read)
188
204
  break if @stop
@@ -218,18 +234,51 @@ module NwcRuby
218
234
  end
219
235
 
220
236
  def install_traps
237
+ # Keep signal handlers tiny. We can't do much from inside a Ruby
238
+ # signal handler:
239
+ # - SSL I/O / socket close can deadlock.
240
+ # - Async::Task#stop takes a Mutex, which raises
241
+ # "can't be called from trap context (ThreadError)".
242
+ # - Thread.new { task.stop } runs outside the reactor and
243
+ # Fiber.scheduler is nil there.
244
+ # So: flip a flag and poke a self-pipe. An Async task watches the
245
+ # read end of the pipe and calls @top_task.stop from inside the
246
+ # reactor, which is the only safe place to do it.
247
+ @signal_pipe_r, @signal_pipe_w = IO.pipe
221
248
  %w[TERM INT].each do |sig|
222
249
  trap(sig) do
223
250
  @stop = true
224
- # Close the socket to unblock the read loop.
225
251
  begin
226
- @conn&.close
227
- rescue StandardError # rubocop:disable Lint/SuppressedException
252
+ @signal_pipe_w.write_nonblock('.')
253
+ rescue IO::WaitWritable, Errno::EPIPE, IOError
254
+ nil
228
255
  end
229
256
  end
230
257
  end
231
258
  end
232
259
 
260
+ def start_signal_watcher(top)
261
+ return unless @signal_pipe_r
262
+
263
+ top.async do
264
+ @signal_pipe_r.read(1)
265
+ @logger.debug('[nwc] signal received, stopping')
266
+ top.stop
267
+ rescue IOError, Errno::EBADF
268
+ nil
269
+ end
270
+ end
271
+
272
+ def close_signal_pipe
273
+ [@signal_pipe_r, @signal_pipe_w].each do |io|
274
+ io&.close
275
+ rescue IOError
276
+ nil
277
+ end
278
+ @signal_pipe_r = nil
279
+ @signal_pipe_w = nil
280
+ end
281
+
233
282
  def default_logger
234
283
  logger = Logger.new($stdout)
235
284
  logger.level = ENV['NWC_LOG_LEVEL'] ? Logger.const_get(ENV['NWC_LOG_LEVEL'].upcase) : Logger::INFO
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NwcRuby
4
- VERSION = '0.2.0'
4
+ VERSION = '0.2.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nwc-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - MegalithicBTC
@@ -222,7 +222,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
222
222
  - !ruby/object:Gem::Version
223
223
  version: '0'
224
224
  requirements: []
225
- rubygems_version: 3.6.9
225
+ rubygems_version: 3.6.8
226
226
  specification_version: 4
227
227
  summary: Ruby client for Nostr Wallet Connect (NIP-47) with safe long-running relay
228
228
  connections.