pgbus 0.15.3 → 0.15.4
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 +1 -0
- data/README.md +12 -3
- data/lib/pgbus/testing.rb +33 -3
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/stream_app.rb +10 -1
- data/lib/pgbus/web/streamer/failover_listener.rb +7 -0
- data/lib/pgbus/web/streamer/heartbeat.rb +7 -0
- data/lib/pgbus/web/streamer/hub_client.rb +7 -0
- data/lib/pgbus/web/streamer/instance.rb +32 -1
- data/lib/pgbus/web/streamer/listener.rb +7 -0
- data/lib/pgbus/web/streamer/outbound_pump.rb +7 -0
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +7 -0
- data/lib/pgbus/web/streamer.rb +3 -0
- 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: 385f64fc61d6cb0c6c7c2cb1ae9e7286a73bdf1da97545ff7d409884243085e8
|
|
4
|
+
data.tar.gz: ea244f460ce992e31d234ab30d1ad9d083a20ae3493f745c04abee2e1a389a39
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a9a3926554aec518d1c59c14749ebb345eb8cce02467dfed0df4c9bb0778eee2e3899517bd1d2533446269bee9bf2e1ed2a935a0658c1b727973aa68d1c7f4c7
|
|
7
|
+
data.tar.gz: 939083f3a554c6539da4f15167517c21ccee8314dcc732b9b29abf4e2271c97f1d1ef48e24d3b64bbe91e7956c177210e57a692b327d720730f340f205330b59
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
### Fixed
|
|
4
4
|
|
|
5
|
+
- **`Pgbus::Testing.disabled!` can no longer turn a Capybara teardown race into a hung test process (issue #443).** The `streams_test_mode` stub closed immediately, so a page on a stream-bearing layout had its `EventSource` reconnect every ~3s for the whole example; a reconnect landing after `disabled!` had switched test mode off — a config-level `after` hook runs *before* `Capybara.reset_sessions!` — took the real path and started a live `Streamer` (listener/dispatcher/heartbeat threads + a LISTEN connection) inside the RSpec process. Its orphaned threads then shared the test's pinned AR connection and CI died at the job timeout with only `message type 0x5a arrived from server while idle`. Three changes: the stub now emits `retry: 86400000` so the browser does not reconnect at all; `Streamer::Instance#shutdown!` snapshots every component's threads (`#threads` on Listener/Dispatcher/Heartbeat/OutboundPump/HubClient/FailoverListener), logs one error naming those still alive after their bounded joins, and returns that list (`Streamer.reset!` forwards it, `nil` when nothing was live); and `Testing.disabled!` raises `Pgbus::Testing::StreamerLeakError` — pointing at `config.append_after` — when threads leaked, while a live streamer that shut down cleanly is only logged. README and docs now recommend `config.append_after` for the teardown hook.
|
|
5
6
|
- **`Pgbus::MCP.rack_app` works on a real hostname again with `mcp` 0.23+ / 1.x.** Since mcp 0.23 the `StreamableHTTPTransport` validates the `Host` header (DNS-rebinding protection, on by default, loopback hosts only) and pgbus had no way to pass the transport's options through — so a gated mount at `https://app.example.com/pgbus/mcp` answered every request `403 "Invalid Host header"`, and consumers pinned `mcp < 1.0` to dodge it (which only helps while the lock stays on 0.22). The rack app now exposes `allowed_hosts:`, `allowed_origins:` and `dns_rebinding_protection:`; the check **follows the gate by default** — off when `token:`/`auth:` is configured (a rebound browser page can never carry the bearer secret, so the check is redundant there), on for the warned-about unauthenticated mount — and `true`/`false` forces it. `mcp >= 0.23` is the floor for `rack_app` (older gems raise `Pgbus::Error` naming the fix); the gem's own bundle now tracks `mcp` 1.x, so lift that `< 1.0` pin. Stdio (`pgbus mcp`) is unaffected.
|
|
6
7
|
|
|
7
8
|
- **Worker forks no longer kill the supervisor's shared LISTEN connection (issue #437).** Under `worker_notify_scope = :supervisor` every fork produced one `[Pgbus::NotifyListener] connection error (PG::ConnectionBad: PQconsumeInput() server closed the connection unexpectedly …) — reconnecting` in the supervisor ~2 s after `Worker started`, and a LISTEN gap (polling fallback) until `reconnect!` completed. The child's fork hygiene closed the Ruby `IO` wrapper of the inherited LISTEN socket, but pg builds `socket_io` with `autoclose=false`, so the fd stayed open — and when the child's GC freed the inherited `PG::Connection`, its `PQfinish` sent a libpq Terminate down that fd, i.e. down the **parent's** connection. `NotifyListener#close_inherited_socket!` now repoints the fd at `/dev/null` (`socket_io.reopen(IO::NULL)`, the ActiveRecord `PostgreSQLAdapter#discard!` idiom) so the eventual `PQfinish` is harmless. Regression-covered by a real-fork integration spec that asserts the parent's `pgbus-listen` backend pid is unchanged and no reconnect is logged. Refs #437.
|
data/README.md
CHANGED
|
@@ -1751,7 +1751,12 @@ require "pgbus/testing/rspec"
|
|
|
1751
1751
|
|
|
1752
1752
|
RSpec.configure do |config|
|
|
1753
1753
|
config.before { Pgbus::Testing.fake! }
|
|
1754
|
-
config
|
|
1754
|
+
# append_after, not after: config-level `after` hooks run in reverse
|
|
1755
|
+
# registration order, so one registered after `capybara/rspec` runs BEFORE
|
|
1756
|
+
# Capybara.reset_sessions! — while the browser page is still open.
|
|
1757
|
+
# append_after runs once the page is closed and its pending SSE requests
|
|
1758
|
+
# are drained (see "SSE streams in tests" below).
|
|
1759
|
+
config.append_after do
|
|
1755
1760
|
Pgbus::Testing.disabled!
|
|
1756
1761
|
Pgbus::Testing.store.clear!
|
|
1757
1762
|
end
|
|
@@ -1763,7 +1768,7 @@ Or scope it to specific groups:
|
|
|
1763
1768
|
```ruby
|
|
1764
1769
|
RSpec.configure do |config|
|
|
1765
1770
|
config.before(:each, :pgbus) { Pgbus::Testing.fake! }
|
|
1766
|
-
config.
|
|
1771
|
+
config.append_after(:each, :pgbus) do
|
|
1767
1772
|
Pgbus::Testing.disabled!
|
|
1768
1773
|
Pgbus::Testing.store.clear!
|
|
1769
1774
|
end
|
|
@@ -1934,10 +1939,14 @@ HTTP/1.1 200 OK
|
|
|
1934
1939
|
Content-Type: text/event-stream
|
|
1935
1940
|
Cache-Control: no-cache, no-transform
|
|
1936
1941
|
|
|
1942
|
+
retry: 86400000
|
|
1943
|
+
|
|
1937
1944
|
: pgbus test mode — connection accepted, no polling
|
|
1938
1945
|
```
|
|
1939
1946
|
|
|
1940
|
-
This is a valid SSE response that the browser's EventSource will accept. No `Streamer` singleton is created, no PG LISTEN connection is opened, and no dispatcher/heartbeat/listener threads are spawned.
|
|
1947
|
+
This is a valid SSE response that the browser's EventSource will accept. No `Streamer` singleton is created, no PG LISTEN connection is opened, and no dispatcher/heartbeat/listener threads are spawned. The `retry:` directive tells `EventSource` to wait 24 hours before reconnecting: without it a page left open re-requests the closed stub every ~3 seconds for the whole example, and that reconnect storm is what turns a teardown race into a real streamer running inside the test process.
|
|
1948
|
+
|
|
1949
|
+
**Hook ordering with Capybara:** `Pgbus::Testing.disabled!` turns `streams_test_mode` back off. If it runs while the browser page is still open, a reconnect landing in that window takes the real stream path and starts a live `Streamer` — with listener/dispatcher/heartbeat threads and a LISTEN connection — inside your RSpec process. RSpec runs config-level `after` hooks in reverse registration order, so a plain `config.after { Pgbus::Testing.disabled! }` registered after `capybara/rspec` fires *before* `Capybara.reset_sessions!`. Register it with `config.append_after` (as in the snippet above) so the page is closed first. If a live streamer does get started and its threads outlive the bounded shutdown, `disabled!` raises `Pgbus::Testing::StreamerLeakError` with this diagnosis rather than letting the suite hang on a corrupted shared connection; a streamer that shut down cleanly is only logged as a warning, because that is expected inside `Pgbus::Testing.disabled! do ... end` real-stream tests.
|
|
1941
1950
|
|
|
1942
1951
|
**Testing actual stream delivery:** If you need to verify end-to-end SSE message delivery in integration tests, disable `streams_test_mode` and use the `PumaTestHarness` from the pgbus test support:
|
|
1943
1952
|
|
data/lib/pgbus/testing.rb
CHANGED
|
@@ -18,6 +18,11 @@ module Pgbus
|
|
|
18
18
|
MODES = %i[fake inline disabled].freeze
|
|
19
19
|
MODE_KEY = :__pgbus_test_mode
|
|
20
20
|
|
|
21
|
+
# Raised by `disabled!` when a live Pgbus::Web::Streamer had to be torn
|
|
22
|
+
# down and some of its threads did not stop within their join budget —
|
|
23
|
+
# a red spec with a diagnosis instead of a hung test process (issue #443).
|
|
24
|
+
class StreamerLeakError < Pgbus::Error; end
|
|
25
|
+
|
|
21
26
|
# Thread-safe in-memory store for events captured in fake/inline mode.
|
|
22
27
|
class EventStore
|
|
23
28
|
def initialize
|
|
@@ -70,15 +75,17 @@ module Pgbus
|
|
|
70
75
|
def mode!(mode, &block)
|
|
71
76
|
raise ArgumentError, "Unknown mode: #{mode}. Valid modes: #{MODES.join(", ")}" unless MODES.include?(mode)
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
# Record the mode BEFORE syncing streams: a StreamerLeakError raised
|
|
79
|
+
# by the teardown must still leave the process in the requested mode.
|
|
75
80
|
unless block
|
|
76
81
|
Thread.main[MODE_KEY] = mode
|
|
82
|
+
sync_streams_test_mode!(mode)
|
|
77
83
|
return
|
|
78
84
|
end
|
|
79
85
|
|
|
80
86
|
old = Thread.current[MODE_KEY]
|
|
81
87
|
Thread.current[MODE_KEY] = mode
|
|
88
|
+
sync_streams_test_mode!(mode)
|
|
82
89
|
yield
|
|
83
90
|
ensure
|
|
84
91
|
if block
|
|
@@ -112,11 +119,34 @@ module Pgbus
|
|
|
112
119
|
|
|
113
120
|
if mode == :disabled
|
|
114
121
|
Pgbus.configuration.streams_test_mode = false
|
|
115
|
-
|
|
122
|
+
reset_streamer!
|
|
116
123
|
else
|
|
117
124
|
Pgbus.configuration.streams_test_mode = true
|
|
118
125
|
end
|
|
119
126
|
end
|
|
127
|
+
|
|
128
|
+
# A live streamer at this point is legitimate inside a
|
|
129
|
+
# `disabled! do ... end` real-stream test, so a clean teardown only
|
|
130
|
+
# warns. Threads that outlived shutdown! are a different matter: left
|
|
131
|
+
# alone they share the test's pinned AR connection with the test thread
|
|
132
|
+
# and the suite hangs with no diagnosis (issue #443) — so raise.
|
|
133
|
+
def reset_streamer!
|
|
134
|
+
return unless defined?(Pgbus::Web::Streamer)
|
|
135
|
+
|
|
136
|
+
leaked = Pgbus::Web::Streamer.reset!
|
|
137
|
+
return if leaked.nil?
|
|
138
|
+
|
|
139
|
+
if leaked.empty?
|
|
140
|
+
Pgbus.logger.warn { "[Pgbus::Testing] disabled! tore down a live Pgbus::Web::Streamer that was started during the test" }
|
|
141
|
+
return
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
raise StreamerLeakError, <<~MSG
|
|
145
|
+
Pgbus::Testing.disabled! shut down a live Pgbus::Web::Streamer but its #{leaked.join(", ")} thread(s) did not stop within their join budget.
|
|
146
|
+
A live streamer inside the test process means an SSE request reached the real stream path while streams_test_mode was off — usually because Pgbus::Testing.disabled! ran in an RSpec `after` hook before Capybara reset the browser session (page still open, EventSource still reconnecting).
|
|
147
|
+
Register the hook with `config.append_after { Pgbus::Testing.disabled! }` so it runs after Capybara.reset_sessions!. See the README section "SSE streams in tests".
|
|
148
|
+
MSG
|
|
149
|
+
end
|
|
120
150
|
end
|
|
121
151
|
end
|
|
122
152
|
end
|
data/lib/pgbus/version.rb
CHANGED
data/lib/pgbus/web/stream_app.rb
CHANGED
|
@@ -31,6 +31,8 @@ module Pgbus
|
|
|
31
31
|
# full request lifecycle.
|
|
32
32
|
class StreamApp
|
|
33
33
|
PATH_PREFIX = "/pgbus/streams"
|
|
34
|
+
# EventSource reconnect delay handed to the streams_test_mode stub (24h).
|
|
35
|
+
TEST_MODE_RETRY_MS = 86_400_000
|
|
34
36
|
private_constant :PATH_PREFIX
|
|
35
37
|
|
|
36
38
|
def initialize(streamer: nil, config: nil, logger: nil, authorize: nil)
|
|
@@ -221,8 +223,15 @@ module Pgbus
|
|
|
221
223
|
[500, { "content-type" => "text/plain" }, ["pgbus: internal error"]]
|
|
222
224
|
end
|
|
223
225
|
|
|
226
|
+
# A closed SSE response makes EventSource reconnect every ~3s by default.
|
|
227
|
+
# In a browser-driven test that storm keeps hitting the endpoint for the
|
|
228
|
+
# whole example, so a reconnect landing after the harness has turned
|
|
229
|
+
# streams_test_mode back off (teardown ordering) would start a LIVE
|
|
230
|
+
# streamer inside the test process (issue #443). Telling the browser to
|
|
231
|
+
# wait a day before retrying closes that window.
|
|
224
232
|
def test_mode_stub
|
|
225
|
-
|
|
233
|
+
retry_line = Pgbus::Streams::Envelope.retry_directive(TEST_MODE_RETRY_MS)
|
|
234
|
+
body = "#{retry_line}: pgbus test mode — connection accepted, no polling\n\n"
|
|
226
235
|
[200, sse_headers, [body]]
|
|
227
236
|
end
|
|
228
237
|
end
|
|
@@ -115,6 +115,13 @@ module Pgbus
|
|
|
115
115
|
end
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
+
# Snapshot of this component's live thread(s). Instance#shutdown! captures
|
|
119
|
+
# it BEFORE calling #stop so a join that timed out is still observable
|
|
120
|
+
# after #stop has cleared the reference (issue #443).
|
|
121
|
+
def threads
|
|
122
|
+
current_impl.threads
|
|
123
|
+
end
|
|
124
|
+
|
|
118
125
|
def stop
|
|
119
126
|
current_impl.stop
|
|
120
127
|
end
|
|
@@ -47,6 +47,13 @@ module Pgbus
|
|
|
47
47
|
self
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# Snapshot of this component's live thread(s). Instance#shutdown! captures
|
|
51
|
+
# it BEFORE calling #stop so a join that timed out is still observable
|
|
52
|
+
# after #stop has cleared the reference (issue #443).
|
|
53
|
+
def threads
|
|
54
|
+
[@thread].compact
|
|
55
|
+
end
|
|
56
|
+
|
|
50
57
|
def stop
|
|
51
58
|
return unless @running
|
|
52
59
|
|
|
@@ -98,6 +98,13 @@ module Pgbus
|
|
|
98
98
|
nil
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
+
# Snapshot of this component's live thread(s). Instance#shutdown! captures
|
|
102
|
+
# it BEFORE calling #stop so a join that timed out is still observable
|
|
103
|
+
# after #stop has cleared the reference (issue #443).
|
|
104
|
+
def threads
|
|
105
|
+
[@reader].compact
|
|
106
|
+
end
|
|
107
|
+
|
|
101
108
|
def stop
|
|
102
109
|
@stopping = true
|
|
103
110
|
close_quietly(@sock)
|
|
@@ -135,16 +135,28 @@ module Pgbus
|
|
|
135
135
|
#
|
|
136
136
|
# Bounded by the configured write deadline per connection; a dead
|
|
137
137
|
# client drops instantly, a slow one stalls for at most write_deadline_ms.
|
|
138
|
+
#
|
|
139
|
+
# Every component join is bounded, so a thread blocked in a slow
|
|
140
|
+
# client write or a libpq call can outlive its #stop. That is not
|
|
141
|
+
# silent any more (issue #443): the names of the components whose
|
|
142
|
+
# threads are still alive afterwards are logged once and RETURNED
|
|
143
|
+
# (empty array = clean shutdown, also on a repeat call). The sentinel
|
|
144
|
+
# + socket close still run — Connection#close takes the same mutex as
|
|
145
|
+
# the writer, so it cannot fire mid-write even under a stuck thread.
|
|
138
146
|
def shutdown!
|
|
139
147
|
@shutdown_mutex.synchronize do
|
|
140
|
-
return unless @started
|
|
148
|
+
return [] unless @started
|
|
141
149
|
|
|
142
150
|
@started = false
|
|
151
|
+
threads = component_threads
|
|
143
152
|
safely { @heartbeat.stop }
|
|
144
153
|
safely { @listener.stop }
|
|
145
154
|
safely { @dispatcher.stop }
|
|
146
155
|
safely { @pump&.stop }
|
|
156
|
+
leaked = threads.select { |_, list| list.any?(&:alive?) }.keys
|
|
157
|
+
report_leaked_threads(leaked) if leaked.any?
|
|
147
158
|
close_all_connections
|
|
159
|
+
leaked
|
|
148
160
|
end
|
|
149
161
|
end
|
|
150
162
|
|
|
@@ -224,6 +236,25 @@ module Pgbus
|
|
|
224
236
|
@logger.warn { "[Pgbus::Streamer::Instance] component stop raised: #{e.class}: #{e.message}" }
|
|
225
237
|
end
|
|
226
238
|
|
|
239
|
+
# Captured BEFORE the stops: every component nils its thread
|
|
240
|
+
# reference in #stop whether or not the join succeeded.
|
|
241
|
+
def component_threads
|
|
242
|
+
{
|
|
243
|
+
"heartbeat" => @heartbeat.threads,
|
|
244
|
+
"listener" => @listener.threads,
|
|
245
|
+
"dispatcher" => @dispatcher.threads,
|
|
246
|
+
"pump" => @pump ? @pump.threads : []
|
|
247
|
+
}
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def report_leaked_threads(leaked)
|
|
251
|
+
@logger.error do
|
|
252
|
+
"[Pgbus::Streamer::Instance] shutdown! finished but the #{leaked.join(", ")} thread(s) are " \
|
|
253
|
+
"still running past their join budget; they exit on their own once their current blocking " \
|
|
254
|
+
"call returns. In a test process this means a live streamer was started mid-suite (issue #443)."
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
227
258
|
# Off-thread durable fanout writer (issue #321). nil (the default) keeps
|
|
228
259
|
# fanout writes inline on the dispatcher thread. When on_dead fires
|
|
229
260
|
# (a write failed), the pump posts a DisconnectMessage onto the shared
|
|
@@ -117,6 +117,13 @@ module Pgbus
|
|
|
117
117
|
!@conn.nil?
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
+
# Snapshot of this component's live thread(s). Instance#shutdown! captures
|
|
121
|
+
# it BEFORE calling #stop so a join that timed out is still observable
|
|
122
|
+
# after #stop has cleared the reference (issue #443).
|
|
123
|
+
def threads
|
|
124
|
+
[@thread].compact
|
|
125
|
+
end
|
|
126
|
+
|
|
120
127
|
def stop
|
|
121
128
|
return unless @running
|
|
122
129
|
|
|
@@ -99,6 +99,13 @@ module Pgbus
|
|
|
99
99
|
# join each worker bounded by the write deadline. Never Thread#kill — a
|
|
100
100
|
# kill mid write_nonblock corrupts IO state (mirrors
|
|
101
101
|
# StreamEventDispatcher#stop). Idempotent.
|
|
102
|
+
# Snapshot of this component's live thread(s). Instance#shutdown! captures
|
|
103
|
+
# it BEFORE calling #stop so a join that timed out is still observable
|
|
104
|
+
# after #stop has cleared the reference (issue #443).
|
|
105
|
+
def threads
|
|
106
|
+
@threads.dup
|
|
107
|
+
end
|
|
108
|
+
|
|
102
109
|
def stop
|
|
103
110
|
return self unless @started
|
|
104
111
|
|
|
@@ -150,6 +150,13 @@ module Pgbus
|
|
|
150
150
|
self
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
+
# Snapshot of this component's live thread(s). Instance#shutdown! captures
|
|
154
|
+
# it BEFORE calling #stop so a join that timed out is still observable
|
|
155
|
+
# after #stop has cleared the reference (issue #443).
|
|
156
|
+
def threads
|
|
157
|
+
[@thread].compact
|
|
158
|
+
end
|
|
159
|
+
|
|
153
160
|
def stop
|
|
154
161
|
return unless @running
|
|
155
162
|
|
data/lib/pgbus/web/streamer.rb
CHANGED
|
@@ -41,6 +41,9 @@ module Pgbus
|
|
|
41
41
|
@current_mutex.synchronize { @current&.stream_counter }
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Tears down the current instance, if any. Returns nil when there was
|
|
45
|
+
# nothing to reset, otherwise Instance#shutdown!'s list of component
|
|
46
|
+
# names whose threads survived their bounded joins ([] = clean).
|
|
44
47
|
def reset!
|
|
45
48
|
instance = nil
|
|
46
49
|
@current_mutex.synchronize do
|