Skip to content

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

Description

@rclod

Running the SDK self-hosted, comments submitted in the browser stayed visible in the tab but never became durable: SQLite kept revision=1, marks='{}', zero document_y_updates and no comment events, and both GET /documents/:slug/state and the bridge marks endpoint returned {}.

The trigger is in the browser console:

[HocuspocusProvider] Connection closed with status Unauthorized: Invalid or expired collab session token

Comments added after that close are the ones lost. A fresh document exercised through the same UI persists comments correctly, so this is degraded-session handling rather than the server, auth, or the comment API.

Four things line up, and each one alone is enough to lose the comment.

1. Renewal never runs proactively. src/editor/index.ts (startCollabRefreshLoop) returns early when the connection is connected && isSynced:

if ((expiresAtMs - now) > 60_000) return;
if (this.collabConnectionStatus === 'connected' && this.collabIsSynced) return;

That is exactly the state a healthy session is in for the whole minute before it expires, so the token is always allowed to lapse and renewal only becomes reachable after the server has already closed the socket.

2. The immediate-refresh hook is unreachable. It is gated on collabClient.terminalCloseReason === 'permission-denied', but nothing in src/ ever assigns a non-null value to terminalCloseReason — the only four writes are = null. authenticationFailed records lastAuthenticationFailureReason and leaves terminalCloseReason alone, so the fast path back can never fire. The two other consumers of that field are dead for the same reason.

3. The REST safety path is skipped on a dead provider. flushShareMarks decides durability from capability flags only:

if (!this.collabEnabled || !this.collabCanEdit || LEGACY_REST_FALLBACK) {
  void shareClient.pushMarks(...)
}

collabEnabled and collabCanEdit describe what the session is permitted to do, not whether a provider is alive to do it. Both stay true after the socket closes, so the mark goes into the local Y.Doc and nowhere else — with no unsaved indication.

4. Unsaved work blocks its own recovery. shouldDeferExpiringCollabRefresh defers whenever there is pending local state, without considering connection status. Adding a comment sets unsyncedChanges = 1, which then defers the refresh indefinitely — the comment blocks the very renewal that would have saved it. The one path that can still fire, maybeRecoverStalledCollab, calls refreshCollabSessionAndReconnect(false), and preserveLocalState: false resets the Y.Doc and discards the comment outright.

Either branch ends with no durable copy. The comment keeps rendering because the mark is a ProseMirror decoration applied locally at submit time, independent of the Yjs marksMap — which is why the UI looks correct while storage is empty.

Suggested behaviour

  • Renew ahead of expiry rather than waiting to be closed; a healthy connection should not suppress renewal.
  • Deferral for typing or unsaved work should apply only while the connection is still usable, and never past a hard deadline.
  • An authentication failure should take a deterministic refresh that preserves local state.
  • Durable persistence should key off provider liveness, not capability flags.
  • A comment should not present as saved until it is durably acknowledged.

PR follows.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions