Overlapping Vault.modify/Vault.process calls permanently stick file.saving

Steps to reproduce

In the Sandbox Vault:

  1. Open the developer console (Ctrl/Cmd-Shift-I).
  2. 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)
  1. Open both saving-probe.md and control-probe.md in editor tabs.
  2. Modify both files with an external tool:
    echo ' EXTERNAL-EDIT' >> saving-probe.md; echo ' EXTERNAL-EDIT' >> control-probe.md
  3. Watch the two open editors.
  4. Type a few characters into the saving-probe.md editor 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.saving is stuck true forever — no later write, and no amount of waiting, clears it; only an app restart (which rebuilds the TFile) recovers. The control file’s flag is false as expected.
  • Step 5: control-probe.md’s editor shows EXTERNAL-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 when file.saving is 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-EDIT bytes 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
  }
}

Thanks for the report, I will have a fix for the next release!