Skip to content

Commit e6956e0

Browse files
dr-whotridge
authored andcommitted
match: bound the hash_search() chain walk (issue #217)
hash_search() walks the entire hash-table chain for the current rolling checksum at every byte offset of the source file. Disk and VM images contain large runs of identical blocks, so a single weak checksum (get_checksum1) can collide thousands of times and pile every one of those blocks onto one chain. When the sender then rolls across a region whose weak checksum keeps landing on that chain without ever producing a strong-checksum match, it re-walks the whole chain for every byte, giving O(file_size * chain_length) behaviour. The result is rsync sitting at 100% CPU for hours with no apparent progress -- the long-standing "rsync hangs on large files" reports. Cap the number of same-weak-checksum candidates examined per offset at MAX_CHAIN_LEN. Once the cap is hit we treat the offset as a non-match and roll forward a byte; any block skipped this way is simply sent as literal data, so the transferred result is always correct -- only the transfer size is marginally affected. This is purely a sender-side search limit: it changes no checksum, emitted byte, or protocol field, so a capped sender interoperates with an unmodified receiver and vice versa. On a synthetic 40000-block basis sharing one weak checksum, syncing a 60KB source dropped from ~18.4s to ~0.7s; the unbounded cost grows with the square of the file size. testsuite/hashsearch-chain_test.py reproduces the pathology with a tiny basis of weak-checksum-colliding decoy blocks and asserts, via the existing false_alarms counter (--debug=deltasum1), that the per-hash-hit chain walk stays bounded. The assertion is exact and machine-independent rather than timing-based.
1 parent e2a24e8 commit e6956e0

2 files changed

Lines changed: 140 additions & 0 deletions

File tree

match.c

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,29 @@ extern struct stats stats;
4444

4545
#define TRADITIONAL_TABLESIZE (1<<16)
4646

47+
/* The maximum number of same-weak-checksum candidates we will compare
48+
* against at a single file offset before giving up and rolling forward a
49+
* byte. A weak checksum that collides thousands of times (very common in
50+
* disk/VM images, which contain large runs of identical blocks) would
51+
* otherwise turn hash_search()'s inner loop into an O(file_size *
52+
* chain_length) scan, pegging a CPU at 100% for hours with no apparent
53+
* progress (issue #217).
54+
*
55+
* Concretely, a synthetic 40000-block basis whose blocks all share one weak
56+
* checksum took ~18.4s to sync a 60KB source on a modern x86_64 box before
57+
* this cap and ~0.7s after it -- and the unbounded cost grows with the
58+
* square of the file size, which is what produced the multi-hour "hangs"
59+
* reported against real multi-GB images.
60+
*
61+
* Capping the per-offset work keeps the search bounded; any block we skip
62+
* over is simply sent as literal data, so the result is always correct --
63+
* only the transfer size is (slightly) affected. This is purely a
64+
* sender-side search limit: it changes no checksum, emitted byte, or
65+
* protocol field, so a capped sender interoperates with any receiver. */
66+
#ifndef MAX_CHAIN_LEN
67+
#define MAX_CHAIN_LEN 1024
68+
#endif
69+
4770
static uint32 tablesize;
4871
static int32 *hash_table;
4972

@@ -182,6 +205,7 @@ static void hash_search(int f,struct sum_struct *s,
182205
int done_csum2 = 0;
183206
uint32 hash_entry;
184207
int32 i, *prev;
208+
int32 chain_len = 0;
185209

186210
if (DEBUG_GTE(DELTASUM, 4)) {
187211
rprintf(FINFO, "offset=%s sum=%04x%04x\n",
@@ -218,6 +242,14 @@ static void hash_search(int f,struct sum_struct *s,
218242
if (sum != s->sums[i].sum1)
219243
continue;
220244

245+
/* Bound the work spent on a single pathological hash
246+
* bucket. If this weak checksum matches more than
247+
* MAX_CHAIN_LEN records, stop scanning and treat this
248+
* offset as a non-match (issue #217). The skipped data
249+
* is sent literally, never corrupted. */
250+
if (++chain_len > MAX_CHAIN_LEN)
251+
break;
252+
221253
/* also make sure the two blocks are the same length */
222254
l = (int32)MIN((OFF_T)s->blength, len-offset);
223255
if (l != s->sums[i].len)

testsuite/hashsearch-chain_test.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#!/usr/bin/env python3
2+
"""Regression test for issue #217: hash_search() must not blow up to O(N^2).
3+
4+
A disk/VM image can contain a huge number of blocks that share the same weak
5+
rolling checksum (e.g. long runs of identical bytes). All such blocks land on
6+
a single hash-table chain. When the sender then rolls across a region whose
7+
weak checksum keeps landing on that chain but never produces a strong-checksum
8+
match, the inner loop of hash_search() used to walk the *entire* chain for
9+
every single byte offset. The transfer would peg one CPU at 100% for hours
10+
with no visible progress -- "rsync hangs at 100% CPU".
11+
12+
We reproduce a small, deterministic version of that pathology:
13+
14+
* Destination/basis file: many identical "decoy" blocks. Each decoy is an
15+
all-C block whose first three bytes are perturbed by (+1,-2,+1). That
16+
perturbation leaves rsync's weak checksum (get_checksum1) unchanged but
17+
changes the strong checksum, so every decoy shares one weak checksum yet
18+
never strong-matches the source -> one very long chain, all false alarms.
19+
20+
* Source file: a short run of the constant byte C. Every window has that
21+
same weak checksum, so without a bound the sender walks the whole chain at
22+
every offset.
23+
24+
Rather than measure wall-clock time (which is hopelessly machine dependent),
25+
we count the work directly. rsync's existing `false_alarms` counter -- shown
26+
by `--debug=deltasum1` -- is incremented once per strong-checksum comparison
27+
that fails, i.e. exactly once per chain entry examined and rejected. With the
28+
per-offset cap in match.c the sender examines at most MAX_CHAIN_LEN entries per
29+
hash hit, so false_alarms / hash_hits stays bounded no matter how long the
30+
chain is; without the cap that ratio equals the full chain length. We assert
31+
the bound, which is the same integer on every machine.
32+
33+
The fix is sender-only (it changes no checksum, byte, or protocol field), so
34+
the transferred result must also still be byte-for-byte correct.
35+
"""
36+
37+
import re
38+
39+
from rsyncfns import (
40+
FROMDIR, TODIR, assert_same, rmtree, run_rsync, test_fail,
41+
)
42+
43+
BLOCK = 256
44+
NBLOCKS = 8000 # chain length -- much longer than match.c's cap (1024)
45+
RUN = 3000 # length of the constant-byte region in the source
46+
C = 100 # the constant source byte
47+
48+
# A correct fix bounds the per-hash-hit chain walk to match.c's MAX_CHAIN_LEN
49+
# (1024). Pick an assertion bound comfortably between that cap and the full
50+
# chain length so the test is insensitive to modest cap retuning but still
51+
# fails hard for the unbounded (pre-fix) walk of NBLOCKS per hit.
52+
MAX_FALSE_ALARMS_PER_HIT = NBLOCKS // 4 # 2000: > cap(1024), << chain(8000)
53+
54+
55+
def make_decoy_block() -> bytes:
56+
b = bytearray([C]) * BLOCK
57+
# (+1,-2,+1) at positions 0,1,2 preserves get_checksum1 (both s1 and s2)
58+
# while changing the block's content and thus its strong checksum.
59+
b[0] = C + 1
60+
b[1] = C - 2
61+
b[2] = C + 1
62+
return bytes(b)
63+
64+
65+
rmtree(FROMDIR)
66+
rmtree(TODIR)
67+
FROMDIR.mkdir(parents=True, exist_ok=True)
68+
TODIR.mkdir(parents=True, exist_ok=True)
69+
70+
src = FROMDIR / 'image.bin'
71+
dst = TODIR / 'image.bin'
72+
73+
# Basis: NBLOCKS identical decoys -> one giant weak-checksum chain.
74+
decoy = make_decoy_block()
75+
with open(dst, 'wb') as f:
76+
for _ in range(NBLOCKS):
77+
f.write(decoy)
78+
79+
# Source: a constant-byte run (constant weak checksum, no strong match).
80+
with open(src, 'wb') as f:
81+
f.write(bytes([C]) * RUN)
82+
83+
proc = run_rsync('-a', '--no-whole-file', f'--block-size={BLOCK}',
84+
'--no-compress', '--debug=deltasum1',
85+
f'{src}', f'{dst}', capture_output=True)
86+
87+
out = proc.stdout + proc.stderr
88+
m = re.search(r'hash_hits=(\d+)\s+false_alarms=(\d+)', out)
89+
if not m:
90+
test_fail(f"could not find deltasum stats in rsync output:\n{out}")
91+
hash_hits = int(m.group(1))
92+
false_alarms = int(m.group(2))
93+
94+
if hash_hits == 0:
95+
test_fail("expected the source to hit the decoy chain but hash_hits=0")
96+
97+
ratio = false_alarms / hash_hits
98+
if ratio > MAX_FALSE_ALARMS_PER_HIT:
99+
test_fail(
100+
f"hash_search() walked ~{ratio:.0f} chain entries per hash hit "
101+
f"(false_alarms={false_alarms}, hash_hits={hash_hits}); the chain of "
102+
f"{NBLOCKS} entries is not being capped -- issue #217 regression")
103+
104+
# Correctness is non-negotiable: the cap only skips matches, never data.
105+
assert_same(dst, src, label='issue #217 chain-cap transfer')
106+
107+
print(f"issue #217: bounded at {ratio:.0f} false alarms/hit "
108+
f"(chain={NBLOCKS}, cap keeps it under {MAX_FALSE_ALARMS_PER_HIT})")

0 commit comments

Comments
 (0)