Skip to content

addEphemeral races with auto-created TimerlessEphemeralStore from applyRemote #75

Description

@jacob-petterle

Problem

When using sync(doc).addEphemeral('cursors', cursorStore) to register a CursorEphemeralStore for LoroEphemeralCursorPlugin, there is a race condition with EphemeralStoreManager.applyRemote().

Root Cause

applyRemote() calls getOrCreate(docId, namespace) which auto-creates a TimerlessEphemeralStore if no store exists for that namespace. Once this auto-created store exists, addEphemeral() / registerExternal() throws "Ephemeral store already exists", permanently blocking the CursorEphemeralStore from being registered.

This happens deterministically when:

  1. Browser A registers a CursorEphemeralStore and sends cursor data
  2. Browser B connects — the sync handshake includes Browser A's cursor ephemeral data
  3. Browser B's applyRemote() runs and auto-creates a TimerlessEphemeralStore for the 'cursors' namespace
  4. Browser B's component mounts and calls addEphemeral('cursors', cursorStore)throws

The CursorEphemeralStore can never be registered on Browser B, so LoroEphemeralCursorPlugin is never added to the editor. Cursor presence fails.

Why it only affects external stores

DECLARED ephemeral stores (passed as the 3rd arg to repo.get()) work perfectly because SyncRefImpl pre-creates the stores via getOrCreateNamespacedStore() in its constructor — before any sync handshake. Canvas presence uses this pattern and works reliably.

External stores (registered via addEphemeral()) are vulnerable because there's always a window between repo.get() (which starts sync) and addEphemeral() (called later, often from a React component).

Proposed Fix

In EphemeralStoreManager.registerExternal(), if the existing store was auto-created (is a TimerlessEphemeralStore), replace it instead of throwing:

registerExternal(docId, namespace, store) {
  // ... existing setup ...
  
  const existing = namespaceStores.get(namespace);
  if (existing) {
    if (existing instanceof TimerlessEphemeralStore) {
      // Auto-created by applyRemote — safe to replace
      const data = existing.encodeAll();
      if (data.length > 0) store.apply(data);  // Migrate accumulated data
      
      const unsub = this.#subscriptions.get(existing);
      unsub?.();
      this.#subscriptions.delete(existing);
      
      namespaceStores.set(namespace, store);
      this.#subscribeToStore(docId, namespace, store);
      return;
    }
    throw new Error(`Ephemeral store "${namespace}" already exists`);
  }
  
  namespaceStores.set(namespace, store);
  this.#subscribeToStore(docId, namespace, store);
}

This makes addEphemeral idempotent for auto-created stores while still throwing for genuine conflicts (two external stores competing for the same namespace).

Secondary Issue: encodeAll() doesn't touch external stores

EphemeralStoreManager.encodeAll() only calls touch() on TimerlessEphemeralStore instances. External stores (like CursorEphemeralStore) skip the touch, causing their data to expire (30s default timeout) during heartbeat-based sync. The real-time broadcast path works, but catch-up (sync-response, heartbeat) silently drops stale data.

Environment

  • @loro-extended/repo@6.0.0-beta.0
  • loro-prosemirror@0.4.2
  • loro-crdt@1.10.6

Workaround

We're currently calling repo.synchronizer.registerExternalStore(docId, namespace, store) BEFORE repo.get() to ensure the store is in the map before sync starts. This works but depends on the internal synchronizer getter.

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