pgbus 0.17.3 → 0.18.1

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: 54e3acb0aefac1a5a7e6a4dfabc972dc9fa830c8d738fbc029bfc667cf4635b2
4
- data.tar.gz: 37b30a6e3f8625d938f499457c4cae88c6bc759d5017274bd425f1f9f59151e9
3
+ metadata.gz: e64af488e23381da4f7685e20a194addef033c1fb175fa64b1de7b169dc67996
4
+ data.tar.gz: b98a831339953b46a44e0a37468277d05b8718c3ac3fa7d7ee82d8857f78928f
5
5
  SHA512:
6
- metadata.gz: 288d8ee976c4cf222f19fc3d3eab68523e1317bedbc94b49ddf1d6bde3fc22e05308e54fc0d4a0980ea9e7dc295452f224edf6088622b9acbc4d3f39dd595d45
7
- data.tar.gz: 98b95204d3e73817c5b432ef85128ee1de8040aad5dacb1bba424e76fe88436028ed7259ae70f2636a9ddec6d18f2df615217497c9b887537dafde0a2ceebfc3
6
+ metadata.gz: f1a4d7d62a2c9a7a2767896246e20ec36ba3b5626ef0d45bb4b2cdc7efb50b14b99e57a848167db58a8d49e35ae5b7b8a367959a66044aa350b8eda7716afa05
7
+ data.tar.gz: f39f4dc967ea079dab0b1d7536bbc6effb9bd044f310a40e0fc1e4a988c03f1b104da4ab12f5c2eed515d67e805f6abc2e2d0944afdf8d77c4025436080b3a83
data/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ### Added
4
4
 
