chunkers: do not re-read a file map that does not reach EOF - #10166
Merged
ThomasWaldmann merged 1 commit intoAug 20, 2026
Conversation
…backup#4363 FileReader used "blockify_gen is None" for both "not started yet" and "exhausted": _fill_buffer() cleared it on StopIteration, and read() / readinto() take a cleared generator as their cue to build a fresh FileFMAPReader.blockify(), which replays the fmap from its start. dread() reads sequentially and ignores its offset argument, so such a replay continued at the current file position and appended duplicate data. An fmap that does not start at 0 seeks back to its start on every replay and never terminates at all. This stayed invisible as long as every fmap reached EOF, because the replay then hits a 0-byte read immediately: that is true for sparsemap() and for "create --map", which always maps the whole file. "create --map --reuse-from" is the first caller that reads only parts of a file. Track exhaustion in its own flag and never restart the generator. blockify() also has to stop treating a short read as EOF, or dropping the restart truncates the input instead: a file object may return less than requested without being at EOF, see buzhash_self_test test_small_reads, which passed only because of the restart.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #10166 +/- ##
==========================================
- Coverage 87.00% 86.55% -0.45%
==========================================
Files 101 101
Lines 17993 17996 +3
Branches 2738 2738
==========================================
- Hits 15654 15577 -77
- Misses 1635 1717 +82
+ Partials 704 702 -2 ☔ View full report in Codecov by Harness. |
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.
FileReaderre-reads a file map that does not extend to EOF, which appends duplicate data to the file it is reading — or never terminates.The bug
FileReaderusesblockify_gen is Nonefor two different things: "not started yet" and "exhausted"._fill_buffer()clears the attribute onStopIteration, andread()/readinto()take a cleared generator as their cue to build a freshFileFMAPReader.blockify()— which replays the fmap from its start.dread()reads sequentially and ignores itsoffsetargument, so the consequences depend on the fmap:dseek()s back to its start on every replay and loops forever.FileFMAPReader.blockify()itself is fine — it yields exactly the mapped ranges and stops.This stayed invisible because an fmap that does reach EOF is replayed into an immediate 0-byte read and stops there, and until now every caller produced such a map:
sparsemap()covers the whole file, and so doescreate --map.create --map --reuse-fromfrom #10137 is the first caller that deliberately reads only parts of a file — it skips the ranges whose chunks it reuses from the reference archive — and it hits this immediately. It is not specific to a chunker; thefixedchunker is affected the same way.The fix
Track exhaustion in its own
blockify_doneflag and never restart the generator.That alone is not enough, though:
blockify()also returned ongot < wanted, i.e. it treated any short read as EOF. The restart happened to paper over that, so removing the restart without fixing it truncates the input instead.buzhash_self_test.py::test_small_reads— whose fake file object returns one byte perread()call — passes on current master only because of the restart, and is the tripwire for getting this half-right. Soblockify()now returns only ongot == 0.Tests
Two regression tests in
reader_test.py:test_filereader_fmap_not_covering_eof— prefix-only, not-starting-at-0, and multi-range partial fmaps. Fails on master (assert 60 <= 30). The in-loop length assert stops a regression from hanging the suite instead of failing it.test_filereader_short_reads_are_not_eof— pins the short-read contract. This one passes on master too (the restart hides it); it fails on a half-applied fix, which is the point.Verification
Built from this branch on Linux (Debian 13, Python 3.13):
src/borg/testsuite/chunkers/+create_cmd_test.py+extract_cmd_test.py: 328 passed, 346 skipped, 0 failed.test_create_map_reuse_from_cdcwent from 5 failures / 100 runs to 0 / 200, and acreate --read-special --map ... --reuse-fromof a real LVM thin snapshot — which previously spun for ~12 minutes of CPU on a single file — now completes andborg extract --stdout ... | cmp - /dev/vgtest/snap2matches byte for byte over 4 GiB.