Files
pad/internal/collab/manager.go
T
xarmian 18087463ce feat(collab): op-log cursor protocol — force-refresh + watermark advance (TASK-1319) (#472)
* feat(collab): op-log cursor protocol — force-refresh + watermark advance (TASK-1319)

Closes both holes left by TASK-1309:

  1. Long-disconnected tab + external-write race. A reconnecting client
     announces its highest applied item_yjs_updates.id via `?since=<id>`.
     If that id is below MIN(id) for the item, rows it expected to
     replay have been pruned and the server sends a `force_refresh`
     control frame and closes the conn. Client recreates the Y.Doc
     and lazy-seeds from items.content. Without this, Tab A's stale
     state would silently overwrite an external CLI/MCP write on the
     next 5s flush.

  2. Browser-only-edited items never GC'd. Browser collab-snapshot
     PATCHes now carry an op_log_cursor body field. The store advances
     items.content_flushed_op_log_id only when the cursor matches the
     current MAX(op-log.id) — proving the markdown captures every
     persisted op. SQL CASE clause re-evaluates MAX at COMMIT time so
     a peer op landing between client-side cursor capture and the
     UPDATE leaves the watermark untouched (no over-advancement).

Combined cursor mechanism:

  - Server attaches op_log_cursor JSON control frames after replay,
    after every successful AppendYjsUpdate (originator), and to every
    peer's binary fan-out (so all peers stay in lockstep without a
    round trip).
  - Client persists per-tab in sessionStorage (NOT localStorage —
    avoids cross-tab cursor leakage that would force-refresh stable
    sessions).
  - Server's MIN(id) check + force_refresh fires only when a non-zero
    `since` is below MIN; `since=0` is treated as a fresh client.

New store methods: MinOpLogID, MaxOpLogID. New ItemUpdate field:
OpLogCursor *int64. New control message types: op_log_cursor,
force_refresh. New OpEvent.OpLogID for cursor piggyback. Existing
collab tests updated to drain TextMessage cursor frames.

Tests cover: initial cursor frame after replay (populated + empty
op-log), force_refresh fires when since<MIN, delta replay when
since>=MIN, cursor broadcast to originator + peers on append, and
watermark advancement gated on cursor==MAX.

Parent: PLAN-1248. Builds on TASK-1309.

* fix(collab): skip stale-Ydoc flush on force_refresh teardown per Codex review (round 1)

A force_refresh tear-down means the local Y.Doc cursor is below the
server's MIN(item_yjs_updates.id) — its derived markdown is stale.
Without this guard the collab $effect cleanup runs flushCollabNow
on the way out and silently PATCHes that stale markdown back to
items.content, overwriting the canonical content the fresh provider
is supposed to lazy-seed from. Per Codex round 1 [P1] of TASK-1319.

* fix(collab): force_refresh on empty op-log + cancel pending flush per Codex review (round 2)

Two P1 fixes:

1. Manager.Join now force_refreshes when since>0 and the op-log is
   empty (hasMin==false), not just when since<MIN. After
   PruneAndApply wipes the entire op-log, MIN is undefined; the
   original predicate would have admitted the stale tab and let its
   on-open Y.encodeStateAsUpdate write resurrect the pre-prune
   document.

2. The +page.svelte onForceRefresh handler now also clears
   collabFlushTimer. Without this a 5s timer that armed before the
   force_refresh frame arrived can still fire AFTER the cleanup
   ran, PATCHing stale Y.Doc-derived markdown to items.content.

New test: TestRoomManagerForceRefreshOnEmptyOpLogWithSince covers
the empty-op-log branch.

Per Codex round 2 [P1] of TASK-1319.

* fix(collab): include forceRefreshNonce in Editor key so it remounts on force_refresh per Codex review (round 3)

