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
- Open a project with Overleaf Workshop (Local Replica SCM enabled)
- Wait for initial sync to complete (
overwrite())
- 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)
- Observe: the edit is not synced to Overleaf
- Now manually edit and save the same file in VS Code (
Cmd+S)
- Make a second manual edit and save — observe: this second edit is also not synced
- Try making changes on Overleaf web → changes are no longer pulled to local
- The only recovery is
Cmd+Shift+P → Reload 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:
- Initial state:
bypassCache[file] = [push: H1, pull: H1] (synced)
- 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)
- Result:
bypassCache[file] = [push: H2, pull: H1] — hashes now differ!
- Any subsequent push enters the
cache[0].hash !== cache[1].hash branch
- 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
-
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
-
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
-
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
Description
The fix for #299/#323 in commit
debdcb5("fix(scm): sync on save not fs changes") replacedlocalWatcher.onDidChangewithonDidSaveTextDocumentas 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
bypassCacheecho-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
overwrite()).texfile directly on disk (e.g.,echo "% modified" >> main.texfrom terminal, or let an AI coding assistant edit it)Cmd+S)Cmd+Shift+P→Reload WindowRoot Cause
Layer 1: Missing sync trigger (the direct regression)
In
localReplicaSCM.js:381-391, the watcher registration was changed from:To (v0.15.10, commit
debdcb5):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
shouldPropagatemethod inlocalReplicaSCM.js:183-202enters a poisoned state:The sequence:
bypassCache[file] = [push: H1, pull: H1](synced)shouldPropagate('push')returns true, but only updatescache[0]to H2 (line 169:setBypassCachefor push only sets cache[0]; cache[1] stays at H1)bypassCache[file] = [push: H2, pull: H1]— hashes now differ!cache[0].hash !== cache[1].hashbranchThe 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
onFileChangedhandler inremoteFileSystemProvider.js:413-443clears all caches:After this,
writeFileat line 729 silently returns:This also means no
vfsWatcher.onDidChangeevent is fired (thenotifycall is missing in the version-mismatch branch), so the pull path can never run, andbypassCache[1]is never updated to heal Layer 2.Why
.overleafignoreis irrelevant hereThe
LocalReplicaSCMProviderdoes not read any.overleafignorefile — it only uses hardcoded default patterns and VS Code extension settings (ignore-patternskey in SCM persist data). Even with perfect file filtering, external programmatic edits to legitimate.texfiles would still trigger this bug.Suggested Fixes
Short-term: Add a VS Code command to force re-sync
Overleaf Workshop: Force Sync Local Changes— triggers a fulloverwrite()-style syncMedium-term: Heal the bypassCache on push failure
Long-term: Add a configurable file system watcher option
This lets users who work with external tools opt back into fs-based sync.
Environment
.texfiles via Write/Edit toolsRelated
debdcb5c("fix(scm): sync on save not fs changes (fixes Unexpected auto-sync and file overwrite when discarding changes with GitHub integration #323, fixes Empty PDF in local mode #299)").overleafignoreconfusion: the extension's ignore mechanism does not read.overleafignorefiles, which led to additional debugging confusion (see discussion aboutinvalid_upload_requesterrors from unfiltered PDF/bak files)