Skip to content

Fix heartbeat route evicting its own sender (CL-7203) - #477

Merged
TheGreatAxios merged 2 commits into
mainfrom
cl-7203-heartbeat-eviction
Aug 30, 2026
Merged

TheGreatAxios merged 2 commits into
mainfrom
cl-7203-heartbeat-eviction

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

POST /rooms/:surface/heartbeat ran registry.sweepStale before registry.heartbeat, so staleness was judged against the client's pre-request lastSeenAt. A heartbeat arriving even a second past the 45s timeout window would sweep out the very client that just heartbeated, answering 404 for a principal that is actively alive.

Fix: call registry.heartbeat first (refreshing lastSeenAt for this request), then sweep, then read registry.states.

Test plan

  • packages/presence/test/routes.test.ts: heartbeat landing 1s past the timeout no longer evicts its own sender
  • packages/presence/test/routes.test.ts: a heartbeat still sweeps a genuinely stale, different principal
  • WORKBENCH_CHECK_SINCE=origin/main bun run typecheck — clean
  • WORKBENCH_CHECK_SINCE=origin/main bun run test — @corbits/presence: 70 pass, 0 fail (one unrelated pre-existing flaky timing failure in packages/chat-ui/test/use-streaming-reply.test.tsx, untouched by this diff)
  • bun run lint — 0 errors
  • bun run check:structural — all checks ok

Reproduces CL-7203: a heartbeat that arrives even one second past the
45s timeout is evicted by sweepStale before registry.heartbeat gets a
chance to refresh its own lastSeenAt, so the route answers 404 for a
client that is actively heartbeating.
sweepStale ran before registry.heartbeat, so staleness was judged
against the pre-request lastSeenAt: a heartbeat landing even a moment
past the timeout would sweep out the very client that just proved it
was alive. Refresh lastSeenAt via heartbeat() first, then sweep.
@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Review

Confirmed the fix: heartbeat() now runs before sweepStale(), so a request's own arrival refreshes its lastSeenAt before staleness is judged — a heartbeat landing just past the timeout no longer evicts its own sender.

Checked the specific risk called out for this reorder: could it let a genuinely stale, different principal survive a sweep it should have been caught by? No — sweepStale still runs on every heartbeat (just after, not before) and evaluates every principal's own absolute lastSeenAt, unaffected by another principal's heartbeat. Confirmed against packages/presence/src/room-registry.ts's heartbeat/sweepStale implementations and the branch's own "a heartbeat still sweeps a genuinely stale, different principal out of the response" test.

Verified:

  • git log/git diff main...HEAD --stat: 2 commits, routes.ts + routes.test.ts only, matches the ticket's scope
  • WORKBENCH_CHECK_CONCURRENCY=2 WORKBENCH_CHECK_SINCE=origin/main bun run typecheck — clean (6 affected packages)
  • packages/presence test suite — 70 pass, 0 fail
  • bunx eslint --cache + prettier --check on the two touched files — clean

No findings. No changes needed.

Cross-lane note (not actionable here)

CL-7205's applyDocUpdate change (PR #484, not yet merged) makes a missing room throw a new PresenceRoomNotFoundError instead of implicitly creating one. routes.ts's /update handler here folds any applyDocUpdate throw into a generic 400 "not a valid Yjs update," which will become inaccurate once #484 lands (a missing room is an operational condition, not a bad request). This PR doesn't touch /update and doesn't make that worse or better — flagging per CL-7212's existing ownership of that fix, no action needed on this branch.

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