From f9be74257a7bfe5de21db91a2f17d69053be8a48 Mon Sep 17 00:00:00 2001 From: FallenSigh Date: Sat, 8 Aug 2026 21:24:44 +0800 Subject: [PATCH] add stress test script --- tools/stress_test.py | 399 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100755 tools/stress_test.py diff --git a/tools/stress_test.py b/tools/stress_test.py new file mode 100755 index 0000000..7267589 --- /dev/null +++ b/tools/stress_test.py @@ -0,0 +1,399 @@ +#!/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()