nwc-ruby 0.2.1 → 0.2.3

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: d5d73701179764ad26d1ad95d212e1abd5856788a951591e3f8489b16e66d0f0
4
- data.tar.gz: 97047cb484ab707715cd0ce4590d356a9b0e23a307729c808f0098f043c94dba
3
+ metadata.gz: d541ad9af31629ad26492d8da61d56b0a63980cc8597a460cca9949df56f382d
4
+ data.tar.gz: 8d48fbbe532bdcf56d889f68f9d7878fa2958381c1ea285fb572aebb70d17d7e
5
5
  SHA512:
6
- metadata.gz: f7bfd6655a81a49a9c04e534a872be4be1438af498b77f452a14706feb8535b9421745cc4f15c488339532019e1f94fb0b56d14c55a565b440f6232fea067b74
7
- data.tar.gz: 8af52b0b23683c1c62e0d64f4c02597c82c9aa64ae277da7d7099208453ba596db58b3762abdb728a08c2d063013befc39f8c699c39ce4e66aa921c77e90a839
6
+ metadata.gz: bbc5e4a3efdac9b24a4a2c5fc183f3945ba9987fd5312c78a7e8c4ba8cff625af5c08521094cc10101fd24a0bef5f35261eb29720d533d7e0167a236ee59d106
7
+ data.tar.gz: fb2c7a1ed8d7785a886b38e250432466354b257cef1e4c8552ef24c493c304dbd446bf4c17e57debcce8bc6875666686c16f901d29474ef08541194e6e2a97f3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Fixed
11
+
12
+ - Silenced the noisy `Async::Task` warn log ("Task may have ended with
13
+ unhandled exception") that was emitted every `recycle_interval` (default
14
+ 5 min) by the heartbeat task. The forced recycle used to be signaled by
15
+ raising `TransportError` from inside the heartbeat's child Async task; the
16
+ parent's reconnect loop caught it correctly, but Async's console logger
17
+ logged the unhandled-in-child exception with a full backtrace first.
18
+ The recycle is now signaled by setting an `@recycle_requested` flag and
19
+ closing the websocket, which unblocks the read loop and lets
20
+ `run_one_connection` return normally without any exception crossing a task
21
+ boundary.
22
+
23
+ ## [0.2.2] — 2026-04-21
24
+
25
+ ### Fixed
26
+
27
+ - Ctrl+C / SIGINT / SIGTERM now reliably exits the notification listener. The
28
+ previous trap handler called `@conn.close` directly, which deadlocks because
29
+ closing an SSL socket from inside a Ruby signal handler is unsafe
30
+ (`docker stop` was the only way out).
31
+ - Replaced the trap-based close with a self-pipe: the trap only flips `@stop`
32
+ and writes one byte to a pipe; an `Async` task reads from the pipe inside the
33
+ reactor and calls `top.stop` from the correct fiber context. This avoids two
34
+ further async-specific pitfalls discovered along the way — `Async::Task#stop`
35
+ raises `ThreadError: can't be called from trap context` (uses a Mutex), and
36
+ offloading to `Thread.new { task.stop }` fails with `NoMethodError` on
37
+ `Fiber.scheduler` because the reactor lives on a different thread.
38
+ - Added `rescue Interrupt, Async::Stop` at the top of the reconnect-loop rescue
39
+ chain in `RelayConnection#run!` so the stop signal is not swallowed by the
40
+ generic reconnect-on-error path.
41
+
10
42
  ## [0.2.1] — 2026-04-20
11
43
 
12
44
  ### Fixed
data/README.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  A production-grade Ruby client for [Nostr Wallet Connect (NIP-47)][nip47].
4
4
 
