fix(engine): probe the basis short final block at its own length - #7244
Merged
Conversation
oferchen
force-pushed
the
fix/engine-tail-block-match
branch
from
August 7, 2026 00:43
ad36cb9 to
d1a4526
Compare
The local-copy executor treated --append-verify as an a-priori prefix comparison: determine_append_mode() compared the destination prefix against the source before transferring and, on mismatch, returned AppendMode::Disabled - silently degrading to an ordinary single-pass whole-file copy. The final bytes were right, so nothing failed, but the whole append -> verify -> retain -> redo model was absent: local --append-verify never appended, never retained a partial, never warned, and never ran a second pass. Upstream's order is the opposite. Pass one is always a pure append: the sender jumps last_match to the destination length and zeroes the block count (match.c:372-391) and the generator emits a sum header with no block sums (generator.c:787). receive_data() then compares whole-file checksums (receiver.c:517-519), which under --append-verify fold in the pre-existing prefix on both sides (match.c:373-386, receiver.c:357-371). A mismatch keeps the appended bytes, because --append implies --inplace (options.c:2400-2411) and receiver.c:1029 takes its `|| inplace` leg for recv_ok == 0, warns (receiver.c:1063-1097), and asks the generator to redo the file with append_mode negated and ignore_times bumped (generator.c:2186-2200) against a session whose whole_file was already forced to 0 (generator.c:2288-2289). Upstream runs that loop locally too - local_server (main.c:1468) forks local_child (main.c:649-655) and do_recv forks again (main.c:1050) so recv_files and generate_files run over a socketpair. execute_transfer is split into execute_transfer_once, which reports a TransferOutcome, and a verify_redo wrapper that supplies the second pass. determine_append_mode now appends regardless and reports verify_failed; the prefix comparison is kept as the predicate because locally it is the whole-file comparison, both sides summing the same appended tail. Measured local pull, 200 KiB source over a 100 KiB zero-filled seed, against rsync 3.4.4: transfers 1 -> 2, Literal 204,800 -> 205,300, Total transferred file size 204,800 -> 409,600, and the retained-update WARNING now reaches stderr under -v and stays silent by default, all matching upstream exactly. Matched data remains over-counted by the pre-existing append accounting defect, which reproduces on plain --append with a matching prefix and is tracked separately.
execute_with_append_verify_rewrites_on_mismatch asserted 6 literal bytes for a 6-byte source over a 3-byte mismatching seed - the count you get only if the append never happens and the file is copied whole in one pass. Measured on that exact fixture, rsync 3.4.4 reports 2 transfers and 9 literal bytes: 3 appended, then all 6 re-sent as literal by the redo, because the 6-byte basis is a single short block that cannot match. Matched data is pinned at its current 3 rather than upstream's 0. Append mode never calls matched() (match.c:389-390 zeroes the block count and skips the hash loop) so the pre-existing prefix contributes nothing upstream, while the local summary derives matched as file_size - literal_bytes. That accounting defect is pre-existing and tracked separately; pinning it here makes the assertion fail loudly and name the upstream answer once it is fixed.
Both were re-read against rsync-3.4.4 rather than trusted. The whole-file checksum comparison is receiver.c:518-519, not 517 - 517 is the DEBUG_GTE(DELTASUM,2) "got file_sum" trace just above it. The leg that makes a dry run skip the recv_ok switch entirely is the `if (!do_xfers)` block at receiver.c:805-810; receiver.c:797 is the unrelated read-batch "Skipping batched update" path.
The local summary derived `matched = file_size - literal_bytes`, which silently assumes every byte that was not literal came from a block match. Append mode falsifies that: the pre-existing prefix is neither literal nor matched, so the derivation reported the whole skipped prefix as matched data. Upstream never derives this figure. `stats.matched_data` grows in exactly one place, `matched()` at match.c:121, reached only through `hash_search()`. A whole-file transfer has `s->count == 0` so the hash loop never runs, and append mode zeroes the count outright (match.c:389-390 `last_match = s->flength; s->count = 0;`). Both therefore report zero matched bytes however little of the file was literal. So report it the way upstream produces it. `FileCopyOutcome` carries a matched-byte count, the delta loop accumulates it at the two points where it emits a matched block, and every path that never consults a signature - whole-file, sparse whole-file, append, the clone/reflink fast paths, and special-file placeholders - reports MATCHED_NONE. This leaves the ordinary delta case numerically identical, because there every byte really is either literal or matched, and corrects append without special-casing the statistic. MEASURED against rsync 3.4.4, `-a --append --ignore-times --stats`, source "abcdef" over a matching "abc": upstream Literal 3 / Matched 0, oc was Literal 3 / Matched 3 and is now Literal 3 / Matched 0. Two tests had encoded the derivation and now assert the upstream values.
The local delta scan reached EOF and probed the basis's trailing short block with whatever the sliding window happened to hold. That probe can essentially never succeed, for two independent reasons. The length. Every failed full-window probe pops one byte, so at EOF the window holds block_length - 1 bytes, while find_tail_match gates each candidate on `block.len() != tail_len` - the same length-equality rule as upstream's `l = MIN(blength, len-offset); if (l != s->sums[i].len) continue;` (match.c:222-224). Only a basis whose final block happened to be exactly block_length - 1 could even reach the strong-sum comparison. The digest. `outgoing` is recorded when a byte is popped but the rolling sum is not corrected until the next push rolls it out. At EOF there is no next push, so the digest still covers the popped byte - describing one more byte than the window holds. Even the coincidental length match above would fail on sum1/sum2. Upstream gets there by shrinking: once `offset + k >= len` there is no next byte, `more` is false and `k` decrements (match.c:321,331), and the scan runs to `end = len + 1 - s->sums[s->count-1].len` (match.c:174) - bounded by the LAST block's length. The window narrows to exactly that block's length, which is the one width the length gate admits. So drain to the basis's final block length explicitly and recompute the sum over exactly those bytes: one probe at the only width that can match, instead of re-probing at every intermediate width. A basis that divides evenly has no short block and stays a clean no-op. What hid this: the window lands on the final block's length, with a digest that agrees, only when a match cleared it with exactly that many bytes left - an identical source, where every full block matches. A fixture built that way passes with or without the fix, so the tests here modify the last full block, which forces EOF to arrive with a full window, and use a 64-bit LCG so no short-period content can let an unaligned window match an earlier block and swallow the tail. MEASURED, local delta over a 292x700+400 basis with the last full block replaced, against rsync 3.4.4: upstream Literal 700 / Matched 204,100; oc was Literal 1,100 / Matched 203,700 and is now 700 / 204,100. Reconstruction was byte-exact throughout - only the split moved.
oferchen
force-pushed
the
fix/engine-tail-block-match
branch
from
August 8, 2026 19:49
d1a4526 to
029e429
Compare
oferchen
marked this pull request as ready for review
August 12, 2026 18:31
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.
Problem
The local delta scan reached EOF and probed the basis's trailing short block with whatever the sliding window happened to hold. That probe can essentially never succeed, for two independent reasons.
The length. Every failed full-window probe pops one byte, so at EOF the window holds
block_length - 1bytes, whilefind_tail_matchgates each candidate onblock.len() != tail_len- the same length-equality rule as upstream'sl = MIN(blength, len-offset); if (l != s->sums[i].len) continue;(match.c:222-224). Only a basis whose final block happened to be exactlyblock_length - 1could even reach the strong-sum comparison.The digest.
outgoingis recorded when a byte is popped, but the rolling sum is not corrected until the next push rolls it out. At EOF there is no next push, so the digest still covers the popped byte - describing one more byte than the window holds. Even the coincidental length match above would fail on sum1/sum2.Measured on a 292x700+400 basis with the source's last full block replaced:
Reconstruction was byte-exact either way. Only the split moved, which is why this survived: nothing is wrong with the output, the trailing block is just re-sent as literal data that upstream matches.
Upstream's rule
Upstream reaches the short block by shrinking the window. Once
offset + k >= lenthere is no next byte, somoreis false andkdecrements on every step (match.c:321,match.c:331), and the scan runs toend = len + 1 - s->sums[s->count-1].len(match.c:174) - bounded by the LAST block's length, not byblength. The window therefore narrows to exactly that block's length, which is the one width the length gate atmatch.c:222-224admits, because a short final block is the only block whose recorded length is belowblength.All three lines were read against
rsync-3.4.4for this change, not carried over.Fix
Drain the window to the basis's final block length explicitly and recompute the rolling sum over exactly those bytes, then probe once - upstream's shrinking window expressed as a single probe at the only width that can match, instead of re-probing at every intermediate width. A basis that divides evenly has no short block and stays a clean no-op.
This reuses the shape from #7239 (t354) rather than inventing a second one: the probe length comes from the basis, never from "whatever is left in the window".
Result
Same fixture, same command, upstream 3.4.4 as the oracle:
Tests
So every fixture here modifies the source's last full block, which forces the scan to slide to EOF holding a full window - the case the identical-source fixture cannot produce. Content comes from a 64-bit LCG: a short-period or repeating fixture would let an unaligned window match an earlier full block by content and swallow the tail, making the test assert about the data rather than about the matcher.
Boundary widths are covered because zsync 0.6 fixed an out-of-bounds access "when processing the last block of a non-compressed download" - the degenerate widths are exactly where this family of implementations has gone wrong before.
Non-vacuity, verified by reverting the fix and re-running:
..._short_final_block_at_a_full_eof_window(292x700+400)..._a_single_byte_final_block(tail_len 1)..._a_final_block_one_byte_short(tail_len 699)..._no_op_when_the_basis_has_no_short_block(exact multiple)The
block_length - 1case failing pre-fix is what exposed the stale-digest half of the defect: that width should have matched on length alone, and did not.The two-slice
VecDequesplit is exercised throughout - the window wraps after sliding through the modified block in every fixture.Verification
cargo fmt --all -- --check: cleancargo clippy --locked --workspace --all-targets --all-features --no-deps -- -D warnings: exit 0cargo nextest run -p engine --all-features: 5042/5042cargo xtask validate --transport local: 59 passed / 3 failed / 3 skipped. The three failures are the pre-existingverbosity: local -v/-vv/-vvv - line 1: oc "a.txt" vs upstream "./"divergence, unrelated and unchanged by this PR.whole-file: local delta,whole-file: local wholeand all threeappend-inplacecells pass.