Skip to content

presence client: report request failures and rejoin after eviction (CL-7202) - #482

Merged
TheGreatAxios merged 4 commits into
cl-7203-heartbeat-evictionfrom
cl-7202-presence-client-error-handling
Aug 30, 2026
Merged

TheGreatAxios merged 4 commits into
cl-7203-heartbeat-evictionfrom
cl-7202-presence-client-error-handling

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Stacked on #477 (CL-7203) — base this PR onto that one, not main.

Summary

connectPresence blind-posted every join/heartbeat/leave/update request (.catch(() => undefined)), never checked response.ok, exposed no way for a caller to hear about a failure, and never rejoined after a failed join or a heartbeat the server had already forgotten about (404 not_joined) — all while its EventSource stayed open regardless, so the UI kept reading as live with no signal that membership had been lost.

Changes to packages/presence/src/client.ts:

  • PresenceFetch's response shape now carries status.
  • PresenceHandle gains onError(listener), firing for every join/heartbeat/leave/update request that never reached the server (rejected fetch, no status) or came back non-2xx (status set).
  • Heartbeats are gated behind a joined flag: a heartbeat is only ever sent once join has actually succeeded. publishCursor/publishTyping fired before the initial join settles now rejoin instead of heartbeating a membership the server doesn't have.
  • A heartbeat 404 flips joined back to false and immediately triggers a rejoin — self-healing an eviction (including the CL-7203 self-eviction race) without the consuming UI having to notice and reconnect by hand.
  • doJoin guards against overlapping join requests (joinInFlight).

Test plan

  • packages/presence/src/client.test.ts: request failures (network + non-ok) reported via onError for join/heartbeat/update; a heartbeat 404 triggers an automatic rejoin; publishing before the initial join settles rejoins instead of heartbeating; onError unsubscribe stops delivery; existing tests updated for the new status field / async join settling
  • WORKBENCH_CHECK_SINCE=origin/main bun run typecheck — clean (6 affected packages)
  • bun run lint — 0 errors
  • bun run check:structural — all checks ok, @corbits/presence/client still browser-safe
  • packages/presence test suite: 77 pass, 0 fail

Scope note

Diff confined to packages/presence/src/client.ts per the ticket — packages/presence/src/room-registry.ts (CL-7204/CL-7205) and the SSE teardown leak in routes.ts (CL-7212, addressed by a different lane) are untouched.

Reproduces CL-7202: connectPresence blind-posts every join/heartbeat/
leave/update request, never checks response.ok, exposes no way for a
caller to hear about a failure, and never rejoins after a failed join
or a heartbeat the server has already forgotten about (404
not_joined) -- all while its EventSource stays open regardless, so
the UI keeps reading as live.
…L-7202)

connectPresence blind-posted every join/heartbeat/leave/update request
and never checked response.ok, so a failed join or a heartbeat 404'd
by the server (not_joined) went unnoticed while the SSE stream stayed
open regardless -- the UI kept reading as live with no signal that
membership had been lost.

PresenceHandle now exposes onError, firing for every request that
never reached the server or came back non-2xx. Heartbeats are gated
behind a joined flag: a heartbeat is only sent once join has actually
succeeded, and a 404 response flips that flag back off and triggers
an immediate rejoin -- the same self-healing a publishCursor/
publishTyping call gets if it fires before the initial join settles.
A caller publishing cursor/typing updates while unjoined called doJoin
on every publish; with no delay between attempts, a client stuck
unable to join (or a room that keeps evicting it) would re-POST
/join as fast as each failed attempt resolved. Joins now back off
exponentially after a failure and reset the moment one succeeds.
A local Yjs edit whose /update POST failed was only ever surfaced
through onError; the edit itself was gone for good, so a dropped
update meant silent, permanent divergence from the server's doc for
a collaborative document. Failed updates now queue and are
redelivered in order on the next successful join or heartbeat --
safe because Yjs updates are idempotent against a doc that has
already applied them.
@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Review

Verified the four acceptance criteria against the diff:

  • post()'s callers now distinguish success/non-2xx/network failure (PresenceFetch carries status, every call site branches on response.ok)
  • PresenceHandle.onError exists and is exercised by tests for join/heartbeat/update failures, including unsubscribe
  • A 404'd heartbeat flips joined back to false and triggers an immediate rejoin
  • Doc updates that fail to post — see fix below

gaas:code-review couldn't be spun up (concurrent subagent limit saturated under current load); I read the diff directly instead of delegating.

Fixed

  • Unbounded rejoin loop. doJoin() had no backoff or attempt bound: a caller publishing cursor/typing while unjoined called doJoin on every publish, so a client stuck unable to join (or a persistently-evicting room) would re-POST /join as fast as each failed attempt resolved — real hammering under e.g. continuous mouse movement, not just the 15s heartbeat tick. Added exponential backoff (1s base, 30s cap) gated by an injectable clock, resetting on success. Tests: client.test.ts's two new "backs off" / "resets the backoff" cases.
  • Dropped doc updates were unrecoverable. The AC ("retried or otherwise surfaced") was met only via the surfaced branch — a failed /update POST just fired onError; the edit itself was gone. For a collaborative Yjs document this is exactly the failure scenario the ticket describes (edits silently never persisted), and it's a real gap: once an update event fires and its POST is lost, that specific delta never reaches the server again on its own. Added a retry queue: failed updates are redelivered in order on the next successful join or heartbeat — safe because Yjs updates are idempotent against a doc that already applied them. Tests cover single-update redelivery (with an exact-payload check) and FIFO ordering across two failed updates.

Verified

  • check:browser-safe-subpaths — @corbits/presence/client still clean (@corbits/error-sink, which the reportError convention would otherwise point at, itself depends on @intx/log and can't be imported browser-side — the onError callback API is the correct browser-safe substitute, not a convention violation)
  • WORKBENCH_CHECK_CONCURRENCY=2 WORKBENCH_CHECK_SINCE=origin/main bun run typecheck — clean (6 affected packages)
  • packages/presence test suite — 81 pass, 0 fail
  • bunx eslint --cache + prettier --check on the touched files — clean
  • Commit scope confined to packages/presence/src/client.ts + its test file, per the ticket

Left alone

room-registry.ts (CL-7204/CL-7205) and the /update route's error-shape interaction with CL-7205's upcoming PresenceRoomNotFoundError (CL-7212) — out of scope for this branch, called out on #477 instead.

Pushed as two follow-up commits on this branch (stacked correctly on cl-7203-heartbeat-eviction, not on it).

@TheGreatAxios
TheGreatAxios merged commit 30622cf into main Aug 30, 2026
5 checks passed
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.

1 participant