5
+ Accept and track Bitcoin Lightning payments in your Rails app without running
6
+ any Lightning infrastructure yourself. Generate Lightning invoices for your
7
+ users to pay, then get notified the instant each one settles — all by talking
8
+ to a Nostr Wallet Connect (NWC) service that someone else operates. The gem
9
+ works with any NWC-compatible backend (Rizful, Alby Hub, Alby Cloud, CoinOs,
10
+ any NIP-47 wallet service), so you're not locked into a single provider and
11
+ can swap vendors by changing one environment variable.
12
+
5
13
  ```ruby
6
14
  require "nwc_ruby"
7
15
 
@@ -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
@@ -131,6 +146,7 @@ module NwcRuby
131
146
  def run_one_connection(top)
132
147
  endpoint = Async::HTTP::Endpoint.parse(@url, alpn_protocols: ['http/1.1'])
133
148
  opened_at = Async::Clock.now
149
+ @recycle_requested = false
134
150
  @logger.info("[nwc] connecting to #{@url}")
135
151
 
136
152
  Async::WebSocket::Client.connect(endpoint) do |conn|
@@ -140,6 +156,7 @@ module NwcRuby
140
156
 
141
157
  @open_cb&.call(self)
142
158
  read_loop(conn)
159
+ @logger.info("[nwc] recycling connection (#{@recycle_interval}s)") if @recycle_requested
143
160
  ensure
144
161
  heartbeat&.stop
145
162
  poll&.stop
@@ -152,6 +169,13 @@ module NwcRuby
152
169
  end
153
170
  end
154
171
 
172
+ # Heartbeat task: sends RFC 6455 ping every @ping_interval seconds and
173
+ # requests a recycle once the connection has been open longer than
174
+ # @recycle_interval. The recycle is signaled via a flag + close rather
175
+ # than by raising across task boundaries, because an exception raised
176
+ # inside a child Async task is logged at warn level by Async's console
177
+ # logger ("Task may have ended with unhandled exception") before the
178
+ # parent's rescue runs — producing a noisy backtrace on every recycle.
155
179
  def start_heartbeat(top, conn, opened_at)
156
180
  top.async do
157
181
  loop do
@@ -161,12 +185,25 @@ module NwcRuby
161
185
 
162
186
  sleep @ping_interval
163
187
  break if @stop
164
-
165
- raise TransportError, "recycle (#{@recycle_interval}s)" if Async::Clock.now - opened_at > @recycle_interval
188
+ break if recycle_due?(opened_at) && request_recycle(conn)
166
189
  end
167
190
  end
168
191
  end
169
192
 
193
+ def recycle_due?(opened_at)
194
+ Async::Clock.now - opened_at > @recycle_interval
195
+ end
196
+
197
+ def request_recycle(conn)
198
+ @recycle_requested = true
199
+ begin
200
+ conn.close
201
+ rescue StandardError
202
+ nil
203
+ end
204
+ true
205
+ end
206
+
170
207
  def start_poll(top)
171
208
  return unless @poll_interval && @poll_cb
172
209
 
@@ -185,7 +222,21 @@ module NwcRuby
185
222
  end
186
223
 
187
224
  def read_loop(conn)
188
- while (message = conn.read)
225
+ loop do
226
+ message =
227
+ begin
228
+ conn.read
229
+ rescue StandardError => e
230
+ # If we asked for the recycle/close ourselves, treat the
231
+ # resulting read error as a clean EOF rather than propagating
232
+ # it up into the reconnect-on-error path (which would log a
233
+ # spurious warning).
234
+ raise unless @recycle_requested || @stop
235
+
236
+ @logger.debug("[nwc] read interrupted by close: #{e.class}")
237
+ nil
238
+ end
239
+ break if message.nil?
189
240
  break if @stop
190
241
 
191
242
  begin
@@ -219,19 +270,51 @@ module NwcRuby
219
270
  end
220
271
 
221
272
  def install_traps
273
+ # Keep signal handlers tiny. We can't do much from inside a Ruby
274
+ # signal handler:
275
+ # - SSL I/O / socket close can deadlock.
276
+ # - Async::Task#stop takes a Mutex, which raises
277
+ # "can't be called from trap context (ThreadError)".
278
+ # - Thread.new { task.stop } runs outside the reactor and
279
+ # Fiber.scheduler is nil there.
280
+ # So: flip a flag and poke a self-pipe. An Async task watches the
281
+ # read end of the pipe and calls @top_task.stop from inside the
282
+ # reactor, which is the only safe place to do it.
283
+ @signal_pipe_r, @signal_pipe_w = IO.pipe
222
284
  %w[TERM INT].each do |sig|
223
285
  trap(sig) do
224
286
  @stop = true
225
- # Close the socket to unblock the read loop.
226
287
  begin
227
- @conn&.close
228
- rescue StandardError
288
+ @signal_pipe_w.write_nonblock('.')
289
+ rescue IO::WaitWritable, Errno::EPIPE, IOError
229
290
  nil
230
291
  end
231
292
  end
232
293
  end
233
294
  end
234
295
 
296
+ def start_signal_watcher(top)
297
+ return unless @signal_pipe_r
298
+
299
+ top.async do
300
+ @signal_pipe_r.read(1)
301
+ @logger.debug('[nwc] signal received, stopping')
302
+ top.stop
303
+ rescue IOError, Errno::EBADF
304
+ nil
305
+ end
306
+ end
307
+
308
+ def close_signal_pipe
309
+ [@signal_pipe_r, @signal_pipe_w].each do |io|
310
+ io&.close
311
+ rescue IOError
312
+ nil
313
+ end
314
+ @signal_pipe_r = nil
315
+ @signal_pipe_w = nil
316
+ end
317
+
235
318
  def default_logger
236
319
  logger = Logger.new($stdout)
237
320
  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.1'
4
+ VERSION = '0.2.3'
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.1
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - MegalithicBTC