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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +8 -0
- data/lib/nwc_ruby/transport/relay_connection.rb +97 -14
- data/lib/nwc_ruby/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d541ad9af31629ad26492d8da61d56b0a63980cc8597a460cca9949df56f382d
|
|
4
|
+
data.tar.gz: 8d48fbbe532bdcf56d889f68f9d7878fa2958381c1ea285fb572aebb70d17d7e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
228
|
-
rescue
|
|
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
|
data/lib/nwc_ruby/version.rb
CHANGED