400 lines
15 KiB
Python
Executable File
400 lines
15 KiB
Python
Executable File
#!/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()
|