139 lines
6.4 KiB
Python
Executable File
139 lines
6.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Finite live HTTP concurrency correctness stress; no throughput/latency claim."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import threading
|
|
import time
|
|
import traceback
|
|
|
|
from common import Fixture, http, seal_results, sha256, write_json
|
|
from resources import process_stats
|
|
|
|
|
|
PAYLOADS = ["small.html", "large.bin", "huge.bin"]
|
|
|
|
|
|
def active_backends(rust):
|
|
status, _, body = rust.admin("/api/status")
|
|
if status != 200:
|
|
return {"error": f"HTTP {status}"}
|
|
values = []
|
|
|
|
def walk(value):
|
|
if isinstance(value, dict):
|
|
if "id" in value and "active" in value and "status" in value and "requests" in value:
|
|
values.append({key: value[key] for key in ("id", "active", "status", "requests")})
|
|
for child in value.values():
|
|
walk(child)
|
|
elif isinstance(value, list):
|
|
for child in value:
|
|
walk(child)
|
|
walk(json.loads(body))
|
|
return values
|
|
|
|
|
|
def batch(rust, port, name, expected, concurrency):
|
|
count = max(32, concurrency * 2)
|
|
expected_sha = hashlib.sha256(expected).hexdigest()
|
|
ready = threading.Barrier(concurrency, timeout=15)
|
|
lock = threading.Lock()
|
|
running = peak = 0
|
|
|
|
def fetch(index):
|
|
nonlocal running, peak
|
|
with lock:
|
|
running += 1
|
|
peak = max(peak, running)
|
|
try:
|
|
if index < concurrency:
|
|
ready.wait()
|
|
status, headers, body = http(port, "/" + name, timeout=15)
|
|
digest = hashlib.sha256(body).hexdigest()
|
|
return {"index": index, "status": status, "bytes": len(body), "sha256": digest,
|
|
"passed": status == 200 and len(body) == len(expected) and digest == expected_sha}
|
|
except Exception as error:
|
|
return {"index": index, "passed": False, "error": str(error)}
|
|
finally:
|
|
with lock:
|
|
running -= 1
|
|
|
|
before = process_stats(rust.process.pid)
|
|
started = time.monotonic()
|
|
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
|
responses = list(executor.map(fetch, range(count)))
|
|
elapsed = time.monotonic() - started
|
|
# Completion means every client consumed or failed its full response. Give
|
|
# asynchronous server bookkeeping a bounded interval to release its leases.
|
|
for _ in range(40):
|
|
active = active_backends(rust)
|
|
if isinstance(active, list) and active and all(row["active"] == 0 for row in active):
|
|
break
|
|
time.sleep(.05)
|
|
after = process_stats(rust.process.pid)
|
|
return {"configured_concurrent_clients": concurrency, "peak_running_client_tasks": peak,
|
|
"client_tasks_include_barrier": True, "requests": count, "elapsed_seconds": elapsed,
|
|
"expected_bytes": len(expected), "expected_sha256": expected_sha,
|
|
"response_failures": sum(not response["passed"] for response in responses),
|
|
"passed": all(response["passed"] for response in responses)
|
|
and isinstance(active, list) and bool(active) and all(row["active"] == 0 for row in active),
|
|
"responses": responses, "active_backends_after_drain": active,
|
|
"server_before": before, "server_after_drain": after}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--binary", action="append", required=True, metavar="LABEL=/ABS/PATH")
|
|
parser.add_argument("--nginx", type=Path, required=True)
|
|
parser.add_argument("--out", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
if args.out.exists() and any(args.out.iterdir()):
|
|
parser.error("--out must be new or empty")
|
|
reports = {}
|
|
for specification in args.binary:
|
|
label, binary = specification.split("=", 1)
|
|
cases, error = [], None
|
|
try:
|
|
with Fixture(args.out / label, workers=4, origin_workers=2) as fixture:
|
|
fixture.nginx(args.nginx, frontend=False)
|
|
rust = fixture.rust(Path(binary), label)
|
|
rust.login()
|
|
for source, port in [("proxy", rust.reverse_port), ("static", rust.static_port)]:
|
|
for name in PAYLOADS:
|
|
expected = (fixture.site / name).read_bytes()
|
|
for concurrency in [1, 16, 128]:
|
|
result = batch(rust, port, name, expected, concurrency)
|
|
case = {"case": f"concurrency.{source}.{name}.c{concurrency}", **result}
|
|
cases.append(case)
|
|
write_json(args.out / label / "cases" / f"{source}-{name}-c{concurrency}.json", case)
|
|
print(f"{label} {'PASS' if case['passed'] else 'FAIL'} {case['case']}", flush=True)
|
|
if rust.process.poll() is not None:
|
|
raise RuntimeError("RustProxy exited during concurrency regression")
|
|
except Exception as exc:
|
|
error = {"error": str(exc), "traceback": traceback.format_exc()}
|
|
write_json(args.out / label / "failure.json", error)
|
|
report = {"kind": "live HTTP/1.1 finite concurrency functional stress", "simulation": False,
|
|
"binary_sha256": sha256(Path(binary)), "throughput_measurement": False,
|
|
"passed_cases": sum(case["passed"] for case in cases),
|
|
"failed_cases": sum(not case["passed"] for case in cases),
|
|
"requests": sum(case["requests"] for case in cases),
|
|
"response_failures": sum(case["response_failures"] for case in cases),
|
|
"completed": error is None and len(cases) == 18,
|
|
"cases": [{key: case[key] for key in ("case", "passed", "requests", "response_failures")} for case in cases],
|
|
"limitations": ["configured client concurrency is not proof of simultaneous active server exchanges",
|
|
"Python client is not a throughput or latency benchmark",
|
|
"FD/RSS are before/after snapshots, not peaks or a long-term leak test"]}
|
|
reports[label] = report
|
|
write_json(args.out / label / "report.json", report)
|
|
write_json(args.out / "comparison.json", reports)
|
|
seal_results(args.out)
|
|
return 0 if all(report["completed"] and not report["failed_cases"] for report in reports.values()) else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|