Skip to content

fix: sync open files to disk before rename to prevent silent corruption#141

Open
Roxxik wants to merge 1 commit into
isaacphi:mainfrom
Roxxik:fix/rename-stale-positions
Open

fix: sync open files to disk before rename to prevent silent corruption#141
Roxxik wants to merge 1 commit into
isaacphi:mainfrom
Roxxik:fix/rename-stale-positions

Conversation

@Roxxik

@Roxxik Roxxik commented Jun 7, 2026

Copy link
Copy Markdown

Problem

rename_symbol can silently corrupt files. If a file is edited on disk by a non-LSP path (an editor or agent writing directly) after the server has opened it, and a rename is then issued, the server plans the WorkspaceEdit against its stale in-memory buffer. The bridge applies those {line, character} ranges to the current on-disk file, overwriting unrelated text at stale positions and missing the real references, while reporting success.

Concretely, renaming SHARED_CONSTANT after prepending lines to a referencing file produced:

let s = SharedStruct::newRENAMED_CONSTANT   // clobbered ::new("test")

with the real references left unrenamed and the tool reporting Successfully renamed.

Root cause

  • Client.OpenFile is a no-op once a file is open, so the server keeps the version it saw at didOpen.
  • The rename path only calls OpenFile; it never re-syncs.
  • The workspace watcher's didChange is debounced (300ms) and races the rename, so it routinely loses.

The corrupted positions are in-bounds (valid coordinates, wrong place), so no range/bounds check can catch this. The buffer must be made current before the rename is planned.

Fix

Add Client.SyncOpenFiles, which re-sends every open file's current on-disk content via didChange, and call it in RenameSymbol before issuing the rename. Notifications and the rename request are ordered on the connection, so the rename is planned against current content.

Test

TestRenameSymbolStaleBuffer reproduces the corruption faithfully under the real configuration (watcher on). rust-analyzer is warmed so the rename completes in ~1ms, well inside the 300ms debounce, and wins the race exactly as in production. The ~200x margin keeps it deterministic; if the rename ever lost the race it fails loudly with ContentModified rather than flaking. It fails (silent corruption) before the fix and passes after; existing rename tests are unaffected.

Known limitations

  • A small TOCTOU window remains between SyncOpenFiles and ApplyWorkspaceEdit's disk re-read; it is inherent to the architecture and strictly smaller than before.
  • Every rename now re-sends all open files' content (bumping versions/re-analysis even for unchanged files). Fine off the hot path; a future optimization could skip unchanged files via mtime/hash if rename latency matters on large sessions.

🤖 Generated with Claude Code

rename_symbol planned its WorkspaceEdit from rust-analyzer's in-memory
buffer, which goes stale when a file is edited on disk by a non-LSP path
after it was opened: OpenFile is a no-op once a file is open, and the
workspace watcher's didChange is debounced (300ms) and races the rename.
The server then returns edit ranges computed for the pre-edit content,
and the bridge applies those {line,character} ranges to the current
(grown) file -- overwriting unrelated text at stale positions and
missing the real references, while reporting success. The clobbered
positions are in-bounds, so no range/bounds check can catch them; the
buffer must be made current before the rename is planned.

Fix: add Client.SyncOpenFiles, which re-sends every open file's current
on-disk content via didChange, and call it in RenameSymbol before
issuing the rename. The didChange notifications and the rename request
are ordered on the connection, so the rename is planned against current
content.

Add a regression test that reproduces the corruption faithfully: watcher
ON (production config), rust-analyzer warmed so the rename completes in
~1ms -- far inside the 300ms debounce -- and wins the race against the
watcher, exactly as in production. It fails (silent corruption) before
the fix and passes after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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