pgbus 0.15.2 → 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: 13ec61547a6821edab1a7aa96e794d522cc6fdcd38dd354bf601c9c760a2fded
4
- data.tar.gz: b79ef51aa82c336ba98e3df071d40e9de5467bec196fb53edaf8a4fa5507327d
3
+ metadata.gz: 385f64fc61d6cb0c6c7c2cb1ae9e7286a73bdf1da97545ff7d409884243085e8
4
+ data.tar.gz: ea244f460ce992e31d234ab30d1ad9d083a20ae3493f745c04abee2e1a389a39
5
5
  SHA512:
6
- metadata.gz: 8647f5cff936fae72bc95ea49c793fc68e514c29c2955d88b668dd6e5905a95418f7efdb8082e77048a584b9a0c86aa2cea42bbcd1f0777df349606ddbd4db08
7
- data.tar.gz: ee10d3f15f0e03a8a46d359c5aaf19f3329ed628dd4ae72d98fe00aec4b9689ee43005b73884b62e997fb3305c0b2c5ec2ce9a747646e558a1ade1cbeca27f0e
6
+ metadata.gz: a9a3926554aec518d1c59c14749ebb345eb8cce02467dfed0df4c9bb0778eee2e3899517bd1d2533446269bee9bf2e1ed2a935a0658c1b727973aa68d1c7f4c7
7
+ data.tar.gz: 939083f3a554c6539da4f15167517c21ccee8314dcc732b9b29abf4e2271c97f1d1ef48e24d3b64bbe91e7956c177210e57a692b327d720730f340f205330b59
data/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
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.
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.
7
+
5
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.
6
9
  - **A process whose `pgbus_processes` row is deleted underneath it now re-registers instead of staying invisible forever (issue #438).** `Heartbeat#beat` updated its row by id; when the row was gone — the dispatcher's stale-process reaper after a ≥ 5-minute heartbeat gap, a manual cleanup, another host's clock skew — `update_all` matched 0 rows, raised nothing, and the process (healthy, logging, serving `/readyz`) was absent from `pgbus_processes` until it restarted, so the supervisor's DB loop-tick check silently fell back to the pipe and `ConsumerPriority` could not see it. The beat now treats an affected-row count of 0 as "row is gone": it logs one WARN naming the old id, kind and pid (so whatever deleted the row can be hunted), re-registers through the existing boot path, and lands that beat's `last_heartbeat_at` / metadata on the new row. A mutex plus a stopped flag keep a beat that races `stop` from resurrecting a row `deregister_process` just deleted. Zero extra queries on the happy path. Refs #438.
7
10
  - **Supervisor logs a worker recycle as a clean exit, not a crash (issue #438).** A worker or consumer hitting `max_jobs` / `max_memory` / `max_lifetime` exits 0 by design, but the supervisor logged `Child worker pid=N exited unexpectedly (status=0)`, burying real crashes. A clean exit outside shutdown is now INFO `exited cleanly (status=0) — restarting (worker recycle)`; non-zero exits keep the WARN, and a signaled exit reports `signal=N` instead of an empty status so an OOM SIGKILL is distinguishable. Restart policy is unchanged. Refs #438.
@@ -70,6 +73,9 @@
70
73
 
71
74
  ### Changed
72
75
 
76
+ - **The gem root's `Gemfile.lock` is now committed.** Every checkout and CI leg resolves the same gem set (the main-Gemfile legs and `release.yml` install it frozen, like the Rails 7.1 and docs locks already were), so a fresh checkout can no longer silently pick up a newer dev dependency than the last green run. The gemspec builds `spec.files` from a path allowlist, so the lock never ships in the gem (verified with `gem build`). `rake release` bumps the `pgbus (X.Y.Z)` pin in all three tracked lockfiles in the bump commit, and `spec/pgbus/frozen_lockfile_sync_spec.rb` guards the root lock too. Contributors: after pulling, `bundle install` is a no-op unless the lock moved; run `bundle install` (not `bundle lock`) when you change a dependency, and commit the lock with it.
77
+ - **Client build toolchain: bun 1.3.11 → 1.4.0.** `.bun-version`, the root `engines.bun` floor, and the docs-site CI job (now pinned to the same `.bun-version` file instead of `latest`) move together. Both `bun.lock` files install `--frozen-lockfile` unchanged; pgbus ships no bun-built artifacts, so nothing in the gem changes.
78
+
73
79
  - **Shutdown budgets are now alignable end-to-end (issue #386).** New `config.shutdown_timeout` bounds how long the supervisor waits for children after forwarding TERM before escalating to SIGKILL — previously a hardcoded 30s, which silently SIGKILLed workers mid-drain the moment `drain_timeout` was raised past it. Default derives `drain_timeout + 5` so the deadline tracks the drain window automatically; an explicit value below `drain_timeout` logs a boot warning. `Consumer#shutdown`'s pool wait (its only drain bound) now follows `config.drain_timeout` instead of a hardcoded 30s, and `Worker#shutdown`'s post-drain residual wait drops from a second full 30s window to 5s — the drain loop already waited `drain_timeout`, and a job still running has proven it won't finish. Rule of thumb: orchestrator stop grace period > `shutdown_timeout` > `drain_timeout`. Refs #386.
74
80
 
75
81
  - **Streams: one LISTEN connection per web host — `streams_listen_scope` (issue #382).** ⚠️ **Default behavior change.** Previously every Puma worker lazily opened its own dedicated streams LISTEN connection on first SSE use, so a web host pinned one direct connection per worker. Under the new default (`streams_listen_scope = :master`) the `pgbus_streams` Puma plugin runs a **MasterHub** in the preforking master: ONE `Web::Streamer::Listener` on the refcounted union of every worker's stream channels, fanning wakes — **including ephemeral payloads** — out to workers over a Unix domain socket with length-prefixed frames (`Streamer::HubProtocol`). Workers connect lazily (nothing is inherited across fork) and the synchronous `ensure_listening` ack contract is preserved cross-process: a sub is registered before LISTEN executes and acked only after, so the no-lost-broadcast guarantee holds. Backpressure follows the streams rules: durable wakes are droppable at a per-worker cap (they self-heal via `read_after`), **ephemeral wakes are never dropped** — a worker that stops draining is evicted, which triggers its own fallback. **Fallback is per-worker listeners, not loss**: whenever the hub is absent or dies (no `preload_app!`, single-mode Puma, crash, eviction) each worker's `FailoverListener` swaps in a real per-worker `Listener` and re-LISTENs its recorded subscriptions — connection footprint balloons back to pre-#382 levels (census-visible) but no broadcast semantics change; the worker stays local until it recycles. Measured (local PG, n=50): the master→worker hop is noise-level free — single-broadcast SSE roundtrip p50 16.00ms via the hub vs 16.93ms per-worker. **`:master` effectively requires `preload_app!`** (the hub waits for the app's pgbus initializer; without it the deadline expires quietly and workers stay per-worker). **Rollback:** `config.streams_listen_scope = :process`. Refs #382, builds on the #381 patterns.
data/README.md CHANGED
@@ -6,7 +6,7 @@ PostgreSQL-native job processing and event bus for Rails, built on [PGMQ](https:
6
6
 
7
7
  📖 **Documentation:** [pgbus.zoolutions.llc](https://pgbus.zoolutions.llc) — guides, flow diagrams, and a full configuration reference. (This README stays the canonical GitHub reference.)
8
8
 
9
- [![Ruby](https://github.com/mhenrixon/pgbus/actions/workflows/main.yml/badge.svg)](https://github.com/mhenrixon/pgbus/actions/workflows/main.yml)
9
+ [![Ruby](https://github.com/zoolutions/pgbus/actions/workflows/main.yml/badge.svg)](https://github.com/zoolutions/pgbus/actions/workflows/main.yml)
10
10
 
11
11
  ## Table of contents
12
12
 
@@ -1035,7 +1035,7 @@ When `config.metrics_enabled = true` (default), the dashboard exposes Prometheus
1035
1035
 
1036
1036
  Pgbus ships an optional, **read-only** [MCP](https://modelcontextprotocol.io) server so an AI agent (or any MCP client) can diagnose pgbus directly — "are queues backed up?", "is `read_ct` advancing?", "are workers heart-beating but not claiming?" — instead of hand-writing `pgmq` / `pg_stat_activity` SQL against production. It is a thin adapter over the same read layer the dashboard uses, so it adds no new database access path.
1037
1037
 
1038
- Add the optional `mcp` gem to your `Gemfile` first (`gem "mcp"`); both entry points below tell you if it's missing.
1038
+ Add the optional `mcp` gem to your `Gemfile` first (`gem "mcp"`, 0.23 or newer — 1.x is fully supported); both entry points below tell you if it's missing.
1039
1039
 
1040
1040
  #### Choosing a deployment
1041
1041
 
@@ -1075,9 +1075,14 @@ Options:
1075
1075
  | `token:` | `nil` | Shared secret. When set, requests must send `Authorization: Bearer <token>` (constant-time compared). |
1076
1076
  | `auth:` | `nil` | A callable `->(rack_request) { ... }` returning truthy to allow — mirrors `config.web_auth`. Wins over `token:`. |
1077
1077
  | `allow_payloads:` | `false` | When true, tools honor a per-call `include_payloads` flag (see Security). |
1078
+ | `dns_rebinding_protection:` | `nil` | The `mcp` gem's Host/Origin validation (on by default since mcp 0.23, loopback hosts only). `nil` follows the gate: **off when `token:`/`auth:` is set, on when unauthenticated.** `true`/`false` forces it. |
1079
+ | `allowed_hosts:` | `nil` | Extra `Host` values accepted when the check is on (`"app.example.com"` matches any port, `"app.example.com:8443"` exactly). |
1080
+ | `allowed_origins:` | `nil` | Extra `Origin` values accepted beyond same-origin when the check is on. |
1078
1081
 
1079
1082
  If you set neither `token:` nor `auth:`, pgbus logs a warning — an unauthenticated diagnostic endpoint exposes operational metadata to anyone who can reach it.
1080
1083
 
1084
+ > **Why the Host check follows the gate.** DNS-rebinding protection defends a server bound to `localhost` against a browser page whose DNS name was re-pointed at `127.0.0.1`. Such a page can never carry your bearer token (the secret doesn't exist at the attacker's origin), so on a gated mount the check is redundant — and left on, it rejects every request to a real hostname (`https://app.example.com/pgbus/mcp` → `403 Invalid Host header`). Pgbus therefore turns it off when a gate is configured and keeps it on for the (warned-about) unauthenticated mount. If your `auth:` callable trusts something a rebound page *would* have — a source-IP allowlist, say — pass `dns_rebinding_protection: true` plus `allowed_hosts:` for your hostname. Requires `mcp >= 0.23`; older gems raise `Pgbus::Error` naming the floor.
1085
+
1081
1086
  > Clients must send `Accept: application/json` and `Content-Type: application/json` on every POST, or the transport replies `406 Not Acceptable`. MCP clients do this automatically.
1082
1087
 
1083
1088
  Need a **standalone HTTP pod** instead of mounting in your main app? The same Rack app works under any Rack server, e.g. a one-line `config.ru`:
@@ -1220,9 +1225,9 @@ pgbus-health --port 9394 # or PGBUS_HEALTH_PORT=9394 pgbus-health
1220
1225
  pgbus-health --port 9394 --path /livez --timeout 2
1221
1226
  ```
1222
1227
 
1223
- ### Rolling restarts (Kamal, docker)
1228
+ ### Rolling restarts (dash, docker)
1224
1229
 
1225
- Kamal distributions with per-role health checks (for example the [`dash` branch](https://github.com/mhenrixon/kamal)) can rolling-restart a non-proxied job role: start the new container, poll its docker `HEALTHCHECK` until healthy, and only then `docker stop` the old one. Wire the pgbus container into that gate:
1230
+ [dash](https://github.com/zoolutions/dash) (per-role health checks) can rolling-restart a non-proxied job role: start the new container, poll its docker `HEALTHCHECK` until healthy, and only then `docker stop` the old one. Wire the pgbus container into that gate:
1226
1231
 
1227
1232
  ```yaml
1228
1233
  # config/deploy.yml
@@ -1253,7 +1258,7 @@ If the orchestrator's stop grace period is *shorter* than `shutdown_timeout`, do
1253
1258
 
1254
1259
  **The overlap window is safe by construction.** Between "new container healthy" and "old container stopped", two supervisors run against the same database. Nothing double-fires: queue claims use `FOR UPDATE SKIP LOCKED`, `single_active_consumer` queues arbitrate via session-level advisory locks (released the instant a killed process's connection dies), two live recurring schedulers dedup on the `(task_key, run_at)` unique record, and dispatcher maintenance is idempotent. "One scheduler per deployment" is a steady-state rule; a deploy window may briefly violate it without consequence.
1255
1260
 
1256
- **What a hard kill still costs.** Jobs killed past the drain window are redelivered after their visibility timeout (at-least-once holds) — but PGMQ's `read_ct` increments exactly like a logical failure, so a long-running job that straddles *repeated* deploy kills can be pushed to the DLQ without its code ever raising. `zombie_detection` logs exactly this pattern (`read_ct > 1` with no recorded failure). Keep jobs shorter than `drain_timeout`, or raise it (and `stop_timeout`) for queues that can't be. For `idempotent!` event handlers there is a separate crash-window caveat tracked in [#385](https://github.com/mhenrixon/pgbus/issues/385).
1261
+ **What a hard kill still costs.** Jobs killed past the drain window are redelivered after their visibility timeout (at-least-once holds) — but PGMQ's `read_ct` increments exactly like a logical failure, so a long-running job that straddles *repeated* deploy kills can be pushed to the DLQ without its code ever raising. `zombie_detection` logs exactly this pattern (`read_ct > 1` with no recorded failure). Keep jobs shorter than `drain_timeout`, or raise it (and `stop_timeout`) for queues that can't be. For `idempotent!` event handlers there is a separate crash-window caveat tracked in [#385](https://github.com/zoolutions/pgbus/issues/385).
1257
1262
 
1258
1263
  ### Boot diagnostics banner
1259
1264
 
@@ -1746,7 +1751,12 @@ require "pgbus/testing/rspec"
1746
1751
 
1747
1752
  RSpec.configure do |config|
1748
1753
  config.before { Pgbus::Testing.fake! }
1749
- 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
1750
1760
  Pgbus::Testing.disabled!
1751
1761
  Pgbus::Testing.store.clear!
1752
1762
  end
@@ -1758,7 +1768,7 @@ Or scope it to specific groups:
1758
1768
  ```ruby
1759
1769
  RSpec.configure do |config|
1760
1770
  config.before(:each, :pgbus) { Pgbus::Testing.fake! }
1761
- config.after(:each, :pgbus) do
1771
+ config.append_after(:each, :pgbus) do
1762
1772
  Pgbus::Testing.disabled!
1763
1773
  Pgbus::Testing.store.clear!
1764
1774
  end
@@ -1929,10 +1939,14 @@ HTTP/1.1 200 OK
1929
1939
  Content-Type: text/event-stream
1930
1940
  Cache-Control: no-cache, no-transform
1931
1941
 
1942
+ retry: 86400000
1943
+
1932
1944
  : pgbus test mode — connection accepted, no polling
1933
1945
  ```
1934
1946
 
1935
- 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.
1936
1950
 
1937
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:
1938
1952
 
data/Rakefile CHANGED
@@ -232,9 +232,11 @@ task :release, %i[version force] do |_t, args|
232
232
 
233
233
  # Step 1b: Regenerate the frozen lockfiles that pin the pgbus path gem, so the
234
234
  # bump ships with them in sync. These are installed with `--frozen`/deployment
235
- # in CI, so if they still name the OLD version they instant-fail (the Rails 7.1
236
- # leg with exit 16, and docs-CI on any docs change). Regenerating here keeps the
237
- # version-pin drift out of the release commit instead of surfacing on the next PR.
235
+ # in CI, so if they still name the OLD version they instant-fail (the root
236
+ # Gemfile.lock on every main-Gemfile leg AND release.yml's own `bundle install`,
237
+ # the Rails 7.1 leg with exit 16, and docs-CI on any docs change). Regenerating
238
+ # here keeps the version-pin drift out of the release commit instead of
239
+ # surfacing on the next PR — or, worse, in the Release workflow itself.
238
240
  header "Frozen lockfiles"
239
241
  # The ONLY thing a version bump changes in these frozen lockfiles is the pgbus
240
242
  # path-gem pin — so bump exactly that line, in place, with a string edit.
@@ -249,7 +251,7 @@ task :release, %i[version force] do |_t, args|
249
251
  # fetch). A targeted pin edit sidesteps all of it, is deterministic on any
250
252
  # machine, and produces the minimal 2-line diff (the PATH spec + the
251
253
  # DEPENDENCIES pin). See #338/#341 and the surgical-bump fix.
252
- frozen_lockfiles = %w[gemfiles/rails_7_1.gemfile.lock docs/Gemfile.lock]
254
+ frozen_lockfiles = %w[Gemfile.lock gemfiles/rails_7_1.gemfile.lock docs/Gemfile.lock]
253
255
  regenerated_lockfiles = []
254
256
  frozen_lockfiles.each do |lockfile|
255
257
  unless File.exist?(lockfile)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Pgbus configuration — https://github.com/mhenrixon/pgbus
3
+ # Pgbus configuration — https://github.com/zoolutions/pgbus
4
4
  #
5
5
  # This is the real config surface. Every setting has a sensible default, so an
6
6
  # empty block gives you a working install; uncomment and edit what you need.
@@ -20,8 +20,24 @@ module Pgbus
20
20
  # Security: requests are rejected with 401 unless they carry the configured
21
21
  # token (or pass the supplied auth callable). Run it on an internal network
22
22
  # / behind your VPN, never internet-exposed.
23
+ #
24
+ # DNS-rebinding protection: since mcp 0.23 the transport validates the Host
25
+ # header (loopback only, by default) and the Origin header (same-origin
26
+ # only). That defends a server bound to localhost against a browser page
27
+ # whose DNS name was re-pointed at 127.0.0.1 — a page that can carry no
28
+ # bearer token, because the secret never reaches the attacker's origin. So
29
+ # when this app is gated (+token+ or +auth+) the check is redundant, and
30
+ # left on it rejects every request to a real hostname
31
+ # (https://app.example.com/pgbus/mcp → 403 "Invalid Host header"). The
32
+ # default therefore follows the gate: off when gated, on when unauthenticated.
33
+ # Override with +dns_rebinding_protection:+, and widen the accepted hosts /
34
+ # origins with +allowed_hosts:+ / +allowed_origins:+ when the check is on.
23
35
  class RackApp
24
36
  BEARER_PREFIX = "Bearer "
37
+ # First mcp release with the transport's allowed_hosts / allowed_origins /
38
+ # dns_rebinding_protection options. Older gems would raise ArgumentError
39
+ # on the pass-through; fail with the fix spelled out instead.
40
+ MIN_MCP_VERSION = Gem::Version.new("0.23.0")
25
41
  # Only the JSON body string is frozen and reused. The outer response triple
26
42
  # and its headers hash MUST be built fresh per call (#unauthorized) so
27
43
  # downstream Rack middleware can mutate them — Rack::TempfileReaper assigns
@@ -40,14 +56,27 @@ module Pgbus
40
56
  # @param auth [#call, nil] custom authenticator taking a Rack::Request and
41
57
  # returning truthy to allow. Mirrors Pgbus.configuration.web_auth. Takes
42
58
  # precedence over +token+ when both are given.
43
- def initialize(data_source: Pgbus::Web::DataSource.new, allow_payloads: false, token: nil, auth: nil)
59
+ # @param allowed_hosts [Array<String>, nil] extra Host values the
60
+ # transport accepts beyond loopback when DNS-rebinding protection is on;
61
+ # a bare name matches any port, "host:port" matches exactly.
62
+ # @param allowed_origins [Array<String>, nil] extra Origin values accepted
63
+ # beyond same-origin when DNS-rebinding protection is on.
64
+ # @param dns_rebinding_protection [Boolean, nil] nil (default) = on only
65
+ # when the app is unauthenticated; true/false forces it. See the class
66
+ # docs for why the gate makes the check redundant.
67
+ def initialize(data_source: Pgbus::Web::DataSource.new, allow_payloads: false, token: nil, auth: nil,
68
+ allowed_hosts: nil, allowed_origins: nil, dns_rebinding_protection: nil)
69
+ check_mcp_version!
44
70
  @token = token
45
71
  @auth = auth
46
72
  @server = Server.build(data_source: data_source, allow_payloads: allow_payloads)
47
73
  @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
48
- @server, stateless: true, enable_json_response: true
74
+ @server,
75
+ stateless: true, enable_json_response: true,
76
+ allowed_hosts: allowed_hosts, allowed_origins: allowed_origins,
77
+ dns_rebinding_protection: dns_rebinding_protection.nil? ? unauthenticated? : dns_rebinding_protection
49
78
  )
50
- warn_unauthenticated! if @token.nil? && @auth.nil?
79
+ warn_unauthenticated! if unauthenticated?
51
80
  end
52
81
 
53
82
  # Mount THIS object, never the bare transport. The auth gate lives here
@@ -90,20 +119,36 @@ module Pgbus
90
119
  Runner.secure_compare?(@token, header.delete_prefix(BEARER_PREFIX))
91
120
  end
92
121
 
122
+ def unauthenticated?
123
+ @token.nil? && @auth.nil?
124
+ end
125
+
93
126
  def warn_unauthenticated!
94
127
  Pgbus.logger.warn do
95
128
  "[Pgbus::MCP] HTTP diagnostic server mounted without authentication. " \
96
129
  "Pass token: or auth: to Pgbus::MCP.rack_app, and keep it on an internal network."
97
130
  end
98
131
  end
132
+
133
+ def check_mcp_version!
134
+ installed = Gem::Version.new(::MCP::VERSION)
135
+ return if installed >= MIN_MCP_VERSION
136
+
137
+ raise Pgbus::Error,
138
+ "Pgbus::MCP.rack_app requires mcp >= #{MIN_MCP_VERSION} (the transport's DNS-rebinding " \
139
+ "options); mcp #{installed} is installed. Run `bundle update mcp`."
140
+ end
99
141
  end
100
142
 
101
143
  module_function
102
144
 
103
145
  # Build a gated Rack app serving the read-only diagnostic tools over HTTP.
104
146
  # See {RackApp} for the parameters and deployment guidance.
105
- def rack_app(data_source: Pgbus::Web::DataSource.new, allow_payloads: false, token: nil, auth: nil)
106
- RackApp.new(data_source: data_source, allow_payloads: allow_payloads, token: token, auth: auth)
147
+ def rack_app(data_source: Pgbus::Web::DataSource.new, allow_payloads: false, token: nil, auth: nil,
148
+ allowed_hosts: nil, allowed_origins: nil, dns_rebinding_protection: nil)
149
+ RackApp.new(data_source: data_source, allow_payloads: allow_payloads, token: token, auth: auth,
150
+ allowed_hosts: allowed_hosts, allowed_origins: allowed_origins,
151
+ dns_rebinding_protection: dns_rebinding_protection)
107
152
  end
108
153
  end
109
154
  end
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.2"
4
+ VERSION = "0.15.4"
5
5
  end
@@ -44,7 +44,7 @@ module Pgbus
44
44
  Pgbus.logger.warn do
45
45
  "[Pgbus] Dashboard is accessible without authentication. " \
46
46
  "Configure Pgbus.configuration.web_auth to restrict access. " \
47
- "See: https://github.com/mhenrixon/pgbus#dashboard-authentication"
47
+ "See: https://github.com/zoolutions/pgbus#dashboard-authentication"
48
48
  end
49
49
  Pgbus::Web::Authentication.auth_warned = true
50
50
  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.2
4
+ version: 0.15.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson