Skip to content

presence: stop room teardown from discarding unflushed content or reviving empty - #484

Merged
TheGreatAxios merged 2 commits into
mainfrom
cl-7205-room-registry-destroy
Aug 30, 2026
Merged

TheGreatAxios merged 2 commits into
mainfrom
cl-7205-room-registry-destroy

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

Fixes CL-7205 (Urgent, data loss). Two compounding bugs in packages/presence/src/room-registry.ts:

  • destroyRoomIfEmpty destroyed a room's live Y.Doc synchronously the instant it looked empty, with no guarantee that a still-in-flight teardown flush (persistence's onEmpty hook) had actually written the doc's content to storage.
  • applyDocUpdate silently auto-created a room for any write via ensureRoom. Combined with the above, a delayed/zombie POST from an evicted client could land after a room was torn down, silently recreating it and writing stale content into it — permanently defeating seedOnJoin's "only seed an empty doc" guard, since the doc then looked non-empty with garbage instead of real content.

What changed

  • applyDocUpdate no longer auto-creates a room. It now looks up the room directly and throws PresenceRoomNotFoundError if none exists — a room only exists once join or an active subscribe* has opened it. This is a room-lifecycle guard, not an authorization check: doc edits stay decoupled from presence/awareness join by this codebase's existing design (confirmed by pre-existing tests posting updates for principals who never joined), and grant-based write authorization is unchanged, living upstream in routes.ts.
  • destroyRoomIfEmpty now defers actually destroying a room until every onEmpty listener's returned promise (if any) has settled. It tracks in-flight teardowns per room id, and before finalizing re-checks that the room object is still the one currently registered and still empty, plus a new monotonic per-room epoch (bumped on join/applyDocUpdate/seedDocText) hasn't changed since the destroy was dispatched. A mismatch means new content or membership landed while the flush was pending, so it re-runs the empty check (and gets its own flush) instead of silently discarding that content.
  • artifact-persistence.ts's flushNow now returns the write's promise, wired back through onEmpty so the registry can wait on it.

Review

  • Greybeard reviewed the approach before implementation; two real holes it caught (a doc update landing during a pending flush, and a full rejoin/edit/leave cycle happening entirely inside a pending flush window) are what the epoch mechanism specifically closes.
  • Critique reviewed the landed commits. One real (non-data-loss) gap found and ticketed separately as CL-7227: if an onEmpty listener's promise never settles, that room's teardown is wedged forever (a soft resource leak, not data loss — the room stays fully functional). Deciding how to bound that safely is a product/architecture question out of scope here; forcing a timeout-based destroy would risk reintroducing this exact data-loss bug.

Coordination note

routes.ts's /rooms/:surface/update handler currently wraps applyDocUpdate in a catch-all that returns a generic 400 bad_request ("update is not a valid Yjs update") for any throw, including the new PresenceRoomNotFoundError. routes.ts is owned by a different lane (CL-7203/CL-7202); this PR does not touch it, but that lane should special-case PresenceRoomNotFoundError into a more accurate response (404/409) rather than folding it into "malformed update".

Test plan

  • WORKBENCH_CHECK_SINCE=origin/main bun run typecheck — clean across all 6 affected packages
  • WORKBENCH_CHECK_SINCE=origin/main bun run test — all green
  • bun run lint — clean
  • bun run check:structural — clean

Reproduces two compounding bugs in destroyRoomIfEmpty/applyDocUpdate:
a room's live Y.Doc is destroyed the instant it looks empty with no
guarantee its pending content was flushed first, and applyDocUpdate
silently auto-creates a room for any write, letting a delayed/zombie
POST from an evicted client repopulate a freshly torn-down room and
defeat seedOnJoin's "only seed an empty doc" guard.
applyDocUpdate no longer auto-creates a room: it only ever writes into
a room that's already open (via join or an active subscription), so a
stale write arriving after a room has been torn down is rejected with
PresenceRoomNotFoundError instead of silently recreating an empty,
unseeded room for it to populate.

destroyRoomIfEmpty now defers actually destroying a room's Y.Doc until
every onEmpty listener's returned promise (persistence's teardown
flush) has settled, and re-checks the room's identity and a monotonic
per-room epoch before finalizing — a rejoin, edit, or reseed that lands
while a flush is still in flight cancels the destroy or gets its own
flush instead of being silently dropped. artifact-persistence.ts wires
its flush's promise back through onEmpty so the registry can wait on
it.

This is a room-lifecycle guard, not an authorization check: doc writes
stay decoupled from presence/awareness join by design, and grant-based
write authorization is unchanged and lives upstream in routes.ts.
@TheGreatAxios
TheGreatAxios merged commit a2a788b 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