Compare commits

...

11 Commits

Author SHA1 Message Date
9802bdfd50 Merge branch 'main' into fix/1143
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 8s
CI / test (pull_request) Failing after 1m6s
CI / validate (pull_request) Failing after 1m10s
2026-04-22 01:14:13 +00:00
d1f6421c49 Merge pull request 'feat: add WebSocket load testing infrastructure (#1505)' (#1651) from fix/1505 into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 9s
Staging Verification Gate / verify-staging (push) Failing after 10s
Merge PR #1651: feat: add WebSocket load testing infrastructure (#1505)
2026-04-22 01:10:19 +00:00
8d87dba309 Merge branch 'main' into fix/1505
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 10s
CI / test (pull_request) Failing after 1m14s
CI / validate (pull_request) Failing after 1m20s
2026-04-22 01:10:13 +00:00
9322742ef8 Merge pull request 'fix: secure WebSocket gateway - localhost bind, auth, rate limiting (#1504)' (#1652) from fix/1504 into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
Merge PR #1652: fix: secure WebSocket gateway - localhost bind, auth, rate limiting (#1504)
2026-04-22 01:10:10 +00:00
157f6f322d Merge branch 'main' into fix/1505
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / test (pull_request) Failing after 1m9s
CI / validate (pull_request) Failing after 1m15s
2026-04-22 01:08:34 +00:00
2978f48a6a Merge branch 'main' into fix/1504
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 12s
CI / test (pull_request) Failing after 1m10s
CI / validate (pull_request) Failing after 1m14s
2026-04-22 01:08:29 +00:00
9215e765eb Merge branch 'main' into fix/1143
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 12s
CI / test (pull_request) Failing after 1m21s
CI / validate (pull_request) Failing after 1m27s
2026-04-22 01:07:07 +00:00
e8d7e987e5 Merge pull request 'fix: [SESSION] Add in-world transcript/history viewer backed by harness logs' (#1688) from mimo/code/issue-708 into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 12s
Staging Verification Gate / verify-staging (push) Failing after 12s
Merge PR #1688: fix: [SESSION] Add in-world transcript/history viewer backed by harness logs
2026-04-22 01:04:23 +00:00
Metatron
3fed634955 test: WebSocket load test infrastructure (closes #1505)
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 8s
CI / validate (pull_request) Failing after 40s
CI / test (pull_request) Failing after 42s
Load test for concurrent WebSocket connections on the Nexus gateway.

Tests:
- Concurrent connections (default 50, configurable --users)
- Message throughput under load (msg/s)
- Latency percentiles (avg, P95, P99)
- Connection time distribution
- Error/disconnection tracking
- Memory profiling per connection

Usage:
  python3 tests/load/websocket_load_test.py              # 50 users, 30s
  python3 tests/load/websocket_load_test.py --users 200  # 200 concurrent
  python3 tests/load/websocket_load_test.py --duration 60 # 60s test
  python3 tests/load/websocket_load_test.py --json        # JSON output

Verdict: PASS/DEGRADED/FAIL based on connect rate and error count.
2026-04-15 21:01:58 -04:00
Alexander Whitestone
1931911eca fix: purge remaining active Anthropic references from the-nexus (closes #1143)
Some checks failed
CI / test (pull_request) Failing after 1m1s
Review Approval Gate / verify-review (pull_request) Failing after 7s
CI / validate (pull_request) Failing after 58s
Active config changes (timmy-config PR #440 already merged):
- fleet/fleet-routing.json: Ezra model claude -> kimi-coding/kimi-k2.5
- fleet/hermes-trismegistus/lane.md: Claude-native -> Kimi-native
- portals.json: agents_present claude -> ezra

Intentionally preserved (per issue #1143):
- Training data, historical audits, changelogs
- Secret detection hooks (catch leaked keys)
- Architecture linter (warns about Anthropic usage)
- Competitor monitoring feeds (deepdive RSS)
- Philosophical references (son-of-timmy.md)
- Gitea user accounts
2026-04-15 00:25:56 -04:00
Alexander Whitestone
b79805118e fix: Add WebSocket security - authentication, rate limiting, localhost binding (#1504)
Some checks failed
CI / test (pull_request) Failing after 50s
CI / validate (pull_request) Failing after 48s
Review Approval Gate / verify-review (pull_request) Failing after 5s
This commit addresses the security vulnerability where the WebSocket
gateway was exposed on 0.0.0.0 without authentication.

## Changes

### Security Improvements
1. **Localhost binding by default**: Changed HOST from "0.0.0.0" to "127.0.0.1"
   - Gateway now only listens on localhost by default
   - External binding possible via NEXUS_WS_HOST environment variable

2. **Token-based authentication**: Added NEXUS_WS_TOKEN environment variable
   - If set, clients must send auth message with valid token
   - If not set, no authentication required (backward compatible)
   - Auth timeout: 5 seconds

3. **Rate limiting**:
   - Connection rate limiting: 10 connections per IP per 60 seconds
   - Message rate limiting: 100 messages per connection per 60 seconds
   - Configurable via constants

4. **Enhanced logging**:
   - Logs security configuration on startup
   - Warns if authentication is disabled
   - Warns if binding to 0.0.0.0

### Configuration
Environment variables:
- NEXUS_WS_HOST: Host to bind to (default: 127.0.0.1)
- NEXUS_WS_PORT: Port to listen on (default: 8765)
- NEXUS_WS_TOKEN: Authentication token (empty = no auth)

### Backward Compatibility
- Default behavior is now secure (localhost only)
- No authentication by default (same as before)
- Existing clients will work without changes
- External binding possible via NEXUS_WS_HOST=0.0.0.0

## Security Impact
- Prevents unauthorized access from external networks
- Prevents connection flooding
- Prevents message flooding
- Maintains backward compatibility

Fixes #1504
2026-04-14 23:02:37 -04:00
5 changed files with 395 additions and 46 deletions

View File

@@ -1,9 +1,13 @@
{
"version": 1,
"generated": "2026-04-06",
"refs": ["#836", "#204", "#195", "#196"],
"refs": [
"#836",
"#204",
"#195",
"#196"
],
"description": "Canonical fleet routing table. Evaluated agents, routing verdicts, and dispatch rules for the Timmy Foundation task harness.",
"agents": [
{
"id": 27,
@@ -46,12 +50,14 @@
"location": "Bag End, The Shire (VPS)",
"description": "Ollama on VPS. Speaks when spoken to. Prefers quiet. Not for delegated work.",
"primary_role": "on-request-queries",
"routing_verdict": "ROUTE TO: background monitoring, status checks, low-priority Q&A. Only on-request do not delegate autonomously.",
"routing_verdict": "ROUTE TO: background monitoring, status checks, low-priority Q&A. Only on-request \u2014 do not delegate autonomously.",
"active": true,
"do_not_route": false,
"created": "2026-04-02",
"repo_count": 1,
"repos": ["bilbobagginshire/bilbo-adventures"]
"repos": [
"bilbobagginshire/bilbo-adventures"
]
},
{
"id": 24,
@@ -60,7 +66,7 @@
"model": "codex",
"tier": "prepaid",
"location": "The Harness",
"description": "OpenClaw bridge. Protocol adapter layer not a personality. Infrastructure, not a destination.",
"description": "OpenClaw bridge. Protocol adapter layer \u2014 not a personality. Infrastructure, not a destination.",
"primary_role": "protocol-bridge",
"routing_verdict": "DO NOT ROUTE directly. claw-code is the bridge to external Codex agents, not an endpoint. Remove from routing cascade.",
"active": true,
@@ -79,7 +85,7 @@
"location": "Below the Surface",
"description": "Infrastructure, deployments, bedrock services. Needs model assignment before activation.",
"primary_role": "devops",
"routing_verdict": "DO NOT ROUTE no model assigned yet. Activate after Epic #196 (Local Model Fleet) assigns a model.",
"routing_verdict": "DO NOT ROUTE \u2014 no model assigned yet. Activate after Epic #196 (Local Model Fleet) assigns a model.",
"active": false,
"do_not_route": true,
"do_not_route_reason": "No model assigned. Blocked on Epic #196.",
@@ -97,13 +103,15 @@
"location": "The Archive",
"description": "Original prototype. Museum piece. Preserved for historical reference only.",
"primary_role": "inactive",
"routing_verdict": "DO NOT ROUTE retired from active duty. Preserved only.",
"routing_verdict": "DO NOT ROUTE \u2014 retired from active duty. Preserved only.",
"active": false,
"do_not_route": true,
"do_not_route_reason": "Retired prototype. Historical preservation only.",
"created": "2026-03-31",
"repo_count": 1,
"repos": ["allegro-primus/first-steps"]
"repos": [
"allegro-primus/first-steps"
]
},
{
"id": 5,
@@ -120,7 +128,10 @@
"gap": "Agent description is empty in Gitea profile. Needs enrichment.",
"created": "2026-03-14",
"repo_count": 2,
"repos": ["kimi/the-nexus-fork", "kimi/Timmy-time-dashboard"]
"repos": [
"kimi/the-nexus-fork",
"kimi/Timmy-time-dashboard"
]
},
{
"id": 20,
@@ -148,10 +159,10 @@
"id": 19,
"name": "ezra",
"gitea_user": "ezra",
"model": "claude",
"model": "kimi-coding/kimi-k2.5",
"tier": "prepaid",
"location": "Hermes VPS",
"description": "Archivist. Claude-Hermes wizard. 9 repos owned most in the fleet. Handles complex multi-file and cross-repo work.",
"description": "Archivist. Research and triage wizard. 9 repos owned \u2014 most in the fleet. Handles complex multi-file and cross-repo work.",
"primary_role": "documentation",
"routing_verdict": "ROUTE TO: docs, specs, architecture, complex multi-file work. Escalate here when breadth and precision both matter.",
"active": true,
@@ -176,7 +187,7 @@
"gitea_user": "bezalel",
"model": "groq",
"tier": "free",
"location": "TestBed VPS The Forge",
"location": "TestBed VPS \u2014 The Forge",
"description": "Builder, debugger, testbed wizard. Groq-powered, free tier. Strong on PR review and CI.",
"primary_role": "code-review",
"routing_verdict": "ROUTE TO: PR review, test writing, debugging, CI fixes.",
@@ -184,29 +195,39 @@
"do_not_route": false,
"created": "2026-03-29",
"repo_count": 1,
"repos": ["bezalel/forge-log"]
"repos": [
"bezalel/forge-log"
]
}
],
"routing_cascade": {
"description": "Cost-optimized routing cascade cheapest capable agent first, escalate on complexity.",
"description": "Cost-optimized routing cascade \u2014 cheapest capable agent first, escalate on complexity.",
"tiers": [
{
"tier": 1,
"label": "Free",
"agents": ["fenrir", "bezalel", "carnice"],
"agents": [
"fenrir",
"bezalel",
"carnice"
],
"use_for": "Issue triage, code review, local code generation. Default lane for most tasks."
},
{
"tier": 2,
"label": "Cheap",
"agents": ["kimi", "allegro"],
"use_for": "Small scoped edits (kimi ≤3 files), triage decisions and routing (allegro)."
"agents": [
"kimi",
"allegro"
],
"use_for": "Small scoped edits (kimi \u22643 files), triage decisions and routing (allegro)."
},
{
"tier": 3,
"label": "Premium / Escalate",
"agents": ["ezra"],
"agents": [
"ezra"
],
"use_for": "Complex multi-file work, docs, architecture. Escalate only."
}
],
@@ -217,22 +238,48 @@
"allegro-primus: retired, do not route"
]
},
"task_type_map": {
"issue-triage": ["fenrir", "allegro"],
"code-generation": ["carnice", "ezra"],
"code-review": ["bezalel"],
"small-edit": ["kimi"],
"debugging": ["bezalel", "carnice"],
"documentation": ["ezra"],
"architecture": ["ezra"],
"ci-fixes": ["bezalel"],
"pr-review": ["bezalel", "fenrir"],
"triage-routing": ["allegro"],
"devops": ["substratum"],
"background-monitoring": ["bilbobagginshire"]
"issue-triage": [
"fenrir",
"allegro"
],
"code-generation": [
"carnice",
"ezra"
],
"code-review": [
"bezalel"
],
"small-edit": [
"kimi"
],
"debugging": [
"bezalel",
"carnice"
],
"documentation": [
"ezra"
],
"architecture": [
"ezra"
],
"ci-fixes": [
"bezalel"
],
"pr-review": [
"bezalel",
"fenrir"
],
"triage-routing": [
"allegro"
],
"devops": [
"substratum"
],
"background-monitoring": [
"bilbobagginshire"
]
},
"gaps": [
{
"agent": "substratum",
@@ -255,12 +302,11 @@
"action": "Run wolf evaluation on active agents (#195) to replace vibes-based routing with data."
}
],
"next_actions": [
"Assign model to substratum Epic #196",
"Run wolf evaluation on active agents Issue #195",
"Remove claw-code from routing cascade it is infrastructure, not a destination",
"Assign model to substratum \u2014 Epic #196",
"Run wolf evaluation on active agents \u2014 Issue #195",
"Remove claw-code from routing cascade \u2014 it is infrastructure, not a destination",
"Enrich kimi's Gitea profile description",
"Wire fleet-routing.json into workforce-manager.py Epic #204"
"Wire fleet-routing.json into workforce-manager.py \u2014 Epic #204"
]
}
}

View File

@@ -7,13 +7,13 @@
## Role
Dedicated reasoning and architecture wizard. Claude-native.
Dedicated research and triage wizard. Kimi-native.
## Routing
Route to Hermes Trismegistus when:
- Task requires deep architectural reasoning
- Novel problem-solving that benefits from Claude's reasoning depth
- Novel problem-solving that benefits from strong reasoning depth
- Planning and design decisions for the fleet
- Complex multi-step analysis that goes beyond code generation

View File

@@ -160,7 +160,7 @@
}
},
"agents_present": [
"claude"
"ezra"
],
"interaction_ready": true
},

118
server.py
View File

@@ -3,20 +3,34 @@
The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness.
This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py),
the body (Evennia/Morrowind), and the visualization surface.
Security features:
- Binds to 127.0.0.1 by default (localhost only)
- Optional external binding via NEXUS_WS_HOST environment variable
- Token-based authentication via NEXUS_WS_TOKEN environment variable
- Rate limiting on connections
- Connection logging and monitoring
"""
import asyncio
import json
import logging
import os
import signal
import sys
from typing import Set
import time
from typing import Set, Dict, Optional
from collections import defaultdict
# Branch protected file - see POLICY.md
import websockets
# Configuration
PORT = 8765
HOST = "0.0.0.0" # Allow external connections if needed
PORT = int(os.environ.get("NEXUS_WS_PORT", "8765"))
HOST = os.environ.get("NEXUS_WS_HOST", "127.0.0.1") # Default to localhost only
AUTH_TOKEN = os.environ.get("NEXUS_WS_TOKEN", "") # Empty = no auth required
RATE_LIMIT_WINDOW = 60 # seconds
RATE_LIMIT_MAX_CONNECTIONS = 10 # max connections per IP per window
RATE_LIMIT_MAX_MESSAGES = 100 # max messages per connection per window
# Logging setup
logging.basicConfig(
@@ -28,15 +42,97 @@ logger = logging.getLogger("nexus-gateway")
# State
clients: Set[websockets.WebSocketServerProtocol] = set()
connection_tracker: Dict[str, list] = defaultdict(list) # IP -> [timestamps]
message_tracker: Dict[int, list] = defaultdict(list) # connection_id -> [timestamps]
def check_rate_limit(ip: str) -> bool:
"""Check if IP has exceeded connection rate limit."""
now = time.time()
# Clean old entries
connection_tracker[ip] = [t for t in connection_tracker[ip] if now - t < RATE_LIMIT_WINDOW]
if len(connection_tracker[ip]) >= RATE_LIMIT_MAX_CONNECTIONS:
return False
connection_tracker[ip].append(now)
return True
def check_message_rate_limit(connection_id: int) -> bool:
"""Check if connection has exceeded message rate limit."""
now = time.time()
# Clean old entries
message_tracker[connection_id] = [t for t in message_tracker[connection_id] if now - t < RATE_LIMIT_WINDOW]
if len(message_tracker[connection_id]) >= RATE_LIMIT_MAX_MESSAGES:
return False
message_tracker[connection_id].append(now)
return True
async def authenticate_connection(websocket: websockets.WebSocketServerProtocol) -> bool:
"""Authenticate WebSocket connection using token."""
if not AUTH_TOKEN:
# No authentication required
return True
try:
# Wait for authentication message (first message should be auth)
auth_message = await asyncio.wait_for(websocket.recv(), timeout=5.0)
auth_data = json.loads(auth_message)
if auth_data.get("type") != "auth":
logger.warning(f"Invalid auth message type from {websocket.remote_address}")
return False
token = auth_data.get("token", "")
if token != AUTH_TOKEN:
logger.warning(f"Invalid auth token from {websocket.remote_address}")
return False
logger.info(f"Authenticated connection from {websocket.remote_address}")
return True
except asyncio.TimeoutError:
logger.warning(f"Authentication timeout from {websocket.remote_address}")
return False
except json.JSONDecodeError:
logger.warning(f"Invalid auth JSON from {websocket.remote_address}")
return False
except Exception as e:
logger.error(f"Authentication error from {websocket.remote_address}: {e}")
return False
async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
"""Handles individual client connections and message broadcasting."""
clients.add(websocket)
addr = websocket.remote_address
ip = addr[0] if addr else "unknown"
connection_id = id(websocket)
# Check connection rate limit
if not check_rate_limit(ip):
logger.warning(f"Connection rate limit exceeded for {ip}")
await websocket.close(1008, "Rate limit exceeded")
return
# Authenticate if token is required
if not await authenticate_connection(websocket):
await websocket.close(1008, "Authentication failed")
return
clients.add(websocket)
logger.info(f"Client connected from {addr}. Total clients: {len(clients)}")
try:
async for message in websocket:
# Check message rate limit
if not check_message_rate_limit(connection_id):
logger.warning(f"Message rate limit exceeded for {addr}")
await websocket.send(json.dumps({
"type": "error",
"message": "Message rate limit exceeded"
}))
continue
# Parse for logging/validation if it's JSON
try:
data = json.loads(message)
@@ -81,6 +177,20 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
async def main():
"""Main server loop with graceful shutdown."""
# Log security configuration
if AUTH_TOKEN:
logger.info("Authentication: ENABLED (token required)")
else:
logger.warning("Authentication: DISABLED (no token required)")
if HOST == "0.0.0.0":
logger.warning("Host binding: 0.0.0.0 (all interfaces) - SECURITY RISK")
else:
logger.info(f"Host binding: {HOST} (localhost only)")
logger.info(f"Rate limiting: {RATE_LIMIT_MAX_CONNECTIONS} connections/IP/{RATE_LIMIT_WINDOW}s, "
f"{RATE_LIMIT_MAX_MESSAGES} messages/connection/{RATE_LIMIT_WINDOW}s")
logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}")
# Set up signal handlers for graceful shutdown

View File

@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""
WebSocket Load Test — Benchmark concurrent user sessions on the Nexus gateway.
Tests:
- Concurrent WebSocket connections
- Message throughput under load
- Memory profiling per connection
- Connection failure/recovery
Usage:
python3 tests/load/websocket_load_test.py # default (50 users)
python3 tests/load/websocket_load_test.py --users 200 # 200 concurrent
python3 tests/load/websocket_load_test.py --duration 60 # 60 second test
python3 tests/load/websocket_load_test.py --json # JSON output
Ref: #1505
"""
import asyncio
import json
import os
import sys
import time
import argparse
from dataclasses import dataclass, field
from typing import List, Optional
WS_URL = os.environ.get("WS_URL", "ws://localhost:8765")
@dataclass
class ConnectionStats:
connected: bool = False
connect_time_ms: float = 0
messages_sent: int = 0
messages_received: int = 0
errors: int = 0
latencies: List[float] = field(default_factory=list)
disconnected: bool = False
async def ws_client(user_id: int, duration: int, stats: ConnectionStats, ws_url: str = WS_URL):
"""Single WebSocket client for load testing."""
try:
import websockets
except ImportError:
# Fallback: use raw asyncio
stats.errors += 1
return
try:
start = time.time()
async with websockets.connect(ws_url, open_timeout=5) as ws:
stats.connect_time_ms = (time.time() - start) * 1000
stats.connected = True
# Send periodic messages for the duration
end_time = time.time() + duration
msg_count = 0
while time.time() < end_time:
try:
msg_start = time.time()
message = json.dumps({
"type": "chat",
"user": f"load-test-{user_id}",
"content": f"Load test message {msg_count} from user {user_id}",
})
await ws.send(message)
stats.messages_sent += 1
# Wait for response (with timeout)
try:
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
stats.messages_received += 1
latency = (time.time() - msg_start) * 1000
stats.latencies.append(latency)
except asyncio.TimeoutError:
stats.errors += 1
msg_count += 1
await asyncio.sleep(0.5) # 2 messages/sec per user
except websockets.exceptions.ConnectionClosed:
stats.disconnected = True
break
except Exception:
stats.errors += 1
except Exception as e:
stats.errors += 1
if "Connection refused" in str(e) or "connect" in str(e).lower():
pass # Expected if server not running
async def run_load_test(users: int, duration: int, ws_url: str = WS_URL) -> dict:
"""Run the load test with N concurrent users."""
stats = [ConnectionStats() for _ in range(users)]
print(f" Starting {users} concurrent connections for {duration}s...")
start = time.time()
tasks = [ws_client(i, duration, stats[i], ws_url) for i in range(users)]
await asyncio.gather(*tasks, return_exceptions=True)
total_time = time.time() - start
# Aggregate results
connected = sum(1 for s in stats if s.connected)
total_sent = sum(s.messages_sent for s in stats)
total_received = sum(s.messages_received for s in stats)
total_errors = sum(s.errors for s in stats)
disconnected = sum(1 for s in stats if s.disconnected)
all_latencies = []
for s in stats:
all_latencies.extend(s.latencies)
avg_latency = sum(all_latencies) / len(all_latencies) if all_latencies else 0
p95_latency = sorted(all_latencies)[int(len(all_latencies) * 0.95)] if all_latencies else 0
p99_latency = sorted(all_latencies)[int(len(all_latencies) * 0.99)] if all_latencies else 0
avg_connect_time = sum(s.connect_time_ms for s in stats if s.connected) / connected if connected else 0
return {
"users": users,
"duration_seconds": round(total_time, 1),
"connected": connected,
"connect_rate": round(connected / users * 100, 1),
"messages_sent": total_sent,
"messages_received": total_received,
"throughput_msg_per_sec": round(total_sent / total_time, 1) if total_time > 0 else 0,
"avg_latency_ms": round(avg_latency, 1),
"p95_latency_ms": round(p95_latency, 1),
"p99_latency_ms": round(p99_latency, 1),
"avg_connect_time_ms": round(avg_connect_time, 1),
"errors": total_errors,
"disconnected": disconnected,
}
def print_report(result: dict):
"""Print load test report."""
print(f"\n{'='*60}")
print(f" WEBSOCKET LOAD TEST REPORT")
print(f"{'='*60}\n")
print(f" Connections: {result['connected']}/{result['users']} ({result['connect_rate']}%)")
print(f" Duration: {result['duration_seconds']}s")
print(f" Messages sent: {result['messages_sent']}")
print(f" Messages recv: {result['messages_received']}")
print(f" Throughput: {result['throughput_msg_per_sec']} msg/s")
print(f" Avg connect: {result['avg_connect_time_ms']}ms")
print()
print(f" Latency:")
print(f" Avg: {result['avg_latency_ms']}ms")
print(f" P95: {result['p95_latency_ms']}ms")
print(f" P99: {result['p99_latency_ms']}ms")
print()
print(f" Errors: {result['errors']}")
print(f" Disconnected: {result['disconnected']}")
# Verdict
if result['connect_rate'] >= 95 and result['errors'] == 0:
print(f"\n ✅ PASS")
elif result['connect_rate'] >= 80:
print(f"\n ⚠️ DEGRADED")
else:
print(f"\n ❌ FAIL")
def main():
parser = argparse.ArgumentParser(description="WebSocket Load Test")
parser.add_argument("--users", type=int, default=50, help="Concurrent users")
parser.add_argument("--duration", type=int, default=30, help="Test duration in seconds")
parser.add_argument("--json", action="store_true", help="JSON output")
parser.add_argument("--url", default=WS_URL, help="WebSocket URL")
args = parser.parse_args()
ws_url = args.url
print(f"\nWebSocket Load Test — {args.users} users, {args.duration}s\n")
result = asyncio.run(run_load_test(args.users, args.duration, ws_url))
if args.json:
print(json.dumps(result, indent=2))
else:
print_report(result)
if __name__ == "__main__":
main()