From 5170155112bf8b1ebf459497174b6035e7025c3a Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 5 Jul 2026 16:58:13 +0200 Subject: [PATCH] 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)}")