fix(host): say what Realtime's client reported when the account inbox goes unanswered

Merged
DA
6d ago
+285-53

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 by backend unavailable for database neondb and a 30 s neon_realtime_cooldown_started trigger=admission_rejected. Generations 630 to 636 failed in half an hour. No session has been established since the neon_realtime_session_lost at 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_unavailable admission forever and stays connecting, so the 30 s watchdog fired with "Realtime has not delivered every part".
  • The desktop is in the same state. Dev builds from main follow 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/inbox and 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 its debug level, so the logger takes debug and troubleOf keeps 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 inside Effect.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 the https: address.
  • allowed_origins: empty means any origin. A WebSocket handshake with Origin: https://tryluke.dev gets 101, neon.realtime.v1 and ready.
  • Publication or replica identity: every table has a primary key, and Realtime uses no publication.
  • The primary: nothing on it is blocking. pg_current_snapshot is 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 old account-inbox-follow.ts, 2 fail and 10 pass.
    • A follow Realtime will not admit. The real @neon/realtime client runs against a stubbed WebSocket, the client's process boundary, that refuses each subscription with backend_unavailable, as production's proxy does now. The test checks that the warning names subscription_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.
  • pnpm check:affected:
    • lint, oxlint, knip and discarded-effect passed.
    • 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.ts fails 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-procedures and open-on-mac's concurrent clone. Both pass alone.
  • Neon logs, pg_* reads and get_realtime were read-only. The only request to production was one unauthenticated WebSocket handshake (a GET) to the Realtime socket, closed after ready.

Invariants

  • ./scripts/check.sh green. (CI)
  • JSON Schema goldens unchanged (LUKE_UPDATE_FIXTURES not 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 as type assertion outside the allowlisted files. (CI via anti-slop)
  • No Effect.runPromise/runSync/runFork outside the runtime edges and the shims tools/anti-slop/effect-edges.json records. (CI via anti-slop/no-run-promise-outside-edges)
  • No new setTimeout/setInterval/new Promise/AbortController/fs.watch outside the files tools/anti-slop/effect-edges.json records. (CI via anti-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.md sentence naming a changed part is edited; root pair stays byte-identical. (CI for pair existence; manual for wording)
  • PRIVACY.md unchanged, or the change is a product decision called out in the PR body.
  • Package package.json deps match what the sources import by bare specifier; effect via catalog:. (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 renderer node: 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, branch br-lucky-block-au052vsp, database_id 319394, proxy build 18-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_SECRET must then be updated in Vercel Production and redeployed. It may not help, because the 09:00 maintenance restart of the compute didn't.
  • Held: falling back to the held /api/inbox read 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's FetchHttpClient caches globalThis.fetch on its first request, and Sentry's chunk, which is never awaited, wraps fetch only after that. Reading fetch per request would fix it. It would also put Sentry's trace headers on every same-origin call and needs a test through mountWebApp, so it's left for its own change.

Made with Luke