Files
shadowsocks-libev/tests/alloc_audit/measure_alloc.py
T
Max Lv bcb3731968 C++20 port 1/14: build, ownership core and allocation audit (#3065)
* Enable C++20 alongside C11 in the build

Add CXX to the project with C++20 (no extensions) and build C++ sources
with -Wextra -fno-exceptions -fno-rtti -fno-threadsafe-statics on top of
the existing warning flags. Give every toolchain a C++ compiler: the zig
Windows toolchain, the static macOS/Windows CI jobs, the static musl
image (clang++ plus libstdc++ headers) and the RPM spec.

* Add C++20 ownership and allocation core

Header-only primitives for the no-heap-after-startup design:
Result/Status, Region, ObjectPool with PoolPtr (std::unique_ptr with a
pool deleter), generation-checked Handle and asynchronous close support,
intrusive RefPtr, FixedBuffer, MonotonicArena, FixedHashMap and FixedLru,
IntrusiveList, and a bounded FixedHeap for third-party allocator hooks.

Unit tests cover each primitive (test_core_*). scripts/check_no_heap.py
rejects heap allocation in C++ sources under src/ and runs as a ctest.
clang-tidy now also analyses C++ sources and the core headers.

* Add heap allocation audit for relayed connections

alloc_counter is a preloadable malloc counter (glibc, macOS) that writes
a snapshot on SIGUSR2. measure_alloc.py warms ss-local and ss-server up,
snapshots both, relays more connections and reports allocations per
connection. It runs report-only as the alloc_audit_report ctest.
interop.peers() now yields the started processes.

* Document the C++20 port and allocation baseline

docs/cpp-core.md describes the language profile, the core primitives,
the phase plan and the measured C baseline (77-89 allocations per
connection in ss-server, 27-39 in ss-local). The compatibility contract
now keeps the C11 public header instead of a C11 code base. CLAUDE.md
drops stale libev, submodule and gnu99 references.

* Fix CI for the C++ core: libstdc++ headers, static unwinder, newer clang

- docker/static: Alpine's libstdc++ headers come with g++, and the C++
  tests' static libstdc++ needs an unwinder that compiler-rt lacks; add
  g++ and llvm-libunwind.
- FixedBuffer: drop the unused kCapacity (clang-tidy 18 reports it as a
  dynamically initialized header static).
- ss-server on Windows: manager_addr was only read in POSIX-only code,
  which newer clang rejects (-Wunused-but-set-global); say that the
  option is ignored there.

* CI: allow running the test and build workflows by hand

Stacked PRs target their parent branch, which these workflows do not
trigger on; workflow_dispatch lets each layer run the full matrix.

* CI: let clang-tidy ignore GCC-only warning flags

The compile database comes from a GCC build, whose flags (for example
-Wno-class-memaccess) clang-tidy does not know and reports as errors.
2026-10-15 09:45:54 +08:00

140 lines
6.1 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, tcp_case # 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 relay(local_port, origin_port, connections, concurrency, size):
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool:
futures = [pool.submit(tcp_case, local_port, 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("--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, origin.server_address[1], args.warmup, args.concurrency, args.size)
time.sleep(.5)
before = snapshot_all(processes, outputs, counts)
relay(local_port, 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")
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())