Skip to content

fix(engine): probe the basis short final block at its own length - #7244

Merged
oferchen merged 7 commits into
masterfrom
fix/engine-tail-block-match
Aug 12, 2026
Merged

fix(engine): probe the basis short final block at its own length#7244
oferchen merged 7 commits into
masterfrom
fix/engine-tail-block-match

Conversation

@oferchen

@oferchen oferchen commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Stacked on #7235. This branches off fix/local-append-verify-redo because it edits the same hunk of delta_transfer.rs that #7235 changed - basing on master would only defer a guaranteed conflict in that exact hunk to merge time. Base is master, so until #7235 merges this diff also carries its four commits. I will rebase onto master the moment #7235 lands. The change under review here is the last commit only.

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 - 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.

Measured on a 292x700+400 basis with the source's last full block replaced:

Literal Matched
upstream 3.4.4 700 204,100
oc before 1,100 203,700

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 >= len there is no next byte, so more is false and k decrements on every step (match.c:321, match.c: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, not by blength. The window therefore narrows to exactly that block's length, which is the one width the length gate at match.c:222-224 admits, because a short final block is the only block whose recorded length is below blength.

All three lines were read against rsync-3.4.4 for 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:

Literal Matched bytes
upstream 3.4.4 700 204,100 -
oc before 1,100 203,700 identical
oc after 700 204,100 identical

Tests

The obvious fixture does not test this. A delta whose source is identical to the basis passes with or without this fix: every full block matches, so the last match clears the window exactly tail_len bytes early, the probe fires by accident, and the tail is matched either way. Measured on this same 292x700+400 geometry with an identical source - all three columns run here, the "before" one against a binary rebuilt with the fix reverted: upstream 0/204,800, oc before 0/204,800, oc after 0/204,800. The three are indistinguishable, so that fixture can discriminate nothing. That accident is the entire reason this defect looked correct for as long as it did.

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:

test with fix fix reverted
..._short_final_block_at_a_full_eof_window (292x700+400) pass FAIL matched 203,700 vs 204,100
..._a_single_byte_final_block (tail_len 1) pass FAIL matched 2,100 vs 2,101
..._a_final_block_one_byte_short (tail_len 699) pass FAIL matched 2,100 vs 2,799
..._no_op_when_the_basis_has_no_short_block (exact multiple) pass pass - correct, it is the no-op guard

The block_length - 1 case 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 VecDeque split is exercised throughout - the window wraps after sliding through the modified block in every fixture.

Verification

  • cargo fmt --all -- --check: clean
  • cargo clippy --locked --workspace --all-targets --all-features --no-deps -- -D warnings: exit 0
  • cargo nextest run -p engine --all-features: 5042/5042
  • cargo xtask validate --transport local: 59 passed / 3 failed / 3 skipped. The three failures are the pre-existing verbosity: 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 whole and all three append-inplace cells pass.

@oferchen
oferchen force-pushed the fix/engine-tail-block-match branch from ad36cb9 to d1a4526 Compare August 7, 2026 00:43
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
oferchen force-pushed the fix/engine-tail-block-match branch from d1a4526 to 029e429 Compare August 8, 2026 19:49
@oferchen
oferchen marked this pull request as ready for review August 12, 2026 18:31
@oferchen
oferchen merged commit 26aefda into master Aug 12, 2026
65 checks passed
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.

1 participant