fix(host): say what Realtime's client reported when the account inbox goes unanswered
Merged
DA
6d ago+285+285+012345678901234567890123456789-53-53-01234567890123456789
Summary
The task was to find out why the account-wide Inbox's live read never answers on the web app, which leaves the Inbox and its badge empty, and to fix it.
Root cause: Neon Realtime's backend for production hasn't admitted a subscription since 2026-10-20 01:27 UTC. This is outside this repository and not specific to the web. Neon's own logs for the production branch (br-lucky-block-au052vsp, database neondb), read with the Neon connection, show:
- Since 01:27 UTC: every backend start fails with
Neon Realtime slot bootstrap failed: logical-slot bootstrap timed out. Each failure is followed bybackend unavailable for database neondband a 30 sneon_realtime_cooldown_started trigger=admission_rejected. Generations 630 to 636 failed in half an hour. No session has been established since theneon_realtime_session_lostat 01:27:03. - Before that, from 23:28 UTC: the backend flapped. It lost 7 live sessions, each to
replica catalog transaction … committed … and ended keeper generation.
The client side behaves correctly given that:
- The tab subscribes fine. The phone from LUKE-WEB-W opened the socket at 11:25:55.94, and all 7 subscriptions passed
authorization_checked: ok. Neon then refused admission. - The client retries silently. Realtime's client retries a
backend_unavailableadmission forever and staysconnecting, so the 30 s watchdog fired with "Realtime has not delivered every part". - The desktop is in the same state. Dev builds from
mainfollow over the same code path. When #4013 said "the desktop showed them all", the backend was in a live window before 01:27. Released desktops poll the held/api/inboxand are unaffected.
Fixing the backend is for Neon, or for the developer through Neon (see Notes).
What this PR changes, so that the next outage like this is diagnosed from Sentry alone:
- The warning names what Realtime's client last reported. The client is opened with a
logger. The 30-second warning now says what the client last reported going wrong for the standing subscription, by the diagnostic's event name and code only. For example:… Realtime has not delivered every part; Realtime's client last reported subscription_retry_scheduled (backend_unavailable). It never includes the entry's message or error (these can carry what the proxy answered), an address, a seal, or a row. The client reports a retried admission only at itsdebuglevel, so the logger takesdebugandtroubleOfkeeps only warnings, errors and retried admissions. What was heard is cleared when a new subscription starts. - A refused seal no longer ends the follow silently. Found while testing against the real client: Realtime's client throws from
subscribe()on a seal it won't take. That throw was a defect insideEffect.sync, and it ended the whole follow with no answer and no report. It now stops that subscription (LiveBroken), which is asked for again after the usual 5 s, like any other broken subscription.
Ruled out:
- An empty part waiting forever: it doesn't. An empty baseline delivers
data: [], and the follow counts it as delivered. - A rejected statement: all 7 were authorized. The mutable-function rejection was #3993.
expiresAt: seals last 60 s and are renewed at −10 s.- Mismatched parts: the parts the server seals are the parts the client waits on.
- The CSP: a
wss:source admits thehttps:address. allowed_origins: empty means any origin. A WebSocket handshake withOrigin: https://tryluke.devgets 101,neon.realtime.v1andready.- Publication or replica identity: every table has a primary key, and Realtime uses no publication.
- The primary: nothing on it is blocking.
pg_current_snapshotis empty and there are no prepared transactions.
Evidence
pnpm exec vitest run src/compose-coding-agents.test.ts(packages/host): 12 passed. The two new tests fail without the change: on the oldaccount-inbox-follow.ts, 2 fail and 10 pass.- A follow Realtime will not admit. The real
@neon/realtimeclient runs against a stubbedWebSocket, the client's process boundary, that refuses each subscription withbackend_unavailable, as production's proxy does now. The test checks that the warning namessubscription_retry_scheduled (backend_unavailable), and no address or capability. - A seal the real client refuses. The test checks that the follow survives the refusal and asks for a seal again 5 s later. Before the change, the follow died and never asked again.
- A follow Realtime will not admit. The real
pnpm check:affected:- lint, oxlint, knip and
discarded-effectpassed. - Host tests passed (415), and so did panel's.
- Typecheck passed for host, panel, desktop and web. The panel and web typechecks were re-run alone after one was killed for memory.
- Desktop's
app-menu.test.tsfails only because the sandbox can't download the Electron binary (fetch failed). That is unrelated, and CI runs it. - Two host tests timed out when every package's tests ran at once:
workspace-proceduresandopen-on-mac's concurrent clone. Both pass alone.
- lint, oxlint, knip and
- Neon logs,
pg_*reads andget_realtimewere read-only. The only request to production was one unauthenticated WebSocket handshake (a GET) to the Realtime socket, closed afterready.
Invariants
-
./scripts/check.shgreen. (CI) - JSON Schema goldens unchanged (
LUKE_UPDATE_FIXTURESnot run, or diff explained line by line). (CI) - Every behavior change has, in this PR, the test that fails without it; no change-detector test added. (manual; root
AGENTS.md"Testing") - Gateway envelope goldens unchanged. (CI)
- No new
astype assertion outside the allowlisted files. (CI via anti-slop) - No
Effect.runPromise/runSync/runForkoutside the runtime edges and the shimstools/anti-slop/effect-edges.jsonrecords. (CI viaanti-slop/no-run-promise-outside-edges) - No new
setTimeout/setInterval/new Promise/AbortController/fs.watchoutside the filestools/anti-slop/effect-edges.jsonrecords. (CI viaanti-slop/no-raw-async-primitives) - No Effect, Stream, or Layer written as a statement and dropped; a description a caller walks past runs nothing. (CI via
pnpm discarded-effect) - Test count equals the pre-PR count or the delta is listed: +2 in
packages/host/src/compose-coding-agents.test.ts(10 → 12). - Each hand-rolled file the PR replaces is deleted, or the part that remains is a permanent adaptor named in root
AGENTS.md's "The permanent adaptors" with the reason it stays. - Every
CLAUDE.md/AGENTS.mdsentence naming a changed part is edited; root pair stays byte-identical. (CI for pair existence; manual for wording) -
PRIVACY.mdunchanged, or the change is a product decision called out in the PR body. - Package
package.jsondeps match what the sources import by bare specifier;effectviacatalog:. (CI) - Barrel/door rule: no Node-reaching Effect module (
@effect/platform-node,effect/sql) behind a barrel the renderer or a web function opens. (CI: existing renderernode:grep plus a new grep for those specifiers in renderer bundles)
Notes
- Blocker, outside this repo: the Inbox stays empty until Neon Realtime's backend starts again. Steps for the developer:
- Report it to Neon (Realtime beta: Discord or console feedback) with project
frosty-night-78924196, branchbr-lucky-block-au052vsp, database_id 319394, proxy build18-9584ad3b4417-erofs, and the log lines above. - Possibly try disabling and re-enabling Realtime on that branch. That issues a new secret, so
NEON_REALTIME_SECRETmust then be updated in Vercel Production and redeployed. It may not help, because the 09:00 maintenance restart of the compute didn't.
- Report it to Neon (Realtime beta: Discord or console feedback) with project
- Held: falling back to the held
/api/inboxread while Realtime doesn't deliver would reverse #3217's "Realtime alone". That's the developer's call, and it isn't in this PR. - Left as a note: Sentry's breadcrumbs on the web show none of the tab's
/api/*fetches. Effect'sFetchHttpClientcachesglobalThis.fetchon its first request, and Sentry's chunk, which is never awaited, wrapsfetchonly after that. Readingfetchper request would fix it. It would also put Sentry's trace headers on every same-origin call and needs a test throughmountWebApp, so it's left for its own change.
Made with Luke