Files
shadowsocks-libev/tests/alloc_audit/measure_alloc.py
T
Max Lv 5c62b2544e C++20 port 11/14: one TCP and UDP relay engine for all programs (#3075)
* Rewrite the resolver in C++ on a fixed query pool

Queries live in pool slots with their first A and AAAA answer inline,
c-ares sockets are watched from a fixed table, and answers reach the
waiter through a callback plus opaque words instead of a back-pointer.
resolv.cpp keeps the C API as a shim. The event Loop can now adopt the
ss_event loop, and a Poll can stop watching a socket it does not own.

* Add the C++ TCP relay engine and run ss-tunnel on it

One engine (src/relay/tcp) for the four TCP relays: connections live in a
fixed pool owned through PoolPtr, handshake, cipher state and buffers are
inline, timeouts are intrusive deadline lists on one timer and DNS answers
come back through generation-checked handles. A full pool refuses new
connections and pauses accepting until a slot frees.

ss-tunnel is the first user; its hand-written relay callbacks are gone.
tests/tunnel_interop.py covers every method against ss-server and
shadowsocks-rust.

* Run ss-local and libshadowsocks-c on the C++ TCP relay engine

The SOCKS5 handshake, the ACL bypass decision (with its own resolver for
host-name rules) and the short wait for a first payload are now engine
code; local.cpp keeps only option parsing and startup. The library entry
points start the same engine.

* Run ss-server on the C++ TCP relay engine

The request header, outbound ACL, target resolution through the relay's
resolver, the anti-probing stop stage and the manager traffic counters
are engine code now; the connmark-to-TOS tracker becomes a pair of relay
hooks. server.cpp keeps option parsing, nftables and the stats timer.

* Fix the build with GCC and libstdc++

crypto.h's min/max macros broke <algorithm> in C++ sources that include
it first, netutils.h's linux/tcp.h clashed with the netinet/tcp.h libuv
includes, and GCC rejects memcpy into crypto_t. Apple clang accepted all
three.

* Run ss-redir and every UDP relay on the C++ engines

ss-redir's TCP side moves onto the relay engine (per-listener TOS for the
DSCP ports). src/relay/udp replaces udprelay.cpp: sessions keyed by
client address own their upstream socket and live in a fixed LRU,
AEAD-2022 server sessions are shared through RefPtr between the
session-ID table and the relays using them, and datagrams waiting for
DNS sit in pool slots. Packets are received into and built in
relay-owned buffers instead of a 64 KiB allocation per datagram. The
programs no longer call crypto_init(); Method sets up libsodium and the
replay filter.

* Let the allocation audit target an IP so DNS costs show separately

* Copy the literal ACL address without strncpy

GCC 13 at -O3 rejects the strncpy from the 256-byte host into the
INET6_ADDRSTRLEN buffer (-Wstringop-truncation) on glibc and UCRT64.

* Include <ctime> for std::time in the UDP relay (libstdc++)

* Build the UDP relay with Clang on musl

musl's CMSG_NXTHDR compares a size_t with a ptrdiff_t, which Clang's
-Wsign-compare rejects in C++. Call it from one helper that suppresses
the warning there.

* Keep clang-tidy clean in the relay engine
2026-10-15 10:05:43 +08:00

151 lines
6.7 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, free_port, peers, receive, socks # noqa: E402
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):
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("--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")
with TCPOrigin(("127.0.0.1", 0), TCPHandler) 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]
commands = [
[str(binary_dir / "ss-server"), "-s", "127.0.0.1", "-p", str(server_port),
"-k", password, "-m", args.method],
[str(binary_dir / "ss-local"), "-s", "127.0.0.1", "-p", str(server_port),
"-l", str(local_port), "-k", password, "-m", args.method],
]
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)
time.sleep(.5)
before = snapshot_all(processes, outputs, counts)
relay(local_port, args.target, origin.server_address[1], args.connections, args.concurrency, args.size)
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())