Skip to content

fix(engine): give local --append-verify the phase-2 redo model - #7235

Merged
oferchen merged 5 commits into
masterfrom
fix/local-append-verify-redo
Aug 11, 2026
Merged

fix(engine): give local --append-verify the phase-2 redo model#7235
oferchen merged 5 commits into
masterfrom
fix/local-append-verify-redo

Conversation

@oferchen

@oferchen oferchen commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Problem

The local-copy executor implemented --append-verify as an a-priori prefix comparison: determine_append_mode() compared the destination's existing 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 correct, so the bug was invisible unless you looked at the transfer accounting. But the entire append -> verify -> retain -> redo model was missing: local --append-verify never appended, never retained a partial, never warned, and never ran a second pass.

Measured on a 200 KiB source with a destination seeded with 100 KiB of zeros (a non-matching prefix):

before upstream 3.4.4
regular files transferred 1 2
Literal data 204,800 205,300
Matched data 0 101,900
Total transferred file size 204,800 409,600
-v stdout payload.bin once payload.bin twice
-v stderr (silent) WARNING: payload.bin failed verification -- update retained (will try again).

Upstream's order, which is the opposite of ours

  1. Append first. In append mode the sender jumps last_match to the destination's length and zeroes the block count, never calling matched() (match.c:372-391), and the generator writes a sum header with no block sums (generator.c:787 - if (append_mode > 0 && f_copy < 0) return 0;). Pass one is always a pure append.
  2. Verify the whole file. receive_data() compares the sender's whole-file checksum against the receiver's (receiver.c:518-519). Under --append-verify both sides fold the pre-existing prefix into that sum (match.c:373-386, receiver.c:357-371) and then the identical appended tail, so the comparison reduces exactly to "do the two prefixes agree".
  3. Retain the result. --append implies --inplace (options.c:2400-2411), so receiver.c:1029 takes its || inplace leg and finish_transfer() runs even for recv_ok == 0. The appended bytes stay on disk.
  4. Warn and request the redo (receiver.c:1063-1097).
  5. Redo as an ordinary delta. The generator re-enters recv_generator() with append_mode negated and ignore_times bumped (generator.c:2186-2200), and whole_file was already forced to 0 for the session because append mode is active (generator.c:2288-2289), so the retained partial is described as the delta basis.

Upstream runs this two-phase loop locally, not just over the network: local_server (main.c:1468) takes do_cmd() down the local_child() fork (main.c:649-655), and do_recv() forks again (main.c:1050) so recv_files() and generate_files() run concurrently over a socketpair. "Local is different" was never available as a justification.

Every line cited above was re-read against rsync-3.4.4 while writing this, not carried over from an existing comment.

Approach

The local executor is a separate implementation of the receiver, so it has to reproduce those semantics explicitly rather than inherit them.

execute_transfer is split into execute_transfer_once (one pass, returns a TransferOutcome) and a thin verify_redo wrapper that supplies the second pass. The wrapper is a direct transcription of generator.c:2175-2217:

  • determine_append_mode no longer cancels the append on a mismatching prefix. It appends anyway and reports verify_failed, which is upstream's recv_ok == 0 for this file. The prefix comparison is kept as the predicate because locally it is the whole-file checksum comparison - both sides sum the same appended tail, so the two sums agree iff the two prefixes do.
  • On failure the wrapper warns, re-stats the retained partial, and runs a second pass with append_allowed/append_verify off, whole_file_enabled off (generator.c:2288-2289, which is what overrides the whole_file = 1 a local transfer would otherwise default to), ignore_times_enabled on (generator.c:2188 - without it the quick-check would skip the redo outright, since pass one just gave the partial the source's size and mtime), and use_sparse_writes restored from the session setting (receiver.c:761,771 negates sparse_files alongside append_mode).

Matched data: same code path, fixed as a separate commit

Matched data was also wrong, and the cause turned out to be the same append path rather than an unrelated defect. The local summary derived matched = file_size - literal_bytes, which silently assumes every non-literal byte came from a block match. Append mode falsifies that: the pre-existing prefix is neither literal nor matched, so the whole skipped prefix was reported as matched data.

Upstream never derives the figure. stats.matched_data grows in exactly one place, matched() at match.c:121, reached only via hash_search() - which a whole-file transfer never enters (s->count == 0) and which append mode skips outright (match.c:389-390 sets last_match = s->flength; s->count = 0;).

So it is now reported 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, special-file placeholders) reports MATCHED_NONE. The ordinary delta case is numerically unchanged - there every byte really is either literal or matched - so this corrects append without special-casing the statistic.

Measured on plain --append with a matching prefix, where no verification failure and no redo are involved at all:

fixture (Literal / Matched) before after upstream
abcdef over abc 3 / 3 3 / 0 3 / 0
200 KiB over its own 100 KiB prefix 102,400 / 102,400 102,400 / 0 102,400 / 0

Result

Measured, local pull, upstream 3.4.4 as the oracle, stdout and stderr captured separately:

before after upstream
regular files transferred 1 2 2
Literal data 204,800 205,300 205,300
Matched data 0 101,900 101,900
Total transferred file size 204,800 409,600 409,600
exit code 0 0 0
destination bytes identical identical -
-v stdout payload.bin x1 payload.bin x2 x2
-v stderr (silent) exact upstream string -
default stdout / stderr silent / silent silent / silent silent / silent

The default-verbosity cell is silent on stdout as well as stderr, so it does not depend on a gate that only reads stderr.

The one remaining difference on this fixture is Total bytes sent / Total bytes received (oc 205,300 / 0 against upstream 206,089 / 5,920). That is the local path's deliberately unsynthesised wire accounting, pre-existing and tracked separately.

Tests

  • verify_redo.rs unit tests pin each negated flag against the upstream line that negates it, and that unrelated flags survive.
  • append.rs - the test that asserted AppendMode::Disabled on a failed verification encoded the bug; it now asserts the append still happens and reports verify_failed.
  • Four tests had encoded pre-fix behaviour and were each re-measured against rsync 3.4.4 on their own fixture before being changed: two in execute_append.rs asserting files_copied() == 1 on a mismatching prefix (now 2), one in bandwidth.rs asserting 6 literal bytes where the append never happened (now 9), and one asserting matched_bytes() == 3 for an appended-over prefix (now 0).
  • New: the retained partial survives for the redo; a clean append stays single-pass, so the redo cannot fire unconditionally and hide behind correct bytes; plain --append never redoes even with a wrong prefix, and its skipped prefix counts as neither literal nor matched.

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.
@oferchen
oferchen force-pushed the fix/local-append-verify-redo branch from 5abe965 to 88d4cb5 Compare August 8, 2026 19:50
@oferchen
oferchen merged commit ba90df0 into master Aug 11, 2026
65 checks passed
@oferchen
oferchen deleted the fix/local-append-verify-redo branch August 11, 2026 13:09
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