#!/usr/bin/env python3 """ Stress test for shadowsocks-libev: measures bandwidth on loopback with different ciphers using ss-server and ss-tunnel, and monitors for memory leaks. Usage: python3 tests/stress_test.py --bin build/bin/ python3 tests/stress_test.py --bin build/bin/ --size 50 --duration 10 python3 tests/stress_test.py --bin build/bin/ --cipher aes-256-gcm """ from __future__ import print_function import argparse import json import os import platform import signal import socket import sys import threading import time from subprocess import Popen # Ciphers to test: AEAD (recommended) + common stream ciphers AEAD_CIPHERS = [ "aes-128-gcm", "aes-256-gcm", "chacha20-ietf-poly1305", ] STREAM_CIPHERS = [ "aes-128-cfb", "aes-256-cfb", "aes-256-ctr", "chacha20-ietf", ] ALL_CIPHERS = AEAD_CIPHERS + STREAM_CIPHERS # Ports (picked high to avoid conflicts) SERVER_PORT = 18388 TUNNEL_LOCAL_PORT = 18389 SINK_PORT = 18390 def get_rss_kb(pid): """Get resident set size in KB for a process.""" system = platform.system() try: if system == "Linux": with open("/proc/%d/status" % pid) as f: for line in f: if line.startswith("VmRSS:"): return int(line.split()[1]) elif system == "Darwin": import subprocess out = subprocess.check_output( ["ps", "-o", "rss=", "-p", str(pid)], stderr=subprocess.DEVNULL ).decode().strip() if out: return int(out) except (IOError, OSError, ValueError): pass return None class SinkServer: """A simple TCP server that accepts connections and discards all data. Sends back a small acknowledgement per chunk so the sender knows data was received (for accurate bandwidth measurement).""" def __init__(self, port): self.port = port self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.sock.bind(("127.0.0.1", port)) self.sock.listen(5) self.sock.settimeout(1.0) self.running = True self.bytes_received = 0 self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() def _run(self): while self.running: try: conn, addr = self.sock.accept() t = threading.Thread( target=self._handle, args=(conn,), daemon=True ) t.start() except socket.timeout: continue except OSError: break def _handle(self, conn): conn.settimeout(5.0) try: while self.running: data = conn.recv(65536) if not data: break self.bytes_received += len(data) except (socket.timeout, OSError): pass finally: conn.close() def stop(self): self.running = False self.sock.close() self.thread.join(timeout=3) def server_args(ss_server, cipher, password, server_port): """Build command-line args for ss-server.""" return [ ss_server, "-s", "127.0.0.1", "-p", str(server_port), "-k", password, "-m", cipher, ] def tunnel_args(ss_tunnel, cipher, password, server_port, local_port, fwd_host, fwd_port): """Build command-line args for ss-tunnel.""" return [ ss_tunnel, "-s", "127.0.0.1", "-p", str(server_port), "-l", str(local_port), "-k", password, "-m", cipher, "-L", "%s:%d" % (fwd_host, fwd_port), ] def kill_proc(proc): """Kill a process gracefully.""" if proc and proc.poll() is None: try: proc.send_signal(signal.SIGTERM) proc.wait(timeout=5) except Exception: try: proc.kill() proc.wait(timeout=3) except Exception: pass def run_bandwidth_test(bin_dir, cipher, data_size_mb, password="stress_test_pw"): """Run a single bandwidth test with the given cipher. Returns dict with: cipher, bandwidth_mbps, duration_sec, server_rss_before_kb, server_rss_after_kb, tunnel_rss_before_kb, tunnel_rss_after_kb, bytes_transferred, error """ result = { "cipher": cipher, "bandwidth_mbps": 0, "duration_sec": 0, "bytes_transferred": 0, "server_rss_before_kb": None, "server_rss_after_kb": None, "tunnel_rss_before_kb": None, "tunnel_rss_after_kb": None, "error": None, } ss_server_bin = os.path.join(bin_dir, "ss-server") ss_tunnel_bin = os.path.join(bin_dir, "ss-tunnel") if not os.path.isfile(ss_server_bin) or not os.path.isfile(ss_tunnel_bin): result["error"] = "binaries not found in %s" % bin_dir return result server_proc = None tunnel_proc = None sink = None devnull = open(os.devnull, "w") try: # Start sink server (the destination ss-tunnel forwards to) sink = SinkServer(SINK_PORT) # Start ss-server server_proc = Popen( server_args(ss_server_bin, cipher, password, SERVER_PORT), stdout=devnull, stderr=devnull, close_fds=True ) # Start ss-tunnel forwarding to the sink tunnel_proc = Popen( tunnel_args(ss_tunnel_bin, cipher, password, SERVER_PORT, TUNNEL_LOCAL_PORT, "127.0.0.1", SINK_PORT), stdout=devnull, stderr=devnull, close_fds=True ) # Wait for processes to start and bind ports # Don't probe the ports as ss-tunnel crashes on empty connections time.sleep(3.0) if server_proc.poll() is not None: result["error"] = "ss-server exited prematurely (code %d)" % server_proc.returncode return result if tunnel_proc.poll() is not None: result["error"] = "ss-tunnel exited prematurely (code %d)" % tunnel_proc.returncode return result # Record RSS before result["server_rss_before_kb"] = get_rss_kb(server_proc.pid) result["tunnel_rss_before_kb"] = get_rss_kb(tunnel_proc.pid) # Send data through the tunnel chunk_size = 64 * 1024 # 64KB chunks total_bytes = data_size_mb * 1024 * 1024 data_chunk = b"\x00" * chunk_size sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(30) try: sock.connect(("127.0.0.1", TUNNEL_LOCAL_PORT)) except (socket.error, OSError) as e: result["error"] = "connect to tunnel failed: %s" % e return result sent = 0 start_time = time.time() try: while sent < total_bytes: remaining = total_bytes - sent to_send = min(chunk_size, remaining) if to_send < chunk_size: data_chunk = b"\x00" * to_send sock.sendall(data_chunk) sent += to_send except (socket.error, OSError) as e: result["error"] = "send error at %d bytes: %s" % (sent, e) finally: elapsed = time.time() - start_time try: sock.shutdown(socket.SHUT_WR) except OSError: pass sock.close() # Wait a bit for data to flow through time.sleep(0.5) result["bytes_transferred"] = sent result["duration_sec"] = elapsed if elapsed > 0: result["bandwidth_mbps"] = (sent * 8) / (elapsed * 1e6) # Record RSS after result["server_rss_after_kb"] = get_rss_kb(server_proc.pid) result["tunnel_rss_after_kb"] = get_rss_kb(tunnel_proc.pid) except Exception as e: result["error"] = str(e) finally: kill_proc(tunnel_proc) kill_proc(server_proc) if sink: sink.stop() devnull.close() return result def format_size(nbytes): if nbytes >= 1024 * 1024 * 1024: return "%.1f GB" % (nbytes / (1024 * 1024 * 1024)) elif nbytes >= 1024 * 1024: return "%.1f MB" % (nbytes / (1024 * 1024)) elif nbytes >= 1024: return "%.1f KB" % (nbytes / 1024) return "%d B" % nbytes def format_rss(kb): if kb is None: return "N/A" if kb >= 1024: return "%.1f MB" % (kb / 1024.0) return "%d KB" % kb def main(): parser = argparse.ArgumentParser( description="Stress test ss-server + ss-tunnel bandwidth on loopback" ) parser.add_argument( "--bin", type=str, required=True, help="Path to directory containing ss-server and ss-tunnel binaries" ) parser.add_argument( "--size", type=int, default=100, help="Data size to transfer per cipher in MB (default: 100)" ) parser.add_argument( "--cipher", type=str, default=None, help="Test only this specific cipher" ) parser.add_argument( "--repeat", type=int, default=1, help="Number of times to repeat each cipher test (default: 1)" ) parser.add_argument( "--stream", action="store_true", help="Include stream ciphers (deprecated, insecure)" ) parser.add_argument( "--leak-threshold", type=int, default=10240, help="RSS growth threshold in KB to flag as potential leak (default: 10240)" ) parser.add_argument( "--json", type=str, default=None, help="Write results as JSON to this file" ) args = parser.parse_args() # Determine ciphers to test if args.cipher: ciphers = [args.cipher] elif args.stream: ciphers = ALL_CIPHERS else: ciphers = AEAD_CIPHERS print("=" * 72) print("shadowsocks-libev stress test") print("=" * 72) print(" Binaries : %s" % os.path.abspath(args.bin)) print(" Data size: %d MB per cipher" % args.size) print(" Ciphers : %s" % ", ".join(ciphers)) print(" Repeats : %d" % args.repeat) print("=" * 72) print() all_results = [] leak_warnings = [] for cipher in ciphers: for run in range(args.repeat): label = cipher if args.repeat > 1: label = "%s (run %d/%d)" % (cipher, run + 1, args.repeat) sys.stdout.write("Testing %-35s ... " % label) sys.stdout.flush() result = run_bandwidth_test(args.bin, cipher, args.size) all_results.append(result) if result["error"]: print("FAILED: %s" % result["error"]) continue bw = result["bandwidth_mbps"] dur = result["duration_sec"] print("%8.1f Mbps (%5.2fs, %s)" % ( bw, dur, format_size(result["bytes_transferred"]) )) # Check for memory leaks for role in ["server", "tunnel"]: before = result["%s_rss_before_kb" % role] after = result["%s_rss_after_kb" % role] if before is not None and after is not None: growth = after - before if growth > args.leak_threshold: msg = ( " WARNING: ss-%s RSS grew by %s " "(%s -> %s) during %s test" % (role, format_rss(growth), format_rss(before), format_rss(after), cipher) ) print(msg) leak_warnings.append(msg) # Summary print() print("=" * 72) print("RESULTS SUMMARY") print("=" * 72) print() print("%-30s %10s %8s %12s %12s" % ( "Cipher", "Bandwidth", "Time", "Server RSS", "Tunnel RSS" )) print("-" * 72) for r in all_results: if r["error"]: print("%-30s %10s" % (r["cipher"], "FAILED")) continue srv_rss = "" tun_rss = "" if r["server_rss_before_kb"] and r["server_rss_after_kb"]: srv_rss = "%s->%s" % ( format_rss(r["server_rss_before_kb"]), format_rss(r["server_rss_after_kb"]) ) if r["tunnel_rss_before_kb"] and r["tunnel_rss_after_kb"]: tun_rss = "%s->%s" % ( format_rss(r["tunnel_rss_before_kb"]), format_rss(r["tunnel_rss_after_kb"]) ) print("%-30s %7.1f Mbps %6.2fs %12s %12s" % ( r["cipher"], r["bandwidth_mbps"], r["duration_sec"], srv_rss or "N/A", tun_rss or "N/A" )) if leak_warnings: print() print("MEMORY LEAK WARNINGS:") for w in leak_warnings: print(w) print() sys.exit(1) print() passed = [r for r in all_results if not r["error"]] failed = [r for r in all_results if r["error"]] print("%d passed, %d failed" % (len(passed), len(failed))) if args.json: with open(args.json, "w") as f: json.dump(all_results, f, indent=2) print("Results written to %s" % args.json) if failed: sys.exit(1) if __name__ == "__main__": main()