fix(save): stop a stale lossy-save refusal from silencing real auto-save failures - #439
Merged
Merged
Conversation
…ave failures
`isLossySaveRefused` decided from set membership alone. The set records what
was SAID to the tab; it is emptied only by `loadMarkdown` and `saveContentAs`.
Whether the refusal still STANDS is a property of the tab, re-decided by four
other reads that fill the buffer — `ensureFullContent`, entering the editor,
entering split view, a cross-window arrival — every one of which can clear
`hasReplacementChars` for a file converted to UTF-8 since the load, and none of
which touches the set.
The auto-save timer's failure handler reads the predicate unconditionally:
if (documentSession.isLossySaveRefused(s.id)) return; // no toast
so a tab that had once been refused, and had since stopped decoding lossily,
went on swallowing `toast.autoSaveFailed` for the rest of its life — including
for a genuine write failure. The intent (a refusal already explained itself, so
the generic message adds nothing) is right; the condition was broader than it.
The predicate now asks the tab as well as the memory. This also answers for a
tab that is gone: `closeTab` splices with no dispose hook, so closed ids stay in
the set, and a tab nobody can find is refusing nothing.
Behaviour while the tab IS still lossy is unchanged: the first attempt still
produces the one explanation, further attempts still say nothing, and the
eligibility gate in MarkdownViewer still drops the tab from the timer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PathGao
pushed a commit
that referenced
this pull request
Aug 3, 2026
…er one #425 gave each document its own heading-fold state by moving the set from the window onto the tab. A tab is not a document, though: `navigate` (following a Markdown link), `goBack` and `goForward` keep the tab and swap the file under it, and `collapsedHeaders` travelled with it. A fold key is a heading slug, unique only within a document, so the incoming file was rendered with any section whose slug happened to match already shut. `loadMarkdown` reads the set on the line after the `navigate` call, so nothing is deferred — the HTML that first appears carries `is-collapsed`, and the outline hides the same section's children on the same render, with nothing on screen to explain either. Measured over 202 real Markdown documents (this repo and its dependencies' READMEs and changelogs): for 27.9% of ordered document pairs, at least one heading slug of the first names a heading in the second. `installation` occurs in 29.7% of them, `usage` in 27.7%. #447 established that the same three routes must clear the reading position, and left folds for a separate change. Rather than have each route remember two resets — the shape #436 and #439 were both about — those routes now call one `forgetPreviousDocument(tab)`, which calls #447's `clearReadingPosition` and a new `clearCollapsedHeaders`. The two helpers stay separate: a stale position moves the viewport, a stale fold hides text, and each needs its own explanation. What they shared was the trigger, which had no name until now. Save As (`updateTabPath`) and rename (`renameTab`) change the path while the text on screen stays put, so they do not get there. Tests guard both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PathGao
added a commit
that referenced
this pull request
Aug 3, 2026
…er one (#448) #425 gave each document its own heading-fold state by moving the set from the window onto the tab. A tab is not a document, though: `navigate` (following a Markdown link), `goBack` and `goForward` keep the tab and swap the file under it, and `collapsedHeaders` travelled with it. A fold key is a heading slug, unique only within a document, so the incoming file was rendered with any section whose slug happened to match already shut. `loadMarkdown` reads the set on the line after the `navigate` call, so nothing is deferred — the HTML that first appears carries `is-collapsed`, and the outline hides the same section's children on the same render, with nothing on screen to explain either. Measured over 202 real Markdown documents (this repo and its dependencies' READMEs and changelogs): for 27.9% of ordered document pairs, at least one heading slug of the first names a heading in the second. `installation` occurs in 29.7% of them, `usage` in 27.7%. #447 established that the same three routes must clear the reading position, and left folds for a separate change. Rather than have each route remember two resets — the shape #436 and #439 were both about — those routes now call one `forgetPreviousDocument(tab)`, which calls #447's `clearReadingPosition` and a new `clearCollapsedHeaders`. The two helpers stay separate: a stale position moves the viewport, a stale fold hides text, and each needs its own explanation. What they shared was the trigger, which had no name until now. Save As (`updateTabPath`) and rename (`renameTab`) change the path while the text on screen stays put, so they do not get there. Tests guard both directions. Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two guards in
documentSession.svelte.tswere reported to me as the same shape — a guard whose name promises more than its condition delivers. One of them is a real bug and is fixed here. The other does not reproduce, and this PR does not change it; the investigation is written up below so the next person does not repeat it.1. A tab that once refused a lossy save went permanently silent about all auto-save failures
The two readers of one predicate
lossySaveWarnedTabsis aSet<string>of tab ids, added to the first timerefuseIfLossilyDecodedblocks a write.isLossySaveRefusedexposed it verbatim:Two call sites read it, and only one of them was safe.
The auto-save eligibility gate (
MarkdownViewer.svelte:1833) — correct, because the&&supplies the missing half:The auto-save failure handler (
MarkdownViewer.svelte:1873) — unconditional:The comment at that site explains the intent, and the intent is right: a refusal has already named the file and the way out, so "auto-save failed" on top says nothing new. The condition was broader than the intent.
The set records what was said; it does not track whether it is still true
The set is emptied in exactly three places:
loadMarkdown(both branches) andsaveContentAs. ButhasReplacementChars— the fact the refusal is about — is re-decided by four other reads, each of which fills a writable buffer and therefore carries its own fidelity verdict since #379:hasReplacementCharsfor a file since converted to UTF-8lossySaveWarnedTabsloadMarkdown(preview + full)saveContentAsensureFullContenttoggleEdit(MarkdownViewer.svelte:1688)toggleSplitView(MarkdownViewer.svelte:2408)windowSession.svelte.ts:207)So the set can outlive the condition. And the eligibility gate lets it: once
decodedLossilygoes false the tab starts auto-saving again — which is correct and intended — while the failure handler stays permanently muted.Severity, stated accurately
I was told this leaves "nothing on screen". That is not quite right, and the difference matters for how the fix should be judged.
saveContent'scatchalready callsoptions.onError('Failed to save file', error), and MarkdownViewer wiresonErrortoaddToast. So a write that throws — the disk-full case — still surfaces a toast, an unlocalized one readingFailed to save file: Os { code: 28, … }. What the stale predicate suppresses is the localizedtoast.autoSaveFailedon top of it.The genuinely silent case is a
saveContentthat returnsfalsewithout throwing. Today, reached from the auto-save timer, that is only!tab— the tab closed between the timer arming and firing. It is about to get bigger: the per-tab in-flight save guard onfix/one-write-per-tab-at-a-timeadds a second such return, in this same file.So: a real defect in the user-visible messaging path, currently degrading a localized message into a raw one rather than erasing all feedback, and sitting directly in front of a change that widens it. Not a data-loss bug, and this PR does not claim to be one.
The fix
The predicate now means what its name says — is this tab's save being refused for the lossy-decode reason, right now — by asking the tab as well as the memory:
Both halves are load-bearing. The set is still what makes the first refusal produce its explanation and the rest stay quiet. The tab is what makes "still" true.
Why not "also clear the set in
closeTab". I looked; it is not needed, and the structural obstacle is real —closeTabis asplicewith no dispose hook, so there is nowhere to hang the cleanup without introducing one, which is out of scope here. Asking the tab answers the closed-tab case for free: a tab nobody can find is refusing nothing. What remains is that the set keeps closed ids forever. Ids arecrypto.randomUUID()and never reused, so this is a bounded leak — one string per lossy-refused tab per window session — not a correctness problem. Left as-is and noted below.Why the fix is in the session and not at the call site. The predicate is the thing that was wrong, and it has two readers. Fixing the handler would have left the next reader to rediscover it — and
isLossySaveRefusedexists precisely so that MarkdownViewer does not have to know how refusals are tracked.MarkdownViewer.svelteis untouched. Notably thes.decodedLossily &&in the eligibility gate stays, and is not now redundant: it is the reactive read.isLossySaveRefusedis called from insideuntrack(…), so its own read of the tab creates no dependency;s.decodedLossilyin the snapshot is what re-runs the effect when the flag changes and re-opens the tab for auto-save.Tests
scripts/lossySaveRefusalScope.test.ts, five cases against the realTabManagerand the realdocumentSessionwith a stubbed backend. Each starts from a tab that decoded lossily, was edited, and was refused once.save_file_contentrejects withStorageFull) returnsfalsewhile the predicate staysfalse, so the timer is free to report it — the bug, end to end in the shape the timer sees it;truethroughout;The last two are the regression guard on the behaviour this must not break. They pass on
masterand must keep passing; the first three are the red-to-green.Counter-proofs:
lossySaveWarnedTabs.has(tabId)(i.e.master)return falseOne existing source-text assertion in
checkedReadMigration.test.tspinned the old one-line body; it is updated to pin the property instead (the tab is consulted), pointing at the new file for the behaviour. The two source-text tests over the MarkdownViewer call sites are unchanged and still pass, which is the check that the component side did not need to move.2.
resolveExternalChange's raw===— investigated, not changedresolveExternalChangepicks the tab that owns a changed file with string equality, in a file that importsisSameFilePathand uses it three times elsewhere:Two findings, and the second is why nothing changed.
The echo is byte-identical, so this is latent, not live
watch_file(src-tauri/src/window_runtime.rs:419) clones the path argument into the notify callback and emits that, never anything from thenotify::Event:The frontend hands it
currentFile, which is$derived(tabManager.activeTab?.path ?? '')— the very stringresolveExternalChangethen compares against. The chain closes: payload ≡activeTab.pathat arm time, andliveModeWatchedPath.test.tsalready pins both ends of it. macOS case folding, NFD/NFC, a Windows drive-letter difference and a symlinked directory can none of them get between the two, because the OS's own path never enters the payload.The proposed fix would not change behaviour even if it were live
This is the part that decided it.
isSameFilePathtrusts keys only when both sides have one, and degrades to exact path equality otherwise — deliberately, and documented as such. The watcher payload is a bare string with nopathKey. SoisSameFilePath({ path: changedPath }, tab)ischangedPath === tab.path, including against a fully-resolved tab:(run against the real
isSameFilePath, with the tab given apathKey— the best case for the identity comparison)Making it bite would mean canonicalizing
changedPath, which is a Tauri round-trip on every external-change event and would turn a synchronous resolver into an async one — restructuring the external-change flow, which is a design decision for the maintainer rather than a drive-by. The performance question is moot for the same reason:isSameFilePathhere compiles to the same string compare, and the cost only appears in the version that would actually do something.Changing the line as proposed would have been a diff that reads like a fix, passes review, and does nothing. Left alone.
Verification
On
upstream/master(b63ff2e):cargo testnot run: no Rust changed. The Rust in section 2 was read, not modified.Not covered
TabManagerand the realdocumentSessionwith a stubbed Tauri bridge. Nobody filled a disk under a converted-to-UTF-8 file by hand.Failed to save file: <error>toast is untouched. It is what a throwing write shows today, now alongside the localized one. Whether a failed auto-save should raise one message or two, and whether a rawOs { code: 28 }belongs in front of a user, is a messaging question this PR does not open.lossySaveWarnedTabsstill grows by one entry per lossy-refused tab, for the window's lifetime. Correctness is now independent of it. The dispose hookcloseTabwould need is a separate structural change.resolveExternalChangestill compares raw strings, for the reasons in section 2. The invariant it depends on is enforced byliveModeWatchedPath.test.tson both the Rust and the frontend side; no new test was added, since a third assertion of the same fact would be noise.saveContent, so it should not collide with the in-flight save guard onfix/one-write-per-tab-at-a-time. If that lands first, note that its new "already saving" early return is asaveContentreturningfalsewithout throwing — exactly the silent case section 1 restores the toast for.🤖 Generated with Claude Code