From 5170155112bf8b1ebf459497174b6035e7025c3a Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 5 Jul 2026 16:58:13 +0200 Subject: [PATCH 1/9] bench: run rsync over localhost network for fair comparison Previously rsync ran as a direct local copy (rsync -aH source/ dest/), bypassing the loopback interface entirely. This meant tc netem latency/loss/rate limits were never applied to rsync, making the comparison fundamentally unfair. Now rsync goes through the network via an rsync daemon on localhost: rsync --daemon --no-detach --config=rsyncd.conf rsync rsync://localhost:PORT/source/ dest/ The daemon is started before rsync tests and killed afterward. Both the rsync client and FastSync client run under the same systemd-run disk I/O limits when applicable. --- test.py | 151 +++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 96 insertions(+), 55 deletions(-) diff --git a/test.py b/test.py index 0ba0dfe..c48fd39 100755 --- a/test.py +++ b/test.py @@ -6,6 +6,7 @@ import subprocess import sys import tempfile import time +import socket TEST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "test_data") DEFAULT_SOURCE_DIR = os.path.join(TEST_DIR, "source") @@ -86,6 +87,12 @@ def netem_reset(): ) +def find_free_port(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('', 0)) + return s.getsockname()[1] + + def generate_test_files(source_dir): if os.path.exists(source_dir): shutil.rmtree(source_dir) @@ -262,64 +269,98 @@ def run_profile(profile_name, source_dir, dest_dir): server_process.kill() server_process.wait() - for case in RSYNC_CASES: - name = case["name"] - rsync_args = case["args"] - print(f"\n --- {name} ---") + # Rsync tests (over network via daemon, so tc netem applies) + rsync_port = find_free_port() + rsyncd_conf = os.path.join(tempfile.gettempdir(), f"rsyncd-{rsync_port}.conf") + with open(rsyncd_conf, "w") as f: + f.write(f"""use chroot = no +max connections = 5 +read only = yes +port = {rsync_port} - if os.path.exists(dest_dir): - shutil.rmtree(dest_dir) +[source] +path = {source_dir} +""") + rsync_daemon = None + try: + rsync_daemon = subprocess.Popen( + ["rsync", "--daemon", "--no-detach", f"--config={rsyncd_conf}"], + stdout=subprocess.DEVNULL, stderr=None + ) + time.sleep(0.5) + + for case in RSYNC_CASES: + name = case["name"] + rsync_args = case["args"] + print(f"\n --- {name} ---") + + if os.path.exists(dest_dir): + shutil.rmtree(dest_dir) + + try: + rsync_cmd = ( + client_prefix + + ["rsync"] + + rsync_args + + [f"rsync://localhost:{rsync_port}/source/", f"{dest_dir}/"] + ) + print(f" Running: {' '.join(rsync_cmd)}") + + start_time = time.monotonic() + rsync_result = subprocess.run( + rsync_cmd, capture_output=True, timeout=120 + ) + end_time = time.monotonic() + duration = end_time - start_time + + mismatches, missing = [], [] + if rsync_result.returncode == 0: + mismatches, missing = verify_transfer(source_dir, dest_dir) + + entry = { + "name": name, + "suite": profile_name, + "time": f"{duration:.4f}s" if rsync_result.returncode == 0 else "N/A", + } + + if rsync_result.returncode == 0 and not mismatches and not missing: + entry["status"] = "Success" + entry["error"] = "" + else: + entry["status"] = "Failed" + errors = [] + if rsync_result.returncode != 0: + err = rsync_result.stderr.strip().split("\n")[0] if rsync_result.stderr else "No output" + errors.append(f"Exit code {rsync_result.returncode}: {err[:80]}") + if missing: + errors.append(f"Missing ({len(missing)}): {', '.join(missing[:5])}") + if mismatches: + errors.append(f"Mismatch ({len(mismatches)}): {', '.join(mismatches[:3])}") + entry["error"] = " | ".join(errors) + + results.append(entry) + + except subprocess.TimeoutExpired: + results.append( + {"name": name, "suite": profile_name, "status": "Timeout", "time": "N/A", "error": "Exceeded 120s"} + ) + except Exception as e: + results.append( + {"name": name, "suite": profile_name, "status": "Error", "time": "N/A", "error": str(e)} + ) + + finally: + if rsync_daemon: + try: + rsync_daemon.wait(timeout=5) + except subprocess.TimeoutExpired: + rsync_daemon.kill() + rsync_daemon.wait() try: - rsync_cmd = ( - ["rsync"] - + rsync_args - + [f"{source_dir}/", f"{dest_dir}/"] - ) - print(f" Running: {' '.join(rsync_cmd)}") - - start_time = time.monotonic() - rsync_result = subprocess.run( - rsync_cmd, capture_output=True, timeout=120 - ) - end_time = time.monotonic() - duration = end_time - start_time - - mismatches, missing = [], [] - if rsync_result.returncode == 0: - mismatches, missing = verify_transfer(source_dir, dest_dir) - - entry = { - "name": name, - "suite": profile_name, - "time": f"{duration:.4f}s" if rsync_result.returncode == 0 else "N/A", - } - - if rsync_result.returncode == 0 and not mismatches and not missing: - entry["status"] = "Success" - entry["error"] = "" - else: - entry["status"] = "Failed" - errors = [] - if rsync_result.returncode != 0: - err = rsync_result.stderr.strip().split("\n")[0] if rsync_result.stderr else "No output" - errors.append(f"Exit code {rsync_result.returncode}: {err[:80]}") - if missing: - errors.append(f"Missing ({len(missing)}): {', '.join(missing[:5])}") - if mismatches: - errors.append(f"Mismatch ({len(mismatches)}): {', '.join(mismatches[:3])}") - entry["error"] = " | ".join(errors) - - results.append(entry) - - except subprocess.TimeoutExpired: - results.append( - {"name": name, "suite": profile_name, "status": "Timeout", "time": "N/A", "error": "Exceeded 120s"} - ) - except Exception as e: - results.append( - {"name": name, "suite": profile_name, "status": "Error", "time": "N/A", "error": str(e)} - ) + os.unlink(rsyncd_conf) + except Exception: + pass except subprocess.CalledProcessError as e: print(f" Error running netem command: {' '.join(e.cmd)}") From 0c2364bd625b64beb41efe1211c5a98c3b52280c Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 5 Jul 2026 18:11:23 +0200 Subject: [PATCH 2/9] test: clear dest dir at start of run --- test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test.py b/test.py index c48fd39..408c2d4 100755 --- a/test.py +++ b/test.py @@ -395,6 +395,8 @@ def main(): sys.exit(1) generate_test_files(args.source_dir) + if os.path.exists(args.dest_dir): + shutil.rmtree(args.dest_dir) os.makedirs(args.dest_dir, exist_ok=True) profiles_to_run = [] From 1c26a7a712da23cc016429db46b7cad4cc369973 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 5 Jul 2026 18:33:04 +0200 Subject: [PATCH 3/9] test: robust netem setup and rsync daemon startup - netem_apply now calls netem_reset() first to clear any leftover qdisc - Rsync daemon startup polls TCP port instead of fixed sleep - Dest dir cleared at start of run --- test.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/test.py b/test.py index 408c2d4..68041d0 100755 --- a/test.py +++ b/test.py @@ -73,6 +73,7 @@ def netem_apply(profile): if not params: netem_reset() return + netem_reset() cmd = ["sudo", "tc", "qdisc", "add", "dev", NETWORK_INTERFACE, "root", "netem"] cmd += ["rate", params["rate"]] cmd += ["delay", params["delay"], params["jitter"]] @@ -288,7 +289,19 @@ path = {source_dir} ["rsync", "--daemon", "--no-detach", f"--config={rsyncd_conf}"], stdout=subprocess.DEVNULL, stderr=None ) - time.sleep(0.5) + for _ in range(25): + time.sleep(0.1) + if rsync_daemon.poll() is not None: + print(f" rsync daemon exited prematurely (rc={rsync_daemon.returncode})") + raise RuntimeError("rsync daemon failed to start") + try: + with socket.create_connection(("127.0.0.1", rsync_port), timeout=0.5): + break + except (ConnectionRefusedError, OSError): + continue + else: + print(f" timed out waiting for rsync daemon on port {rsync_port}") + raise RuntimeError("rsync daemon did not start") for case in RSYNC_CASES: name = case["name"] From 6a609c1b07cc6759cc505df5dee3f48ec3dbe636 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 5 Jul 2026 18:42:35 +0200 Subject: [PATCH 4/9] test: add text=True to rsync subprocess.run Without text=True, stderr is bytes, and bytes.split('\n') throws TypeError: a bytes-like object is required, not 'str'. --- test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test.py b/test.py index 68041d0..bbd8578 100755 --- a/test.py +++ b/test.py @@ -322,7 +322,7 @@ path = {source_dir} start_time = time.monotonic() rsync_result = subprocess.run( - rsync_cmd, capture_output=True, timeout=120 + rsync_cmd, capture_output=True, text=True, timeout=120 ) end_time = time.monotonic() duration = end_time - start_time From 895927fbad875776f9d90af160d037dbc18ad26c Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 5 Jul 2026 18:44:36 +0200 Subject: [PATCH 5/9] test: include stdout in rsync error output --- test.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test.py b/test.py index bbd8578..654c3b6 100755 --- a/test.py +++ b/test.py @@ -344,8 +344,15 @@ path = {source_dir} entry["status"] = "Failed" errors = [] if rsync_result.returncode != 0: - err = rsync_result.stderr.strip().split("\n")[0] if rsync_result.stderr else "No output" - errors.append(f"Exit code {rsync_result.returncode}: {err[:80]}") + errs = [] + if rsync_result.stderr: + errs.append(rsync_result.stderr.strip().split("\n")[0]) + if rsync_result.stdout: + errs.append(rsync_result.stdout.strip().split("\n")[0]) + if not errs: + errs.append("No output") + err = " | ".join(errs) + errors.append(f"Exit code {rsync_result.returncode}: {err[:150]}") if missing: errors.append(f"Missing ({len(missing)}): {', '.join(missing[:5])}") if mismatches: From b680a8dff4293305a5018741052f01a1484017b3 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 5 Jul 2026 18:46:45 +0200 Subject: [PATCH 6/9] test: fix indentation in rsync error handling --- test.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test.py b/test.py index 654c3b6..f0b028c 100755 --- a/test.py +++ b/test.py @@ -344,15 +344,15 @@ path = {source_dir} entry["status"] = "Failed" errors = [] if rsync_result.returncode != 0: - errs = [] - if rsync_result.stderr: - errs.append(rsync_result.stderr.strip().split("\n")[0]) - if rsync_result.stdout: - errs.append(rsync_result.stdout.strip().split("\n")[0]) - if not errs: - errs.append("No output") - err = " | ".join(errs) - errors.append(f"Exit code {rsync_result.returncode}: {err[:150]}") + errs = [] + if rsync_result.stderr: + errs.append(rsync_result.stderr.strip().split("\n")[0]) + if rsync_result.stdout: + errs.append(rsync_result.stdout.strip().split("\n")[0]) + if not errs: + errs.append("No output") + err = " | ".join(errs) + errors.append(f"Exit code {rsync_result.returncode}: {err[:150]}") if missing: errors.append(f"Missing ({len(missing)}): {', '.join(missing[:5])}") if mismatches: From 266c1e37c7853280c74c0bbcb40ed1d72ddcbaa9 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 5 Jul 2026 18:57:37 +0200 Subject: [PATCH 7/9] test: skip systemd-run boilerplate in rsync errors, add raw retry --- test.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/test.py b/test.py index f0b028c..4f76400 100755 --- a/test.py +++ b/test.py @@ -345,14 +345,32 @@ path = {source_dir} errors = [] if rsync_result.returncode != 0: errs = [] - if rsync_result.stderr: - errs.append(rsync_result.stderr.strip().split("\n")[0]) - if rsync_result.stdout: - errs.append(rsync_result.stdout.strip().split("\n")[0]) + for line in (rsync_result.stderr or "").split("\n"): + line = line.strip() + if not line: + continue + if line.startswith("Running as unit:"): + continue + errs.append(line) + for line in (rsync_result.stdout or "").split("\n"): + line = line.strip() + if not line: + continue + errs.append(line) if not errs: errs.append("No output") - err = " | ".join(errs) - errors.append(f"Exit code {rsync_result.returncode}: {err[:150]}") + err = " | ".join(errs[-3:]) + errors.append(f"Exit code {rsync_result.returncode}: {err[:200]}") + # Retry without systemd-run to reveal the actual error + if client_prefix: + plain = subprocess.run( + ["rsync", "-aH", f"rsync://localhost:{rsync_port}/source/", "/tmp/"], + capture_output=True, text=True, timeout=30 + ) + if plain.returncode != 0: + plain_errs = [l for l in (plain.stderr or "").split("\n") if l.strip()] + if plain_errs: + errors.append(f"raw: {plain_errs[-1][:150]}") if missing: errors.append(f"Missing ({len(missing)}): {', '.join(missing[:5])}") if mismatches: From 251d2a340715cdeb33a35994759475e7432f2973 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 5 Jul 2026 19:06:12 +0200 Subject: [PATCH 8/9] test: simplify rsync daemon startup, capture stderr --- test.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/test.py b/test.py index 4f76400..851e08a 100755 --- a/test.py +++ b/test.py @@ -274,13 +274,11 @@ def run_profile(profile_name, source_dir, dest_dir): rsync_port = find_free_port() rsyncd_conf = os.path.join(tempfile.gettempdir(), f"rsyncd-{rsync_port}.conf") with open(rsyncd_conf, "w") as f: - f.write(f"""use chroot = no -max connections = 5 + f.write(f"""port = {rsync_port} read only = yes -port = {rsync_port} [source] -path = {source_dir} + path = {source_dir} """) rsync_daemon = None @@ -289,13 +287,13 @@ path = {source_dir} ["rsync", "--daemon", "--no-detach", f"--config={rsyncd_conf}"], stdout=subprocess.DEVNULL, stderr=None ) - for _ in range(25): + for _ in range(50): time.sleep(0.1) if rsync_daemon.poll() is not None: - print(f" rsync daemon exited prematurely (rc={rsync_daemon.returncode})") + print(f" rsync daemon exited (rc={rsync_daemon.returncode})") raise RuntimeError("rsync daemon failed to start") try: - with socket.create_connection(("127.0.0.1", rsync_port), timeout=0.5): + with socket.create_connection(("127.0.0.1", rsync_port), timeout=0.3): break except (ConnectionRefusedError, OSError): continue From 7d528b1b4d88e29a3ef95f02927467f81ccd2ad9 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 5 Jul 2026 19:06:25 +0200 Subject: [PATCH 9/9] test: use temp dir for rsync error retry --- test.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/test.py b/test.py index 851e08a..b1304bc 100755 --- a/test.py +++ b/test.py @@ -361,14 +361,18 @@ read only = yes errors.append(f"Exit code {rsync_result.returncode}: {err[:200]}") # Retry without systemd-run to reveal the actual error if client_prefix: - plain = subprocess.run( - ["rsync", "-aH", f"rsync://localhost:{rsync_port}/source/", "/tmp/"], - capture_output=True, text=True, timeout=30 - ) - if plain.returncode != 0: - plain_errs = [l for l in (plain.stderr or "").split("\n") if l.strip()] - if plain_errs: - errors.append(f"raw: {plain_errs[-1][:150]}") + tmp_dest = tempfile.mkdtemp() + try: + plain = subprocess.run( + ["rsync", "-aH", f"rsync://localhost:{rsync_port}/source/", f"{tmp_dest}/"], + capture_output=True, text=True, timeout=30 + ) + if plain.returncode != 0: + plain_errs = [l for l in (plain.stderr or "").split("\n") if l.strip()] + if plain_errs: + errors.append(f"raw: {plain_errs[-1][:150]}") + finally: + shutil.rmtree(tmp_dest, ignore_errors=True) if missing: errors.append(f"Missing ({len(missing)}): {', '.join(missing[:5])}") if mismatches: