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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9ccee33eff147c5f62d51634db10d7d2529806a9b84d3373c8e66c4b93dfe68d
4
- data.tar.gz: b3ebfda680231e62bc65a92822af22bcbb33a6666bfa9997be15387dcf2100cf
3
+ metadata.gz: 385f64fc61d6cb0c6c7c2cb1ae9e7286a73bdf1da97545ff7d409884243085e8
4
+ data.tar.gz: ea244f460ce992e31d234ab30d1ad9d083a20ae3493f745c04abee2e1a389a39
5
5
  SHA512:
6
- metadata.gz: 76e267110b0064e3b257fa10d0dab5a05f9705b6dabc5b86884cf3655df5125498cc5957c589662d04603653d40b516c5f8d073db9d1937829bc012979c91f00
7
- data.tar.gz: ddb7653b85459ff790f48386932f9096950386cb52643eb146c3f3f6c3dd53a8383935df80b69efa124874f7018884c9211264c35aa656494c0e8ff760bf7bd2
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.after do
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.after(:each, :pgbus) do
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
- sync_streams_test_mode!(mode)
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
- Pgbus::Web::Streamer.reset! if defined?(Pgbus::Web::Streamer)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.15.3"
4
+ VERSION = "0.15.4"
5
5
  end
@@ -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
- body = ": pgbus test mode — connection accepted, no polling\n\n"
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
 
@@ -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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.3
4
+ version: 0.15.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson