Files
shadowsocks-libev/tests/interop.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

286 lines
12 KiB
Python

#!/usr/bin/env python3
"""Real TCP/UDP interoperability, independent of HTTP proxy environment variables."""
import argparse
import base64
import concurrent.futures
import contextlib
import os
from pathlib import Path
import shutil
import socket
import socketserver
import struct
import subprocess
import tempfile
import threading
import time
class TCPHandler(socketserver.BaseRequestHandler):
def handle(self):
self.request.settimeout(15)
try:
while chunk := self.request.recv(16384):
self.request.sendall(chunk)
except (OSError, TimeoutError):
pass
class UDPHandler(socketserver.BaseRequestHandler):
def handle(self):
data, sock = self.request
sock.sendto(data, self.client_address)
class TCPOrigin(socketserver.ThreadingTCPServer):
daemon_threads = True
class UDPOrigin(socketserver.ThreadingUDPServer):
daemon_threads = True
def receive(sock, size):
data = bytearray()
while len(data) < size:
chunk = sock.recv(size - len(data))
if not chunk:
raise AssertionError(f"unexpected EOF after {len(data)}/{size} bytes")
data.extend(chunk)
return bytes(data)
def address(sock, kind):
if kind == 1:
host = socket.inet_ntop(socket.AF_INET, receive(sock, 4))
elif kind == 4:
host = socket.inet_ntop(socket.AF_INET6, receive(sock, 16))
elif kind == 3:
host = receive(sock, receive(sock, 1)[0]).decode("ascii")
else:
raise AssertionError(f"invalid SOCKS address type {kind}")
return host, struct.unpack("!H", receive(sock, 2))[0]
def socks(proxy_port, command, destination):
sock = socket.create_connection(("127.0.0.1", proxy_port), timeout=10)
try:
sock.sendall(b"\x05\x01\x00")
assert receive(sock, 2) == b"\x05\x00", "SOCKS authentication failed"
host, port = destination
encoded = host.encode("ascii")
sock.sendall(bytes([5, command, 0, 3, len(encoded)]) + encoded + struct.pack("!H", port))
header = receive(sock, 4)
assert header[:3] == b"\x05\x00\x00", f"SOCKS request failed: {header.hex()}"
relay = address(sock, header[3])
return sock, relay
except BaseException:
sock.close()
raise
def tcp_case(proxy_port, origin_port, size):
sock, _ = socks(proxy_port, 1, ("localhost", origin_port))
payload = os.urandom(size)
with sock, concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
# Upload and download concurrently to exercise backpressure in both directions.
upload = pool.submit(sock.sendall, payload)
assert receive(sock, len(payload)) == payload, "TCP payload mismatch"
upload.result(timeout=10)
def udp_case(proxy_port, origin_port):
control, relay = socks(proxy_port, 3, ("0.0.0.0", 0))
host, port = relay
if host in ("0.0.0.0", "::"):
host = "127.0.0.1"
with control, socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(10)
for size in (1, 128, 1200, 4096):
payload = os.urandom(size)
header = b"\x00\x00\x00\x01" + socket.inet_aton("127.0.0.1") + struct.pack("!H", origin_port)
sock.sendto(header + payload, (host, port))
try:
reply, sender = sock.recvfrom(65535)
except TimeoutError as error:
raise AssertionError(f"UDP timeout for {size}-byte payload") from error
assert sender[1] == port, "UDP reply bypassed relay"
assert reply[:3] == b"\x00\x00\x00", "fragmented or invalid SOCKS UDP reply"
if reply[3] == 1:
offset = 10
elif reply[3] == 4:
offset = 22
elif reply[3] == 3:
offset = 7 + reply[4]
else:
raise AssertionError("invalid UDP address type")
assert reply[offset:] == payload, "UDP payload mismatch"
def free_port():
# TCP and UDP have separate port reservations, especially on Windows.
# Probe both while holding the TCP socket so a TCP-only free port cannot
# select a reserved or occupied UDP endpoint.
for _ in range(100):
with socket.socket() as tcp, socket.socket(type=socket.SOCK_DGRAM) as udp:
tcp.bind(("127.0.0.1", 0))
port = tcp.getsockname()[1]
try:
udp.bind(("127.0.0.1", port))
except OSError:
continue
return port
raise RuntimeError("could not find a free TCP/UDP port")
def wait_ready(process, port):
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
if process.poll() is not None:
raise AssertionError(f"peer exited with {process.returncode}")
try:
with socket.create_connection(("127.0.0.1", port), timeout=.1):
return
except OSError:
time.sleep(.03)
raise AssertionError(f"peer failed to listen on port {port}")
@contextlib.contextmanager
def peers(commands, ports, environment=None):
processes = []
with tempfile.TemporaryDirectory(prefix="ss-interop-") as temp, contextlib.ExitStack() as stack:
logs = []
try:
for index, (command, port) in enumerate(zip(commands, ports)):
path = Path(temp) / f"peer-{index}.log"
log = stack.enter_context(path.open("wb"))
logs.append(path)
process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, env=environment)
processes.append(process)
wait_ready(process, port)
yield processes
except BaseException:
for path in logs:
print(path.name + ":\n" + path.read_text(errors="replace")[-6000:], flush=True)
raise
finally:
for process in reversed(processes):
if process.poll() is None:
process.terminate()
for process in reversed(processes):
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--self", action="store_true", help="use this build for both peers")
parser.add_argument("--printable-salt", action="store_true", help="enable experimental C client TCP prefix")
parser.add_argument("--server-bin", help="independent stock ss-server executable (requires --self)")
parser.add_argument("--method", help="run one cipher")
parser.add_argument("--bin", default=os.environ.get("SS_BIN_DIR", "build/bin"), help="program directory")
parser.add_argument("--plugin", help="SIP003 fixture executable (requires --self)")
parser.add_argument("--isolate-windows-runtime", action="store_true",
help="remove MSYS2/toolchain DLL directories from child PATH")
args = parser.parse_args()
if args.server_bin and not args.self:
parser.error("--server-bin requires --self")
if args.plugin and not args.self:
parser.error("--plugin requires --self")
environment = None
if args.isolate_windows_runtime:
if os.name != "nt":
parser.error("--isolate-windows-runtime requires Windows")
environment = os.environ.copy()
system_root = os.environ["SYSTEMROOT"]
environment["PATH"] = os.pathsep.join((system_root, os.path.join(system_root, "System32")))
binary_dir = Path(args.bin).resolve()
suffix = ".exe" if os.name == "nt" else ""
local, server = [str(binary_dir / (name + suffix)) for name in ("ss-local", "ss-server")]
if args.server_bin:
server = str(Path(args.server_bin).resolve())
rust_local, rust_server = shutil.which("sslocal"), shutil.which("ssserver")
missing = [p for p in (local, server) if not os.path.isfile(p)]
if not args.self:
missing += [name for name, found in (("sslocal", rust_local), ("ssserver", rust_server)) if not found]
if missing:
print("Missing interoperability prerequisites: " + ", ".join(missing))
return 1 if os.environ.get("SS_REQUIRE_INTEROP") == "1" else 77
methods = ["2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305", "aes-128-gcm", "aes-256-gcm",
"chacha20-ietf-poly1305"]
if args.printable_salt:
methods = ["aes-256-gcm", "chacha20-ietf-poly1305"]
if args.method:
methods = [args.method]
failures = 0
with TCPOrigin(("127.0.0.1", 0), TCPHandler) as tcp, UDPOrigin(("127.0.0.1", 0), UDPHandler) as udp:
for origin in (tcp, udp):
threading.Thread(target=origin.serve_forever, daemon=True).start()
try:
for method in methods:
key_size = 16 if method == "2022-blake3-aes-128-gcm" else 32
psk = base64.b64encode(os.urandom(key_size)).decode("ascii")
directions = ("self",) if args.self else ("C client -> Rust server", "Rust client -> C server")
for direction in directions:
label = f"{method}: {direction}"
print("RUN " + label, flush=True)
server_port, local_port = free_port(), free_port()
c_server = [server, "-s", "127.0.0.1", "-p", str(server_port), "-k", psk, "-m", method, "-u"]
c_local = [local, "-s", "127.0.0.1", "-p", str(server_port), "-l", str(local_port), "-k", psk, "-m", method, "-u"]
if args.printable_salt:
c_local.append("--printable-salt")
commands = [c_server, c_local]
plugin_temp = tempfile.TemporaryDirectory(prefix="ss-plugin-") if args.plugin else None
markers = []
if args.plugin:
for mode, command in zip(("server", "client"), commands):
marker = Path(plugin_temp.name) / mode
markers.append(marker)
command.extend(["--plugin", str(Path(args.plugin).resolve()),
"--plugin-opts", f"{mode};marker={marker}"])
if direction.startswith("C client"):
commands[0] = [rust_server, "-s", f"127.0.0.1:{server_port}", "-k", psk, "-m", method, "-U"]
elif direction.startswith("Rust client"):
commands[1] = [rust_local, "-b", f"127.0.0.1:{local_port}", "-s", f"127.0.0.1:{server_port}", "-k", psk, "-m", method, "-U"]
try:
with peers(commands, [server_port, local_port], environment):
for marker in markers:
deadline = time.monotonic() + 10
while not marker.exists() and time.monotonic() < deadline:
time.sleep(.03)
assert marker.exists(), "plugin did not become ready"
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
cases = [pool.submit(tcp_case, local_port, tcp.server_address[1], size)
for size in (1, 65536, 1048576)]
for case in cases:
case.result(timeout=30)
udp_case(local_port, udp.server_address[1])
for marker in markers:
pid = int(marker.read_text())
try:
os.kill(pid, 0)
except ProcessLookupError:
pass
else:
raise AssertionError(f"plugin child {pid} survived parent shutdown")
print("PASS TCP (3 concurrent streams) + UDP: " + label, flush=True)
except (OSError, AssertionError, TimeoutError) as error:
failures += 1
print(f"FAIL {label}: {error}", flush=True)
finally:
if plugin_temp is not None:
plugin_temp.cleanup()
finally:
tcp.shutdown()
udp.shutdown()
return int(failures != 0)
if __name__ == "__main__":
raise SystemExit(main())