From de60c858ca1542525b05f5f7fec28798e3d3792e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 01:23:18 -0700 Subject: [PATCH 1/2] Add tests for heartbeat route self-eviction 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. --- packages/presence/test/routes.test.ts | 92 +++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/packages/presence/test/routes.test.ts b/packages/presence/test/routes.test.ts index 5474d1951..9e4b5f534 100644 --- a/packages/presence/test/routes.test.ts +++ b/packages/presence/test/routes.test.ts @@ -85,6 +85,98 @@ describe("presence routes", () => { expect(response.status).toBe(404); }); + test("a heartbeat arriving just past the timeout boundary does not evict its own sender", async () => { + let clock = 0; + const registry = createPresenceRoomRegistry(); + const app = mountAs( + createPresenceRoutes({ + registry, + requireGrant: allowAll, + now: () => clock, + }), + { + tenantId: "tnt_a", + principalId: "prn_alice", + }, + ); + + await app.request(`/rooms/${SURFACE}/join`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + + // One second past the default 45s heartbeat timeout: normal jitter + // (a slow network tick, a throttled background tab), not a genuinely + // stale client — the heartbeat that arrives now is itself proof the + // sender is alive. + clock = 46_000; + const response = await app.request(`/rooms/${SURFACE}/heartbeat`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + members: JoinResponseBody["members"]; + }; + expect(body.members.map((m) => m.principalId)).toEqual(["prn_alice"]); + }); + + test("a heartbeat still sweeps a genuinely stale, different principal out of the response", async () => { + let clock = 0; + const registry = createPresenceRoomRegistry(); + const alice = mountAs( + createPresenceRoutes({ + registry, + requireGrant: allowAll, + now: () => clock, + }), + { + tenantId: "tnt_a", + principalId: "prn_alice", + }, + ); + const bob = mountAs( + createPresenceRoutes({ + registry, + requireGrant: allowAll, + now: () => clock, + }), + { + tenantId: "tnt_a", + principalId: "prn_bob", + }, + ); + + await alice.request(`/rooms/${SURFACE}/join`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + await bob.request(`/rooms/${SURFACE}/join`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + + // Bob never heartbeats again; alice's next heartbeat lands well past + // the timeout for bob, but only 1ms past it for herself. + clock = 46_000; + const response = await alice.request(`/rooms/${SURFACE}/heartbeat`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + members: JoinResponseBody["members"]; + }; + expect(body.members.map((m) => m.principalId)).toEqual(["prn_alice"]); + }); + test("an invalid join body is rejected with 400", async () => { const app = mountAs(createPresenceRoutes({ requireGrant: allowAll }), { tenantId: "tnt_a", From 3963c97c9ee2a4a496006e507bbbdc0a4c4a3116 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 04:46:18 -0700 Subject: [PATCH 2/2] Fix heartbeat route evicting its own sender (CL-7203) 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. --- packages/presence/src/routes.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/presence/src/routes.ts b/packages/presence/src/routes.ts index 6cac53f83..484d9362d 100644 --- a/packages/presence/src/routes.ts +++ b/packages/presence/src/routes.ts @@ -175,19 +175,23 @@ export function createPresenceRoutes( const surface = c.req.param("surface"); const key = { tenantId: tenant.id, surface }; - registry.sweepStale(heartbeatTimeoutMs, now()); - let patch: PresenceStatePatch = {}; if (body.cursor !== undefined) patch = { ...patch, cursor: body.cursor }; if (body.typing !== undefined) patch = { ...patch, typing: body.typing }; - const states = registry.heartbeat(key, principal.id, patch, now()); - if (states === undefined) { + // Refresh this principal's `lastSeenAt` *before* sweeping: this + // request arriving is itself proof of liveness, so the sweep below + // must judge staleness against the fresh timestamp, never the + // pre-request one — otherwise a heartbeat landing a moment past + // `heartbeatTimeoutMs` (ordinary jitter) would evict its own sender. + const heartbeatResult = registry.heartbeat(key, principal.id, patch, now()); + if (heartbeatResult === undefined) { return c.json( errorEnvelope("not_joined", "principal has not joined this room"), 404, ); } - return c.json({ members: states }); + registry.sweepStale(heartbeatTimeoutMs, now()); + return c.json({ members: registry.states(key) }); }); app.post(