presence: stop room teardown from discarding unflushed content or reviving empty - #484
Merged
Merged
Conversation
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.
This was referenced Aug 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes CL-7205 (Urgent, data loss). Two compounding bugs in
packages/presence/src/room-registry.ts:destroyRoomIfEmptydestroyed a room's liveY.Docsynchronously the instant it looked empty, with no guarantee that a still-in-flight teardown flush (persistence'sonEmptyhook) had actually written the doc's content to storage.applyDocUpdatesilently auto-created a room for any write viaensureRoom. 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 defeatingseedOnJoin's "only seed an empty doc" guard, since the doc then looked non-empty with garbage instead of real content.What changed
applyDocUpdateno longer auto-creates a room. It now looks up the room directly and throwsPresenceRoomNotFoundErrorif none exists — a room only exists oncejoinor an activesubscribe*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 inroutes.ts.destroyRoomIfEmptynow defers actually destroying a room until everyonEmptylistener'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-roomepoch(bumped onjoin/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'sflushNownow returns the write's promise, wired back throughonEmptyso the registry can wait on it.Review
epochmechanism specifically closes.onEmptylistener'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/updatehandler currently wrapsapplyDocUpdatein a catch-all that returns a generic400 bad_request("update is not a valid Yjs update") for any throw, including the newPresenceRoomNotFoundError.routes.tsis owned by a different lane (CL-7203/CL-7202); this PR does not touch it, but that lane should special-casePresenceRoomNotFoundErrorinto 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 packagesWORKBENCH_CHECK_SINCE=origin/main bun run test— all greenbun run lint— cleanbun run check:structural— clean