Return an error from a failed BEP 52 leaf read instead of raising - #22
Merged
daniboybye merged 1 commit intoSep 12, 2026
Conversation
`Merkle.leaf_range_response_from_disk/7` documents `{:ok, [hash()]} | {:error,
term()}` and already handles a failed `:file.open/2` that way, but the per-leaf
reads underneath it pattern-matched `{:ok, block} = :file.pread(fd, offset,
size)`. An I/O error, or a file truncated between the stat that produced
`file_length` and the read, therefore raised `MatchError` out of the middle of a
function whose contract says it returns errors.
`HashServe` catches the raise and answers `hash_reject`, which is the correct
thing to put on the wire for a hash request we cannot serve (BEP 52) — so this was
low severity in the shipped path, and wrong everywhere else: any other caller got
an exception for an ordinary disk condition, and the reject was reached by
accident rather than by decision.
`read_leaf_cache/4` now short-circuits on the first unreadable leaf, since the
cache is only useful complete — every consumer does `Map.fetch!/2` on it.
One subtlety worth naming: `:file.pread/3` answers a bare `:eof`, not an error
tuple, when there is nothing at the offset. That is *not* the same as the
legitimate case of a leaf beyond the end of the file, which BEP 52's power-of-two
leaf padding makes routine and which hashes to the zero hash without reading at
all. The padded case is now a guard clause on `offset >= file_length`, so a bare
`:eof` from an actual read means `file_length` no longer describes the file and is
reported as a failure.
+2 tests: the truncated-file contract, and a whole-file read whose last block is
short, so the ragged tail the new guard touches stays covered.
Co-authored-by: Cursor <cursoragent@cursor.com>
Owner
Author
|
CI note: |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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.
Stacked on #21 — the base is
fix/tracker-dns-and-rate-readout, so the diff here is one commit. Merge #21 first and this retargets tomastercleanly.Picked up from the known-bugs list as one of the smallest open items.
The defect
Merkle.leaf_range_response_from_disk/7is specced{:ok, [hash()]} | {:error, term()}and already handles a failed:file.open/2that way. The per-leaf reads underneath it did not:An I/O error — or a file truncated between the stat that produced
file_lengthand the read — raisedMatchErrorout of the middle of a function whose contract says it returns errors.Why it was low severity, and why it was still wrong
HashServecatches the raise and answershash_reject, which is the correct thing to put on the wire for a hash request we cannot serve (BEP 52). So the shipped path behaved acceptably. It was wrong in two ways regardless: any other caller got an exception for an ordinary disk condition, and the reject was reached by accident rather than by decision — arescuestanding in for a return value.The subtlety
:file.pread/3answers a bare:eof, not an error tuple, when there is nothing at the offset. That is not the same as the legitimate case of a leaf beyond the end of the file, which BEP 52's power-of-two leaf padding makes routine and which hashes to the zero hash without reading at all.The padded case is now a guard clause on
offset >= file_length, so a bare:eofcoming back from an actual read meansfile_lengthno longer describes the file, and is reported as a failure rather than silently hashing a short read.read_leaf_cache/4short-circuits on the first unreadable leaf, since the cache is only useful complete — every consumer doesMap.fetch!/2on it.Tests
+2, both in
torrent_merkle_test.exs:file_lengthreturns{:error, :eof}rather than raising. This is the truncated-under-us scenario, constructed by writing one block and declaring four.Verification
All 12 gates of
.github/workflows/build-and-publish.ymljobbuildrun locally, in CI's order:mix testmix dialyzermix credo --allmix format --check-formattedmix compile --warnings-as-errors(dev + test)mix sobelow,mix hex.auditmix deps.get --check-locked(dev + test),mix deps.unlock --check-unusedscripts/compile-property-deps.shThe changelog entry was added to the unreleased 0.6.7 section rather than starting a new one, since #21 has not been merged or tagged yet.
Not reproducible locally, so not claimed green: the Trivy filesystem scan, the Codecov upload, and the SARIF upload to code scanning.