282 lines
15 KiB
Python
Executable File
282 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Real, bounded RustProxy H2/TLS, H3/QUIC and H1 WebSocket acceptance.
|
|
|
|
Install Python dependencies aioquic, httpx[http2] and websockets. Example:
|
|
python3 scripts/performance/transport_regression.py --binary before=/abs/binary \
|
|
--binary after=/abs/binary --out /abs/results --python-deps /abs/optional/pydeps
|
|
|
|
Only fresh temporary databases and loopback listeners are used. Certificates
|
|
are generated by RustProxy and verified by clients; no protocol fallback is used.
|
|
This is an interoperability/functional suite, not a throughput benchmark.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import copy
|
|
import hashlib
|
|
import importlib.metadata
|
|
import json
|
|
from pathlib import Path
|
|
import platform
|
|
import socket
|
|
import sys
|
|
import time
|
|
import traceback
|
|
|
|
from common import Fixture, seal_results, sha256, write_json
|
|
from peer import Peer
|
|
|
|
|
|
def response_details(response):
|
|
return {"status": response.status, "protocol": response.protocol,
|
|
"alpn": response.alpn, "stream_id": response.stream_id,
|
|
"bytes": len(response.body), "sha256": hashlib.sha256(response.body).hexdigest(),
|
|
"cache": response.headers.get("x-cache"),
|
|
"cache_reason": response.headers.get("x-cache-reason")}
|
|
|
|
|
|
def tls_port(fixture):
|
|
while True:
|
|
port = fixture.port()
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp:
|
|
try:
|
|
udp.bind(("127.0.0.1", port))
|
|
return port
|
|
except OSError:
|
|
continue
|
|
|
|
|
|
def transport_config(rust, reverse_tls, static_tls, websocket_port, websocket_origin):
|
|
config = copy.deepcopy(rust.base)
|
|
for source, port in [("reverse", reverse_tls), ("static", static_tls)]:
|
|
identifier = f"{source}-tls"
|
|
config["listeners"]["endpoints"].append({
|
|
"id": identifier, "name": identifier, "scope": "internal", "enabled": True,
|
|
"binding": {"address": f"127.0.0.1:{port}", "tls": True, "http2": True,
|
|
"http3": {"max_concurrent_connections": 16,
|
|
"max_concurrent_bidistream": 16,
|
|
"max_concurrent_unistream": 16, "max_idle_timeout": 10}}})
|
|
vhost = next(v for v in config["vhosts"] if v["id"] == source)
|
|
vhost["frontend"]["listener_ids"].append(identifier)
|
|
vhost["http2_enabled"] = True
|
|
vhost["http3_enabled"] = True
|
|
config["listeners"]["endpoints"].append({
|
|
"id": "websocket", "name": "WebSocket", "scope": "internal", "enabled": True,
|
|
"binding": {"address": f"127.0.0.1:{websocket_port}", "tls": False,
|
|
"http2": False, "http3": None}})
|
|
config["vhosts"].append({
|
|
"id": "websocket", "frontend": {"hosts": ["127.0.0.1", "localhost"],
|
|
"listener_ids": ["websocket"]},
|
|
"websockets": True,
|
|
"backends": [{"id": "echo", "url": f"http://127.0.0.1:{websocket_origin}",
|
|
"allow_private": True}],
|
|
"load_balancing": {"health_check": {"mode": "off"}}, "routes": []})
|
|
config["cache"].update({"enabled": False, "expose_status": True, "expose_hits": True,
|
|
"expose_cache_status": True, "expose_only_hits": False})
|
|
config["compression"] = {"gzip": False, "br": False, "zstd": False, "deflate": False}
|
|
config["web_services"][0]["cache_control"] = "public, max-age=60"
|
|
return config
|
|
|
|
|
|
async def http_cases(check, session, protocol, source, cache, fixture):
|
|
prefix = f"{protocol}.{source}.cache_{'on' if cache else 'off'}"
|
|
expected = (fixture.site / "small.html").read_bytes()
|
|
path = f"/transport-{protocol}.html"
|
|
first = await session.request("GET", path)
|
|
check(prefix + ".get", first.status == 200 and first.body == expected,
|
|
**response_details(first))
|
|
second = await session.request("GET", path)
|
|
check(prefix + ".cache_delivery", second.status == 200 and second.body == expected
|
|
and ((first.headers.get("x-cache") == "MISS" and second.headers.get("x-cache") == "HIT")
|
|
if cache else second.headers.get("x-cache") not in ("MISS", "HIT")),
|
|
first_cache=first.headers.get("x-cache"), **response_details(second))
|
|
head = await session.request("HEAD", path)
|
|
check(prefix + ".head", head.status == 200 and head.body == b""
|
|
and head.headers.get("content-length") == str(len(expected)), **response_details(head))
|
|
etag = second.headers.get("etag")
|
|
check(prefix + ".validator", bool(etag), etag=etag)
|
|
ranged = await session.request("GET", path, {"range": "bytes=11-40", "if-range": etag or ""})
|
|
check(prefix + ".range", ranged.status == 206 and ranged.body == expected[11:41]
|
|
and ranged.headers.get("content-range") == f"bytes 11-40/{len(expected)}",
|
|
**response_details(ranged))
|
|
mismatch = await session.request("GET", path, {"range": "bytes=11-40", "if-range": '"different"'})
|
|
check(prefix + ".if_range_mismatch", mismatch.status == 200 and mismatch.body == expected,
|
|
**response_details(mismatch))
|
|
conditional = await session.request("GET", path, {"if-none-match": etag or ""})
|
|
check(prefix + ".conditional_304", conditional.status == 304 and conditional.body == b"",
|
|
**response_details(conditional))
|
|
invalid = await session.request("GET", path, {"range": "bytes=999999-"})
|
|
check(prefix + ".range_416", invalid.status == 416 and invalid.body == b""
|
|
and invalid.headers.get("content-range") == f"bytes */{len(expected)}",
|
|
**response_details(invalid))
|
|
for name in ["large.bin", "huge.bin"]:
|
|
expected_file = (fixture.site / name).read_bytes()
|
|
response = await session.request("GET", "/" + name)
|
|
check(prefix + ".get." + name, response.status == 200 and response.body == expected_file,
|
|
**response_details(response))
|
|
parallel = await asyncio.gather(*(session.request("GET", "/large.bin") for _ in range(4)))
|
|
expected_large = (fixture.site / "large.bin").read_bytes()
|
|
check(prefix + ".multiplex_four_streams", all(r.status == 200 and r.body == expected_large for r in parallel)
|
|
and len({r.stream_id for r in parallel}) == 4,
|
|
stream_ids=[r.stream_id for r in parallel], response_bytes=[len(r.body) for r in parallel])
|
|
if source == "reverse":
|
|
for payload in [b"", (bytes(range(256)) * 512) + b"streamed-end"]:
|
|
response = await session.request("POST", "/echo?transport=preserved",
|
|
{"content-type": "application/octet-stream"}, payload)
|
|
data = json.loads(response.body) if response.status == 200 else {}
|
|
check(prefix + f".post_stream_{len(payload)}", response.status == 200
|
|
and data.get("method") == "POST" and data.get("body_bytes") == len(payload)
|
|
and data.get("body_sha256") == hashlib.sha256(payload).hexdigest()
|
|
and data.get("path") == "/echo?transport=preserved",
|
|
upload_bytes=len(payload), **response_details(response))
|
|
else:
|
|
response = await session.request("POST", path, body=b"static-method-gate")
|
|
check(prefix + ".post_static_method_gate", response.status == 405, **response_details(response))
|
|
|
|
|
|
async def websocket_cases(check, port, cache):
|
|
import websockets
|
|
|
|
prefix = f"h1.websocket.cache_{'on' if cache else 'off'}"
|
|
async with websockets.connect(f"ws://127.0.0.1:{port}/echo", open_timeout=5,
|
|
close_timeout=3, ping_interval=None, compression=None) as session:
|
|
text = "RustProxy echo: Zażółć gęślą jaźń"
|
|
await session.send(text)
|
|
returned = await asyncio.wait_for(session.recv(), timeout=5)
|
|
check(prefix + ".text_echo", returned == text)
|
|
binary = bytes(range(256)) * 256
|
|
await session.send(binary)
|
|
returned = await asyncio.wait_for(session.recv(), timeout=5)
|
|
check(prefix + ".binary_echo", returned == binary, bytes=len(returned))
|
|
pong = await session.ping(b"rustproxy-transport")
|
|
await asyncio.wait_for(pong, timeout=5)
|
|
check(prefix + ".ping_pong", True)
|
|
await session.close(code=1000, reason="fixture complete")
|
|
check(prefix + ".normal_close", session.close_code == 1000, close_code=session.close_code)
|
|
|
|
|
|
async def run_variant(binary, label, output, workers):
|
|
import websockets
|
|
from transport_clients import client
|
|
|
|
cases = []
|
|
started = time.monotonic()
|
|
|
|
def check(case, passed, **details):
|
|
cases.append({"case": case, "passed": bool(passed), **details})
|
|
print(f"{label} {'PASS' if passed else 'FAIL'} {case}", flush=True)
|
|
write_json(output / "checks.json", cases)
|
|
|
|
try:
|
|
with Fixture(output, workers=workers) as fixture:
|
|
plain = (fixture.site / "small.html").read_bytes()
|
|
for name in ["transport-h2.html", "transport-h3.html", "transport-shared.html"]:
|
|
(fixture.site / name).write_bytes(plain)
|
|
peer = Peer(fixture.origin_port, fixture.site)
|
|
websocket_origin, websocket_port = fixture.port(), fixture.port()
|
|
|
|
async def echo(session, _path=None):
|
|
async for message in session:
|
|
await session.send(message)
|
|
|
|
try:
|
|
async with websockets.serve(echo, "127.0.0.1", websocket_origin,
|
|
ping_interval=None, compression=None, close_timeout=3):
|
|
rust = fixture.rust(binary, label)
|
|
reverse_tls, static_tls = tls_port(fixture), tls_port(fixture)
|
|
config = transport_config(rust, reverse_tls, static_tls, websocket_port, websocket_origin)
|
|
rust.apply(config, "transport-listeners")
|
|
fixture.stop(rust.process)
|
|
rust.start("transport")
|
|
certificate = rust.data / "certs" / "default.pem"
|
|
write_json(output / "transport.json", {
|
|
"certificate_sha256": sha256(certificate), "certificate_verification": True,
|
|
"h2_alpn_required": "h2", "h3_alpn_required": "h3",
|
|
"reverse_tls_tcp_udp": reverse_tls, "static_tls_tcp_udp": static_tls,
|
|
"websocket_h1_tcp": websocket_port, "fallback_allowed": False})
|
|
for cache in [False, True]:
|
|
if cache:
|
|
config["cache"]["enabled"] = True
|
|
rust.apply(config, "transport-cache-on")
|
|
for protocol in ["h2", "h3"]:
|
|
for source, port in [("reverse", reverse_tls), ("static", static_tls)]:
|
|
group = f"{protocol}.{source}.cache_{'on' if cache else 'off'}"
|
|
try:
|
|
async with client(protocol, port, certificate) as session:
|
|
await http_cases(check, session, protocol, source, cache, fixture)
|
|
except Exception as exc:
|
|
check(group + ".runner", False, error=str(exc), traceback=traceback.format_exc())
|
|
try:
|
|
await websocket_cases(check, websocket_port, cache)
|
|
except Exception as exc:
|
|
check(f"h1.websocket.cache_{'on' if cache else 'off'}.runner", False,
|
|
error=str(exc), traceback=traceback.format_exc())
|
|
for source, port in [("reverse", reverse_tls), ("static", static_tls)]:
|
|
try:
|
|
async with client("h2", port, certificate) as h2:
|
|
warm = await h2.request("GET", "/transport-shared.html")
|
|
async with client("h3", port, certificate) as h3:
|
|
hit = await h3.request("GET", "/transport-shared.html")
|
|
check(f"cache_cross_protocol.{source}.h2_to_h3",
|
|
warm.status == 200 and hit.status == 200 and hit.body == plain
|
|
and hit.headers.get("x-cache") == "HIT",
|
|
first_cache=warm.headers.get("x-cache"), **response_details(hit))
|
|
except Exception as exc:
|
|
check(f"cache_cross_protocol.{source}.runner", False,
|
|
error=str(exc), traceback=traceback.format_exc())
|
|
check("runtime.still_alive", rust.process.poll() is None)
|
|
finally:
|
|
peer.close()
|
|
except Exception as exc:
|
|
check("runtime.runner", False, error=str(exc), traceback=traceback.format_exc())
|
|
report = {"label": label, "simulation": False, "seconds": time.monotonic() - started,
|
|
"passed": sum(case["passed"] for case in cases),
|
|
"failed": sum(not case["passed"] for case in cases), "cases": cases}
|
|
write_json(output / "report.json", report)
|
|
print(f"{label}: {report['passed']} passed, {report['failed']} failed", flush=True)
|
|
return report
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--binary", action="append", required=True, metavar="LABEL=/ABS/PATH")
|
|
parser.add_argument("--out", type=Path, required=True)
|
|
parser.add_argument("--workers", type=int, default=4)
|
|
parser.add_argument("--python-deps", type=Path)
|
|
args = parser.parse_args()
|
|
if args.python_deps:
|
|
sys.path.insert(0, str(args.python_deps.resolve()))
|
|
variants = {}
|
|
for value in args.binary:
|
|
label, separator, path = value.partition("=")
|
|
if not separator or not label.replace("-", "").replace("_", "").isalnum() or label in variants:
|
|
parser.error("--binary must be unique SIMPLE_LABEL=/ABS/PATH")
|
|
variants[label] = Path(path).resolve()
|
|
args.out = args.out.resolve()
|
|
args.out.mkdir(parents=True, exist_ok=True)
|
|
from transport_clients import validate_head_adapter
|
|
write_json(args.out / "environment.json", {
|
|
"kind": "real RustProxy transport acceptance", "simulation": False,
|
|
"python": sys.version, "platform": platform.platform(),
|
|
"dependencies": {name: importlib.metadata.version(name) for name in ["aioquic", "httpx", "h2", "websockets"]},
|
|
"aioquic_head_adapter": validate_head_adapter(),
|
|
"started_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
"not_covered": ["production certificates", "TLS upstreams", "H3 upstreams", "H2/H3 WebSocket extended CONNECT",
|
|
"network loss or migration", "24-hour soak", "real-browser navigation"]})
|
|
reports = {}
|
|
for label, binary in variants.items():
|
|
reports[label] = asyncio.run(run_variant(binary, label, args.out / label, args.workers))
|
|
write_json(args.out / "comparison.json", {
|
|
"variants": {label: {key: report[key] for key in ["passed", "failed", "seconds"]}
|
|
for label, report in reports.items()},
|
|
"cases": [{"case": case, **{label: next((row["passed"] for row in report["cases"] if row["case"] == case), "not_run")
|
|
for label, report in reports.items()}}
|
|
for case in sorted({row["case"] for report in reports.values() for row in report["cases"]})]})
|
|
seal_results(args.out)
|
|
return 1 if any(report["failed"] for report in reports.values()) else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|