fix(load): stop an overtaken load from stranding a document on its slice (#547) - #552
Merged
Conversation
…ice (#547) A document over 50KB is read twice: `open_markdown_preview` returns the first 50KB so something renders at once, and a background full read replaces it. #247 gave every load a revision and made the second stage refuse to apply a stale result. The first stage was left unguarded, across two awaits. The startup path delivers a file on two channels that nothing dedupes. Rust's setup emits `file-path` with argv (`lib.rs:3063`) and `send_markdown_path` re-reads `std::env::args()` (`window_runtime.rs:575`); on macOS `RunEvent::Opened` does both in the same handler — pushes the path onto `startup_files` AND emits `file-path` (`lib.rs:3155-3160`). The frontend listens for the event (`MarkdownViewer.svelte:3087`, not awaited) and separately drains the stash (`:3366`). So two loads run on one tab, and which preview read returns first is a coin flip: t=0 load A rev=1 -> open_markdown_preview (slow) t=0+ load B rev=2 -> open_markdown_preview (fast) t=110 B stage 2 lands -> whole file, isTruncated false <- correct t=120 A stage 1 lands -> 50KB slice, isTruncated true <- overwrites it t=220 A stage 2 -> rev 2 != 1, correctly refused A destroys the good state and then declines to repair the damage it caused. The tab keeps `isTruncated`, nothing retries, and every save from then on is refused with 'Refusing to save a partially loaded document' — reported through the auto-save timer, so a load failure surfaces as a save failure. Which of `canApplyFullLoad`'s five conditions fails is only ever the revision: path, isDirty, isEditing and isSplit are all unchanged at bail time. The guard is the one the second stage already applies, moved to cover every write a load makes. It has to sit ahead of `setTabDecodedLossy`/`setTabEncoding` and not merely ahead of the buffer: #544 made the encoding verdict part of the same write, a 50KB byte cut can split a multi-byte character (`utf8_truncation_boundary` in `lib.rs:476` is UTF-8 only, and `samples/encoding-gbk-large.md` is 103,723 bytes of GBK), and `tab.encoding` is what the save writes with. A stale prefix's verdict is the wrong one. The non-markdown branch takes the same guard. It reads the whole file so it cannot strand a slice, but a stale one still overwrites the winner's buffer and encoding and flips the tab into the editor. Not fixed here: a tab can also carry `isTruncated` with an empty buffer, from `markTabContentUnavailable()` when session restore defers or fails a read (`windowSession.svelte.ts:255`, `:283`). That is the blank-pane half of the report, it is a different cause that happens to share the flag, and a guard on this race does not touch it. Also left alone: the refusal message is an untranslated English string where `toast.partialDocument` already exists, translated, and is what every editing entry point shows for this state. `scripts/largeFileLoadRevision.test.ts` was 18 lines of source regex, which is why nothing caught this — it passes with the bug present. It now drives the real TabManager and the real session through both orderings. Revert the guards and three of the four new tests go red; that regex test stays green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 8, 2026
Collaborator
Author
|
根因分析正确。三个入口( 修复逻辑:在每次写入 buffer 之前检查 revision,而不只是第二阶段结束时检查。改动最小——只加了守卫,没有动任何状态机。测试覆盖了四种顺序,能抓到 overturned load。 一个未解决的问题(但属于独立 issue):50KB 阈值本身没有测量依据。121KB 文件全读 0.032ms,50KB 切片 0.022ms——差异在微秒级。这个机制在多 MB 文件上才有意义。但这是阈值的问题,不是竞态的问题。 |
This was referenced Aug 8, 2026
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.
What this is
One guard, applied to every write a load makes instead of only to the last one.
Closes #547, reported by @PathGao.A document over 50KB is read twice:
open_markdown_previewreturns the first50KB so something renders at once, and a background full read replaces it. #247
gave every load a revision and made the second stage refuse to apply a stale
result. The first stage was left unguarded, across two awaits.
Mechanism
The startup path delivers one filename on two channels that nothing dedupes:
lib.rs:3063setup()emitsfile-pathwithargv[1]window_runtime.rs:575send_markdown_pathre-readsstd::env::args()lib.rs:3155-3160RunEvent::Openedpushes ontostartup_filesand emitsfile-pathThe frontend listens for the event (
MarkdownViewer.svelte:3087, not awaited) andseparately drains the stash (
:3366). So two loads run on one tab, and whichpreview read returns first is a coin flip:
A destroys the good state and then declines to repair the damage it caused.
Nothing retries. The tab keeps
isTruncated, and every save from then on isrefused — surfaced through the auto-save timer, so a load failure is announced as
a save failure.
Which of
canApplyFullLoad's five conditions fails is only ever the revision:path,
isDirty,isEditingandisSplitare all unchanged at bail time.Not dev-only, and not a command line quirk. None of the three channels is
gated on
cfg(dev)—RunEvent::Openedis gated ontarget_os = "macos"only,and it is the Finder path: double-click, Open With, drag onto the icon.
tauri.conf.jsonregistersfileAssociations, so double-clicking a.mdfile isthis path. A dev build makes the race more observable — slower renders, a debug
Rust build, Vite serving thousands of modules all widen the window — not more
real.
The guard has to sit ahead of
setTabDecodedLossy/setTabEncodingand not merelyahead of the buffer. #544 made the encoding verdict part of the same write, a 50KB
byte cut can split a multi-byte character (
utf8_truncation_boundaryinlib.rs:476is UTF-8 only, andsamples/encoding-gbk-large.mdis 103,723 bytesof GBK), and
tab.encodingis what the save writes with. A stale prefix's verdictis the wrong one.
The non-markdown branch takes the same guard. It reads the whole file so it cannot
strand a slice, but a stale one still overwrites the winner's buffer and encoding
and flips the tab into the editor.
Scope
The blank pane is not fixed here. A tab can also carry
isTruncatedwith anempty buffer, from
markTabContentUnavailable()when session restore defers orfails a read (
windowSession.svelte.ts:255,:283). That is the other half ofthe report, it is a different cause that happens to share one flag, and a guard on
this race does not touch it. Worth its own issue.
The refusal message is left as it is. It is an untranslated English string
where
toast.partialDocumentalready exists, translated, and is what all fiveediting entry points show for this flag. Swapping it was tried and dropped: that
sentence says "cannot edit yet", and by the time a save is refused the reader is
already editing — so it is accurate for the entry points and wrong here. Doing it
properly means a new key in 26 languages, in the shape of
lossySaveBlocked(what happened, why the write is refused, the way out), for a state this change is
meant to make unreachable. Separately:
saveContentAsrefuses too, so unsavededits in this state have no exit at all, where the lossy refusal deliberately
leaves Save As open as one. Both belong with the blank-pane issue.
Noticed and not fixed: the 50KB threshold has no recorded measurement. It
arrives in
31adc64with a one-line message and no numbers. On this machine,reading
samples/stress-test.mdwhole is 0.032ms against 0.022ms for its first50KB — and the two-stage path then reads it whole anyway and renders twice. The
mechanism earns its keep on multi-MB documents, roughly two orders of magnitude
above where it currently engages; the render cost is the part still unmeasured, and
it has to be measured in the app. Mainstream editors do not hand out a partial
writable buffer at all: VS Code declines to display a file past its limit and
offers a Configure Limit button, and Emacs prompts at
large-file-warning-thresholdand offers
find-file-literally, which turns features off rather than showing lessof the document. Both degrade capability, never fidelity. Markpad's preview
slice degrades fidelity, which is safe only under the invariant that a partial
buffer is never writable — the invariant this race broke. Its own issue.
Tests
scripts/largeFileLoadRevision.test.tswas 18 lines of source regex, which is whynothing caught this: it passes with the bug present, and it still does. It now also
drives the real TabManager and the real document session through every ordering.
Revert the three guards and three of the four new tests go red — the stranded
slice, the refused save, and the ordering sweep. The regex test stays green, which
is the point of adding behavioural ones next to it.
Verification
Not verified: reproduced in a packaged build. The race needs the first load's
preview read to land more than ~100ms after the second load's full read has been
applied through its idle callback, and both reads are of the same file issued
milliseconds apart — once it is in the page cache they are both fast and the gap
closes. Repeated launches by hand did not produce it. The evidence that the state
is reachable and that the guard closes it is the test harness, which reproduces the
exact end state (
isTruncated, 50,000-byte buffer,saveContentfalse) and theexact interleaving deterministically.
No file was ever corrupted by this: the refusal held every time. What broke was the
tab becoming permanently unsaveable while looking normal, and saying so as if the
save were the problem.
🤖 Generated with Claude Code