Files
shadowsocks-libev/tests/alloc_audit/measure_alloc.py
Max Lv c69fb4c4d9 Seal the heap: bounded library heaps and zero-allocation program tests
The programs point libuv, c-ares and Mbed TLS (MBEDTLS_PLATFORM_MEMORY in
the bundled build) at their own FixedHeap before anything runs, mark the
end of startup with heap_seal(), and in verbose mode report heap use and
any allocation after it at exit. The library build keeps the host's
allocator.

measure_alloc.py gains --udp, and the alloc_steady_programs_* tests now
fail on any malloc per relayed TCP connection (by name and by address)
or UDP association, for every cipher family.
2026-10-15 00:34:30 +08:00

161 lines
7.2 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""Count heap allocations per proxied TCP connection in ss-local and ss-server.
Both peers run with the alloc_counter shim preloaded. After a warm-up the
script snapshots each process's allocation counter, relays more connections,
and snapshots again. The difference, divided by the number of connections,
is what one connection costs in the steady state. The no-heap goal for the
C++ core is zero.
Report-only by default; --max-per-connection N turns it into a check.
Example:
python3 tests/alloc_audit/measure_alloc.py --bin build/bin \\
--shim build/lib/libss_alloc_audit.dylib --method aes-256-gcm
"""
import argparse
import base64
import concurrent.futures
import json
import os
from pathlib import Path
import signal
import sys
import tempfile
import threading
import time
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from interop import (TCPHandler, TCPOrigin, UDPHandler, UDPOrigin, free_port, peers, receive, socks, # noqa: E402
udp_case)
def read_snapshot(path, previous_lines, timeout=10):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
lines = path.read_text().splitlines() if path.exists() else []
if len(lines) > previous_lines:
pid, allocations, frees, size = (int(v) for v in lines[-1].split())
return {"pid": pid, "allocations": allocations, "frees": frees, "bytes": size}, len(lines)
time.sleep(.02)
raise AssertionError(f"no allocation snapshot written to {path}")
def snapshot_all(processes, outputs, counts):
results = []
for index, (process, path) in enumerate(zip(processes, outputs)):
os.kill(process.pid, signal.SIGUSR2)
result, counts[index] = read_snapshot(path, counts[index])
results.append(result)
return results
def tcp_case(local_port, target, origin_port, size):
sock, _ = socks(local_port, 1, (target, origin_port))
payload = os.urandom(size)
with sock, concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
upload = pool.submit(sock.sendall, payload)
assert receive(sock, len(payload)) == payload, "TCP payload mismatch"
upload.result(timeout=10)
def relay(local_port, target, origin_port, connections, concurrency, size, udp=False):
if udp:
# Each case is a UDP association with four datagrams.
for _ in range(connections):
udp_case(local_port, origin_port)
return
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool:
futures = [pool.submit(tcp_case, local_port, target, origin_port, size) for _ in range(connections)]
for future in futures:
future.result(timeout=60)
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--bin", default="build/bin", help="directory with ss-local and ss-server")
parser.add_argument("--shim", required=True, help="path to the alloc_counter shared library")
parser.add_argument("--method", default="aes-256-gcm")
parser.add_argument("--warmup", type=int, default=50, help="connections before the first snapshot")
parser.add_argument("--connections", type=int, default=200, help="connections between snapshots")
parser.add_argument("--concurrency", type=int, default=10)
parser.add_argument("--size", type=int, default=16384, help="bytes echoed per connection")
parser.add_argument("--max-per-connection", type=float, help="fail if any peer exceeds this")
parser.add_argument("--udp", action="store_true", help="relay UDP associations instead of TCP streams")
parser.add_argument("--target", default="localhost",
help="destination host sent to the server; a name makes the server resolve it")
parser.add_argument("--json", action="store_true", help="print the result as JSON")
args = parser.parse_args()
binary_dir = Path(args.bin).resolve()
shim = str(Path(args.shim).resolve())
preload = "DYLD_INSERT_LIBRARIES" if sys.platform == "darwin" else "LD_PRELOAD"
key_size = 16 if args.method == "2022-blake3-aes-128-gcm" else 32
password = base64.b64encode(os.urandom(key_size)).decode("ascii")
origin_class, handler = (UDPOrigin, UDPHandler) if args.udp else (TCPOrigin, TCPHandler)
with origin_class(("127.0.0.1", 0), handler) as origin, \
tempfile.TemporaryDirectory(prefix="ss-alloc-") as temp:
threading.Thread(target=origin.serve_forever, daemon=True).start()
server_port, local_port = free_port(), free_port()
names = ["ss-server", "ss-local"]
outputs = [Path(temp) / f"{name}.counts" for name in names]
udp = ["-u"] if args.udp else []
commands = [
[str(binary_dir / "ss-server"), "-s", "127.0.0.1", "-p", str(server_port),
"-k", password, "-m", args.method] + udp,
[str(binary_dir / "ss-local"), "-s", "127.0.0.1", "-p", str(server_port),
"-l", str(local_port), "-k", password, "-m", args.method] + udp,
]
environments = []
for output in outputs:
env = os.environ.copy()
env[preload] = shim
env["SS_ALLOC_AUDIT_OUT"] = str(output)
environments.append(env)
# peers() takes one environment for all of its commands, so each
# peer gets its own context and its own output file.
with peers([commands[0]], [server_port], environments[0]) as server, \
peers([commands[1]], [local_port], environments[1]) as local:
processes = server + local
counts = [0, 0]
relay(local_port, args.target, origin.server_address[1], args.warmup, args.concurrency, args.size, args.udp)
time.sleep(.5)
before = snapshot_all(processes, outputs, counts)
relay(local_port, args.target, origin.server_address[1], args.connections, args.concurrency, args.size,
args.udp)
time.sleep(.5)
after = snapshot_all(processes, outputs, counts)
report = {"method": args.method, "connections": args.connections, "size": args.size, "peers": {}}
failed = False
for name, b, a in zip(names, before, after):
delta = a["allocations"] - b["allocations"]
per = delta / args.connections
report["peers"][name] = {
"allocations": delta,
"frees": a["frees"] - b["frees"],
"bytes": a["bytes"] - b["bytes"],
"allocations_per_connection": round(per, 2),
}
if args.max_per_connection is not None and per > args.max_per_connection:
failed = True
if args.json:
print(json.dumps(report, indent=2))
else:
print(f"method {args.method}, {args.connections} connections of {args.size} bytes to {args.target}")
for name, peer in report["peers"].items():
print(f" {name:9} {peer['allocations_per_connection']:8.2f} allocations/connection "
f"({peer['allocations']} total, {peer['bytes']} bytes)")
if failed:
print(f"FAIL: more than {args.max_per_connection} allocations per connection")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())