The collab $effect cleanup runs on forceRefreshNonce bump, but the
<Editor> {#key} was `${item.id}:true` — itemID doesn't change, so
the keyed Editor wasn't unmounting. The Tiptap Collaboration
extension only binds in onMount, so the editor stayed wired to the
stale (destroyed) Y.Doc while a fresh provider+doc were set up
in parallel. Edits would either be unsynced or eventually flush
stale markdown again.

Adding forceRefreshNonce to the key forces the Editor to remount
in lockstep with the doc swap. Per Codex round 3 [P1] of TASK-1319.

* fix(collab): refetch item.content before lazy-seed on force_refresh per Codex review (round 4)

After force_refresh the collab $effect rebuilds the Y.Doc and the
lazy-seed (TASK-1261) seeds it from item.content. But item.content
was the cached page-state copy — possibly stale relative to the
server (the WS force_refresh can beat the SSE/visibility refresh
that would otherwise update it). Lazy-seeding stale content into
a fresh op-log re-introduces exactly the staleness force_refresh
was supposed to clear: the next 5s flush PATCHes that stale view
back to canonical items.content.

onForceRefresh now does an api.items.get() before bumping the
nonce so the rebuild's lazy seed reads server-fresh content. A
failed fetch falls through to the bump anyway (an editor on
possibly-stale content is still better than a broken editor).

Per Codex round 4 [P1] of TASK-1319.

* fix(collab): suppress cursor during replay + move force_refresh check before getOrCreate per Codex review (round 5)

Two more findings:

1. [P1] writeLoop sends op_log_cursor frames for live ops broadcast
   during the replay window. A client disconnecting after one of
   those cursors lands but BEFORE the rest of replay completes
   would persist a cursor pointing past unreplayed rows. On
   reconnect with since=that-cursor, server replays nothing — the
   client's Y.Doc would be missing causally-required ops.

   Fix: per-roomConn replayDone atomic.Bool. writeLoop suppresses
   cursor frames while it's false. runConn flips it after the
   post-replay initial cursor is on the wire. Live binary frames
   continue to flow during replay (Yjs CRDT commutativity); only
   the cursor metadata is gated.

2. [P2] Force-refresh path leaked an empty room. getOrCreate
   inserted into m.rooms before the force_refresh bail-out left
   an orphan entry that PruneSweep would later treat as 'active'
   and skip indefinitely.

   Fix: schema-rebuild + force_refresh checks now run BEFORE
   getOrCreate. Both are store-only mutations and the per-item
   lock is held throughout, so concurrency is unchanged.

New test: TestRoomManagerCursorSuppressedDuringReplay regression-
guards the cursor-suppression behaviour.

Per Codex round 5 [P1+P2] of TASK-1319.

* fix(collab): tighten initial cursor + sync-destroy provider on force_refresh per Codex review (round 6)

Two more P1 fixes:

1. runConn's empty-replay fallback used MaxOpLogID() to anchor
   the initial cursor. A live op landing between replayTo
   returning and the cursor write would be reflected in MAX
   but its binary frame might not have flowed through this
   conn's writeLoop yet — the cursor would advertise an id
   the client hasn't received. Initial cursor is now strictly
   max(highestReplayed, since); MaxOpLogID is removed from
   the opLogStore interface.

2. Provider.handleControlMessage's force_refresh branch now
   calls this.destroy() SYNCHRONOUSLY before invoking the
   onForceRefresh callback. Previously the consumer's recovery
   path (async items.get refetch) would race the provider's
   own onClose-triggered reconnect, which would re-open with
   since=0 and push Y.encodeStateAsUpdate of the stale Y.Doc
   — recreating the corruption force_refresh was meant to
   prevent. destroy() sets destroyed=true so scheduleReconnect
   short-circuits.

Per Codex round 6 [P1] of TASK-1319.

* fix(collab): block flush scheduling during force_refresh recovery per Codex review (round 7)

Previously, after onForceRefresh fires:
  1. Provider is destroyed synchronously.
  2. Async items.get refetch is in flight.
  3. forceRefreshNonce bumps after refetch resolves.
  4. $effect cleanup runs, then rebuild.

But during steps 2-3 the editor component is still mounted with
the stale Y.Doc, and a local edit fires handleContentUpdate which
calls scheduleCollabFlush. clearTimeout earlier in onForceRefresh
only canceled the timer at THAT moment; a new edit during the
refetch window arms a fresh timer that fires before cleanup. That
PATCHes stale Y.Doc-derived markdown back to canonical content,
recreating the corruption force_refresh was meant to prevent.

Fix: forceRefreshInFlight flag set in onForceRefresh, blocks
scheduleCollabFlush, resets after the fresh provider is wired
(end of $effect run). Per Codex round 7 [P1].

* fix(collab): gate runCollabFlush itself on force_refresh in-flight per Codex review (round 8)

scheduleCollabFlush blocked the 5s timer path, but direct callers
of flushCollabNow / runCollabFlush (beforeunload handler,
rich-to-raw toggle) bypassed the guard. A page reload or raw
toggle DURING the force_refresh recovery window still PATCHed
stale Y.Doc-derived markdown to canonical items.content.

Pulling the guard into runCollabFlush covers every caller in one
spot and returns 'deduped' so the result-shape contract holds.

Per Codex round 8 [P1] of TASK-1319.

* fix(collab): distinct 'skipped' result for force_refresh path; raw-toggle aborts per Codex review (round 9)

runCollabFlush returning 'deduped' on the force_refresh-blocked
path was indistinguishable from a legitimate same-content dedupe.
The rich→raw toggle treats 'deduped' as 'server already has this
markdown' and seeds rawSeedMarkdown from it — letting the user's
next raw edit overwrite canonical items.content with content
derived from the stale Y.Doc.

Add a distinct 'skipped' result for the force_refresh path. Raw
toggle aborts on it (with a 'try again in a moment' toast); other
callers fall through unchanged because no other call site
behaviorally depends on 'deduped' vs 'skipped'.

Per Codex round 9 [P1] of TASK-1319.

* fix(collab): server-side gate + post-await client guard against stale collab-snapshot per Codex review (round 10)

A force_refresh frame can arrive WHILE a collab-snapshot PATCH is
already mid-flight to the server. The client-side
forceRefreshInFlight check at PATCH-start can't catch this race;
the request lands at the server with stale Y.Doc-derived markdown.

Two-pronged fix:

1. Server: handler now checks op_log_cursor against MIN(op-log.id)
   for collab-snapshot PATCHes and returns 409 Conflict when
   cursor < MIN. Such cursors prove the flushing tab's Y.Doc was
   built on rows that have been pruned (PruneAndApply, schema
   rebuild, dormant GC). The markdown is, by construction, stale.

2. Client: post-await check on forceRefreshInFlight returns
   'skipped' instead of 'flushed' so saveStatus / lastFlushedContent
   don't seed from a known-stale base even if the server happened
   to accept the PATCH (e.g. MIN advanced after handler validation).

New tests: TestCollabSnapshotRejectsCursorBelowMin (gate fires),
TestCollabSnapshotAcceptsCursorAtOrAboveMin (negative path).

Also de-leak an unused slice in the round-5 cursor-suppression test
so staticcheck stays clean.

Per Codex round 10 [P1] of TASK-1319.

* fix(collab): reject collab-snapshot when cursor>0 and op-log empty per Codex review (round 11)

The HTTP-layer gate I added in round 10 mirrored only PART of the
WS-upgrade force_refresh predicate. Round 5 had already taught us
that 'op-log entirely pruned' is a separate stale path from
'cursor below MIN' (PruneAndApply, schema rebuild, dormant GC all
leave hasMin=false), and the WS check now uses
`since > 0 && (!hasMin || since < minID)`. The HTTP gate had
only the second clause.

Mirror the WS predicate at the handler so a stale collab-snapshot
PATCH against an empty op-log gets a 409 too. New regression:
TestCollabSnapshotRejectsCursorOnEmptyOpLog.

Per Codex round 11 [P1] of TASK-1319.

* fix(collab): reject collab-snapshot cursor=0 on non-empty op-log per Codex review (round 12)

Round-11 gate accepted cursor=0 unconditionally. But a stateful tab
whose previous session disconnected BEFORE receiving the
post-replay cursor frame (network blip during the writeMu burst
between replay binaries and the cursor) ends up with sessionStorage
cursor=0 + a non-empty Y.Doc populated by prior replay binaries.
On reconnect with since=0 the server treats it as fresh, replays
nothing if the op-log was meanwhile pruned, and the client's
on-open Y.encodeStateAsUpdate resurrects pre-prune ops. The next
flush carries cursor=0 + stale-derived markdown.

The gate now refuses any incompatible cursor:
  - cursor>0 + empty op-log (prior rule)
  - cursor<MIN + non-empty op-log (prior rule, now naturally
    catches cursor=0 too because 0 < any positive MIN)

The WS replay path is unchanged — full replay from since=0 is
the recovery for clients that genuinely lost their cursor; the
corruption manifested through the flush PATCH which we now gate.

New test: TestCollabSnapshotRejectsCursorZeroOnNonEmptyOpLog.

Per Codex round 12 [P1] of TASK-1319.

* fix(collab): close cursor=0 client/server gaps + lock validation+write atomically per Codex review (round 13)

Four P1 issues addressed:

1. Client always sends op_log_cursor (including 0) so the server
   gate sees the field. Previously cursor=0 was omitted, which
   silently bypassed the server's stale-snapshot rejection.

2. Provider construction now resets sessionStorage cursor to 0
   when the Y.Doc is empty. The Y.Doc isn't persisted across
   page reload, so a stored cursor=N + fresh empty Y.Doc would
   announce since=N to the server and miss rows 1..N from
   replay (server only replays id > N).

3. onOpen skips Y.encodeStateAsUpdate when lastOpLogID === 0.
   A populated Y.Doc + cursor=0 is the network-blip-during-cursor-
   write failure mode; pushing that state can resurrect ops the
   server has pruned. Server replay + lazy-seed handle recovery
   without our push.

4. Server gate now runs INSIDE the per-item collab setup lock
   (new RoomManager.UnderItemLock helper) so a concurrent prune
   (PruneAndApply, schema rebuild, dormant GC) cannot land
   between the MIN check and the items.content write. Without
   this, a tight race let stale snapshots overwrite canonical
   content the prune just installed.

Per Codex round 13 [P1] of TASK-1319.

* fix(collab): gate handleDocUpdate on cursorAnchored to close stale-Ydoc edit path per Codex review (round 14)

Round 13 fix skipped on-open send for lastOpLogID===0, but local
edits via handleDocUpdate still propagated. A populated Y.Doc +
no-cursor-yet client could type, the edit would land in the
op-log with id N, server would send originator cursor=N, and
the next 5s flush would carry an 'anchored' cursor that passed
the server's MIN check — overwriting items.content with stale-
Y.Doc-derived markdown.

Add a cursorAnchored boolean. Set on first op_log_cursor frame
receipt (including cursor=0 against an empty op-log — that's a
legitimate 'server has nothing' signal). handleDocUpdate refuses
to send before this. Local edits buffer in the editor; once the
cursor arrives (or force_refresh rebuilds the provider), the
existing reconnect/edit paths catch them up.

Per Codex round 14 [P1] of TASK-1319.

* fix(collab): buffer + flush pre-anchor local updates per Codex review (round 15)

Round 14 silently dropped local Yjs updates fired before the
first op_log_cursor frame anchored the session. Yjs updates are
incremental: a dropped keystroke leaves later ops referencing
structs no peer can resolve, breaking convergence.

Buffer pre-anchor updates in a Uint8Array[] (capped at 1000 to
prevent unbounded growth in pathological 'anchor never arrives'
scenarios — overflow triggers force_refresh-style recovery).
On the first cursor frame, flush the buffer in order so the
server gets every causally-required struct before any post-
anchor updates land.

Per Codex round 15 [P1] of TASK-1319.

* fix(collab): destroy provider before force_refresh on pre-anchor buffer overflow per Codex review (round 16)

Round 15 overflow path called onForceRefresh but didn't destroy
the provider synchronously. A late op_log_cursor arriving before
the page-level rebuild (the recovery callback is async — refetches
items.content) would flip cursorAnchored=true, the partially-
populated buffer would flush, but the DROPPED prefix (the
overflowed entries) would leave server-side ops causally
incomplete — exactly the bug the buffer was supposed to prevent.

destroy() sets destroyed=true, removes message listener,
short-circuits scheduleReconnect, closes the socket. Late cursor
frames can no longer anchor a doomed provider.

Per Codex round 16 [P2] of TASK-1319.

* fix(collab): refuse rebuild on refetch fail + broaden on-open gate to cursorAnchored per Codex review (round 17)

Two findings:

[P1] force_refresh recovery bumped forceRefreshNonce in finally
even when the item.content refetch failed. The rebuild then
lazy-seeded from the cached (possibly-stale) item.content, and
the next flush would PATCH that stale view back to the server.
Move the bump into .then() so a failed refetch surfaces a
'please reload' toast and leaves the editor effectively
read-only (forceRefreshInFlight stays true, blocking flushes).

[P2] Send-on-open gate was lastOpLogID > 0, which silently
dropped local edits made during a brief offline window after a
legitimate 'cursor=0' anchor (empty op-log session). Switch to
cursorAnchored — the boolean specifically distinguishes
'unanchored' (stale Y.Doc + no server confirmation) from
'anchored at cursor=0' (legitimate empty op-log).

Per Codex round 17 [P1+P2] of TASK-1319.

* fix(collab): force_refresh on cursor=0 against non-empty Y.Doc per Codex review (round 18)

cursor=0 means the server's op-log is currently empty. A
non-empty Y.Doc at first-cursor receipt implies the ops came
from an earlier connection within this provider's life that
never reached its post-replay cursor frame, followed by a
server-side prune (PruneAndApply, schema rebuild, dormant GC)
during our disconnect. Anchoring at cursor=0 in that state
would mark a stale Y.Doc as authoritative; the next on-open
state push or flush would resurrect pre-prune state and
overwrite canonical items.content.

Detect the configuration via Y.encodeStateVector length and
invoke the same force_refresh-style recovery the explicit
server frame triggers: destroy provider, clear sessionStorage,
fire onForceRefresh so the page rebuilds from items.content.

Per Codex round 18 [P1] of TASK-1319.

* fix(collab): gate cursor=0 force_refresh on remoteSyncApplied per Codex review (round 19)

Round 18 force_refreshed the provider whenever cursor=0 arrived
against a non-empty Y.Doc. But local pre-anchor edits (user typed
before the initial cursor=0 of a legitimate empty-op-log session
arrived) ALSO populate Y.Doc — yet those edits live in
preAnchorUpdates and were supposed to flush on anchor. The
predicate spuriously triggered force_refresh, dropping the
buffered local edits.

Track remoteSyncApplied (set when readSyncMessage applies
anything to Y.Doc — replay binary or live peer op). Only force_
refresh on cursor=0 when remoteSyncApplied is true: that's the
true 'remote replay landed but server now reports empty op-log
=> mid-session prune' signature.

Per Codex round 19 [P1] of TASK-1319.

* fix(collab): repair brace mis-merge in wsProvider cursor=0 guard

The round-19 patch overlapped the round-18 inner block, producing
an extra brace + over-indented body. Collapsing into a single
clean block restores parseability without changing semantics
beyond what round 19 already documented.

* fix(collab): gate syncStep2 reply on cursorAnchored per Codex review (round 20)

readSyncMessage writes an inline syncStep2 reply when it receives
a peer's syncStep1. That reply embeds our current Y.Doc state.
If a peer's syncStep1 arrives before our first op_log_cursor
(pre-anchor window), the reply path bypasses handleDocUpdate's
cursorAnchored gate and lets potentially-stale Y.Doc state reach
the server before the cursor=0 + remoteSyncApplied force_refresh
recovery has a chance to fire.

Suppress the reply while unanchored. Peer state propagation
still works: the buffered preAnchorUpdates flush on anchor, and
the lazy-seed rebuild after a force_refresh seeds canonical
content from items.content.

Per Codex round 20 [P1] of TASK-1319.

* fix(collab): fold mid-replay live op ids into post-replay cursor + remoteSyncApplied only on apply per Codex review (round 21)

Two more findings:

[P1 server] writeLoop suppresses cursor frames during replay to
prevent the client persisting a cursor past unreplayed rows.
But binary frames for those live ops still go through
(commutativity), so the client APPLIES them to its Y.Doc. The
post-replay initial cursor only covered max(highestReplayed,
since), leaving the cursor below the highest applied op. On
empty-replay sessions this trips the client's
'cursor=0 + remoteSyncApplied' force_refresh path and discards
buffered pre-anchor edits.

Track maxLiveOpLogIDDuringReplay on the roomConn (atomic
compare-and-swap) and fold it into the post-replay cursor.

[P1 client] remoteSyncApplied was set on every MESSAGE_SYNC,
including syncStep1 (which only carries a state vector — it
doesn't apply state). A peer's syncStep1 arriving pre-anchor
would falsely flag remote-sync-applied and trip the cursor=0
force_refresh on legitimate empty-op-log sessions. Set the
flag only after readSyncMessage returns, and only for
syncStep2 / update subtypes.

Per Codex round 21 [P1] of TASK-1319.

* fix(collab): widen writeMu critical section + drop omitempty on op_log_id per Codex review (round 22)

Two more P1s:

[P1 server] writeLoop's mid-replay record-max happened OUTSIDE
writeMu, so runConn's post-replay read could race the record:
runConn loads → writeLoop's atomic store of higher value →
runConn sends cursor below the live id. Move the entire
per-event sequence (binary write + replayDone observation +
record-or-send) inside writeMu, and have runConn acquire
writeMu around its read+cursor-write+replayDone-flip. The lock
serializes the two paths cleanly: writeLoop events that ran
first have already recorded; events that arrive after replayDone
flips emit their own cursor frames.

[P1 protocol] OpLogID had `omitempty` JSON tag — a legitimate
cursor=0 (empty op-log session) serialized as
`{"type":"op_log_cursor"}` with no op_log_id field. The
client's strict-type check then rejected it as malformed,
leaving the session unanchored and local edits buffered
forever. Drop omitempty so 0 is wire-visible. Other control
types (applier_request/ack) carry an extra op_log_id:0 in
their JSON, which their client dispatches ignore.

Per Codex round 22 [P1] of TASK-1319.

* fix(collab): route originator cursor through writeLoop FIFO per Codex review (round 23)

readLoop sent the originator's op_log_cursor directly via
sendOpLogCursor right after AppendYjsUpdate, bypassing the bus/
writeLoop ordering. With a peer op already queued in rc.bus, the
sequence on the wire could be:
  1. originator cursor=N (newer local op)
  2. peer binary (older op)
  3. peer cursor=M < N (rejected by client's max-take logic)

Client persists cursor=N. If the client then disconnects before
applying the peer binary, reconnect with since=N replays nothing
(server has nothing > N) and the older peer op is lost forever
to this client's Y.Doc.

Fix: writeLoop now processes self events too — skipping the
binary echo (the originator already has Y.Doc state) but routing
the cursor frame through the same FIFO bus channel as peer ops.
The originator's cursor=N now arrives strictly AFTER all
older-id peer events on the same channel.

Per Codex round 23 [P1] of TASK-1319.
2026-05-09 21:45:46 -04:00

812 lines
30 KiB
Go

package collab
import (
"encoding/json"
"errors"
"log/slog"
"sync"
"time"
"github.com/gorilla/websocket"
)
// DefaultSchemaVersion is the schema-version stamp used by all rooms
// today. TASK-1268 plumbs the rebuild flow: each Join's announced
// client version is checked against the latest op-log row's stamp
// and the room manager prunes the op-log when they diverge. The
// constant itself bumps in lockstep with the web client's
// `web/src/lib/collab/schemaVersion.ts` SCHEMA_VERSION on any
// breaking change to the Tiptap extension set or Y.Doc shape.
const DefaultSchemaVersion = "1"
// SchemaVersion exposes the version this manager stamps on persisted
// op-log rows. The HTTP collab handler uses it to validate incoming
// `?schema_version=...` query params before upgrading the WebSocket —
// a client running a different version is rejected at the upgrade
// stage rather than admitted and silently corrupting the op-log.
func (m *RoomManager) SchemaVersion() string { return m.schemaVersion }
// errTooManyJoinRetries surfaces when RoomManager.Join lost the
// addConn-vs-grace-expiry race more times than feels like a real
// race. In practice this should never trigger — the race window is
// microseconds — but it caps the retry loop so a misbehaving room
// can't deadlock a Join indefinitely.
var errTooManyJoinRetries = errors.New("collab: too many room-close races; aborting Join")
// errManagerClosed is returned by Join when Close has already run.
// http.Server.Shutdown does NOT wait for hijacked WS handlers, so a
// late Join can race a finishing shutdown. Returning a fast error
// closes the WS cleanly and avoids touching a torn-down store.
var errManagerClosed = errors.New("collab: room manager is closed")
// RoomManagerConfig collects optional knobs for NewRoomManagerWithConfig.
// Production callers should use NewRoomManager (which fills in the
// defaults); the config form exists so tests can drop graceTTL to a
// few milliseconds without sleeping the full minute.
type RoomManagerConfig struct {
// SchemaVersion stamped on every persisted op-log row.
// Empty → DefaultSchemaVersion.
SchemaVersion string
// GraceTTL controls how long a Room survives without subscribers.
// Zero → DefaultGraceTTL.
GraceTTL time.Duration
}
// RoomManager is the single entry point for the collab WS handler.
// It owns the OpBus, the per-item Room map, and the lifecycle (lazy
// create, grace-TTL reclaim, graceful shutdown).
//
// Construction is via NewRoomManager(store, bus). The bus must be
// the SAME instance that any other broadcasting code (e.g. future
// designated-applier hooks in TASK-1257) shares — multiple buses
// would silo their fan-out and break cross-tab live editing.
type RoomManager struct {
store opLogStore
bus OpBus
schemaVersion string
graceTTL time.Duration
mu sync.Mutex
rooms map[string]*Room
closed bool // set under mu by Close; Join short-circuits when true
// activeJoins tracks every in-flight Join goroutine so Close can
// act as a true drain barrier on server shutdown. Without this
// Wait, http.Server.Shutdown returns before hijacked WS sessions
// finish their tear-down, and a deferred store close races
// in-flight AppendYjsUpdate calls. The Add call lives inside
// m.mu so it can't interleave with Close's closed=true write —
// either the Add happens before closed=true (Wait will block
// for it) or closed=true happens first (Join returns
// errManagerClosed without ever Add'ing).
activeJoins sync.WaitGroup
// itemLocks is a per-item Mutex pool that serialises Join's
// addConn+replayTo critical section with PruneAndApply. Without
// this, a CLI/MCP/API direct write that ApplyExternalContent
// classified as "no live editors" can race a fresh Join: the new
// client's replayTo loads the soon-to-be-pruned op-log and
// ends up with stale Y.Doc state, which later overwrites the
// freshly-written items.content on the next idle flush.
//
// The lock is released before Join's readLoop so concurrent
// peers can edit simultaneously — only the setup phase (where
// op-log staleness matters) is serialised. Per Codex review
// round 5.
itemLocksMu sync.Mutex
itemLocks map[string]*sync.Mutex
}
// itemLock returns the lazily-allocated mutex guarding setup-phase
// operations on itemID. Locks live in the manager for the lifetime of
// the process — for a workspace with many items this is at most a few
// hundred bytes per item, which is acceptable.
func (m *RoomManager) itemLock(itemID string) *sync.Mutex {
m.itemLocksMu.Lock()
defer m.itemLocksMu.Unlock()
if l, ok := m.itemLocks[itemID]; ok {
return l
}
if m.itemLocks == nil {
m.itemLocks = make(map[string]*sync.Mutex)
}
l := &sync.Mutex{}
m.itemLocks[itemID] = l
return l
}
// NewRoomManager wires the store + bus together with production defaults.
func NewRoomManager(store opLogStore, bus OpBus) *RoomManager {
return NewRoomManagerWithConfig(store, bus, RoomManagerConfig{})
}
// NewRoomManagerWithConfig is the explicit-config form. Empty config
// fields fall back to package defaults.
func NewRoomManagerWithConfig(store opLogStore, bus OpBus, cfg RoomManagerConfig) *RoomManager {
schemaVersion := cfg.SchemaVersion
if schemaVersion == "" {
schemaVersion = DefaultSchemaVersion
}
graceTTL := cfg.GraceTTL
if graceTTL <= 0 {
graceTTL = DefaultGraceTTL
}
return &RoomManager{
store: store,
bus: bus,
schemaVersion: schemaVersion,
graceTTL: graceTTL,
rooms: make(map[string]*Room),
}
}
// ErrForceRefreshSent is returned by Join when the client's
// announced `?since=<id>` was below MIN(item_yjs_updates.id) — rows
// it expected to replay have been pruned. The handler has already
// emitted a force_refresh JSON control frame; the caller should
// close the conn cleanly. Per TASK-1319.
var ErrForceRefreshSent = errors.New("collab: client cursor below op-log MIN; force_refresh sent")
// Join attaches a freshly-upgraded WebSocket connection to the room
// for itemID. Replays the op-log to the new peer, spins up an inbound
// reader and an outbound writer, and blocks until the WebSocket
// closes (graceful close frame or transport failure). The caller —
// typically the HTTP handler — should defer conn.Close so that any
// resources held by the WS upgrader are released after this returns.
//
// `since` is the client's announced highest-applied op-log id (parsed
// from `?since=<id>` on the upgrade URL). When non-zero AND below
// MIN(item_yjs_updates.id) for this item, Join sends a force_refresh
// control frame and returns ErrForceRefreshSent — the client must
// discard local Y.Doc state and reconnect with `?since=0`. Per
// TASK-1319.
//
// Returns whatever error caused the WebSocket to close, or nil on a
// normal close. The handler typically logs but doesn't act on the
// return value: the connection is gone either way.
func (m *RoomManager) Join(itemID string, conn *websocket.Conn, since int64) error {
// Gate Add on the closed flag under m.mu so a late Join (e.g. a
// hijacked WS handler that didn't enter Join until AFTER Close
// returned) can't sneak past the drain barrier.
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return errManagerClosed
}
m.activeJoins.Add(1)
m.mu.Unlock()
defer m.activeJoins.Done()
itemLock := m.itemLock(itemID)
for attempt := 0; attempt < 3; attempt++ {
// Acquire the per-item setup lock BEFORE creating the room
// so the schema-rebuild + force_refresh checks below can
// run without leaking an empty m.rooms entry on bail-out.
// Per Codex round 5 [P2] of TASK-1319.
itemLock.Lock()
// Schema-mismatch rebuild (TASK-1268). Runs before the
// room is created so a rebuild-then-bail path can't leave
// an orphan room behind; the rebuild itself is a store-
// only mutation that doesn't depend on the in-memory Room.
// Concurrent fresh Joins for this item block on itemLock,
// so a peer arriving in this window sees the post-rebuild
// op-log when it gets its turn.
if err := m.maybeRebuildOnSchemaMismatch(itemID); err != nil {
itemLock.Unlock()
return err
}
// Resume-cursor / force_refresh check (TASK-1319). Run
// AFTER the schema rebuild so a post-rebuild empty op-log
// (which has no MIN) is treated correctly.
//
// `since > 0` means the client claims to have applied at
// least one persisted op locally. Two ways that claim is
// incompatible with the current op-log:
// - No rows exist (`!hasMin`): the entire op-log was
// pruned (PruneAndApply, schema rebuild, or dormant
// GC). The client's Y.Doc is built on top of ops that
// no longer exist; admitting it would let its on-open
// `Y.encodeStateAsUpdate` write resurrect the stale
// pre-prune document and overwrite items.content on
// the next flush.
// - `since < minID`: rows the client expected to replay
// have been pruned (the same hazard, just with a
// non-empty post-prune suffix).
// Both branches force_refresh and bail BEFORE we touch
// m.rooms — no orphan-room leak. Per Codex round 5 [P2].
if since > 0 {
minID, hasMin, merr := m.store.MinOpLogID(itemID)
if merr != nil {
itemLock.Unlock()
return merr
}
needsRefresh := !hasMin || since < minID
if needsRefresh {
slog.Info("collab: client cursor incompatible with op-log; sending force_refresh",
"item_id", itemID,
"since", since,
"min_id", minID,
"has_min", hasMin,
)
_ = sendForceRefreshFrame(conn)
itemLock.Unlock()
return ErrForceRefreshSent
}
}
room := m.getOrCreate(itemID)
if room == nil {
// Close raced in between our closed-check above and
// getOrCreate. Bail with the same fast error so the
// handler closes the WS cleanly.
itemLock.Unlock()
return errManagerClosed
}
rc := &roomConn{
id: nextConnID(),
conn: conn,
bus: m.bus.Subscribe(itemID),
connectedAt: time.Now(),
}
if err := room.addConn(rc); err != nil {
itemLock.Unlock()
// Race: the grace timer reclaimed the room between
// getOrCreate and addConn. Unsubscribe the channel we
// just opened (otherwise the bus leaks the slot until
// the bus is closed) and retry. The next getOrCreate
// won't find the now-deleted room and will mint a
// fresh one.
m.bus.Unsubscribe(rc.bus)
if errors.Is(err, errRoomClosing) {
continue
}
return err
}
return m.runConn(room, rc, itemLock, since)
}
return errTooManyJoinRetries
}
// distantFuture is the prune-everything cutoff we hand to
// PruneYjsUpdatesBefore. The store's prune is a strict-less-than on
// created_at; any row written with a sane RFC3339 timestamp will
// satisfy `created_at < 9999-01-01`.
var distantFuture = time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC)
// maybeRebuildOnSchemaMismatch implements the TASK-1268 rebuild flow.
//
// Reads the latest persisted op-log row's schema_version for itemID.
// If a row exists AND its version differs from the manager's current
// `schemaVersion`, the entire op-log for the item is pruned. Caller
// MUST hold the per-item setup lock so a concurrent peer's replayTo
// can't load the soon-to-be-pruned rows.
//
// Returns nil on the no-rows path and on the matched-version path —
// both are "nothing to do". A real DB error from either step short-
// circuits Join with the same error so the WS upgrade fails loudly.
//
// **Data loss disclosure.** When the latest op-log row's id exceeds
// `items.content_flushed_op_log_id` for the item (i.e. unflushed
// edits exist), the prune is unrecoverable: those ops are stamped
// with the OLD schema and can't be replayed against the new schema
// regardless of where they're stored. Lazy-seed (TASK-1261) will
// repopulate the Y.Doc from items.content, which is stale relative
// to the unflushed ops. We log a warn so operators see when a
// schema bump is dropping unsaved client edits. Per Codex review of
// TASK-1309 round 4 [P2].
func (m *RoomManager) maybeRebuildOnSchemaMismatch(itemID string) error {
latest, latestID, ok, err := m.store.LatestYjsUpdateSchemaVersion(itemID)
if err != nil {
return err
}
if !ok || latest == m.schemaVersion {
return nil
}
// Pre-prune watermark check. Unflushed ops would be lost; we
// can't avoid the loss (old-schema ops can't migrate forward),
// but we surface it.
flushedID, flushedOK, err := m.store.GetItemContentFlushedOpLogID(itemID)
if err != nil {
// Watermark read failed — proceed with the prune (we still
// have to: the schema-mismatch case is non-negotiable) but
// log the failure separately.
slog.Warn("collab: schema-mismatch rebuild: watermark read failed",
"item_id", itemID,
"error", err,
)
} else if !flushedOK || latestID > flushedID {
// flushedOK==false → never flushed, every op is unflushed.
// latestID > flushedID → some ops past the watermark.
// We can't avoid the prune here (old-schema ops can't replay
// in the new schema regardless of where they're stored), but
// the WARN tells operators a schema bump dropped some
// unsaved client edits — they may want to investigate which
// items were affected and contact the affected users.
// Per Codex review of TASK-1309 round 4 [P2].
slog.Warn("collab: schema-mismatch rebuild will drop unflushed ops",
"item_id", itemID,
"latest_op_log_id", latestID,
"content_flushed_op_log_id", flushedID,
"watermark_set", flushedOK,
)
}
pruned, err := m.store.PruneYjsUpdatesBefore(itemID, distantFuture)
if err != nil {
return err
}
slog.Info("collab: schema-version mismatch; pruned op-log",
"item_id", itemID,
"server_version", m.schemaVersion,
"persisted_version", latest,
"rows_pruned", pruned,
)
return nil
}
// DefaultPruneMinAge is the floor age for op-log dormancy in the
// periodic prune sweeper (TASK-1309). An item must have NO op-log
// rows newer than `now - minAge` to be eligible. 24 hours covers
// every realistic mobile-suspend / network-blip / lock-screen
// interval and leaves headroom for travel-on-flaky-wifi reconnects.
//
// Pass `0` (or any non-positive value) to PruneSweep to fall back
// to this default.
const DefaultPruneMinAge = 24 * time.Hour
// PruneSweepResult records what one PruneSweep accomplished. Surfaced
// to the server-level periodic ticker so it can log a one-line
// summary per sweep.
type PruneSweepResult struct {
// ItemsScanned: items returned by the dormancy query at sweep
// start. Some of these may turn out to be non-dormant by the
// time we acquire their per-item lock and run the conditional
// delete; those count toward ItemsSkipped, not ItemsPruned.
ItemsScanned int
// ItemsPruned: items where the conditional DELETE actually
// removed rows (i.e. confirmed dormant under the lock).
ItemsPruned int
// ItemsSkipped: items that became non-dormant between the
// candidate query and the conditional DELETE (a peer reconnected
// and wrote a row), or that were skipped because an active
// in-memory Room exists (covers the grace-TTL window after the
// last peer disconnected).
ItemsSkipped int
// RowsPruned: total rows deleted across all pruned items.
RowsPruned int64
// Errors: per-item prune failures. Sweep continues past errors
// so a single broken item doesn't block GC for the whole table.
Errors int
}
// PruneSweep finds every item whose ENTIRE op-log is older than
// `minAge` and prunes the whole op-log for those items under the
// per-item lock. Returns a summary.
//
// **Why whole-log only.** Yjs op streams are causally linked: a
// recent op can reference structs created in older ops. Prefix-
// pruning (delete old rows, keep recent) corrupts replay because
// the suffix's references can't be resolved. Per Codex review of
// the original TASK-1309 [P1]. Whole-log prune is safe because the
// next cold connect lazy-seeds from items.content (TASK-1261),
// producing a fresh self-consistent Y.Doc.
//
// `minAge` is the minimum age of the NEWEST op-log row for an item
// to be considered dormant. Pass 0 to use DefaultPruneMinAge.
//
// Coordination:
// - Per-item lock matches the lock Join takes for its addConn +
// replayTo critical section; a fresh peer can't race the
// prune-then-replay sequence.
// - In-memory active Room check before the prune skips items
// where peers are still attached (the grace-TTL window after
// the last conn dropped also counts as "active" — the room
// could come back via grace-cancel-on-reconnect).
// - Conditional DELETE in the store re-checks dormancy
// atomically. If a row was appended between the candidate
// query and the DELETE (e.g. a sneaky readLoop write under
// appendMu), the DELETE deletes nothing.
//
// Per TASK-1309 (PLAN-1248).
func (m *RoomManager) PruneSweep(minAge time.Duration) (PruneSweepResult, error) {
var res PruneSweepResult
if minAge <= 0 {
minAge = DefaultPruneMinAge
}
cutoff := time.Now().Add(-minAge)
items, err := m.store.ListDormantOpLogItemsBefore(cutoff)
if err != nil {
return res, err
}
res.ItemsScanned = len(items)
for _, itemID := range items {
// Bail early if Close has fired — no point pruning a
// store the manager is winding down.
m.mu.Lock()
closed := m.closed
hasRoom := m.rooms[itemID] != nil
m.mu.Unlock()
if closed {
return res, nil
}
if hasRoom {
// Active Room (or grace-TTL pending). Skip — the
// next sweep can pick this item up if the room
// goes idle by then. Active rooms naturally accrue
// new op-log rows that would defeat dormancy
// anyway; skipping here is just a fast-path before
// taking the per-item lock.
res.ItemsSkipped++
continue
}
lock := m.itemLock(itemID)
lock.Lock()
// Re-check active room under the lock. A Join could have
// raced between our outer check and this lock acquisition;
// the lock now serialises us against any in-flight Join's
// addConn+replayTo, but a Join that has already created
// the Room and released the lock could have left m.rooms
// non-nil. (Joins hold the lock across replay; once
// released, the room is live.)
m.mu.Lock()
hasRoom = m.rooms[itemID] != nil
m.mu.Unlock()
if hasRoom {
lock.Unlock()
res.ItemsSkipped++
continue
}
// Conditional DELETE: deletes everything for itemID iff
// no row >= cutoff exists. n=0 means a recent row was
// appended between the candidate query and now; that's
// fine, just a skip.
n, err := m.store.PruneItemOpLogIfDormantBefore(itemID, cutoff)
lock.Unlock()
if err != nil {
slog.Warn("collab: prune sweep: per-item prune failed",
"item_id", itemID,
"error", err,
)
res.Errors++
continue
}
if n == 0 {
res.ItemsSkipped++
continue
}
res.RowsPruned += n
res.ItemsPruned++
}
return res, nil
}
// runConn drives one connection through its full lifecycle: spawn
// writer (drains the bus subscription concurrently with replay),
// stream the op-log replay, run reader, tear down.
//
// The writer is started BEFORE the replay so live broadcasts that
// arrive during a long replay can't overflow the 64-event bus
// channel and silently drop. Yjs CRDTs are commutative — applying
// op 100 (live) then op 50 (replay) produces the same final Y.Doc
// as the reverse order — so interleaving replay frames and live
// updates on the same conn is correct. Both code paths write
// through rc.writeMessage which holds writeMu, so we never violate
// gorilla's "one writer at a time per conn" rule.
//
// The trade-off: a peer might briefly see updates "out of causal
// order" during the replay window. That's a UX wobble, not a
// correctness issue. The alternative — buffer-then-flush — would
// require an unbounded queue or risk losing live updates the way
// the original implementation did.
func (m *RoomManager) runConn(room *Room, rc *roomConn, itemLock *sync.Mutex, since int64) error {
writerDone := make(chan struct{})
go func() {
defer close(writerDone)
room.writeLoop(rc)
}()
highestReplayed, replayErr := room.replayTo(rc, since)
// Release the per-item setup lock before the long-lived readLoop
// so concurrent peers + future PruneAndApply calls aren't gated
// on this conn's full lifetime.
itemLock.Unlock()
if replayErr != nil {
room.removeConn(rc)
<-writerDone
return replayErr
}
// Anchor the client's resume cursor (TASK-1319). The cursor
// MUST NEVER advertise an id whose binary frame this conn
// hasn't actually delivered, otherwise a disconnect right after
// the cursor leaves the client's persisted resume cursor
// pointing past unreplayed rows. Two safe sources only:
// - `highestReplayed`: an id we just sent the binary frame
// for during replayTo.
// - `since`: the client's announced cursor — the client has
// already applied that op-log id locally, so re-advertising
// it never regresses past content.
// We deliberately do NOT consult MAX(op-log.id) here: a live
// op that landed during replay would be reflected in MAX but
// hasn't been broadcast through this conn's writeLoop yet, and
// a cursor=MAX would let the client persist a value that
// outpaces its received binary frames. Per Codex round 6 [P1].
// Acquire writeMu across the read-max + cursor-send +
// replayDone-flip so writeLoop's per-event critical section
// (round 22) cannot interleave a record-vs-read window.
// Holding the lock means: any in-flight writeLoop event
// finishes its record/send before we read max; any subsequent
// event sees replayDone=true and emits its own live cursor.
// Per Codex round 22 [P1] of TASK-1319.
rc.writeMu.Lock()
cursorID := highestReplayed
if cursorID < since {
cursorID = since
}
if liveMax := rc.maxLiveOpLogIDDuringReplay.Load(); liveMax > cursorID {
cursorID = liveMax
}
payload, perr := json.Marshal(ControlMessage{
Type: ControlMessageOpLogCursor,
OpLogID: cursorID,
})
if perr != nil {
rc.writeMu.Unlock()
room.removeConn(rc)
<-writerDone
return perr
}
if werr := rc.conn.WriteMessage(websocket.TextMessage, payload); werr != nil {
rc.writeMu.Unlock()
room.removeConn(rc)
<-writerDone
return werr
}
rc.replayDone.Store(true)
rc.writeMu.Unlock()
// Read loop blocks until the WS closes.
readErr := room.readLoop(rc)
// Reader returned: take the conn out of the room (which closes
// the bus subscription, which unblocks the writer).
room.removeConn(rc)
// Wait for the writer to drain before returning so the handler's
// `defer conn.Close()` doesn't fire mid-WriteMessage.
<-writerDone
return readErr
}
// getOrCreate returns the existing Room for itemID or, atomically
// under m.mu, mints a new one. Holding m.mu across the lookup +
// insertion keeps the grace-expiry path (which also takes m.mu)
// from interleaving and orphaning a freshly-created Room.
//
// Returns nil after Close has been called — the caller should
// translate that to errManagerClosed. In practice Join checks
// m.closed earlier and bails before reaching here, but this guard
// keeps a future caller honest if getOrCreate gets reused.
func (m *RoomManager) getOrCreate(itemID string) *Room {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return nil
}
if r, ok := m.rooms[itemID]; ok {
return r
}
r := &Room{
itemID: itemID,
store: m.store,
bus: m.bus,
schemaVersion: m.schemaVersion,
graceTTL: m.graceTTL,
conns: make(map[*websocket.Conn]*roomConn),
pendingAcks: make(map[string]*pendingApplierAck),
onIdle: m.markRoomGone,
}
m.rooms[itemID] = r
return r
}
// markRoomGone is the Room → Manager callback the grace timer fires
// on its way out. The Room has already set closing = true under its
// own mutex; here we just unhook the manager's lookup so the next
// Join mints a fresh Room.
func (m *RoomManager) markRoomGone(itemID string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.rooms, itemID)
slog.Debug("collab: room reclaimed after grace TTL", "item_id", itemID)
}
// RoomCount is a test/debug accessor. Production code shouldn't make
// decisions based on this — the count is racy with grace-timer
// expirations.
func (m *RoomManager) RoomCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.rooms)
}
// ErrRoomActiveDuringPrune is returned by PruneAndApply when a live
// room (with at least one connected peer) appears for the itemID
// between the caller's ApplyExternalContent check and PruneAndApply's
// own re-check under the per-item lock. Callers should fall through
// to a plain direct write (without pruning the op-log) — the live
// peers' Y.Doc state cannot be invalidated safely.
var ErrRoomActiveDuringPrune = errors.New("collab: room became active during prune attempt")
// UnderItemLock runs fn while the per-item setup lock for itemID is
// held — the SAME lock Join's addConn+replayTo acquires and the SAME
// lock PruneAndApply runs its prune+write under. Used by the items
// PATCH handler's collab-snapshot validation path so the
// MIN(op-log.id) check and the items.content write are atomic
// w.r.t. concurrent prunes (PruneAndApply, schema rebuild, dormant
// GC's per-item DELETE). Without this serialization, a prune
// landing between the check and the write lets the stale
// collab-snapshot overwrite canonical content. Per Codex round 13
// [P1] of TASK-1319.
//
// Best-effort with a fast bail-out: if Close has fired the lock
// goroutine returns ErrManagerClosed without invoking fn. fn's
// own error (if any) is returned verbatim.
func (m *RoomManager) UnderItemLock(itemID string, fn func() error) error {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return errManagerClosed
}
m.mu.Unlock()
lock := m.itemLock(itemID)
lock.Lock()
defer lock.Unlock()
return fn()
}
// PruneAndApply runs applyFn under the per-item setup lock so it is
// strictly serialised with any in-flight Join's addConn+replayTo for
// the same itemID. Used by the items PATCH handler to prune the
// op-log + write items.content directly when ApplyExternalContent
// classifies the request as "no live editors" (ErrNoActiveRoom or
// ErrNoApplierAvailable).
//
// Returns ErrRoomActiveDuringPrune if a room with live conns has
// appeared since the caller's classification check; otherwise the
// error from applyFn (if any). The caller is expected to fall
// through to a plain direct write in the active-room case so the
// PATCH still completes.
//
// Why this matters: ApplyExternalContent's "no room" answer is a
// point-in-time snapshot. Without serialisation, a fresh Join can
// slip in between that check and the prune, replay the
// soon-to-be-pruned op-log into a new client, and end up with stale
// Y.Doc state that later overwrites the freshly-written
// items.content on the next idle flush. Per Codex review round 5.
func (m *RoomManager) PruneAndApply(itemID string, applyFn func() error) error {
lock := m.itemLock(itemID)
lock.Lock()
defer lock.Unlock()
// Re-verify under the lock: if a room with live conns has
// appeared, refuse to prune (peers' Y.Doc would diverge from
// an empty op-log).
m.mu.Lock()
hasLivePeers := false
if r, ok := m.rooms[itemID]; ok {
r.mu.Lock()
hasLivePeers = len(r.conns) > 0
r.mu.Unlock()
}
m.mu.Unlock()
if hasLivePeers {
return ErrRoomActiveDuringPrune
}
return applyFn()
}
// closeFrameDeadline is the absolute time budget for sending a
// CloseMessage frame via WriteControl before falling through to a
// plain Close. Generous enough that a healthy connection always
// completes; short enough that a stuck-write conn doesn't block
// the revoke path.
const closeFrameDeadline = 1 * time.Second
// CloseConn force-closes a single WebSocket connection registered
// with the manager, sending a close frame with a machine-readable
// reason first. Used by the auth-revalidation timer in
// handleCollab (TASK-1256) to evict a peer whose workspace access
// was revoked mid-stream.
//
// - itemID scopes the lookup; (purely informational here, the
// close-frame call doesn't actually need it but the
// param keeps the API symmetric for a future
// find-by-room metric).
// - conn the *exact* websocket.Conn the manager is tracking;
// not a tab/session id.
// - code a websocket.Close* code (e.g. ClosePolicyViolation
// for "you are no longer authorized").
// - reason human-readable string the close frame carries to the
// client. Kept short — the WS spec caps the close
// frame's reason at ~123 bytes.
//
// CRITICAL: the close frame is sent via conn.WriteControl which
// is concurrency-safe with the room's writeLoop / replay (per
// gorilla's documented contract — WriteControl does not contend
// on the conn's normal write mutex). Acquiring writeMu would
// instead block the revoke until any in-flight WriteMessage to a
// slow peer finished, defeating the "evict immediately" goal.
//
// Best-effort: WriteControl errors (already-closed conn, deadline
// exceeded) fall through to plain Close. Either way the conn is
// not usable when this returns.
func (m *RoomManager) CloseConn(itemID string, conn *websocket.Conn, code int, reason string) {
if conn == nil {
return
}
_ = conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(code, reason),
time.Now().Add(closeFrameDeadline),
)
_ = conn.Close()
_ = itemID // reserved for future per-room metrics; see doc above
}
// Close stops every active room AND blocks until every in-flight
// Join goroutine has returned. After Close, Join is undefined —
// callers must coordinate shutdown so no new Join races happen
// alongside Close. Used by Server.Stop on graceful shutdown to
// ensure no collab goroutine is still running by the time the
// store is closed.
//
// Two phases:
//
// 1. closeAll on every room — closes each WebSocket from the
// server side, which causes the corresponding readLoop to
// return, removeConn to fire, the bus subscription to close,
// and writeLoop to exit. The Join goroutine that was running
// runConn then returns naturally.
// 2. activeJoins.Wait — blocks until step 1's effects propagate
// through every still-running Join. Without this Wait, Close
// returns before the goroutines actually exit.
func (m *RoomManager) Close() {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return
}
m.closed = true
rooms := make([]*Room, 0, len(m.rooms))
for _, r := range m.rooms {
rooms = append(rooms, r)
}
m.rooms = make(map[string]*Room)
m.mu.Unlock()
for _, r := range rooms {
r.closeAll()
}
m.activeJoins.Wait()
}