5
+ - **`config.streams_authorize`: the engine's stream endpoint can supply a connection context.** The engine mounts `Pgbus::Web::StreamApp.new` with no `authorize:` hook, so every connection was authorized with no context. Connection-driven presence (`streams_presence_patterns`) then joined nobody, and `visible_to:` predicates that need a context (they receive `nil`, so `user&.admin?` is falsy) hid every broadcast, unless the app mounted its own `StreamApp`. `config.streams_authorize = ->(env, stream_name) { … }` takes the same contract as `StreamApp.new(authorize:)`: a falsy return answers 403, `true` authorizes with no context, any other value (the current user, say) becomes the connection context. It is read per request, so the order of initializers and route loading doesn't matter. An explicit `StreamApp.new(authorize:)` wins; any `StreamApp` without one, the engine's included, uses the configured hook, and nil (the default) keeps today's behaviour. `validate!` rejects a non-callable. Closes #522.
6
+
5
7
  - **Streams presence keeps one row per tab, with per-tab state, a change hook and automatic expiry (experimental).** Connection-driven presence (`streams_presence_patterns`) kept one row per member, so a user with two tabs open vanished from the room when either tab closed. On a migrated table it now keeps one row per SSE connection: `pgbus_presence_members` gains `connection_id`, `seat` (the client's per-tab id) and `state jsonb`. `<pgbus-stream-source presence>` (`pgbus_stream_from @room, presence: true`) mints one seat per document and sends it as `?seat=`; the stream app stores it on the connection and the dispatcher joins with `connection_id:` and `seat:`. New API: `Presence#join(member_id:, connection_id:, seat:, metadata:)`, `#leave(connection_id:)`, `#touch(connection_id:)` (one statement per stream, returning the ids it found), `#set_state(connection_id:, state:)` (merges into that row) and `.seats_supported?`; `#members` rows carry `connection_id`, `seat` and `state`. Member-keyed calls keep working on their own row. New settings: `streams_presence_on_change = ->(stream_name, change) { }`, called after each seat join, leave and expiry with `{ type:, connection_id:, seat:, member_id: }` (errors logged and swallowed), and `streams_presence_ttl` (seconds, or a pattern => seconds Hash with `:default`; 60 by default). The heartbeat touches live seats each tick, re-joins a live connection whose row was expired, and at most every ttl / 2 expires silent seats across all streams, including rows of crashed processes, reporting each as a `:leave`. On a seat table `sweep!` only expires member-keyed rows, and pgbus warns at boot when a ttl does not exceed `streams_heartbeat_interval`. **Upgrade:** `rails generate pgbus:add_presence_seats` (queued by `pgbus:update`; fresh `pgbus:add_presence` tables already have the columns). It replaces the `(stream_name, member_id)` unique index with partial unique indexes on `(stream_name, member_id) WHERE connection_id IS NULL` and `(stream_name, connection_id) WHERE connection_id IS NOT NULL`, and is reversible. Until it runs, presence behaves as before; a running process notices the migrated table within 30 seconds (at once on a member-keyed `join` outside a transaction). Closes #515.
6
8
 
7
9
  - **The home page and Insights explain themselves.** The home page (`/pgbus`) showed numbers without words ("Failed / DLQ 5 / 3", a Throughput card that was always `0.0` on the web, Queue Health percentages coloured against thresholds it never stated). It now opens with a health verdict strip — the OK / DEGRADED / STALLED verdict and reasons `/readyz` and the MCP compute (`HealthAnalyzer`), with links to the page that explains them. Eight cards each say what their number means and link to the page that explains it: Queues (paused, dead-letter, holding jobs nothing drains), Enqueued (claimable now vs scheduled, running or retrying), Processes (healthy · stalled · stale), Recurring, Retrying (the Jobs page's Retrying count, when the next attempt runs, how many are due now, the newest error), Dead-lettered (the newest message's reason, read as the DLQ page reads it), Throughput (jobs/min over the last 5 minutes from job stats, or "needs job stats") and Parked. The queues table gains a Status column in words (the queue page's summary line: paused and why, nothing drains it, what is waiting). Queue Health states each threshold ("12% on pgmq.q_pgbus_events — vacuum due (warns above 10%, critical above 20%)"; the MVCC card names both its own 30 s / 60 s warnings and the verdict's 300 s) and its table always renders, with an empty state. Recent failures show the job and attempt and link to the failure. New `Web::DataSource#dashboard_signals` and `#queue_status_lines` (in `DataSource::DashboardSignals`) and the pure presenters `Web::HealthVerdict` and `Web::InsightsCharts`; `summary_stats`, `/pgbus/api/stats`, `/pgbus/api/metrics` and the AppSignal probe are unchanged. On Insights each chart has a caption sentence (also the chart's accessible name) and a `<details>` data table rendered on the server (at most 60 rows: long ranges roll minutes up), empty series show an empty state, and the page draws from series it embeds instead of fetching `/pgbus/api/insights` a second time (that endpoint is unchanged). Closes #492.
@@ -54,6 +56,12 @@
54
56
 
55
57
  ### Fixed
56
58
 
59
+ - **A draining worker no longer strands its read-ahead claims.** The worker loop checked `can_process?` once to decide whether to return buffered claims, then read the lifecycle again to decide whether to exit. A drain (`graceful_shutdown`) or stop (`immediate_shutdown`) from another thread that landed between the two reads let the loop exit with the buffer still full (a pause does not exit the loop, so its claims go back on the next step), so those messages stayed invisible until their visibility timeout instead of going straight back to the queue (`vt: 0`). The worker now returns whatever is still buffered after the loop, however it exits. This was the intermittent `worker_read_ahead_spec.rb:217` failure (about 1 run in 20). Refs #486.
60
+
61
+ - **`pgbus:connected` now fires only once the connection is registered and its presence join has run.** `StreamApp` wrote the frame at hijack time, before the streamer's dispatcher had handled the connect, so a page that acted on its connection id as soon as it heard the event — `Pgbus.stream(x).presence.set_state(connection_id:)` from an action, for instance — found no row (`set_state` returned nil) and had to retry. The dispatcher now writes the frame at the end of the connect, after the archive replay, `registry.register` and the presence join attempt (a stream without presence, or a member resolver that returns nil, has no seat to join; a join that raises is logged and the frame still follows), through the connection's mutex-serialised writer (`Connection#write_connected` / `FalconConnection#write_connected`). The event name, its `connectionId` payload and the element's `connection-id` attribute are unchanged; listeners hear it after any replayed frames instead of before them, and a connection that dies during the replay never gets one. A repeated disconnect for an already-unregistered connection no longer records a second `disconnect` stream stat. Closes #521.
62
+
63
+ - **A closed tab leaves its presence seat at once, not on the next heartbeat.** The server never reads a hijacked SSE socket after the request, so a client that went away was noticed only when a heartbeat write failed: up to `streams_heartbeat_interval` later (15 s by default), and often a beat later still, because the first write after the client's FIN succeeds. Every viewer of a presence roster kept the closed tab for that long. A new `PeerCloseWatcher` thread per streamer waits on every live hijacked socket with `IO.select` and probes a readable one with a non-blocking read: EOF or a reset marks the connection dead, closes it and posts the same `DisconnectMessage` the heartbeat posts, so the seat leaves (and `streams_presence_on_change` fires once) within milliseconds on Puma and on Falcon's default hijack path; a connection closed within its first `streams_peer_close_interval` after opening is noticed within that interval. A TLS client that vanishes without close_notify (OpenSSL's or Puma MiniSSL's SSL error on read) counts as closed, the probe skips a socket while a write holds its mutex, and `#stop` wakes the select through a self-pipe so shutdown does not wait out the interval. The client sends nothing after its request, so a readable socket means a close; stray bytes are read and discarded. Falcon's experimental streaming-body path has no socket and keeps the heartbeat. New setting `streams_peer_close_interval` (1.0 s by default): how long a newly opened connection waits to join the watched set; `nil` turns the watcher off. Closes #519.
64
+
57
65
  - **Insights charts are readable in dark mode.** The charts were themed only through `foreColor`: in dark mode the donut kept white slice separators, axis borders and the crosshair label stayed light, and the "No data" fallback was `text-gray-400 dark:text-gray-500` (below WCAG AA, and invisible to the contrast guard because it lived in JS). `charts.js` now themes every chart from one `palette(isDark)` (theme mode, axis and grid colours, donut separators in the card colour, legend and tooltip, animations off under `prefers-reduced-motion`) and redraws on the dark-mode toggle without a reload. The latency headers and chart titles no longer carry "(ms)" / "(jobs/min)"; the P95 latency card's yellow is now AA. The static contrast guard scans the dashboard's JS modules too. Refs #492.
58
66
 
59
67
  - **The dashboard home page reads the process rows and the stream registry once per render.** `DataSource` memoizes the raw process rows, `stream_queue_names` and `queue_health_stats` per instance (cleared by `reset_cache!`, as `queues_with_metrics` already was), so the stats, the verdict and the panels share one query each. `HealthApp` now calls `reset_cache!` on an injected DataSource before every `/readyz` check, so a long-lived one never answers with the first check's facts (it already did that for queues). Refs #492.
data/README.md CHANGED
@@ -1443,6 +1443,7 @@ Pgbus.configure do |c|
1443
1443
  "presence_room" => 30 # 30 seconds for presence
1444
1444
  }
1445
1445
  c.streams_heartbeat_interval = 15 # seconds
1446
+ c.streams_peer_close_interval = 1.0 # notice a closed client at once; nil = heartbeat only
1446
1447
  c.streams_max_connections = 2_000 # per web-server process (Puma worker or Falcon process)
1447
1448
  c.streams_idle_timeout = 3_600 # close idle connections after 1h
1448
1449
  c.streams_listen_health_check_ms = 250 # PG LISTEN keepalive + ensure_listening ack budget
@@ -1617,16 +1618,26 @@ Pgbus::Streams.filters.register(:workspace_member) do |user, stream_name|
1617
1618
  end
1618
1619
  ```
1619
1620
 
1620
- The authorize hook on `Pgbus::Web::StreamApp` doubles as a context provider — return any non-boolean value (typically a `User` model) and pgbus will pass it to the filter predicate when evaluating broadcasts:
1621
+ The authorize hook doubles as a context provider — return any non-boolean value (typically a `User` model) and pgbus will pass it to the filter predicate when evaluating broadcasts. The engine's stream endpoint reads it from `config.streams_authorize`, so no custom mount is needed:
1621
1622
 
1622
1623
  ```ruby
1623
- Pgbus::Web::StreamApp.new(authorize: ->(env, _stream_name) {
1624
- user = User.find_by(id: env["rack.session"][:user_id])
1625
- return false unless user
1626
- user # ← context attached to the connection
1627
- })
1624
+ Pgbus.configure do |c|
1625
+ c.streams_authorize = ->(env, stream_name) {
1626
+ user = User.find_by(id: env["rack.session"]&.dig("user_id"))
1627
+ next unless user
1628
+
1629
+ # Check THIS user against THIS stream: here "room:42" names a room.
1630
+ room_id = stream_name.delete_prefix("room:") if stream_name.start_with?("room:")
1631
+ room = Room.find_by(id: room_id) if room_id
1632
+ user if room&.member?(user) # nil → 403; the user becomes the context
1633
+ }
1634
+ end
1628
1635
  ```
1629
1636
 
1637
+ Authorize per stream here. The signed stream name proves pgbus minted it, not that this user may read it: a user removed from a room keeps a working signed name until the signing key rotates (`Turbo.signed_stream_verifier_key` when turbo-rails is loaded, `streams_signed_name_secret` otherwise), so a hook that only checks "logged in" lets them keep listening. Rotating the key only refuses new connections: an SSE connection already open stays open until it closes or its worker restarts.
1638
+
1639
+ The hook gets the Rack env, not a controller (the stream endpoint is a bare Rack app), so read the session (`env["rack.session"]`) or a signed cookie from it. A falsy return answers 403, `true` authorizes with no context. An explicit `Pgbus::Web::StreamApp.new(authorize: …)` wins; every `StreamApp` built without one, the engine's mount included, uses `streams_authorize`. `validate!` rejects a non-callable value at boot; with `eager_validation = false` it is not checked there, and every stream request then fails closed with a 500.
1640
+
1630
1641
  Then label broadcasts with the filter you want to apply:
1631
1642
 
1632
1643
  ```ruby
@@ -1650,7 +1661,7 @@ An actor who just triggered a change already applied it via the HTTP response of
1650
1661
  Pgbus.stream(room).broadcast(html, exclude: connection_id)
1651
1662
  ```
1652
1663
 
1653
- Every server-minted SSE connection exposes its id to the page: right after the open handshake, pgbus sends a `pgbus:connected` frame carrying the connection id, and `<pgbus-stream-source>` captures it onto its `connection-id` attribute and re-dispatches it as a `pgbus:connected` event (also present in `pgbus:open`'s detail). The page reads that id and sends it back as the `X-Pgbus-Connection` header on the action request that triggers the broadcast:
1664
+ Every server-minted SSE connection exposes its id to the page: once the streamer has registered the connection (after the connect replay, and after its presence seat has joined on a presence stream), pgbus sends a `pgbus:connected` frame carrying the connection id, and `<pgbus-stream-source>` captures it onto its `connection-id` attribute and re-dispatches it as a `pgbus:connected` event. A write keyed by the connection id right after the event, such as `presence.set_state(connection_id:)`, finds the connection's row. The page reads that id and sends it back as the `X-Pgbus-Connection` header on the action request that triggers the broadcast:
1654
1665
 
1655
1666
  ```js
1656
1667
  document.addEventListener("pgbus:connected", (event) => {
@@ -1795,7 +1806,7 @@ Pgbus.configure do |c|
1795
1806
  end
1796
1807
  ```
1797
1808
 
1798
- Identity comes from the connection's authorize-hook context (the value your `StreamApp` `authorize:` callable returns). The built-in extractor handles the common shapes without any configuration: a `Hash` with `:member_id` (or `:id`) and optional `:metadata`, or any object responding to `#id` (e.g. a `User` model). For anything else, provide a custom extractor — a `->(context) { { id:, metadata: } }` callable returning `nil` for a context with no derivable identity (anonymous connections are simply skipped, not an error):
1809
+ Identity comes from the connection's authorize-hook context (the value `config.streams_authorize`, or your own `StreamApp` `authorize:` callable, returns). Without a hook every connection is anonymous and nobody joins. The built-in extractor handles the common shapes without any configuration: a `Hash` with `:member_id` (or `:id`) and optional `:metadata`, or any object responding to `#id` (e.g. a `User` model). For anything else, provide a custom extractor — a `->(context) { { id:, metadata: } }` callable returning `nil` for a context with no derivable identity (anonymous connections are simply skipped, not an error):
1799
1810
 
1800
1811
  ```ruby
1801
1812
  Pgbus.configure do |c|
@@ -1806,7 +1817,7 @@ Pgbus.configure do |c|
1806
1817
  end
1807
1818
  ```
1808
1819
 
1809
- Membership work runs on the dispatcher thread (which already releases AR connections each pass); the heartbeat posts a batched touch per tick. Presence failures are logged and swallowed so a presence-table hiccup can't knock a live SSE connection out of the registry.
1820
+ Membership work runs on the dispatcher thread (which already releases AR connections each pass); the heartbeat posts a batched touch per tick. A closed tab leaves at once (within `streams_peer_close_interval` for a connection that just opened): a watcher thread notices the client's close on the socket (Puma and Falcon's hijack path) instead of waiting for the next heartbeat write to fail. Falcon's experimental streaming-body path still leaves on the heartbeat. Presence failures are logged and swallowed so a presence-table hiccup can't knock a live SSE connection out of the registry.
1810
1821
 
1811
1822
  #### Seats: one row per tab (experimental)
1812
1823
 
@@ -37,8 +37,9 @@
37
37
  // pgbus:close { code, reason }
38
38
  //
39
39
  // Connection id (issue #165 — actor-echo suppression): the server sends a
40
- // `pgbus:connected` frame right after the open handshake carrying the
41
- // server-minted connection id. This element captures it, reflects it onto
40
+ // `pgbus:connected` frame carrying the server-minted connection id once the
41
+ // streamer has registered the connection and joined its presence seat
42
+ // (issue #521), after the connect replay. This element captures it, reflects it onto
42
43
  // the `connection-id` attribute, and re-dispatches it as `pgbus:connected`.
43
44
  // A page reads it (from the element or a `<meta name="pgbus-connection-id">`
44
45
  // the app mirrors it to) and sends it back as the `X-Pgbus-Connection`
@@ -1 +1 @@
1
- {"version":3,"sources":["src/stream_source_element.js"],"sourcesContent":["// <pgbus-stream-source> — the browser-side counterpart to\n// Pgbus::StreamsHelper#pgbus_stream_from. Drop-in replacement for\n// turbo-rails' <turbo-cable-stream-source> that speaks SSE + pgbus\n// instead of WebSocket + ActionCable.\n//\n// Attributes (set by the view helper):\n// src — absolute path to the SSE endpoint, including\n// the URL-safe signed stream name\n// since-id — PGMQ msg_id watermark at render time; the\n// client sends this as ?since= on the FIRST\n// connect so the server can replay any\n// broadcasts from the render-to-connect gap\n// (rails/rails#52420 fix)\n// signed-stream-name — present for parity with turbo-rails; unused\n// by this element because the signed name is\n// already in the URL path\n// channel — compatibility shim; ignored\n// presence — boolean; when present the element sends this\n// document's presence seat as ?seat= on every\n// connect (issue #515)\n//\n// Events (dispatched on the element):\n// message (MessageEvent) data = turbo-stream HTML;\n// lastEventId = the frame's msg_id (revision)\n// pgbus:message { msgId, data } — same frame, for optimistic-UI\n// reconciliation (skip morph if a newer rev for the\n// target was already applied). See #168.\n// pgbus:event { event, data, msgId } — a typed broadcast (any SSE\n// event name other than turbo-stream). See #170.\n// pgbus:<event> { data, msgId } — the same typed broadcast, named\n// for ergonomic addEventListener(\"pgbus:presence\").\n// pgbus:open { lastEventId, connectionId }\n// pgbus:connected { connectionId }\n// pgbus:replay-start { fromId, toId }\n// pgbus:replay-end {}\n// pgbus:gap-detected { lastSeenId, archiveOldestId }\n// pgbus:close { code, reason }\n//\n// Connection id (issue #165 — actor-echo suppression): the server sends a\n// `pgbus:connected` frame right after the open handshake carrying the\n// server-minted connection id. This element captures it, reflects it onto\n// the `connection-id` attribute, and re-dispatches it as `pgbus:connected`.\n// A page reads it (from the element or a `<meta name=\"pgbus-connection-id\">`\n// the app mirrors it to) and sends it back as the `X-Pgbus-Connection`\n// header on action requests. The broadcaster then passes\n// `exclude: request.headers[\"X-Pgbus-Connection\"]` so the actor does not\n// receive the echo of its own broadcast.\n//\n// The element integrates with Turbo via connectStreamSource /\n// disconnectStreamSource + dispatching MessageEvent(\"message\") so Turbo\n// Stream HTML is automatically consumed by the existing StreamObserver.\n//\n// Presence seat (issue #515): with the `presence` attribute, the element\n// appends `?seat=<id>` to the SSE request. The seat is minted once per\n// document (shared by every presence element on the page and kept across\n// Turbo Drive visits, so it names the browser tab) and stays the same across\n// reconnects. The server stores it on the connection's presence row, which\n// lets a page tell its own tab apart from the member's other tabs. Read it\n// from `element.pgbusSeat`.\n//\n// Transport strategy:\n// - FIRST connect: use fetch() + ReadableStream to include ?since=\n// in the URL. Native EventSource cannot send Last-Event-ID on the\n// initial request, so we need fetch to carry the watermark.\n// - RECONNECT: switch to native EventSource, which sends Last-Event-ID\n// automatically based on the last id: we observed. The native\n// client is more battle-tested for reconnection backoff.\n\nimport { Turbo } from \"@hotwired/turbo-rails\"\nconst { connectStreamSource, disconnectStreamSource } = Turbo\n\nlet documentSeat = null\n\n// One seat per document, minted on first use (see \"Presence seat\" above).\nfunction seatForDocument() {\n if (!documentSeat) {\n documentSeat = globalThis.crypto && crypto.randomUUID\n ? crypto.randomUUID()\n : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`\n }\n return documentSeat\n}\n\nclass PgbusStreamSourceElement extends HTMLElement {\n static get observedAttributes() {\n return [\"src\", \"since-id\"]\n }\n\n constructor() {\n super()\n this.abortController = null\n this.eventSource = null\n this.lastEventId = null\n this.connectionId = null\n this.closed = false\n }\n\n // The server-minted connection id for this SSE connection, or null\n // until the `pgbus:connected` frame arrives. Public read accessor so\n // a reactive runtime can grab it without poking at attributes.\n get pgbusConnectionId() {\n return this.connectionId\n }\n\n // This document's presence seat when the element has the `presence`\n // attribute, otherwise null.\n get pgbusSeat() {\n return this.hasAttribute(\"presence\") ? seatForDocument() : null\n }\n\n connectedCallback() {\n this.closed = false\n connectStreamSource(this)\n const sinceId = this.getAttribute(\"since-id\")\n this.lastEventId = sinceId && sinceId !== \"\" ? sinceId : null\n this.openFetchStream()\n }\n\n disconnectedCallback() {\n this.closed = true\n disconnectStreamSource(this)\n this.teardown()\n }\n\n teardown() {\n if (this.abortController) {\n this.abortController.abort()\n this.abortController = null\n }\n if (this.eventSource) {\n this.eventSource.close()\n this.eventSource = null\n }\n }\n\n // First connect: use fetch() so we can include ?since=<watermark> on\n // the URL. Parses the SSE event stream by hand because EventSource\n // doesn't expose custom query strings uniformly across browsers.\n async openFetchStream() {\n // Each transport open is a fresh connection: the server mints a new id\n // and sends a new pgbus:connected frame. Clear the previous id so\n // pgbus:open (which fires before that frame arrives) can't surface a\n // stale connection id — a stale id would produce a wrong X-Pgbus-Connection\n // exclude header during reconnect windows.\n this.resetConnectionId()\n const url = this.buildUrl({ includeSince: true })\n this.abortController = new AbortController()\n\n try {\n const response = await fetch(url, {\n headers: { Accept: \"text/event-stream\" },\n credentials: \"same-origin\",\n signal: this.abortController.signal\n })\n\n if (!response.ok) {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: response.status, reason: response.statusText }\n }))\n return\n }\n\n this.setAttribute(\"connected\", \"\")\n this.dispatchEvent(new CustomEvent(\"pgbus:open\", {\n detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }\n }))\n\n const reader = response.body.getReader()\n const decoder = new TextDecoder(\"utf-8\")\n let buffer = \"\"\n\n while (!this.closed) {\n const { value, done } = await reader.read()\n if (done) {\n this.removeAttribute(\"connected\")\n this.switchToEventSource()\n return\n }\n\n buffer += decoder.decode(value, { stream: true })\n const events = buffer.split(\"\\n\\n\")\n buffer = events.pop() // last chunk may be incomplete\n\n for (const block of events) {\n this.handleBlock(block)\n }\n }\n } catch (err) {\n if (err.name !== \"AbortError\") {\n this.removeAttribute(\"connected\")\n // Fall through to reconnect via native EventSource, which\n // will carry Last-Event-ID from this.lastEventId forward.\n this.switchToEventSource()\n }\n }\n }\n\n // Reconnect path: native EventSource with Last-Event-ID baked into\n // the initial handshake by the browser.\n switchToEventSource() {\n if (this.closed) return\n\n // Fresh connection on reconnect — drop the previous connection id so\n // pgbus:open can't emit a stale one before the new pgbus:connected\n // frame lands. See openFetchStream.\n this.resetConnectionId()\n const url = this.buildUrl({ includeSince: true })\n this.eventSource = new EventSource(url, { withCredentials: true })\n\n this.eventSource.addEventListener(\"open\", () => {\n this.setAttribute(\"connected\", \"\")\n this.dispatchEvent(new CustomEvent(\"pgbus:open\", {\n detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }\n }))\n })\n\n this.eventSource.addEventListener(\"error\", () => {\n this.removeAttribute(\"connected\")\n })\n\n this.eventSource.addEventListener(\"turbo-stream\", (event) => {\n this.lastEventId = event.lastEventId\n this.emitTurboStream(event.data, event.lastEventId)\n })\n\n this.eventSource.addEventListener(\"pgbus:connected\", (event) => {\n this.handleConnected(event.data)\n })\n\n this.eventSource.addEventListener(\"pgbus:gap-detected\", (event) => {\n const detail = this.safeJsonParse(event.data)\n this.dispatchEvent(new CustomEvent(\"pgbus:gap-detected\", { detail }))\n })\n\n this.eventSource.addEventListener(\"pgbus:shutdown\", () => {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: \"shutdown\", reason: \"worker restart\" }\n }))\n })\n\n // Typed broadcasts (issue #170): EventSource only fires listeners\n // registered by name, so we register one per declared typed event.\n for (const name of this.declaredTypedEvents()) {\n this.eventSource.addEventListener(name, (event) => {\n if (event.lastEventId) this.lastEventId = event.lastEventId\n this.emitTypedEvent(name, event.data, event.lastEventId)\n })\n }\n }\n\n // Parses a single SSE event block (: comment | id: ... | event: ... | data: ...)\n handleBlock(block) {\n if (!block || block.startsWith(\":\")) return // comment\n\n let id = null\n let event = \"message\"\n const dataLines = []\n\n for (const line of block.split(\"\\n\")) {\n if (line.startsWith(\"id:\")) id = line.slice(3).trim()\n else if (line.startsWith(\"event:\")) event = line.slice(6).trim()\n // Per the SSE spec: strip only a single leading space after the colon\n // (never trim — payload whitespace is significant, issue #392) and\n // rejoin consecutive data: lines with \\n, matching native EventSource.\n else if (line.startsWith(\"data:\")) dataLines.push(line.slice(5).replace(/^ /, \"\"))\n }\n const data = dataLines.join(\"\\n\")\n\n if (id !== null) this.lastEventId = id\n\n if (event === \"turbo-stream\") {\n this.emitTurboStream(data, id)\n } else if (event === \"pgbus:connected\") {\n this.handleConnected(data)\n } else if (event === \"pgbus:gap-detected\") {\n this.dispatchEvent(new CustomEvent(\"pgbus:gap-detected\", {\n detail: this.safeJsonParse(data)\n }))\n } else if (event === \"pgbus:shutdown\") {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: \"shutdown\", reason: \"worker restart\" }\n }))\n } else {\n // A typed broadcast (issue #170): event: presence | reactive | ...\n this.emitTypedEvent(event, data, id)\n }\n }\n\n // Captures the server-minted connection id from a `pgbus:connected`\n // frame: stores it, reflects it onto the `connection-id` attribute (so\n // it's visible in the DOM / to MutationObservers), and re-dispatches it\n // as a `pgbus:connected` CustomEvent. Idempotent across reconnects — the\n // server mints a fresh id per connection, so a reconnect updates it.\n handleConnected(data) {\n const detail = this.safeJsonParse(data)\n const connectionId = detail && detail.connectionId\n if (!connectionId) return\n\n this.connectionId = connectionId\n this.setAttribute(\"connection-id\", connectionId)\n this.dispatchEvent(new CustomEvent(\"pgbus:connected\", {\n detail: { connectionId }\n }))\n }\n\n // Clears the cached connection id and its reflected attribute. Called at\n // the start of every transport open so a reconnect doesn't carry the\n // previous connection's id into pgbus:open before the new pgbus:connected\n // frame arrives.\n resetConnectionId() {\n this.connectionId = null\n this.removeAttribute(\"connection-id\")\n }\n\n // Delivers a turbo-stream frame to two audiences (issue #168):\n //\n // 1. Turbo's StreamObserver, via a `message` MessageEvent whose `data`\n // is the turbo-stream HTML. The MessageEvent's standard\n // `lastEventId` field carries the frame's msg_id, so a reactive\n // runtime that listens for `message` can read the revision without\n // any pgbus-specific API. (Turbo ignores lastEventId.)\n //\n // 2. A reactive runtime doing optimistic-UI reconciliation, via a\n // `pgbus:message` CustomEvent carrying { msgId, data }. msgId is the\n // monotonic per-stream revision (a negative value marks an ephemeral\n // frame that was never persisted). Pattern: track the highest msgId\n // you have applied per target; when a frame arrives, skip the morph\n // if you have already applied a newer revision for that target —\n // this stops a late echo from clobbering a newer optimistic edit.\n // Complements #165: exclude handles the actor; this handles\n // out-of-order delivery for everyone else.\n //\n // msgId is parsed to a Number when numeric so consumers can compare\n // revisions with `>` directly; left as-is otherwise.\n emitTurboStream(data, id) {\n const msgId = id === null || id === undefined || id === \"\" ? null\n : (Number.isNaN(Number(id)) ? id : Number(id))\n\n const message = new MessageEvent(\"message\", { data, lastEventId: id == null ? \"\" : String(id) })\n this.dispatchEvent(message)\n\n this.dispatchEvent(new CustomEvent(\"pgbus:message\", {\n detail: { msgId, data }\n }))\n }\n\n // Delivers a typed broadcast (issue #170) — a frame whose SSE event name\n // is something other than turbo-stream (e.g. \"presence\", \"reactive\").\n // The payload is still whatever the broadcaster sent (usually a Turbo\n // Stream); the typed name lets a client route without sniffing the HTML.\n // Dispatched two ways for ergonomics:\n // - a generic `pgbus:event` { event, data, msgId } (one listener for all)\n // - a named `pgbus:<event>` { data, msgId } (addEventListener by name)\n emitTypedEvent(event, data, id) {\n const msgId = id === null || id === undefined || id === \"\" ? null\n : (Number.isNaN(Number(id)) ? id : Number(id))\n\n this.dispatchEvent(new CustomEvent(\"pgbus:event\", {\n detail: { event, data, msgId }\n }))\n this.dispatchEvent(new CustomEvent(`pgbus:${event}`, {\n detail: { data, msgId }\n }))\n }\n\n // Typed event names the EventSource (reconnect) path should listen for,\n // declared by the app via the `listen-events` attribute (comma- or\n // space-separated). EventSource only invokes listeners registered by\n // name, so unlike the fetch path it cannot route unknown typed events\n // generically; declaring them here keeps typed delivery working across\n // reconnects. The fetch (initial) path always routes typed events.\n declaredTypedEvents() {\n const raw = this.getAttribute(\"listen-events\")\n if (!raw) return []\n return raw.split(/[\\s,]+/).filter((name) => name && name !== \"turbo-stream\")\n }\n\n buildUrl({ includeSince }) {\n const src = this.getAttribute(\"src\")\n const since = includeSince && this.lastEventId ? this.lastEventId : null\n const seat = this.pgbusSeat\n if (!since && !seat) return src\n\n const url = new URL(src, window.location.origin)\n if (since) url.searchParams.set(\"since\", since)\n if (seat) url.searchParams.set(\"seat\", seat)\n return url.toString()\n }\n\n safeJsonParse(str) {\n try { return JSON.parse(str) } catch { return null }\n }\n}\n\nif (!customElements.get(\"pgbus-stream-source\")) {\n customElements.define(\"pgbus-stream-source\", PgbusStreamSourceElement)\n}\n\nexport { PgbusStreamSourceElement }\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["src/stream_source_element.js"],"sourcesContent":["// <pgbus-stream-source> — the browser-side counterpart to\n// Pgbus::StreamsHelper#pgbus_stream_from. Drop-in replacement for\n// turbo-rails' <turbo-cable-stream-source> that speaks SSE + pgbus\n// instead of WebSocket + ActionCable.\n//\n// Attributes (set by the view helper):\n// src — absolute path to the SSE endpoint, including\n// the URL-safe signed stream name\n// since-id — PGMQ msg_id watermark at render time; the\n// client sends this as ?since= on the FIRST\n// connect so the server can replay any\n// broadcasts from the render-to-connect gap\n// (rails/rails#52420 fix)\n// signed-stream-name — present for parity with turbo-rails; unused\n// by this element because the signed name is\n// already in the URL path\n// channel — compatibility shim; ignored\n// presence — boolean; when present the element sends this\n// document's presence seat as ?seat= on every\n// connect (issue #515)\n//\n// Events (dispatched on the element):\n// message (MessageEvent) data = turbo-stream HTML;\n// lastEventId = the frame's msg_id (revision)\n// pgbus:message { msgId, data } — same frame, for optimistic-UI\n// reconciliation (skip morph if a newer rev for the\n// target was already applied). See #168.\n// pgbus:event { event, data, msgId } — a typed broadcast (any SSE\n// event name other than turbo-stream). See #170.\n// pgbus:<event> { data, msgId } — the same typed broadcast, named\n// for ergonomic addEventListener(\"pgbus:presence\").\n// pgbus:open { lastEventId, connectionId }\n// pgbus:connected { connectionId }\n// pgbus:replay-start { fromId, toId }\n// pgbus:replay-end {}\n// pgbus:gap-detected { lastSeenId, archiveOldestId }\n// pgbus:close { code, reason }\n//\n// Connection id (issue #165 — actor-echo suppression): the server sends a\n// `pgbus:connected` frame carrying the server-minted connection id once the\n// streamer has registered the connection and joined its presence seat\n// (issue #521), after the connect replay. This element captures it, reflects it onto\n// the `connection-id` attribute, and re-dispatches it as `pgbus:connected`.\n// A page reads it (from the element or a `<meta name=\"pgbus-connection-id\">`\n// the app mirrors it to) and sends it back as the `X-Pgbus-Connection`\n// header on action requests. The broadcaster then passes\n// `exclude: request.headers[\"X-Pgbus-Connection\"]` so the actor does not\n// receive the echo of its own broadcast.\n//\n// The element integrates with Turbo via connectStreamSource /\n// disconnectStreamSource + dispatching MessageEvent(\"message\") so Turbo\n// Stream HTML is automatically consumed by the existing StreamObserver.\n//\n// Presence seat (issue #515): with the `presence` attribute, the element\n// appends `?seat=<id>` to the SSE request. The seat is minted once per\n// document (shared by every presence element on the page and kept across\n// Turbo Drive visits, so it names the browser tab) and stays the same across\n// reconnects. The server stores it on the connection's presence row, which\n// lets a page tell its own tab apart from the member's other tabs. Read it\n// from `element.pgbusSeat`.\n//\n// Transport strategy:\n// - FIRST connect: use fetch() + ReadableStream to include ?since=\n// in the URL. Native EventSource cannot send Last-Event-ID on the\n// initial request, so we need fetch to carry the watermark.\n// - RECONNECT: switch to native EventSource, which sends Last-Event-ID\n// automatically based on the last id: we observed. The native\n// client is more battle-tested for reconnection backoff.\n\nimport { Turbo } from \"@hotwired/turbo-rails\"\nconst { connectStreamSource, disconnectStreamSource } = Turbo\n\nlet documentSeat = null\n\n// One seat per document, minted on first use (see \"Presence seat\" above).\nfunction seatForDocument() {\n if (!documentSeat) {\n documentSeat = globalThis.crypto && crypto.randomUUID\n ? crypto.randomUUID()\n : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`\n }\n return documentSeat\n}\n\nclass PgbusStreamSourceElement extends HTMLElement {\n static get observedAttributes() {\n return [\"src\", \"since-id\"]\n }\n\n constructor() {\n super()\n this.abortController = null\n this.eventSource = null\n this.lastEventId = null\n this.connectionId = null\n this.closed = false\n }\n\n // The server-minted connection id for this SSE connection, or null\n // until the `pgbus:connected` frame arrives. Public read accessor so\n // a reactive runtime can grab it without poking at attributes.\n get pgbusConnectionId() {\n return this.connectionId\n }\n\n // This document's presence seat when the element has the `presence`\n // attribute, otherwise null.\n get pgbusSeat() {\n return this.hasAttribute(\"presence\") ? seatForDocument() : null\n }\n\n connectedCallback() {\n this.closed = false\n connectStreamSource(this)\n const sinceId = this.getAttribute(\"since-id\")\n this.lastEventId = sinceId && sinceId !== \"\" ? sinceId : null\n this.openFetchStream()\n }\n\n disconnectedCallback() {\n this.closed = true\n disconnectStreamSource(this)\n this.teardown()\n }\n\n teardown() {\n if (this.abortController) {\n this.abortController.abort()\n this.abortController = null\n }\n if (this.eventSource) {\n this.eventSource.close()\n this.eventSource = null\n }\n }\n\n // First connect: use fetch() so we can include ?since=<watermark> on\n // the URL. Parses the SSE event stream by hand because EventSource\n // doesn't expose custom query strings uniformly across browsers.\n async openFetchStream() {\n // Each transport open is a fresh connection: the server mints a new id\n // and sends a new pgbus:connected frame. Clear the previous id so\n // pgbus:open (which fires before that frame arrives) can't surface a\n // stale connection id — a stale id would produce a wrong X-Pgbus-Connection\n // exclude header during reconnect windows.\n this.resetConnectionId()\n const url = this.buildUrl({ includeSince: true })\n this.abortController = new AbortController()\n\n try {\n const response = await fetch(url, {\n headers: { Accept: \"text/event-stream\" },\n credentials: \"same-origin\",\n signal: this.abortController.signal\n })\n\n if (!response.ok) {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: response.status, reason: response.statusText }\n }))\n return\n }\n\n this.setAttribute(\"connected\", \"\")\n this.dispatchEvent(new CustomEvent(\"pgbus:open\", {\n detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }\n }))\n\n const reader = response.body.getReader()\n const decoder = new TextDecoder(\"utf-8\")\n let buffer = \"\"\n\n while (!this.closed) {\n const { value, done } = await reader.read()\n if (done) {\n this.removeAttribute(\"connected\")\n this.switchToEventSource()\n return\n }\n\n buffer += decoder.decode(value, { stream: true })\n const events = buffer.split(\"\\n\\n\")\n buffer = events.pop() // last chunk may be incomplete\n\n for (const block of events) {\n this.handleBlock(block)\n }\n }\n } catch (err) {\n if (err.name !== \"AbortError\") {\n this.removeAttribute(\"connected\")\n // Fall through to reconnect via native EventSource, which\n // will carry Last-Event-ID from this.lastEventId forward.\n this.switchToEventSource()\n }\n }\n }\n\n // Reconnect path: native EventSource with Last-Event-ID baked into\n // the initial handshake by the browser.\n switchToEventSource() {\n if (this.closed) return\n\n // Fresh connection on reconnect — drop the previous connection id so\n // pgbus:open can't emit a stale one before the new pgbus:connected\n // frame lands. See openFetchStream.\n this.resetConnectionId()\n const url = this.buildUrl({ includeSince: true })\n this.eventSource = new EventSource(url, { withCredentials: true })\n\n this.eventSource.addEventListener(\"open\", () => {\n this.setAttribute(\"connected\", \"\")\n this.dispatchEvent(new CustomEvent(\"pgbus:open\", {\n detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }\n }))\n })\n\n this.eventSource.addEventListener(\"error\", () => {\n this.removeAttribute(\"connected\")\n })\n\n this.eventSource.addEventListener(\"turbo-stream\", (event) => {\n this.lastEventId = event.lastEventId\n this.emitTurboStream(event.data, event.lastEventId)\n })\n\n this.eventSource.addEventListener(\"pgbus:connected\", (event) => {\n this.handleConnected(event.data)\n })\n\n this.eventSource.addEventListener(\"pgbus:gap-detected\", (event) => {\n const detail = this.safeJsonParse(event.data)\n this.dispatchEvent(new CustomEvent(\"pgbus:gap-detected\", { detail }))\n })\n\n this.eventSource.addEventListener(\"pgbus:shutdown\", () => {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: \"shutdown\", reason: \"worker restart\" }\n }))\n })\n\n // Typed broadcasts (issue #170): EventSource only fires listeners\n // registered by name, so we register one per declared typed event.\n for (const name of this.declaredTypedEvents()) {\n this.eventSource.addEventListener(name, (event) => {\n if (event.lastEventId) this.lastEventId = event.lastEventId\n this.emitTypedEvent(name, event.data, event.lastEventId)\n })\n }\n }\n\n // Parses a single SSE event block (: comment | id: ... | event: ... | data: ...)\n handleBlock(block) {\n if (!block || block.startsWith(\":\")) return // comment\n\n let id = null\n let event = \"message\"\n const dataLines = []\n\n for (const line of block.split(\"\\n\")) {\n if (line.startsWith(\"id:\")) id = line.slice(3).trim()\n else if (line.startsWith(\"event:\")) event = line.slice(6).trim()\n // Per the SSE spec: strip only a single leading space after the colon\n // (never trim — payload whitespace is significant, issue #392) and\n // rejoin consecutive data: lines with \\n, matching native EventSource.\n else if (line.startsWith(\"data:\")) dataLines.push(line.slice(5).replace(/^ /, \"\"))\n }\n const data = dataLines.join(\"\\n\")\n\n if (id !== null) this.lastEventId = id\n\n if (event === \"turbo-stream\") {\n this.emitTurboStream(data, id)\n } else if (event === \"pgbus:connected\") {\n this.handleConnected(data)\n } else if (event === \"pgbus:gap-detected\") {\n this.dispatchEvent(new CustomEvent(\"pgbus:gap-detected\", {\n detail: this.safeJsonParse(data)\n }))\n } else if (event === \"pgbus:shutdown\") {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: \"shutdown\", reason: \"worker restart\" }\n }))\n } else {\n // A typed broadcast (issue #170): event: presence | reactive | ...\n this.emitTypedEvent(event, data, id)\n }\n }\n\n // Captures the server-minted connection id from a `pgbus:connected`\n // frame: stores it, reflects it onto the `connection-id` attribute (so\n // it's visible in the DOM / to MutationObservers), and re-dispatches it\n // as a `pgbus:connected` CustomEvent. Idempotent across reconnects — the\n // server mints a fresh id per connection, so a reconnect updates it.\n handleConnected(data) {\n const detail = this.safeJsonParse(data)\n const connectionId = detail && detail.connectionId\n if (!connectionId) return\n\n this.connectionId = connectionId\n this.setAttribute(\"connection-id\", connectionId)\n this.dispatchEvent(new CustomEvent(\"pgbus:connected\", {\n detail: { connectionId }\n }))\n }\n\n // Clears the cached connection id and its reflected attribute. Called at\n // the start of every transport open so a reconnect doesn't carry the\n // previous connection's id into pgbus:open before the new pgbus:connected\n // frame arrives.\n resetConnectionId() {\n this.connectionId = null\n this.removeAttribute(\"connection-id\")\n }\n\n // Delivers a turbo-stream frame to two audiences (issue #168):\n //\n // 1. Turbo's StreamObserver, via a `message` MessageEvent whose `data`\n // is the turbo-stream HTML. The MessageEvent's standard\n // `lastEventId` field carries the frame's msg_id, so a reactive\n // runtime that listens for `message` can read the revision without\n // any pgbus-specific API. (Turbo ignores lastEventId.)\n //\n // 2. A reactive runtime doing optimistic-UI reconciliation, via a\n // `pgbus:message` CustomEvent carrying { msgId, data }. msgId is the\n // monotonic per-stream revision (a negative value marks an ephemeral\n // frame that was never persisted). Pattern: track the highest msgId\n // you have applied per target; when a frame arrives, skip the morph\n // if you have already applied a newer revision for that target —\n // this stops a late echo from clobbering a newer optimistic edit.\n // Complements #165: exclude handles the actor; this handles\n // out-of-order delivery for everyone else.\n //\n // msgId is parsed to a Number when numeric so consumers can compare\n // revisions with `>` directly; left as-is otherwise.\n emitTurboStream(data, id) {\n const msgId = id === null || id === undefined || id === \"\" ? null\n : (Number.isNaN(Number(id)) ? id : Number(id))\n\n const message = new MessageEvent(\"message\", { data, lastEventId: id == null ? \"\" : String(id) })\n this.dispatchEvent(message)\n\n this.dispatchEvent(new CustomEvent(\"pgbus:message\", {\n detail: { msgId, data }\n }))\n }\n\n // Delivers a typed broadcast (issue #170) — a frame whose SSE event name\n // is something other than turbo-stream (e.g. \"presence\", \"reactive\").\n // The payload is still whatever the broadcaster sent (usually a Turbo\n // Stream); the typed name lets a client route without sniffing the HTML.\n // Dispatched two ways for ergonomics:\n // - a generic `pgbus:event` { event, data, msgId } (one listener for all)\n // - a named `pgbus:<event>` { data, msgId } (addEventListener by name)\n emitTypedEvent(event, data, id) {\n const msgId = id === null || id === undefined || id === \"\" ? null\n : (Number.isNaN(Number(id)) ? id : Number(id))\n\n this.dispatchEvent(new CustomEvent(\"pgbus:event\", {\n detail: { event, data, msgId }\n }))\n this.dispatchEvent(new CustomEvent(`pgbus:${event}`, {\n detail: { data, msgId }\n }))\n }\n\n // Typed event names the EventSource (reconnect) path should listen for,\n // declared by the app via the `listen-events` attribute (comma- or\n // space-separated). EventSource only invokes listeners registered by\n // name, so unlike the fetch path it cannot route unknown typed events\n // generically; declaring them here keeps typed delivery working across\n // reconnects. The fetch (initial) path always routes typed events.\n declaredTypedEvents() {\n const raw = this.getAttribute(\"listen-events\")\n if (!raw) return []\n return raw.split(/[\\s,]+/).filter((name) => name && name !== \"turbo-stream\")\n }\n\n buildUrl({ includeSince }) {\n const src = this.getAttribute(\"src\")\n const since = includeSince && this.lastEventId ? this.lastEventId : null\n const seat = this.pgbusSeat\n if (!since && !seat) return src\n\n const url = new URL(src, window.location.origin)\n if (since) url.searchParams.set(\"since\", since)\n if (seat) url.searchParams.set(\"seat\", seat)\n return url.toString()\n }\n\n safeJsonParse(str) {\n try { return JSON.parse(str) } catch { return null }\n }\n}\n\nif (!customElements.get(\"pgbus-stream-source\")) {\n customElements.define(\"pgbus-stream-source\", PgbusStreamSourceElement)\n}\n\nexport { PgbusStreamSourceElement }\n"],"mappings":"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","names":[]}
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Configuration
5
+ # The authorize hook the engine-mounted StreamApp uses (issue #522). Same
6
+ # contract as StreamApp.new(authorize:):
7
+ #
8
+ # config.streams_authorize = ->(env, stream_name) {
9
+ # user = current_user_from(env)
10
+ # user if user && may_read?(user, stream_name)
11
+ # }
12
+ #
13
+ # A falsy return answers 403, `true` authorizes with no context, any other
14
+ # value becomes the connection context (what visible_to: filters and
15
+ # connection-driven presence read). nil (the default) authorizes every
16
+ # connection with no context. An app that mounts its own
17
+ # StreamApp.new(authorize:) keeps its hook; this one is not consulted.
18
+ #
19
+ # Authorize per stream: the signed name proves pgbus minted it, not that
20
+ # this user may read it (a removed member keeps a working name until the
21
+ # secret rotates). A non-callable value fails validate!; with
22
+ # eager_validation = false every stream request fails closed with a 500.
23
+ module StreamAuthorization
24
+ attr_accessor :streams_authorize
25
+
26
+ private
27
+
28
+ def validate_stream_authorization!
29
+ return if streams_authorize.nil? || streams_authorize.respond_to?(:call)
30
+
31
+ raise Pgbus::ConfigurationError,
32
+ "streams_authorize must respond to #call (a ->(env, stream_name) { } lambda) or be nil"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -5,6 +5,7 @@ require "logger"
5
5
  module Pgbus
6
6
  class Configuration
7
7
  include PresenceSettings
8
+ include StreamAuthorization
8
9
 
9
10
  # Connection settings
10
11
  attr_accessor :database_url, :connection_params, :pool_size, :pool_timeout
@@ -217,7 +218,7 @@ module Pgbus
217
218
 
218
219
  # Streams (turbo-rails replacement, SSE-based)
219
220
  attr_accessor :streams_enabled, :streams_path, :streams_signed_name_secret,
220
- :streams_default_retention, :streams_retention, :streams_heartbeat_interval,
221
+ :streams_default_retention, :streams_retention, :streams_heartbeat_interval, :streams_peer_close_interval,
221
222
  :streams_max_connections, :streams_idle_timeout, :streams_listen_health_check_ms,
222
223
  :streams_write_deadline_ms, :streams_falcon_streaming_body,
223
224
  :streams_stats_enabled, :streams_test_mode,
@@ -461,6 +462,10 @@ module Pgbus
461
462
  @streams_default_retention = 5 * 60 # 5 minutes
462
463
  @streams_retention = {}
463
464
  @streams_heartbeat_interval = 15
465
+ # Seconds a new SSE connection waits to join the peer-close watch; a
466
+ # client that closes a watched socket is noticed at once (issue #519).
467
+ # nil turns the watcher off and leaves disconnects to the heartbeat.
468
+ @streams_peer_close_interval = 1.0
464
469
  @streams_max_connections = 2_000
465
470
  @streams_idle_timeout = 3_600 # 1 hour
466
471
  # 250ms — this value plays two roles: (1) the TCP keepalive
@@ -1028,6 +1033,11 @@ module Pgbus
1028
1033
  raise Pgbus::ConfigurationError, "streams_heartbeat_interval must be a positive number"
1029
1034
  end
1030
1035
 
1036
+ unless streams_peer_close_interval.nil? ||
1037
+ (streams_peer_close_interval.is_a?(Numeric) && streams_peer_close_interval.positive?)
1038
+ raise Pgbus::ConfigurationError, "streams_peer_close_interval must be nil or a positive number"
1039
+ end
1040
+
1031
1041
  unless streams_idle_timeout.is_a?(Numeric) && streams_idle_timeout.positive?
1032
1042
  raise Pgbus::ConfigurationError, "streams_idle_timeout must be a positive number"
1033
1043
  end
@@ -1096,6 +1106,7 @@ module Pgbus
1096
1106
  end
1097
1107
 
1098
1108
  validate_presence_settings!
1109
+ validate_stream_authorization!
1099
1110
 
1100
1111
  return if streams_orphan_threshold.nil?
1101
1112
  return if streams_orphan_threshold.is_a?(Numeric) && streams_orphan_threshold.positive?
@@ -210,6 +210,11 @@ module Pgbus
210
210
  @wake_signal.wait(timeout: config.polling_interval) if @lifecycle.draining? || @lifecycle.paused?
211
211
  end
212
212
 
213
+ # The lifecycle can change on another thread between the
214
+ # return_claims check and the breaks above, so a loop that exits with
215
+ # the state it never returned claims for would strand them until their
216
+ # visibility timeout. Nothing buffered outlives the loop.
217
+ return_claims
213
218
  shutdown
214
219
  end
215
220
 
@@ -49,8 +49,9 @@ module Pgbus
49
49
  end
50
50
 
51
51
  # Emits a `pgbus:connected` frame carrying the server-minted
52
- # connection id as JSON. Sent once, right after the open handshake,
53
- # so the page can read its own connection id and send it back as
52
+ # connection id as JSON. Sent once by the streamer's Dispatcher, after
53
+ # the connection is registered and its presence seat joined (issue
54
+ # #521), so the page can read its own connection id and send it back as
54
55
  # `X-Pgbus-Connection` on action requests (actor-echo suppression,
55
56
  # issue #165). Deliberately omits an `id:` line: this is connection
56
57
  # metadata, not a broadcast, and giving it a cursor id would corrupt
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.17.3"
4
+ VERSION = "0.18.1"
5
5
  end
@@ -16,13 +16,16 @@ module Pgbus
16
16
  # Call flow (happy path, Puma 6.1+ hijack):
17
17
  # 1. Parse the signed name from PATH_INFO
18
18
  # 2. Verify it via Pgbus::Streams::SignedName (raises → 404)
19
- # 3. Run the authorize hook (raises → 403)
19
+ # 3. Run the authorize hook: authorize:, else config.streams_authorize
20
+ # (falsy → 403)
20
21
  # 4. Parse the cursor from ?since= or Last-Event-ID
21
22
  # 5. Check streams_max_connections per worker (over → 503)
22
23
  # 6. Check rack.hijack? (missing → 501)
23
24
  # 7. Hijack, write the HTTP response line + SSE headers + opening
24
25
  # comment directly to the socket
25
- # 8. Build a Connection, hand to Streamer.current.register(...)
26
+ # 8. Build a Connection, hand to Streamer.current.register(...). The
27
+ # Dispatcher writes the pgbus:connected frame once the connection
28
+ # is registered and its presence seat joined (issue #521)
26
29
  # 9. Return [-1, {}, []] (Puma's async protocol)
27
30
  #
28
31
  # On errors, returns a normal [status, headers, body] response so
@@ -38,11 +41,15 @@ module Pgbus
38
41
  TEST_MODE_RETRY_MS = 86_400_000
39
42
  private_constant :PATH_PREFIX
40
43
 
44
+ # Used when neither authorize: nor config.streams_authorize is set.
45
+ ALLOW_ALL = ->(_env, _stream_name) { true }
46
+ private_constant :ALLOW_ALL
47
+
41
48
  def initialize(streamer: nil, config: nil, logger: nil, authorize: nil)
42
49
  @streamer_override = streamer
43
50
  @config_override = config
44
51
  @logger_override = logger
45
- @authorize = authorize || ->(_env, _stream_name) { true }
52
+ @authorize = authorize
46
53
  end
47
54
 
48
55
  def call(env)
@@ -55,7 +62,7 @@ module Pgbus
55
62
  stream_name = verify!(signed_name)
56
63
  return not_found("invalid signed stream name") if stream_name.nil?
57
64
 
58
- authorize_result = @authorize.call(env, stream_name)
65
+ authorize_result = authorize_hook.call(env, stream_name)
59
66
  return forbidden unless authorize_result
60
67
 
61
68
  # If authorize returned a non-boolean value (e.g. a User model),
@@ -134,7 +141,7 @@ module Pgbus
134
141
  io = hijack_result || env["rack.hijack_io"]
135
142
 
136
143
  connection_id = SecureRandom.hex(8)
137
- write_headers(io, stream_name: stream_name, since_id: since_id, connection_id: connection_id)
144
+ write_headers(io, stream_name: stream_name, since_id: since_id)
138
145
 
139
146
  connection = Pgbus::Web::Streamer::Connection.new(
140
147
  id: connection_id,
@@ -162,7 +169,6 @@ module Pgbus
162
169
  body.write(Pgbus::Streams::Envelope.comment(
163
170
  "pgbus stream open since_id=#{since_id} stream=#{stream_name}"
164
171
  ))
165
- body.write(Pgbus::Streams::Envelope.connected(id: connection_id))
166
172
 
167
173
  connection = Pgbus::Web::Streamer::FalconConnection.new(
168
174
  id: connection_id,
@@ -186,11 +192,22 @@ module Pgbus
186
192
  }
187
193
  end
188
194
 
189
- def write_headers(io, stream_name:, since_id:, connection_id:)
195
+ def write_headers(io, stream_name:, since_id:)
190
196
  io.write(Pgbus::Streams::Envelope.http_response_headers)
191
197
  io.write(Pgbus::Streams::Envelope.retry_directive(2_000))
192
198
  io.write(Pgbus::Streams::Envelope.comment("pgbus stream open since_id=#{since_id} stream=#{stream_name}"))
193
- io.write(Pgbus::Streams::Envelope.connected(id: connection_id))
199
+ end
200
+
201
+ # An explicit authorize: wins; otherwise config.streams_authorize, read
202
+ # per request so the engine's mount (built when routes load) sees a hook
203
+ # set in an initializer that runs later (issue #522). Only nil means
204
+ # "unset": any other non-callable (false included) must reach #call and
205
+ # fail closed with a 500, never fall through to ALLOW_ALL.
206
+ def authorize_hook
207
+ return @authorize unless @authorize.nil?
208
+
209
+ hook = config.streams_authorize
210
+ hook.nil? ? ALLOW_ALL : hook
194
211
  end
195
212
 
196
213
  def streamer
@@ -88,20 +88,21 @@ module Pgbus
88
88
  end
89
89
 
90
90
  def write_comment(text)
91
- bytes = Pgbus::Streams::Envelope.comment(text)
92
- result = @writer.write(self, bytes, deadline_ms: @write_deadline_ms)
93
- if result == :ok
94
- @last_write_at = monotonic
95
- else
96
- mark_dead!
97
- end
98
- result
91
+ write_frame(Pgbus::Streams::Envelope.comment(text))
99
92
  end
100
93
 
101
94
  def write_sentinel(bytes)
102
95
  @writer.write(self, bytes, deadline_ms: @write_deadline_ms)
103
96
  end
104
97
 
98
+ # The pgbus:connected frame carrying this connection's id. Written by
99
+ # the Dispatcher once the connection is registered and its presence
100
+ # seat joined (issue #521), so a client that acts on the id right
101
+ # after the event finds its row.
102
+ def write_connected
103
+ write_frame(Pgbus::Streams::Envelope.connected(id: @id))
104
+ end
105
+
105
106
  def idle_for
106
107
  monotonic - @last_write_at
107
108
  end
@@ -142,6 +143,16 @@ module Pgbus
142
143
 
143
144
  private
144
145
 
146
+ def write_frame(bytes)
147
+ result = @writer.write(self, bytes, deadline_ms: @write_deadline_ms)
148
+ if result == :ok
149
+ @last_write_at = monotonic
150
+ else
151
+ mark_dead!
152
+ end
153
+ result
154
+ end
155
+
145
156
  # The SSE event name for a frame: the envelope's typed event when
146
157
  # present, else the default turbo-stream. Plain ReadAfter::Envelopes
147
158
  # (no event field) and StreamEnvelopes with a nil event both fall
@@ -74,14 +74,12 @@ module Pgbus
74
74
  end
75
75
 
76
76
  def write_comment(text)
77
- bytes = Pgbus::Streams::Envelope.comment(text)
78
- result = write_to_body(bytes)
79
- if result == :ok
80
- @last_write_at = monotonic
81
- else
82
- mark_dead!
83
- end
84
- result
77
+ write_frame(Pgbus::Streams::Envelope.comment(text))
78
+ end
79
+
80
+ # See Connection#write_connected (issue #521).
81
+ def write_connected
82
+ write_frame(Pgbus::Streams::Envelope.connected(id: @id))
85
83
  end
86
84
 
87
85
  def write_sentinel(bytes)
@@ -120,6 +118,16 @@ module Pgbus
120
118
  event && !event.to_s.empty? ? event.to_s : Pgbus::Streams::DEFAULT_SSE_EVENT
121
119
  end
122
120
 
121
+ def write_frame(bytes)
122
+ result = write_to_body(bytes)
123
+ if result == :ok
124
+ @last_write_at = monotonic
125
+ else
126
+ mark_dead!
127
+ end
128
+ result
129
+ end
130
+
123
131
  def write_to_body(bytes)
124
132
  @mutex.synchronize do
125
133
  return :closed if @dead || @body.closed?
@@ -3,14 +3,15 @@
3
3
  module Pgbus
4
4
  module Web
5
5
  module Streamer
6
- # Composes the Streamer's three background threads (Listener, Dispatcher,
7
- # Heartbeat) with the shared Registry and dispatch_queue. One Instance
8
- # per Puma worker. Owns the lifecycle of all three threads and the
9
- # dedicated PG::Connection for LISTEN.
6
+ # Composes the Streamer's background threads (Listener, Dispatcher,
7
+ # Heartbeat, and the PeerCloseWatcher unless streams_peer_close_interval
8
+ # is nil) with the shared Registry and dispatch_queue. One Instance per
9
+ # Puma worker. Owns the lifecycle of those threads and the dedicated
10
+ # PG::Connection for LISTEN.
10
11
  #
11
12
  # Lifecycle:
12
13
  # Instance.new(...) — allocates wiring, does NOT start threads
13
- # #start — spawns listener/dispatcher/heartbeat in order
14
+ # #start — spawns listener/dispatcher/heartbeat/watcher in order
14
15
  # #register(conn) — enqueues a ConnectMessage into the dispatch queue
15
16
  # #shutdown! — sends shutdown sentinel to every connection and
16
17
  # stops all threads in reverse order
@@ -21,7 +22,7 @@ module Pgbus
21
22
  # defaults from the configuration.
22
23
  class Instance
23
24
  attr_reader :registry, :listener, :dispatcher, :heartbeat, :dispatch_queue, :stream_counter, :pump,
24
- :autoscaler
25
+ :autoscaler, :peer_close_watcher
25
26
 
26
27
  def initialize(
27
28
  client: Pgbus.client,
@@ -77,6 +78,7 @@ module Pgbus
77
78
  logger: @logger,
78
79
  presence_expiry: Array(@config.streams_presence_patterns).any?
79
80
  )
81
+ @peer_close_watcher = build_peer_close_watcher
80
82
  @started = false
81
83
  @shutdown_mutex = Mutex.new
82
84
  end
@@ -91,6 +93,7 @@ module Pgbus
91
93
  @listener.start
92
94
  @dispatcher.start
93
95
  @heartbeat.start
96
+ @peer_close_watcher&.start
94
97
  self
95
98
  end
96
99
 
@@ -119,7 +122,9 @@ module Pgbus
119
122
  end
120
123
 
121
124
  # Graceful shutdown for Puma worker restart. Order matters:
122
- # 1. Heartbeat first (stop writing comments to connections we're
125
+ # 0. Peer-close watcher first (issue #519): stop reaping sockets
126
+ # we're about to close ourselves; #stop wakes its select at once
127
+ # 1. Heartbeat next (stop writing comments to connections we're
123
128
  # about to close)
124
129
  # 2. Listener next (stop accepting new NOTIFYs)
125
130
  # 3. Dispatcher next (drain the queue; it's now finite because
@@ -150,6 +155,7 @@ module Pgbus
150
155
 
151
156
  @started = false
152
157
  threads = component_threads
158
+ safely { @peer_close_watcher&.stop }
153
159
  safely { @heartbeat.stop }
154
160
  safely { @listener.stop }
155
161
  safely { @dispatcher.stop }
@@ -242,6 +248,7 @@ module Pgbus
242
248
  def component_threads
243
249
  {
244
250
  "heartbeat" => @heartbeat.threads,
251
+ "peer_close_watcher" => @peer_close_watcher ? @peer_close_watcher.threads : [],
245
252
  "listener" => @listener.threads,
246
253
  "dispatcher" => @dispatcher.threads,
247
254
  "pump" => @pump ? @pump.threads : []
@@ -276,6 +283,15 @@ module Pgbus
276
283
  )
277
284
  end
278
285
 
286
+ # Notices a closed SSE client at once instead of on the next
287
+ # heartbeat write (issue #519). nil interval: heartbeat only.
288
+ def build_peer_close_watcher
289
+ interval = @config.streams_peer_close_interval
290
+ return nil unless interval
291
+
292
+ PeerCloseWatcher.new(registry: @registry, dispatch_queue: @dispatch_queue, interval: interval, logger: @logger)
293
+ end
294
+
279
295
  def build_pump
280
296
  threads = @config.streams_writer_threads
281
297
  return nil unless threads.positive?
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ module Streamer
6
+ # Notices an SSE client that went away the moment its socket says so,
7
+ # instead of on the next heartbeat write (issue #519).
8
+ #
9
+ # The server never reads a hijacked SSE socket after the request, so a
10
+ # closed tab used to stay registered (and present) until a heartbeat
11
+ # write failed, up to streams_heartbeat_interval later. Often longer:
12
+ # the first write after the client's FIN still succeeds. The client
13
+ # sends nothing after its request, so the socket only turns readable
14
+ # when the peer closes or resets it. This thread waits on every live
15
+ # socket with IO.select and probes the readable ones with a
16
+ # non-blocking read. EOF or a reset means the client is gone: the
17
+ # connection is marked dead, closed, and handed to the dispatcher's
18
+ # disconnect path, the same DisconnectMessage the heartbeat posts. That
19
+ # path is idempotent, so a heartbeat racing the watcher still leaves
20
+ # the seat once.
21
+ #
22
+ # Only connections with a real socket are watched: Puma's and Falcon's
23
+ # rack.hijack paths. Falcon's experimental streaming-body path
24
+ # (FalconConnection) exposes no socket and keeps the heartbeat.
25
+ #
26
+ # Half-close: a client that shuts down only its write side (SHUT_WR)
27
+ # reads as EOF and is reaped. EventSource, fetch streams and reverse
28
+ # proxies never half-close an SSE request, so EOF means the client left.
29
+ #
30
+ # The probe takes the connection's write mutex with try_lock: a TLS
31
+ # wrapper (Puma's MiniSSL) is not safe to read while IoWriter writes
32
+ # through it. A socket mid-write is skipped and probed on a later pass.
33
+ #
34
+ # `interval` bounds how long a new connection waits to join the
35
+ # watched set; a close on a watched socket wakes the select at once,
36
+ # and #stop wakes it through a self-pipe.
37
+ class PeerCloseWatcher
38
+ # Stray bytes from a client are read and discarded in chunks of this.
39
+ DRAIN_BYTES = 4096
40
+
41
+ # How long #stop waits for the thread. It is woken at once, so this
42
+ # only bounds a reap stuck closing a socket (see Heartbeat#stop).
43
+ STOP_JOIN_SECONDS = 5
44
+
45
+ def initialize(registry:, dispatch_queue:, interval: 1.0, logger: Pgbus.logger)
46
+ @registry = registry
47
+ @queue = dispatch_queue
48
+ @interval = interval
49
+ @logger = logger
50
+ @running = false
51
+ @thread = nil
52
+ @wake_reader, @wake_writer = IO.pipe
53
+ end
54
+
55
+ def start
56
+ return if @running
57
+
58
+ @wake_reader, @wake_writer = IO.pipe if @wake_reader.closed?
59
+ @running = true
60
+ @thread = Thread.new { run_loop }
61
+ self
62
+ end
63
+
64
+ # See Heartbeat#threads (issue #443).
65
+ def threads
66
+ [@thread].compact
67
+ end
68
+
69
+ def stop
70
+ return unless @running
71
+
72
+ @running = false
73
+ wake
74
+ @thread&.join(STOP_JOIN_SECONDS)
75
+ @thread = nil
76
+ @wake_reader.close
77
+ @wake_writer.close
78
+ self
79
+ end
80
+
81
+ # One wait-and-probe pass, at most `interval` long. Public for tests.
82
+ def tick
83
+ by_socket = watched_sockets
84
+ ready, = IO.select([@wake_reader, *by_socket.keys], nil, nil, @interval)
85
+ Array(ready).each do |socket|
86
+ socket.equal?(@wake_reader) ? drain_wake : probe(by_socket[socket])
87
+ end
88
+ rescue IOError, Errno::EBADF => e
89
+ # A socket was closed under the select (shutdown, a dispatcher
90
+ # write that failed). The next pass rebuilds the set without it.
91
+ @logger.debug { "[Pgbus::Streamer::PeerCloseWatcher] select interrupted: #{e.class}: #{e.message}" }
92
+ end
93
+
94
+ private
95
+
96
+ def wake
97
+ @wake_writer.write_nonblock(".", exception: false)
98
+ rescue IOError
99
+ nil
100
+ end
101
+
102
+ def drain_wake
103
+ @wake_reader.read_nonblock(64, exception: false)
104
+ end
105
+
106
+ # One socket's probe never ends the pass for the others.
107
+ def probe(connection)
108
+ return unless connection.mutex.try_lock
109
+
110
+ closed = begin
111
+ peer_closed?(connection)
112
+ ensure
113
+ connection.mutex.unlock
114
+ end
115
+ reap(connection) if closed
116
+ rescue StandardError => e
117
+ @logger.error { "[Pgbus::Streamer::PeerCloseWatcher] probe of #{connection.id} raised: #{e.class}: #{e.message}" }
118
+ end
119
+
120
+ def run_loop
121
+ while @running
122
+ begin
123
+ tick
124
+ rescue StandardError => e
125
+ @logger.error { "[Pgbus::Streamer::PeerCloseWatcher] tick raised: #{e.class}: #{e.message}" }
126
+ sleep @interval
127
+ end
128
+ end
129
+ end
130
+
131
+ # select needs a real IO: Puma's TLS socket and Falcon's hijacked
132
+ # stream answer #to_io with the underlying socket.
133
+ def watched_sockets
134
+ sockets = {}
135
+ @registry.each_connection do |connection|
136
+ next if connection.dead?
137
+
138
+ socket = socket_of(connection)
139
+ sockets[socket] = connection if socket
140
+ end
141
+ sockets
142
+ end
143
+
144
+ def socket_of(connection)
145
+ io = connection.io
146
+ return nil unless io.respond_to?(:to_io) && io.respond_to?(:read_nonblock)
147
+
148
+ socket = io.to_io
149
+ socket if socket.is_a?(::BasicSocket) && !socket.closed?
150
+ end
151
+
152
+ def peer_closed?(connection)
153
+ # A nil read is EOF; :wait_readable (TLS, a spurious wake) and stray
154
+ # bytes leave the connection alone.
155
+ connection.io.read_nonblock(DRAIN_BYTES, exception: false).nil?
156
+ rescue IO::WaitReadable
157
+ # A socket wrapper that raises instead of honouring exception:
158
+ # false (EAGAIN is a SystemCallError, so this rescue comes first).
159
+ false
160
+ rescue IOError, SystemCallError, *tls_errors
161
+ true
162
+ end
163
+
164
+ # A TLS socket whose client vanished without close_notify raises its
165
+ # library's SSL error ("unexpected eof while reading") on read.
166
+ def tls_errors
167
+ errors = []
168
+ errors << ::OpenSSL::SSL::SSLError if defined?(::OpenSSL::SSL::SSLError)
169
+ errors << ::Puma::MiniSSL::SSLError if defined?(::Puma::MiniSSL::SSLError)
170
+ errors
171
+ end
172
+
173
+ def reap(connection)
174
+ return if connection.dead?
175
+
176
+ connection.mark_dead!
177
+ connection.close
178
+ @queue << StreamEventDispatcher::DisconnectMessage.new(connection: connection)
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -401,6 +401,16 @@ module Pgbus
401
401
  @stream_counter.increment_connections(stream)
402
402
  @registry.register(connection)
403
403
  presence_join(connection, stream)
404
+ # Step 6: announce the connection id only now (issue #521). A
405
+ # client that writes keyed by it right after pgbus:connected
406
+ # (Presence#set_state) must find the registered connection and
407
+ # its presence row. If that write finds the socket gone, run the
408
+ # disconnect now rather than leave the connection registered,
409
+ # and its seat on the roster, until the next heartbeat. A later
410
+ # DisconnectMessage for it (heartbeat, peer-close watcher) is a
411
+ # no-op: unregister reports false and the seat has already left.
412
+ connection.write_connected
413
+ handle_disconnect(DisconnectMessage.new(connection: connection)) if connection.dead?
404
414
  end
405
415
 
406
416
  record_stat(
@@ -409,10 +419,12 @@ module Pgbus
409
419
  started_at: started_at
410
420
  )
411
421
  rescue StandardError => e
412
- # Same leak path for exceptions in steps 1-4. Mark dead and
422
+ # Same leak path for exceptions in steps 1-6. Mark dead and
413
423
  # scrub state so a transient failure on a single connect
414
424
  # doesn't permanently bloat @full_to_logical or leave a
415
- # dangling LISTEN on the PG connection.
425
+ # dangling LISTEN on the PG connection. A connection already
426
+ # registered in step 5 is dead now, so the heartbeat sweep
427
+ # unregisters it (and leaves its seat) like any dead connection.
416
428
  remove_in_flight(stream, connection)
417
429
  @scanned_cursor.delete(connection)
418
430
  cleanup_stream_if_unused(stream)
@@ -429,6 +441,10 @@ module Pgbus
429
441
  @stream_counter.decrement_connections(stream) if removed
430
442
  presence_leave(connection, stream)
431
443
  cleanup_stream_if_unused(stream)
444
+ # A second DisconnectMessage for the same connection (heartbeat,
445
+ # peer-close watcher, a failed pgbus:connected write) is a no-op
446
+ # and must not count as another disconnect.
447
+ return unless removed
432
448
 
433
449
  record_stat(
434
450
  stream_name: stream,
data/lib/pgbus.rb CHANGED
@@ -289,8 +289,10 @@ module Pgbus
289
289
  # Do NOT call close — the parent's @pgmq_mutex is in undefined
290
290
  # state post-fork and attempting to acquire it can deadlock.
291
291
  # The next call to Pgbus.client will lazily create a fresh one.
292
+ # Cached streams hold the client they were built with, so they go too.
292
293
  def reset_client!
293
294
  @client = nil
295
+ @stream_cache = nil
294
296
  end
295
297
 
296
298
  def logger
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.17.3
4
+ version: 0.18.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -316,6 +316,7 @@ files:
316
316
  - lib/pgbus/configuration.rb
317
317
  - lib/pgbus/configuration/capsule_dsl.rb
318
318
  - lib/pgbus/configuration/presence_settings.rb
319
+ - lib/pgbus/configuration/stream_authorization.rb
319
320
  - lib/pgbus/current_attributes.rb
320
321
  - lib/pgbus/database_tasks_guard.rb
321
322
  - lib/pgbus/dead_letter_header.rb
@@ -477,6 +478,7 @@ files:
477
478
  - lib/pgbus/web/streamer/master_hub.rb
478
479
  - lib/pgbus/web/streamer/master_hub_boot.rb
479
480
  - lib/pgbus/web/streamer/outbound_pump.rb
481
+ - lib/pgbus/web/streamer/peer_close_watcher.rb
480
482
  - lib/pgbus/web/streamer/presence_tracker.rb
481
483
  - lib/pgbus/web/streamer/registry.rb
482
484
  - lib/pgbus/web/streamer/stream_counter.rb