Steps to reproduce
In the Sandbox Vault:
- Open the developer console (Ctrl/Cmd-Shift-I).
- Paste and run:
const v = app.vault;
const f = await v.create('saving-probe.md', 'seed');
const c = await v.create('control-probe.md', 'seed'); // control: same steps, no overlap
// Two writes to the same file, the second starting while the first
// is still in flight (large bodies guarantee the overlap):
const p1 = v.modify(f, 'a'.repeat(200000));
const p2 = v.modify(f, 'b'.repeat(200000));
await Promise.all([p1, p2]); // both writes fully complete
console.log(f.saving); // -> true (nothing is writing anymore)
await v.modify(f, 'one ordinary write');
console.log(f.saving); // -> true (a later normal write does not heal it)
await v.modify(c, 'single write');
console.log(c.saving); // -> false (control file behaves correctly)
- Open both
saving-probe.mdandcontrol-probe.mdin editor tabs. - Modify both files with an external tool:
echo ' EXTERNAL-EDIT' >> saving-probe.md; echo ' EXTERNAL-EDIT' >> control-probe.md - Watch the two open editors.
- Type a few characters into the
saving-probe.mdeditor and let autosave run, then read the file on disk.
Did you follow the troubleshooting guide? [Y]
Expected result
After both writes settle in step 2, f.saving is false, and both open editors pick up the external edits from step 4 — as control-probe.md in fact does.
Actual result
- Step 2:
f.savingis stucktrueforever — no later write, and no amount of waiting, clears it; only an app restart (which rebuilds theTFile) recovers. The control file’s flag isfalseas expected. - Step 5:
control-probe.md’s editor showsEXTERNAL-EDIT;saving-probe.md’s editor silently ignores it — the file’s disk content and the open buffer now disagree, with no indication to the user. (The editor’s reload path reads through the vault cache whenfile.savingis set, and the vault watcher skips clearing that cache —if (!file.saving) file.cache(null)— so the external change never reaches the buffer.) - Step 6: the stale buffer autosaves over the file, destroying the external edit on disk — silent, permanent data loss, no conflict prompt. (Verified: after typing, disk contained the buffer text; the
EXTERNAL-EDITbytes were gone.)
Environment
SYSTEM INFO:
Obsidian version: 1.13.1
Installer version: 1.12.7
Operating system: #202606011647~1780583630~22.04~70ad774 SMP PREEMPT_DYNAMIC Thu J 7.0.11-76070011-generic
Login status: not logged in
Language: en
Insider build toggle: off
Live preview: on
Base theme: adapt to system
Community theme: none
Snippets enabled: 0
Restricted mode: on
RECOMMENDATIONS:
none
Additional information
Root cause (from reading app.js): Vault.modify and Vault.process maintain file.saving with remember/restore bookkeeping:
// de-minified shape of Vault.prototype.modify (Vault.prototype.process is identical)
const remembered = file.saving; // remember what I saw at entry
file.saving = true;
try {
await this.adapter.write(file.path, data, opts);
} finally {
file.saving = remembered; // put the REMEMBERED value back
}
Remember/restore is only correct when writer sections nest like a stack. When a second call starts while the first is in flight, the second remembers true and restores true after the last write completes — leaving the flag on with no write in flight. Nothing in the app ever sets it back to false unconditionally.
Disk operations are serialized in start order, so overlapping callers always finish in exactly the order that pins the flag. All three pairings stick it: modify+modify, modify+process, process+process.
The editor’s save guards against this, but Obsidian’s internal vault.process link updates (e.g. on rename) can still overlap an in-flight editor autosave of the same file.
Plugins that call vault.modify/vault.process also race.
Suggested fix — count writers instead of using a flag (adapter will still linearize the writes)
async function modify(file, data, opts) {
file.saving = (file.saving || 0) + 1; // one more write in flight
try {
await this.adapter.write(file.path, data, opts);
} finally {
file.saving--; // subtract, don't restore
}
}