Skip to content

fix: ignore stale large-file loads - #247

Merged
alecdotdev merged 1 commit into
sftwrdotdev:masterfrom
PathGao:codex/fix-large-file-stale-load
Jul 27, 2026
Merged

fix: ignore stale large-file loads#247
alecdotdev merged 1 commit into
sftwrdotdev:masterfrom
PathGao:codex/fix-large-file-stale-load

Conversation

@PathGao

@PathGao PathGao commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Fixes #246.

Summary

  • Give each tab load a revision and ignore older background completions.
  • Apply complete large-file content only while the request is current, the tab is clean, and edit/split mode has not changed.
  • Recheck those conditions after async rendering before writing state.

Validation

  • node --test --import tsx scripts/largeFileLoadRevision.test.ts
  • npm run check

@PathGao
PathGao marked this pull request as ready for review July 27, 2026 15:45
@alecdotdev
alecdotdev merged commit 41de13b into sftwrdotdev:master Jul 27, 2026
1 check passed
@PathGao
PathGao deleted the codex/fix-large-file-stale-load branch July 29, 2026 07:21
PathGao pushed a commit that referenced this pull request Aug 3, 2026
`MarkdownViewer.svelte` declares six top-level bindings that the file
never mentions again. They read as live state — a container element, a
render debounce and its timer handle, a per-tab load revision map, a
caret element and its measured offset — so anyone tracing how rendering
or scroll sync works has to prove each one is inert before ruling it
out.

Five were never used at all. `containerEl`, `renderTimeout` and
`renderDebounceMs` arrive in b46a283 and `caretEl`/`caretAbsoluteTop` in
ef219cf, each already at exactly one occurrence in the commit that adds
them: the declaration, and nothing else. `renderDebounceMs = 50` in
particular suggests a debounce that does not exist.

One is refactor residue. `loadRevisionByTab` had four occurrences when
41de13b (#247) added it here. bb8dbf3 (#274) moved document loading into
`documentSession.svelte.ts`, which carries its own map and the three
call sites; the declaration here stayed behind. Two identically named
maps in two files, one of them dead, is worse than either alone —
`scripts/largeFileLoadRevision.test.ts` pins the live one in
`documentSession.svelte.ts` and is unaffected.

Nothing else changes. `npm run check` and `npm run build` were the
check that matters: an unreferenced binding is only safe to remove if
the compiler agrees nothing reaches it, including through Svelte's
`bind:this`, which is why this is verified by a build rather than by
grep alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PathGao pushed a commit that referenced this pull request Aug 3, 2026
`MarkdownViewer.svelte` declares six top-level bindings that the file
never mentions again. They read as live state — a container element, a
render debounce and its timer handle, a per-tab load revision map, a
caret element and its measured offset — so anyone tracing how rendering
or scroll sync works has to prove each one is inert before ruling it
out.

Five were never used at all. `containerEl`, `renderTimeout` and
`renderDebounceMs` arrive in b46a283 and `caretEl`/`caretAbsoluteTop` in
ef219cf, each already at exactly one occurrence in the commit that adds
them: the declaration, and nothing else. `renderDebounceMs = 50` in
particular suggests a debounce that does not exist.

One is refactor residue. `loadRevisionByTab` had four occurrences when
41de13b (#247) added it here. bb8dbf3 (#274) moved document loading into
`documentSession.svelte.ts`, which carries its own map and the three
call sites; the declaration here stayed behind. Two identically named
maps in two files, one of them dead, is worse than either alone —
`scripts/largeFileLoadRevision.test.ts` pins the live one in
`documentSession.svelte.ts` and is unaffected.

Nothing else changes. `npm run check` and `npm run build` were the
check that matters: an unreferenced binding is only safe to remove if
the compiler agrees nothing reaches it, including through Svelte's
`bind:this`, which is why this is verified by a build rather than by
grep alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PathGao added a commit that referenced this pull request Aug 3, 2026
`MarkdownViewer.svelte` declares six top-level bindings that the file
never mentions again. They read as live state — a container element, a
render debounce and its timer handle, a per-tab load revision map, a
caret element and its measured offset — so anyone tracing how rendering
or scroll sync works has to prove each one is inert before ruling it
out.

Five were never used at all. `containerEl`, `renderTimeout` and
`renderDebounceMs` arrive in b46a283 and `caretEl`/`caretAbsoluteTop` in
ef219cf, each already at exactly one occurrence in the commit that adds
them: the declaration, and nothing else. `renderDebounceMs = 50` in
particular suggests a debounce that does not exist.

One is refactor residue. `loadRevisionByTab` had four occurrences when
41de13b (#247) added it here. bb8dbf3 (#274) moved document loading into
`documentSession.svelte.ts`, which carries its own map and the three
call sites; the declaration here stayed behind. Two identically named
maps in two files, one of them dead, is worse than either alone —
`scripts/largeFileLoadRevision.test.ts` pins the live one in
`documentSession.svelte.ts` and is unaffected.

Nothing else changes. `npm run check` and `npm run build` were the
check that matters: an unreferenced binding is only safe to remove if
the compiler agrees nothing reaches it, including through Svelte's
`bind:this`, which is why this is verified by a build rather than by
grep alone.

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
PathGao added a commit that referenced this pull request Aug 8, 2026
…ice (#547) (#552)

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: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-authored-by: Claude Opus 5 <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.

fix: prevent large-file background loads from overwriting newer tab edits

2 participants