Skip to content

Tell a browser to refetch when a server's channel subscription comes back - #311

Merged
davidmckayv merged 3 commits into
CopilotKit:mainfrom
zopeVaibhav:fix/channel-listener-resync
Aug 31, 2026
Merged

Tell a browser to refetch when a server's channel subscription comes back#311
davidmckayv merged 3 commits into
CopilotKit:mainfrom
zopeVaibhav:fix/channel-listener-resync

Conversation

@zopeVaibhav

Copy link
Copy Markdown
Contributor

Closes #310.

What this changes

A conversation deleted while a server's Postgres subscription was down used to sit on the roster
until somebody reloaded the page. It now clears itself.

Announcements between servers travel as NOTIFY, which reaches whoever is subscribed at the time
and is never replayed. server/src/channels/events.ts subscribed with a message handler and nothing
else, so a dropped subscription silently swallowed every channel deletion, pin and message announced
while it was away.

The client could not compensate. Its recovery is socket.onopen in
app/src/lib/channels/use-channel-events.ts, and its socket never dropped — the connection that
dropped was the server's, on a wire the browser cannot see. Nothing else covers it either:
app/src/query-client.ts:7 sets refetchOnWindowFocus: false, and the roster query overrides
neither that nor refetchInterval.

server/src/computer/policy-listener.ts:59 already answers this for the action policy, with
onlisten as the third argument to listen. This carries the same hook to the channel listener.
It cannot re-read the way the policy does — the policy is one row, and this is a stream of
per-member deltas Postgres does not keep — so instead the server tells its browsers to refetch,
which is the recovery they already run on their own reconnect.

Two decisions worth reviewing:

  • The first establish is skipped. A resync means "there was a gap". The first establish has no
    earlier subscription behind it for anything to have been missed between, so sending one there asks
    every connection to refetch on a boot where nothing was lost. Without the flag the message means
    "possibly a gap", which is not something a client can act on differently — and it breaks seven
    existing tests in this file that register a connection before starting the listener.
  • The resync is broadcast to every connection, not to a member list. There is no member list to
    narrow it to: the events that were lost named their own recipients, and those events are gone.

Where it runs

  • New state that outlives a request? None. The one new piece of state is a let subscribed
    boolean inside startChannelActivityListener, which is per-process by design: it records
    whether this process has subscribed before, and a second process has its own answer to that
    and needs it.
  • What happens on the second replica? Each replica holds its own subscription and its own
    sockets, and each answers for its own. When one replica's subscription drops, it resyncs the
    browsers it is holding; the others were not affected and say nothing. That is the correct
    outcome — a browser is connected to exactly one replica, and only that replica knows whether
    its own delivery had a gap.
  • Anything serialised? Nothing. A resync is idempotent: it carries no delta, and two of them
    arriving together cost one refetch each on a query that is already the source of truth.
  • Anything fanned out to a browser? Yes, and it reaches only sockets this process holds —
    hub.resyncAll() walks connections, which is this process's own registry, exactly as
    hub.deliver already does. It is deliberately not announced through Postgres: a replica whose
    subscription never dropped has nothing to tell its browsers about.
  • New listener, port, or schedule? None. Same subscription, same connection, one extra
    argument.

Boundary and audit

  • Every acting call still goes through the gateway. No acting path is touched; this is the
    channel roster's fan-out.
  • New refusals and new failures each write a row. No new refusal or failure exists — a resync
    cannot be refused, and a connection that throws on write is one that is closing, handled the
    way deliver already handles it.
  • Nothing new is trusted from the client. The resync travels server to browser only, and nothing
    the browser sends is read.

Changelog

  • CHANGELOG.md, under Unreleased: "A conversation deleted while a server was reconnecting no
    longer lingers on the screen."

Proof

Four new tests in server/tests/channel-events.integration.test.ts. The integration one breaks the
subscription for real rather than simulating it: it takes the backend pid from pg_stat_activity
and terminates it in a loop until the query returns nothing, so the announcement is published into a
gap that is known to exist rather than assumed to. It then proves the subscription is healthy again
with a later event, so the missing one cannot be blamed on a dead listener.

Against the listener as it stands on main, with only server/src/channels/events.ts reverted:

(fail) channel event hub > a resync reaches every connection, whoever it belongs to
(fail) channel event hub > a detached connection receives no resync
(fail) channel event hub > one failing connection does not deny the resync to the rest
(fail) a server whose subscription dropped > tells its connections to refetch when the subscription comes back
 11 pass
 4 fail

With the change:

 15 pass
 0 fail

Run eight times, no flakes. bun run lint, bun run format:check and bun run typecheck are clean.

Whole suite: 2058 pass against 2054 on main, the difference being these four. Seven failures are
present identically on main and on this branch — routine-sweep.integration.test.ts asserting on
routines it does not own, which fails against a local database holding seeded rows. Untouched by
this change.

No screenshot. The surface change is the absence of a stale row, which a still image cannot show,
and the assertion that replaces it is the last line of the integration test: the browser is sent
{"resync":true} and never receives the announcement made during the gap.

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified + CI verify green; template-appropriate correctness fix.

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI verify green pre-rebase; CHANGELOG-only rebase; template-appropriate.

@davidmckayv
davidmckayv merged commit 554b90f into CopilotKit:main Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A channel event announced while a server's subscription is down is lost to every browser that stayed connected

2 participants