fix(tabs): ask the filesystem whether two paths name the same file - #416
Conversation
Three independent fixes each hit the same wall and each left the same note - the lossy-save guard, one-tab-per-path, and the reopen guard all compare paths with exact string equality, so on macOS and Windows `/notes/A.md` and `/notes/a.md` read as two files. Three fixes writing "this needs the backend to canonicalize" is the signal that it belongs at the source. Case is not the whole problem. APFS is normalization-insensitive too: `café.md` written NFC opens as NFD and both are one inode, and NFC and NFD are different code points, not different case - so `toLowerCase` cannot reach it by construction. Asking the filesystem answers case, normalization and symlinks at once, using that volume's own folding rules rather than ones we guessed. Identity is stored beside the path, not instead of it. Canonicalizing `tab.path` would rename a tab opened through a symlink to its target, changing the title, the recent-files entry and Copy Path to something the user never typed; it is also unstable (delete and recreate under another spelling and the value changes) and cannot always be computed (Save As names a file that does not exist yet). `pathKey` is the filesystem's answer, `path` stays what the user opened, and a missing key degrades to exact string equality - today's behaviour - so a missed entry point loses the improvement without introducing a bug. VS Code folds case with `toLowerCase` behind a platform check rather than asking the filesystem, and has the resulting bug on record as microsoft/vscode#123660: two genuinely distinct files on a case-sensitive volume, one of them unreachable. It cannot do better - its file layer is abstract, with remote and virtual providers and no `canonicalize` to call. Ours is `std::fs`. Symlinks resolve, because `atomic_write` already follows them deliberately: for the question all three guards are asking - would writing here destroy the file behind that buffer - a link and its target are already one file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e22df28 to
73a6e8a
Compare
|
Linux and Windows Both failures are the same line: The distribution is the diagnosis:
macOS could never execute the other half. Cross-platform CI was not a burden here; it was the only place that branch runs. The fix asserts the property, not the mechanism- assert!(canonical_identity(&lowered).is_err()); // "the lookup failed"
+ assert_ne!(by_lowered.as_ref(), Some(&by_real), "two files must keep two identities");That is what Also moved Evidence per platform
No assertion was weakened, and no |
Three independent fixes hit the same wall and each left the same note in the code:
refuseIfLossilyDecodedtargetPath !== tab.path/notes/Legacy.mdfor a tab opened as/notes/legacy.mdwrites mojibake over the originalclaimPath(#413)receiving.path === filePathThree fixes writing "this needs the backend to canonicalize" is the signal it belongs at the source.
Case is not the whole problem
Verified on this machine before choosing anything:
NFC and NFD are different code points, not different case.
toLowerCase()cannot reach that by construction — no amount of case folding makescaféequalcafé. APFS is normalization-insensitive as well as case-insensitive, and asking the filesystem answers case, normalization and symlinks at once, using that volume's folding rules rather than ones we guessed.(A process note worth recording: a first probe using Python's
os.path.realpathsuggested canonicalization was useless — Python does not fold case. Rust'sfs::canonicalizegoes throughrealpath(3)and returns the on-disk name. The conclusion was wrong until the probe was rewritten in the language that will actually run.)Identity is stored beside the path, not instead of it
Canonicalizing
tab.pathitself was tempting — it would have made four call sites I could not touch correct for free. It was rejected:~/notes/today.md(a symlink) and the tab renames itself to2026-08-03.md; so do the title bar, Copy Path, and the recents entry. That is exactly the implicit behaviour change the project's own guidance argues against.tab.pathmutates on its own.canonicalizefails.So
pathstays what the user opened andpathKeycarries what the filesystem says. The degradation direction is safe: a missing key falls back to exact string equality — today's behaviour — so a missed entry point loses the improvement without introducing a bug.Every one of the 12 assignments to
tab.pathis paired with apathKeyassignment; a stale key is worse than no key.What VS Code does, and why we can do better
Read from source rather than memory:
ExtUri.getComparisonKeyisuri.path.toLowerCase()behind_ignorePathCasing, decided byuri.scheme === Schemas.file ? !isLinux : true— a platform heuristic, not a question to the filesystem.IUriIdentityService.asCanonicalUrilikewise uses a capability bit, and "canonical" means the first spelling seen, LRU-cached.It has the predicted bug on record: microsoft/vscode#123660 —
foo.dartandFOO.DARTon a case-sensitive volume, only one shown, and the wrong one opened.VS Code cannot do better: its file layer is abstract, with remote, virtual and in-memory providers and no
canonicalizeto call. Markpad's isstd::fs. Deviating from the mainstream implementation here is deliberate, and the reason is that our constraints are looser.One thing we copy exactly: VS Code never rewrites the URI, it only folds at comparison time. That is the same split as
path/pathKey.Symlinks resolve
atomic_writealready follows them on purpose ("resolve symlinks so we update the real file"). For the question all three guards are asking — would writing here destroy the file behind that buffer — a link and its target are already one file. Treating them as two documents produces exactly the two-timers-one-file race this is meant to prevent. Display is unaffected, which is the payoff of the two-field split.(dev, ino)was considered and rejected: it would also fold hard links, butatomic_writewrites a temp file and renames over the target, so every save changes the inode and a stored key goes stale immediately.Cost
loadMarkdownawaits one canonicalization before any decision, and that single I/O is what lets every later comparison be synchronous.refuseIfLossilyDecodedkeepstargetPath !== tab.pathas a cheap pre-check, so Ctrl+S and the 1.5s auto-save add no I/O at all.Behaviour on case-sensitive volumes
This is where
toLowerCasebreaks and asking does not. The Rust test probes the volume's actual behaviour before asserting:canonicalize('/vol/a.md')returnsErr, and the two files keep two identities.toLowerCasewould have merged two genuinely distinct documents and then closed one of the user's tabs — vscode#123660.The normalization test probes the same way (
if fs::metadata(&nfd).is_ok()).Tests
scripts/pathIdentityCaseFolding.test.tsdrives the realTabManageranddocumentSessionagainst a stubbed backend. The stub contains notoLowerCase— it answers with the name stored in its directory, the wayrealpathdoes.masterOne red per gap (② has two routes): Save As under another spelling still overwrites the source · one file spelled two ways is one tab (open route) · … (Save As route) · following a link to another spelling of the current file does not destroy unsaved edits. The four greens are the control group — a genuinely different file still gets its own tab, an explicit revert still discards, an unresolvable path still compares literally.
The counter-proof caught a bad test of my own. Gap ③'s first version simply re-opened the other spelling — and it was green on
master, becausemasterbuilds a duplicate tab, the content lands in the new one, and the old tab's edits survive. Gap ③ had degenerated into gap ②. It only reaches the #412 guard through{ navigate: true }— following a link the author wrote. Without running the counter-proof it would have shipped as a test that can never fail.One existing assertion was adjusted rather than overridden:
lossyDecodeSaveGuard.test.tsmatchedrefuseIfLossilyDecoded(tab, selected)literally and the signature gained a parameter. It is now a prefix match; both intents — picking the source file again must be refused, the guard must precedesave_file_content— are unchanged, with a comment pointing at the new behavioural test.Not covered
Recommended as the immediate follow-up:
claimPath's first claim still compares literally in four entry points, all inMarkdownViewer.svelte, which was owned by another change while this was written —goBack/goForward,renameTab,insertTransferredTab, andopenMarkdownTargetInNewTab'saddTab. All of them callloadMarkdownafterwards, and back-registration means every subsequent comparison is correct, but the claim itself can still create a duplicate tab that is not reclaimed. Closing it is roughly four small changes threading the resolved key through.(dev, ino)is ruled out above. A genuine dead end.resolveExternalChangestill compares the watcher's path literally. Low risk (we supplied that path towatch_file, so it comes back as written), but incomplete.normalizeComparableMarkdownPathstill folds only Windows/UNC. It answers a different question — does this link point at the open file — but the two mechanisms now disagree in kind, and that is worth unifying later.\\?\stripping has a unit test, NTFS behaviour does not.🤖 Generated with Claude Code