mirror of
https://github.com/shadowsocks/shadowsocks-libev.git
synced 2026-10-21 14:33:55 +00:00
* 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.
140 lines
6.1 KiB
Python
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())
|