Skip to content

Keep comments durable across collab session token expiry - #76

Open
rclod wants to merge 1 commit into
EveryInc:mainfrom
rclod:upstream-pr/comment-durability
Open

Keep comments durable across collab session token expiry#76
rclod wants to merge 1 commit into
EveryInc:mainfrom
rclod:upstream-pr/comment-durability

Conversation

@rclod

@rclod rclod commented Aug 27, 2026

Copy link
Copy Markdown

Fixes #72.

A collab session token expires while the provider is connected. The server closes the socket 4401 Invalid or expired collab session token, and comments added afterwards stay visible in the tab while storage keeps revision=1, marks='{}' and zero document_y_updates.

#72 has the full trace. The short version is that four things have to line up, and each one alone loses the comment:

  1. startCollabRefreshLoop returned early on connected && isSynced — the state a healthy session is in for the whole minute before expiry — so renewal never ran ahead of the close.
  2. The immediate-refresh hook was gated on collabClient.terminalCloseReason, which nothing in src/ ever assigns. Unreachable.
  3. flushShareMarks picked the durable REST path from collabEnabled / collabCanEdit. Those are capability flags and stay true on a closed provider, so the mark went to the local Y.Doc and nowhere else.
  4. shouldDeferExpiringCollabRefresh deferred on pending local state without checking connection status, so the unsaved comment blocked its own rescue; the fallback recovery passed preserveLocalState: false and reset the Y.Doc, discarding it.

Approach

The decisions move into src/bridge/collab-session-renewal.ts as pure functions. The editor is browser-only and cannot be instantiated in this harness, so the logic that lost data was untestable where it lived — this follows the same seam as src/bridge/marks-preservation.ts.

  • Renewal runs on a healthy connection, defers for typing or unsaved work only while the connection is still usable, and never defers past a 15s hard deadline. Backoff and an in-flight guard keep a failing refresh from spinning.
  • Auth failures take exactly one deterministic refresh that preserves local marks. The reason string cannot separate expired from revoked — the server surfaces an expired token as permission-denied through onAuthenticate — so the refresh response decides. A genuine revocation still returns 401/403/404/410 and falls through to the existing read-only teardown.
  • Durable persistence keys off provider liveness. REST is used only when no provider is connected, so it never writes alongside a live Yjs writer; while connected the provider stays the sole owner of the canonical document. Marks are keyed by mark id and merged by key, so a mark that replays on reconnect converges rather than duplicating.

terminalCloseReason is now actually assigned for a real denial, which also revives the two other consumers that were dead for the same reason.

Tests

src/tests/collab-session-renewal-expiry.test.ts drives a deterministic fake clock across expiry. Each scenario also runs against a reference implementation of the inline logic it replaces, asserting the old rule returns the wrong answer — a test both implementations pass would not have caught this defect. Covers proactive renewal, the hard deadline overriding deferral, the unsaved-work deadlock, backoff, preserve-on-reconnect, the REST liveness decision, auth classification, and durable acknowledgement.

src/tests/collab-comment-durability-across-expiry.test.ts runs against a temp database with a live collab room loaded for the slug, and proves a comment written while the provider is dead persists once, converges on replay rather than duplicating, and still admits a genuinely new comment.

Both new files pass, along with proof-sdk-agent-bridge-client, server-routes-and-share (74/74), and collab-browser-persist-after-external-apply-regression.

Notes

A collab session token expired while the provider was connected. The
server closed the socket 4401 "Invalid or expired collab session token",
and comments added afterwards stayed visible in the tab while SQLite kept
revision=1, marks='{}' and zero document_y_updates.

Four independent defects had to line up, and each one alone loses the
comment:

1. src/editor/index.ts renewal loop returned early whenever the
   connection was `connected && isSynced` — exactly the state a session
   is in for the whole minute before it expires — so it never renewed
   ahead of expiry and only reacted after being closed.

2. Nothing in src/ ever assigned collabClient.terminalCloseReason. The
   immediate-refresh hook that read it was therefore unreachable, so an
   auth failure had no fast path back.

3. flushShareMarks gated the durable REST write on collabEnabled and
   collabCanEdit. Those are capability flags and stay true on a provider
   that has been closed out, so the comment went to the local Y.Doc and
   nowhere else, with no unsaved state surfaced.

4. shouldDeferExpiringCollabRefresh deferred renewal whenever there was
   pending local state, without considering connection status. Adding a
   comment set unsyncedChanges=1, which then blocked the very refresh
   that would have saved it. The stalled-collab recovery path that could
   still fire passed preserveLocalState=false, resetting the Y.Doc and
   discarding the comment outright.

The decisions move into src/bridge/collab-session-renewal.ts as pure
functions, because the editor is browser-only and cannot be instantiated
in this harness — the logic that lost data was untestable where it lived.
Renewal now runs on a healthy connection, defers only while the
connection is usable, and never defers past a hard deadline. Auth
failures take one deterministic refresh that preserves local marks. REST
persistence is selected on provider liveness rather than capability, and
only while no provider is connected, so it never writes alongside a live
Yjs writer.

Tests: collab-session-renewal-expiry.test.ts drives a fake clock across
expiry and runs each scenario against a reference implementation of the
replaced inline logic, asserting the old rule gets it wrong — a test both
implementations pass would not have caught this.
collab-comment-durability-across-expiry.test.ts proves a comment written
while the provider is dead persists once and converges on replay.
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.

Comments can be lost when a collab session token expires while the provider is connected

1 participant