mirror of
https://github.com/Av0idD4rk/packmate.git
synced 2026-10-23 14:31:01 +00:00
162 lines
6.3 KiB
Python
162 lines
6.3 KiB
Python
import argparse
|
|
import asyncio
|
|
import json
|
|
import math
|
|
import os
|
|
import socket
|
|
import struct
|
|
import time
|
|
from pathlib import Path
|
|
|
|
PAYLOAD_MARKER = b"PMSTRESS"
|
|
|
|
|
|
def build_payload(run_id: int, stream_number: int, shard: int, sequence: int, payload_bytes: int) -> bytes:
|
|
prefix = struct.pack("!QQII", run_id, stream_number, shard, sequence)
|
|
padding = bytearray(
|
|
((stream_number + sequence + i) % 251 for i in range(payload_bytes - len(prefix)))
|
|
)
|
|
if sequence == 0:
|
|
padding[:len(PAYLOAD_MARKER)] = PAYLOAD_MARKER
|
|
return prefix + padding
|
|
|
|
|
|
def percentile(values: list[float], fraction: float) -> float:
|
|
if not values:
|
|
return math.inf
|
|
values.sort()
|
|
return values[max(0, math.ceil(len(values) * fraction) - 1)]
|
|
|
|
|
|
async def send_stream(
|
|
stream_number: int,
|
|
host: str,
|
|
port: int,
|
|
frames: int,
|
|
payload_bytes: int,
|
|
run_id: int,
|
|
shard: int,
|
|
) -> float:
|
|
started = time.perf_counter()
|
|
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=10)
|
|
sock = writer.get_extra_info("socket")
|
|
if sock is not None:
|
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
|
|
try:
|
|
for sequence in range(frames):
|
|
payload = build_payload(run_id, stream_number, shard, sequence, payload_bytes)
|
|
writer.write(struct.pack("!I", len(payload)) + payload)
|
|
await writer.drain()
|
|
response_size = struct.unpack("!I", await asyncio.wait_for(reader.readexactly(4), 10))[0]
|
|
response = await asyncio.wait_for(reader.readexactly(response_size), 10)
|
|
if response_size != len(payload) or response != payload:
|
|
raise ValueError(
|
|
f"invalid echo for sequence={sequence}: expected={len(payload)} actual={response_size}"
|
|
)
|
|
finally:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
return (time.perf_counter() - started) * 1000
|
|
|
|
|
|
async def run(args: argparse.Namespace) -> dict:
|
|
next_stream = 0
|
|
counter_lock = asyncio.Lock()
|
|
latencies: list[float] = []
|
|
errors: list[str] = []
|
|
abort_file = Path(args.abort_file) if args.abort_file else None
|
|
|
|
async def worker() -> None:
|
|
nonlocal next_stream
|
|
while True:
|
|
async with counter_lock:
|
|
if abort_file and abort_file.exists():
|
|
return
|
|
if next_stream >= args.streams:
|
|
return
|
|
local_number = next_stream
|
|
next_stream += 1
|
|
if args.minimum_duration_seconds > 0:
|
|
scheduled_at = started + (local_number * args.minimum_duration_seconds / args.streams)
|
|
delay = scheduled_at - time.perf_counter()
|
|
if delay > 0:
|
|
await asyncio.sleep(delay)
|
|
stream_number = args.stream_offset + local_number
|
|
try:
|
|
latency = await send_stream(
|
|
stream_number,
|
|
args.host,
|
|
args.port,
|
|
args.frames,
|
|
args.payload_bytes,
|
|
args.run_id,
|
|
args.shard,
|
|
)
|
|
latencies.append(latency)
|
|
except Exception as exc: # individual failures are reported and bounded by the success threshold
|
|
if len(errors) < 100:
|
|
errors.append(f"stream={stream_number}: {type(exc).__name__}: {exc}")
|
|
|
|
started_at_unix_seconds = time.time()
|
|
started = time.perf_counter()
|
|
await asyncio.gather(*(worker() for _ in range(args.concurrency)))
|
|
elapsed = time.perf_counter() - started
|
|
finished_at_unix_seconds = time.time()
|
|
successes = len(latencies)
|
|
result = {
|
|
"runId": args.run_id,
|
|
"shard": args.shard,
|
|
"requestedStreams": args.streams,
|
|
"successfulStreams": successes,
|
|
"failedStreams": args.streams - successes,
|
|
"framesPerStream": args.frames,
|
|
"payloadBytes": args.payload_bytes,
|
|
"minimumDurationSeconds": args.minimum_duration_seconds,
|
|
"startedAtUnixSeconds": round(started_at_unix_seconds, 6),
|
|
"finishedAtUnixSeconds": round(finished_at_unix_seconds, 6),
|
|
"elapsedSeconds": round(elapsed, 3),
|
|
"streamsPerSecond": round(successes / elapsed, 3) if elapsed else 0,
|
|
"latencyP50Millis": round(percentile(latencies, 0.50), 3) if latencies else None,
|
|
"latencyP95Millis": round(percentile(latencies, 0.95), 3) if latencies else None,
|
|
"latencyP99Millis": round(percentile(latencies, 0.99), 3) if latencies else None,
|
|
"latencyMaxMillis": round(max(latencies, default=0), 3),
|
|
"abortedByCaptureMonitor": bool(abort_file and abort_file.exists()),
|
|
"sampleErrors": errors,
|
|
}
|
|
Path(args.report).write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
|
print(json.dumps(result), flush=True)
|
|
return result
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Generate real framed TCP traffic for Packmate")
|
|
parser.add_argument("--host", default=os.getenv("STRESS_TARGET_HOST", "172.31.250.10"))
|
|
parser.add_argument("--port", type=int, default=int(os.getenv("STRESS_TARGET_PORT", "18080")))
|
|
parser.add_argument("--streams", type=int, required=True)
|
|
parser.add_argument("--frames", type=int, default=8)
|
|
parser.add_argument("--payload-bytes", type=int, default=1024)
|
|
parser.add_argument("--concurrency", type=int, default=256)
|
|
parser.add_argument("--run-id", type=int, required=True)
|
|
parser.add_argument("--shard", type=int, required=True)
|
|
parser.add_argument("--stream-offset", type=int, required=True)
|
|
parser.add_argument("--report", required=True)
|
|
parser.add_argument("--abort-file")
|
|
parser.add_argument("--minimum-duration-seconds", type=float, default=0)
|
|
args = parser.parse_args()
|
|
if (
|
|
args.streams <= 0
|
|
or args.frames <= 0
|
|
or args.payload_bytes < 32
|
|
or args.concurrency <= 0
|
|
or args.minimum_duration_seconds < 0
|
|
):
|
|
parser.error("streams, frames and concurrency must be positive; payload must be at least 32 bytes")
|
|
return args
|
|
|
|
|
|
if __name__ == "__main__":
|
|
arguments = parse_args()
|
|
outcome = asyncio.run(run(arguments))
|
|
raise SystemExit(0 if outcome["successfulStreams"] == arguments.streams else 1)
|