Skip to content

fix(save): ask the file who wrote it, and check before overwriting (#692) - #698

Merged
PathGao merged 1 commit into
masterfrom
fix/ask-the-file-not-the-clock
Aug 21, 2026
Merged

fix(save): ask the file who wrote it, and check before overwriting (#692)#698
PathGao merged 1 commit into
masterfrom
fix/ask-the-file-not-the-clock

Conversation

@PathGao

@PathGao PathGao commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Depends on nothing — #696 is merged. Reads best after #697. Merge order: #697, then this, then #699.

What this is

Two failures of one mechanism, and one replacement for both.

Whether a file-changed event was somebody else's write was guessed from the clock: each of our own writes opened a 400ms window in which every event for that path was discarded as ours.

Too early. A program writing inside that window had its event dropped, with nothing re-queued:

T+0     our auto-save writes          window open until T+400
T+150   VS Code writes
T+160   event arrives, inside window  → DISCARDED
                                      → buffer holds ours, disk holds theirs,
                                        tab reads clean, nothing is pending
T+1500  one more keystroke, auto-save → their edit is gone, silently

That is loss of somebody else's work, produced by the guard that exists to prevent it.

Too late. An event arriving after the window — a network share, a synced folder, any watcher latency over 400ms — read as external:

T+0     our auto-save writes          window open until T+400
T+200   the user types again          tab is dirty
T+600   the event finally arrives     → "This file changed on disk while you
                                         had unsaved changes."  (it did not)

Nothing is lost, but the bar is now sometimes lying, and Zed is disliked for exactly this. What people learn from a question that is sometimes false is to dismiss it, including the times it is true.

Mechanism

originalContent is already the text last known to be in the file — every load sets it, every successful save sets it. So "the file differs from originalContent" is the question, exactly, with no window and no clock:

async function fileDiffersFromBaseline(tab, path) {
	if (!path || tab.isTruncated) return true;
	const [content] = await invoke('read_file_content_checked', { path });
	return content !== tab.originalContent;
}

Equal means nobody has written since, whoever ran the last write. Different means somebody has, however long ago. Both directions above collapse. selfWriteUntilByPath, markSelfWrite, clearSelfWrite, shouldReloadExternalChange and SELF_WRITE_GRACE_MS are all deleted; nothing replaces them but the function above.

An external write of identical bytes now resolves to ignore, which is right — nothing changed.

A truncated tab (the >5MB preview slice) always answers "changed" and needs no better answer: ensureFullContent gates every path that can write, so a tab we have never written has no write of ours to recognise.

The same question, asked before the write

The second half, and the reason this is one PR. That check now also runs immediately before save_file_content, and refuses the save if the disk has moved.

This is the guard that does not depend on the watcher, which matters because the watcher is not dependable: Live Mode is off by default, its events can be dropped, and a watch armed on a file does not always survive that file being renamed over (#697). None of that reaches a check made at the moment of writing.

Every editor with a claim to not losing work has one, and this is the shape they agree on:

on the event before the write
VS Code reload / ignore / compare refuses: "The content of the file is newer" → Compare / Overwrite
Vim autoread, opt-in re-stats: "WARNING: The file has been changed since reading it!!!"
Emacs file-supersession, raised as soon as you type
Sublime reload / ignore all / cancel
Markpad, before reload / keep mine nothing
Markpad, after reload / keep mine refuses, and raises the same bar

Vim has no file watcher at all and is still safe, which is the argument for this check existing independently of the event path rather than instead of it.

A refusal, not a merge and not a retry. "Keep my version" authorises exactly the next save — one save, because a third program writing a minute later is a question the user has not answered. Cmd+S while the bar is up carries the same authorisation: without that the bar was a trap, since the disk really has changed, so the guard refuses, so the bar goes back up, and nothing the user presses gets them out.

The gap between the read and the rename is real and is the same gap Vim and VS Code have. It narrows exposure from "the whole time the document is open" to "the length of one write".

Scope

One read per watcher event and one per save. Every event that is not ignored already led to a read, so the new cost is a read per own save — while typing with auto-save on, one per 1.5s, of a file being written at the same rate. Files over 5MB take the isTruncated branch and skip it.

Not changed: the auto-save toast. A refused save returns false like a failed one, and the generic "auto-save failed" is suppressed exactly as it already is for the lossy-decode refusal — the conflict bar is on screen saying what happened and offering both ways out.

Not changed, and next: canCloseTab saves a dirty tab without consulting the bar, so closing a tab with an unanswered conflict still silently answers "keep mine". And the bar still offers an irreversible Reload with no way to see what would be lost.

Tests

scripts/externalChangeReload.spec.ts is rebuilt on a fake disk — a Map the stubbed read_file_content_checked and save_file_content share — because every question in it is now "what does the file say?". That is also what let the fake clock go: no test here manipulates time any more.

Seventeen cases. The four that name this change:

  • our own write is not an external change, however late the event arrives (asked twice, to show there is no window to fall out of)
  • a late event about our own save is not raised as a conflict, with the user's later typing still unsaved
  • a foreign write moments after our own is still reported — the case that used to lose data
  • a save is refused when the disk moved under it: returns false, their bytes still on disk, the refusal reported, and the user's buffer intact
  • answering "keep mine" authorises the next save and only the next one
  • an ordinary save of an untouched file still just saves

Checked by breaking what they guard: stubbing fileDiffersFromBaseline to always answer "changed" turns four red; removing the guard from the write path turns two red.

scripts/reopenDirtyDocument.spec.ts and scripts/truncatedBufferGuard.spec.ts are updated for the new call shape — the resolution is async now, and it does a read of its own, which the read counters had to stop attributing to the open.

Verification

npm audit             0 vulnerabilities
npm run check         814 files, 0 errors, 0 warnings
npm test              963 pass, 0 fail
npm run test:vitest   44 files, 377 pass

npm run test:vitest also reports 14 failures in this environment (session-restore and window-tag snapshots, assert.deepEqual reference-identity under node 26 + jsdom). Byte-identical on a clean checkout of the base branch in the same environment, verified by stashing and re-running.

cargo test not re-run: no Rust changed here. #697 covers that side.

Not verified by hand: the end-to-end gesture with a real second editor. The behaviours above are pinned against a fake disk, which is where the decisions are made; what is not pinned is watcher latency in the wild, and the point of this change is that no decision depends on it any more.

Base automatically changed from fix/auto-reload-in-edit-mode to master August 21, 2026 17:58
)

Whether a `file-changed` event was somebody else's write was guessed from the
clock: each of our own writes opened a 400ms window in which every event for
that path was discarded as ours. A clock cannot answer a question about
identity, and it was wrong in both directions.

Too early: another program writing inside the window had its event dropped,
with nothing re-queued. The buffer then held our text and the disk held
theirs, with the tab looking clean, so the next keystroke's auto-save put ours
back over theirs — silent loss of somebody else's edit, which is the exact
thing the guard exists to prevent.

Too late: an event arriving after the window read as external, and if the user
had typed since, the conflict bar asked about their own save. Zed is disliked
for precisely this: a question that is sometimes false teaches people to
dismiss it, including the times it is true.

`originalContent` is already the text last known to be in the file, so
comparing the file against it answers the question exactly and needs no window.
The whole grace-window mechanism is deleted.

The same comparison now also runs immediately before a write. Live Mode is off
by default, its events can be dropped, and a watch does not always survive the
file being renamed over — none of which reaches a check made at the moment of
writing. Every editor with a claim to not losing work has one: VS Code refuses
the save ("The content of the file is newer"), Vim re-stats before each write,
Emacs raises file-supersession. Vim has no watcher at all and is still safe.

A refusal, not a merge: the conflict bar is raised, and answering "keep mine"
authorises exactly the next save. Cmd+S while the bar is up carries the same
authorisation, or the bar would be a trap with no way out.
@PathGao
PathGao force-pushed the fix/ask-the-file-not-the-clock branch from ca532ef to dc311fd Compare August 21, 2026 18:09
@PathGao
PathGao merged commit a23be13 into master Aug 21, 2026
4 checks passed
@PathGao
PathGao deleted the fix/ask-the-file-not-the-clock branch August 21, 2026 18:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant