Files
xserial/tools/stress_test.py
2026-08-08 21:24:44 +08:00

400 lines
15 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
pipeview-tauri 极限压力测试脚本
模拟各种高数据量场景,测试 Tauri 前端的渲染性能和稳定性:
- 文本洪水:大文本行 + ANSI 颜色
- 十六进制洪水:大批量 hex dump
- 波形洪水:高频 plot 采样点
- 混合模式:同时发送文本 + 波形MixedTextPlot
- 突发模式:间歇性峰值流量
用法:
python tools/stress_test.py --mode text --rate 5000 --port 8091
python tools/stress_test.py --mode plot --rate 200 --channels 8 --port 8092
python tools/stress_test.py --mode mixed --rate 1000 --port 8093
python tools/stress_test.py --mode burst --rate 10000 --burst-size 500 --port 8094
"""
import argparse
import math
import random
import socket
import struct
import sys
import threading
import time
from collections import defaultdict
PLOT_ESCAPE = 0x1E
PLOT_MARKER = ord("P")
DISCONNECT_WINERRORS = {
10053, # Software caused connection abort
10054, # Connection reset by peer
10058, # Socket shutdown race on Windows
}
# ── ANSI Color Palette ─────────────────────────────────────────────
ANSI_COLORS = [
"\033[31m", "\033[32m", "\033[33m", "\033[34m", "\033[35m", "\033[36m",
"\033[91m", "\033[92m", "\033[93m", "\033[94m", "\033[95m", "\033[96m",
"\033[1;31m", "\033[1;32m", "\033[1;33m", "\033[1;34m",
]
ANSI_RESET = "\033[0m"
# ── Lorem ipsum lines for text stress testing ──────────────────────
LOREM_LINES = [
"[INFO] Sensor data received: temperature={temp:.2f}°C humidity={hum:.1f}% pressure={pres:.1f}hPa",
"[DEBUG] Frame #{n:06d} decoded pipeline={pipe} latency={lat:.3f}ms",
"[WARN] Buffer utilization {pct:.1f}% — threshold approaching",
"[ERROR] Checksum mismatch at offset {off:#x} expected={exp:#x} got={got:#x}",
"[TRACE] I2C transaction: addr={addr:#x} reg={reg:#x} val={val:#x}",
"[METRIC] throughput={tput:.1f} lines/s memory={mem:.1f}MB active_sessions={sess}",
"[EVENT] Session {sid} state changed: {old} -> {new}",
"[DATA] {ts} | CH{ch:02d} | {v0:+08.4f} | {v1:+08.4f} | {v2:+08.4f}",
]
# ── Text stress generator ───────────────────────────────────────────
def generate_text_line(seq: int, ansi: bool = False, payload_size: int = 80) -> bytes:
"""Generate a single text line with optional ANSI colors."""
template = random.choice(LOREM_LINES)
line = template.format(
temp=20.0 + 10 * math.sin(seq * 0.1),
hum=45.0 + 20 * math.cos(seq * 0.07),
pres=1013.0 + random.uniform(-5, 5),
n=seq, pipe=f"pipe_{seq % 4}", lat=random.uniform(0.1, 5.0),
pct=random.uniform(10, 95), off=seq * 16,
exp=random.randint(0, 255), got=random.randint(0, 255),
addr=0x40 + (seq % 8), reg=0x00 + (seq % 16), val=random.randint(0, 65535),
tput=random.uniform(100, 10000), mem=random.uniform(50, 500), sess=random.randint(1, 8),
sid=seq % 8 + 1, old="disconnected", new="connected",
ts=time.strftime("%H:%M:%S", time.localtime()),
ch=seq % 8, v0=random.uniform(-10, 10), v1=random.uniform(-10, 10), v2=random.uniform(-10, 10),
)
# Pad or trim to target payload size
if len(line) < payload_size:
line += " " + "x" * (payload_size - len(line) - 1)
elif len(line) > payload_size:
line = line[:payload_size]
if ansi:
color = random.choice(ANSI_COLORS)
line = f"{color}{line}{ANSI_RESET}"
return (line + "\n").encode("utf-8")
def generate_hex_line(seq: int, bytes_per_line: int = 32) -> bytes:
"""Generate a hex dump line (as text, mimicking hex view data)."""
data = bytes([(seq * bytes_per_line + i) % 256 for i in range(bytes_per_line)])
hex_part = " ".join(f"{b:02X}" for b in data)
ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in data)
return f"[{seq:06d}] {hex_part} |{ascii_part}|\n".encode("utf-8")
# ── Plot stress generator ───────────────────────────────────────────
def build_plot_frame(
sample_index: int,
channels: int,
plot_format: str,
samples_per_channel: int,
amplitude: float,
frequency_hz: float,
sample_rate_hz: float,
) -> bytes:
"""Build a raw COBS-encoded plot frame (matching test_plot.py format)."""
values = []
if plot_format == "xy":
for _ in range(samples_per_channel):
t = (sample_index * samples_per_channel + len(values)) / sample_rate_hz
values.append(amplitude * math.sin(2 * math.pi * frequency_hz * t))
values.append(amplitude * math.cos(2 * math.pi * frequency_hz * t))
elif plot_format == "block":
for ch in range(channels):
phase = 2 * math.pi * ch / channels
for _ in range(samples_per_channel):
t = (sample_index * samples_per_channel + len(values) // channels) / sample_rate_hz
values.append(amplitude * math.sin(2 * math.pi * frequency_hz * t + phase))
else: # interleaved
for _ in range(samples_per_channel):
t = (sample_index * samples_per_channel + len(values) // channels) / sample_rate_hz
for ch in range(channels):
phase = 2 * math.pi * ch / channels
values.append(amplitude * math.sin(2 * math.pi * frequency_hz * t + phase))
# Pack as f32 little-endian
payload = struct.pack(f"<{len(values)}f", *values)
return cobs_encode(payload)
def cobs_encode(data: bytes) -> bytes:
"""Consistent Overhead Byte Stuffing encoder."""
result = bytearray()
block_start = 0
while block_start < len(data):
end = min(block_start + 254, len(data))
block = data[block_start:end]
if end < len(data):
result.append(len(block) + 1)
result.extend(block)
else:
result.append(len(block) + 1 if len(block) < 254 else 255)
result.extend(block)
result.append(0)
block_start = end
return bytes(result)
def build_mixed_frame(seq: int, text_rate_per_plot: int, channels: int) -> bytes:
"""Build a MixedTextPlot frame: text lines + one COBS plot frame."""
frames = []
for i in range(text_rate_per_plot):
frames.append(generate_text_line(seq * text_rate_per_plot + i, ansi=True))
# Add one plot frame per text_rate_per_plot text lines
plot_data = build_plot_frame(seq, channels, "interleaved", 32, 100.0, 10.0, 1000.0)
frames.append(bytes([PLOT_ESCAPE, PLOT_MARKER]) + plot_data)
return b"".join(frames)
# ── TCP Server ──────────────────────────────────────────────────────
class StressServer:
def __init__(self, host: str, port: int, mode: str, rate: float,
payload_size: int, channels: int, burst_size: int,
duration: float, ansi: bool):
self.host = host
self.port = port
self.mode = mode
self.rate = rate # lines or frames per second
self.payload_size = payload_size
self.channels = channels
self.burst_size = burst_size
self.duration = duration
self.ansi = ansi
self.stats = defaultdict(int)
self.running = False
self.clients = []
def start(self):
self.running = True
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((self.host, self.port))
sock.listen(5)
sock.settimeout(1.0)
print(f"[STRESS] {self.mode.upper()} mode | {self.rate} lines/s")
print(f"[STRESS] Listening on {self.host}:{self.port}")
if self.duration > 0:
print(f"[STRESS] Duration: {self.duration}s")
print(f"[STRESS] Payload: {self.payload_size}B | Channels: {self.channels}")
if self.mode == "burst":
print(f"[STRESS] Burst size: {self.burst_size} lines")
print()
stats_thread = threading.Thread(target=self._print_stats, daemon=True)
stats_thread.start()
accept_thread = threading.Thread(target=self._accept_loop, args=(sock,), daemon=True)
accept_thread.start()
try:
while self.running:
time.sleep(0.1)
except KeyboardInterrupt:
print("\n[STRESS] Shutting down...")
finally:
self.running = False
sock.close()
def _accept_loop(self, sock):
while self.running:
try:
conn, addr = sock.accept()
print(f"[STRESS] Client connected: {addr[0]}:{addr[1]}")
t = threading.Thread(target=self._client_loop, args=(conn, addr), daemon=True)
t.start()
self.clients.append(t)
except socket.timeout:
continue
except OSError:
break
def _client_loop(self, conn: socket.socket, addr):
seq = 0
start_time = time.time()
last_count_time = start_time
local_count = 0
# Pre-compute interval
if self.mode == "burst":
interval = 0
burst_interval = max(0.016, self.burst_size / max(self.rate, 1))
else:
interval = 1.0 / max(self.rate, 1) if self.rate > 0 else 0.016
try:
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
while self.running:
elapsed = time.time() - start_time
if self.duration > 0 and elapsed >= self.duration:
break
if self.mode == "burst":
self._send_burst(conn, seq)
seq += self.burst_size
local_count += self.burst_size
time.sleep(burst_interval)
else:
data = self._generate(seq)
conn.sendall(data)
seq += 1
local_count += 1
if interval > 0:
# Spin-wait for precise timing at high rates
target = start_time + (seq / self.rate)
sleep_time = target - time.time()
if sleep_time > 0:
time.sleep(min(sleep_time, interval))
# Periodic stats update
if time.time() - last_count_time >= 1.0:
with threading.Lock():
self.stats["lines"] += local_count
self.stats["bytes"] += local_count * self.payload_size # approximate
local_count = 0
last_count_time = time.time()
except OSError as e:
if hasattr(e, "winerror") and e.winerror in DISCONNECT_WINERRORS:
pass
elif not self.running:
pass
else:
self.stats["errors"] += 1
finally:
try:
conn.close()
except OSError:
pass
print(f"[STRESS] Client disconnected: {addr[0]}:{addr[1]}")
def _send_burst(self, conn, start_seq):
"""Send burst_size lines as fast as possible."""
data = b"".join(self._generate(start_seq + i) for i in range(self.burst_size))
conn.sendall(data)
def _generate(self, seq: int) -> bytes:
if self.mode == "text":
return generate_text_line(seq, ansi=self.ansi, payload_size=self.payload_size)
elif self.mode == "hex":
return generate_hex_line(seq, bytes_per_line=max(4, self.payload_size // 3))
elif self.mode == "plot":
return build_plot_frame(
seq, self.channels, "interleaved",
samples_per_channel=32, amplitude=100.0,
frequency_hz=10.0, sample_rate_hz=1000.0,
)
elif self.mode == "mixed":
return build_mixed_frame(seq, text_rate_per_plot=5, channels=self.channels)
else:
return generate_text_line(seq, ansi=True, payload_size=self.payload_size)
def _print_stats(self):
while self.running:
time.sleep(2.0)
with threading.Lock():
lines = self.stats["lines"]
errors = self.stats["errors"]
b = self.stats["bytes"]
if lines > 0:
kb_s = b / 2048 # KB/s over 2 seconds
print(f"[STATS] {lines:>8d} lines | {kb_s:>8.1f} KB/s | {errors:>4d} errors")
self.stats.clear()
# ── CLI ─────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="pipeview-tauri 极限压力测试",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
# 文本洪水5000 行/秒1KB 行宽ANSI 彩色
python tools/stress_test.py --mode text --rate 5000 --payload 1024 --ansi
# 波形洪水200 帧/秒8 通道
python tools/stress_test.py --mode plot --rate 200 --channels 8 --port 8092
# 突发模式:每秒爆发一次 2000 行峰值
python tools/stress_test.py --mode burst --rate 10000 --burst-size 2000
# 混合模式:文本 + 波形MixedTextPlot 帧)
python tools/stress_test.py --mode mixed --rate 1000 --channels 4
# 压测 60 秒后自动停止
python tools/stress_test.py --mode text --rate 10000 --duration 60
""",
)
parser.add_argument("--host", default="127.0.0.1", help="绑定地址 (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=8091, help="端口 (default: 8091)")
parser.add_argument(
"--mode", choices=["text", "hex", "plot", "mixed", "burst"],
default="text", help="测试模式 (default: text)"
)
parser.add_argument(
"--rate", type=float, default=1000,
help="目标速率 (lines/s 或 frames/s, default: 1000)"
)
parser.add_argument(
"--payload", type=int, default=256,
help="文本行/hex 行目标字节数 (default: 256)"
)
parser.add_argument(
"--channels", type=int, default=4,
help="Plot 通道数 (default: 4)"
)
parser.add_argument(
"--burst-size", type=int, default=500,
help="突发模式每次发送的行数 (default: 500)"
)
parser.add_argument(
"--duration", type=float, default=0,
help="运行时长0 表示无限制 (default: 0)"
)
parser.add_argument(
"--ansi", action="store_true",
help="文本模式启用 ANSI 颜色"
)
args = parser.parse_args()
if args.mode == "plot" and args.rate > 500:
print("[WARN] Plot rate > 500 may overwhelm the renderer. Consider --rate 100-200.")
print()
server = StressServer(
host=args.host,
port=args.port,
mode=args.mode,
rate=args.rate,
payload_size=args.payload,
channels=args.channels,
burst_size=args.burst_size,
duration=args.duration,
ansi=args.ansi,
)
server.start()
if __name__ == "__main__":
main()