Skip to content

Regression in v0.15.10: "sync on save not fs changes" breaks sync for programmatic/external file edits, with cascading state corruption #396

Description

@wangcy1123

Description

The fix for #299/#323 in commit debdcb5 ("fix(scm): sync on save not fs changes") replaced localWatcher.onDidChange with onDidSaveTextDocument as the sole mechanism for pushing local file modifications to Overleaf. While this correctly prevents unwanted syncs from git operations and compilation outputs, it introduces a regression: programmatic file edits that bypass VS Code's editor (e.g., from AI coding assistants like Claude Code, Copilot, or any external script) are never synced to Overleaf.

Worse, attempting to recover by manually saving the file after an external edit corrupts the bypassCache echo-prevention state, causing all subsequent syncs for that file to be silently dropped. In some cases, the OT version cache (doc.version, doc.localCache, doc.remoteCache) is also cleared, requiring a full VS Code window reload to recover.

Steps to Reproduce

  1. Open a project with Overleaf Workshop (Local Replica SCM enabled)
  2. Wait for initial sync to complete (overwrite())
  3. Use any external tool to modify a synced .tex file directly on disk (e.g., echo "% modified" >> main.tex from terminal, or let an AI coding assistant edit it)
  4. Observe: the edit is not synced to Overleaf
  5. Now manually edit and save the same file in VS Code (Cmd+S)
  6. Make a second manual edit and save — observe: this second edit is also not synced
  7. Try making changes on Overleaf web → changes are no longer pulled to local
  8. The only recovery is Cmd+Shift+PReload Window

Root Cause

Layer 1: Missing sync trigger (the direct regression)

In localReplicaSCM.js:381-391, the watcher registration was changed from:

// Before v0.15.10 (hypothetical):
this.localWatcher.onDidChange(...)  // ← was registered
this.localWatcher.onDidCreate(...)
this.localWatcher.onDidDelete(...)

To (v0.15.10, commit debdcb5):

// sync from local to vfs: file updates via editor saves (onDidSaveTextDocument above),
// file creation and deletion still via watcher (these are explicit user actions)
this.localWatcher.onDidCreate(async (uri) => await this.syncToVFS(uri, 'update')),
this.localWatcher.onDidDelete(async (uri) => await this.syncToVFS(uri, 'delete')),
// NOTE: onDidChange is deliberately NOT registered for local→remote sync

Programmatic file writes (including those from AI agents, scripts, or other tools) do not trigger onDidSaveTextDocument. They are permanently invisible to the sync mechanism.

Layer 2: bypassCache poisoning (cascading failure)

After a user manually saves a file that was externally edited, the shouldPropagate method in localReplicaSCM.js:183-202 enters a poisoned state:

shouldPropagate(action, relPath, content) {
    const cache = this.bypassCache.get(relPath);
    if (cache) {
        // ... hash equality checks ...
        if (cache[0].hash !== cache[1].hash) {          // ← TRIGGERED
            if (action === 'push' && now - cache[0].date < 500 || 
                action === 'pull' && now - cache[1].date < 500) {
                this.setBypassCache(relPath, content, action);
                return true;
            }
            this.setBypassCache(relPath, content, action);
            return false;                                // ← SILENTLY BLOCKS SYNC
        }
    }
    this.setBypassCache(relPath, content, action);
    return true;
}

The sequence:

  1. Initial state: bypassCache[file] = [push: H1, pull: H1] (synced)
  2. User saves after external edit → shouldPropagate('push') returns true, but only updates cache[0] to H2 (line 169: setBypassCache for push only sets cache[0]; cache[1] stays at H1)
  3. Result: bypassCache[file] = [push: H2, pull: H1]hashes now differ!
  4. Any subsequent push enters the cache[0].hash !== cache[1].hash branch
  5. After 500ms passes: returns false — sync permanently blocked with zero user feedback

The only way to heal the bypassCache is a corresponding pull that updates cache[1]. But:

Layer 3: OT version cache clearing (total sync breakdown)

If the OT update from the manual save causes a version mismatch on Overleaf's side, the onFileChanged handler in remoteFileSystemProvider.js:413-443 clears all caches:

onFileChanged: (update) => {
    const doc = res.fileEntity;
    if (update.v === doc.version) {
        doc.version += 1;
        // ... normal update ...
    } else {
        doc.remoteCache = undefined;  // ← CLEARED
        doc.localCache = undefined;   // ← CLEARED
        // No notification fired → no pull triggered → bypassCache never heals
    }
}

After this, writeFile at line 729 silently returns:

if (doc.version === undefined || doc.localCache === undefined || 
    doc.remoteCache === undefined) {
    return;  // SILENT NO-OP — sync permanently broken
}

This also means no vfsWatcher.onDidChange event is fired (the notify call is missing in the version-mismatch branch), so the pull path can never run, and bypassCache[1] is never updated to heal Layer 2.

Why .overleafignore is irrelevant here

The LocalReplicaSCMProvider does not read any .overleafignore file — it only uses hardcoded default patterns and VS Code extension settings (ignore-patterns key in SCM persist data). Even with perfect file filtering, external programmatic edits to legitimate .tex files would still trigger this bug.

Suggested Fixes

  1. Short-term: Add a VS Code command to force re-sync

    • Overleaf Workshop: Force Sync Local Changes — triggers a full overwrite()-style sync
    • At minimum, gives users a manual recovery path without full window reload
  2. Medium-term: Heal the bypassCache on push failure

    • If a push returns false due to the conflict block, schedule a pull for that file
    • Or reset both cache[0] and cache[1] when entering the "return false" path after the 500ms window
  3. Long-term: Add a configurable file system watcher option

    "overleaf-workshop.sync.watchFileSystemChanges": {
      "enabled": false,        // default false (current behavior)
      "excludePatterns": []    // additional minimatch patterns
    }

    This lets users who work with external tools opt back into fs-based sync.

Environment

  • Extension version: 0.15.10
  • VS Code variant: VS Code
  • OS: macOS 26.5.2
  • Scenario: AI coding assistant (Claude Code) editing .tex files via Write/Edit tools

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions