Skip to content

checksum: keep the link alive while hashing a file (fixes #839)#1031

Open
dr-who wants to merge 1 commit into
RsyncProject:masterfrom
dr-who:fix-839-checksum-keepalive
Open

checksum: keep the link alive while hashing a file (fixes #839)#1031
dr-who wants to merge 1 commit into
RsyncProject:masterfrom
dr-who:fix-839-checksum-keepalive

Conversation

@dr-who

@dr-who dr-who commented Jul 20, 2026

Copy link
Copy Markdown
Member

Alternative to #840 for issue #839 — credit to @tenox7 for the report and for identifying the keepalive as the mechanism. I reproduced the bug, then found #840's version doesn't fix the reported case; details below.

Reproducing it

With --checksum the sender hashes every regular file while building the file list (flist.c calls file_checksum() from make_file()), and sends nothing meanwhile. A single 2 GiB file (~4.3s to hash):

$ rsync -a --checksum --timeout=3 rsync://host/mod/ dst/
[Receiver] io timeout after 4 seconds -- exiting
rsync error: timeout in data send/receive (code 30)

Controls: the same transfer without --checksum → fine; --checksum --timeout=30 → fine; --checksum with no timeout → fine. So it is purely the unbroken silence while hashing.

It's driven by per-file hash time, not file count. 500,000 small files take 4.6s to hash in total and do not trip it, because list entries keep streaming and the link never goes quiet. One big file does.

Two problems with #840

1. The hardcoded 5-second interval can't fix the reported case. I applied #840 and re-ran the reproducer:

build --timeout=3 --timeout=6 --timeout=12
master times out times out times out
#840 still times out fixed fixed
this PR fixed fixed fixed

With --timeout=3 the peer has already given up before #840's first keepalive at 5s.

2. The am_daemon gate is too narrow. The same hang happens over a remote shell:

lsh --checksum --timeout=2   master exit=30   this PR exit=0
lsh --checksum --timeout=1   master exit=30   this PR exit=0

This change

rsync already has the right primitive: maybe_send_keepalive() sends a MSG_NOOP and self-throttles on allowed_lull, which set_io_timeout() computes as half of --timeout — exactly the interval this needs, at any timeout value. The generator, sender and receiver already call it during their long stretches (generator.c:296, sender.c:116, receiver.c:390); the file-list hashing path simply never did.

file_checksum() had six copies of the same chunk loop, one per digest — which is why #840 had to patch each. Rather than add the call six times and leave a seventh digest free to forget it, the loop is now a single CHECKSUM_FEED() macro carrying the tick.

It takes the update as an expression deliberately: the digests have differing signatures (EVP_DigestUpdate, XXH64_update, md5_update, mdfour_update), so a function-pointer iterator would need a wrapper per digest, and it could not express the pre-27 MD4 case, which uses CSUM_CHUNK and a different tail condition. Each caller keeps its own trailing-partial-chunk handling unchanged.

Verification

  • Digest-identical to the unpatched build, checked in both directions (master sender vs patched receiver and vice versa) over files at the chunk boundaries — 0, 1, 63, 64, 65, 32767, 32768, 32769, 65537 and multi-chunk — for every checksum-choice this build offers, with a flipped byte as a positive control.
  • Fixes both the daemon and remote-shell paths, at --timeout=1 upward.
  • Full testsuite: 106 passed, 0 failed.
  • I checked generate_and_send_sums() for the same pattern: it does not need it, because it writes each block sum as it computes, so that link never goes quiet.

On a regression test: I deliberately haven't added one. Exercising this needs a multi-GB file and a wall-clock race against --timeout, which would be slow and timing-flaky across the CI platforms. Happy to add one if you'd like it, but it seemed worse than documenting the measured reproduction here.

…t#839)

With --checksum the sender hashes every regular file while it builds the file
list (flist.c calls file_checksum() from make_file()), and nothing is sent to
the peer for as long as that takes.  On a large file the far end therefore sees
a silent link and aborts once --timeout elapses:

    rsync -a --checksum --timeout=3 rsync://host/mod/ dst/
    [Receiver] io timeout after 4 seconds -- exiting
    rsync error: timeout in data send/receive (code 30)

A single 2 GiB file reproduces it here (~4.3s to hash).  The same transfer
without --checksum, or with a --timeout longer than the hash, is fine, so it is
purely the unbroken silence while hashing.

Note it is driven by the time to hash one file, not by how many there are:
500,000 small files take 4.6s to hash in total and do *not* trip it, because
list entries keep streaming out and the link never goes quiet.

rsync already has the right primitive for this.  maybe_send_keepalive() sends a
MSG_NOOP and self-throttles on allowed_lull, which set_io_timeout() computes as
half of --timeout -- exactly the "send something every half the threshold"
interval this needs, at whatever timeout the user picked.  The generator, sender
and receiver already call it during their long stretches; the file-list hashing
path simply never did.

Tick it as we hash.  file_checksum() had six copies of the same chunk loop (one
per digest), so rather than add the call six times -- and leave a seventh digest
free to forget it -- the loop is now a single CHECKSUM_FEED() macro carrying the
tick.  It takes the update as an expression because the digests have differing
signatures; a function-pointer iterator would need a wrapper per digest and
could not express the pre-27 MD4 tail, so each caller keeps its own trailing
partial chunk handling unchanged.

The tick is throttled by bytes hashed rather than fired per piece: the MD4 loop
runs in CSUM_CHUNK (64) byte pieces, so a per-piece tick would consult the clock
16.7 million times per GiB -- 512x more often than the other digests.  That
measured +2.6% on an MD4 pass here (1.95s -> 2.00s for 1 GiB, matching the 2.9ns
vDSO time() call), and would be far worse where time() is a real syscall.
Consulting it once per CHUNK_SIZE of hashing puts every digest on the same
footing and the cost back in the noise (1.94s).

Verified digest-identical to the unpatched build in both directions (master
sender vs patched receiver and vice versa) over files at the chunk boundaries --
0, 1, 63, 64, 65, 32767, 32768, 32769, 65537 and multi-chunk sizes -- for every
checksum-choice this build offers, with a flipped byte as a positive control.
generate_and_send_sums() was checked for the same pattern and does not need it:
it writes each block sum as it computes, so that link never goes quiet.
@dr-who
dr-who force-pushed the fix-839-checksum-keepalive branch from 192c461 to 123576e Compare July 20, 2026 02:14
@dr-who

dr-who commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

Follow-up: the keep-alive tick is now throttled by bytes hashed rather than fired once per piece.

The first version called it from every loop iteration, which was fine for the digests that work in CHUNK_SIZE (32 KiB) pieces but not for the MD4 family — that loop runs in CSUM_CHUNK (64) byte pieces, so it consulted the clock 16.7 million times per GiB, 512× more often than the others.

Measured on a 1 GiB file with --timeout=60 (best of 5):

md4 (affected) md5 (unaffected)
master 1.95s 2.27s
per-piece tick 2.00s (+2.6%) 2.27s
throttled 1.94s 2.29s

That +2.6% matches the arithmetic exactly — time() is a 2.9 ns vDSO call here, so 16.7M of them is ~0.048s. It is small on Linux, but it is pure waste, it is asymmetric between digests, and on a platform where time() is a real syscall rather than a vDSO call it would be a serious regression.

checksum_keepalive() now takes the size of the piece just hashed and only consults the clock once per CHUNK_SIZE of it, so every digest ticks at the same rate and the cost is back in the noise. When no --timeout is set it is a single compare on a global, which the compiler inlines away.

Re-verified after the change: digest-identical to master in both directions across the chunk-boundary sizes, still fixes the hang at --timeout=1 and 2 (master exits 30, this exits 0), full testsuite 107 passed / 0 failed.

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