Compare commits

...

10 Commits

Author SHA1 Message Date
1ff5badf1e Merge branch 'main' into fix/1539
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 13s
CI / test (pull_request) Failing after 1m16s
CI / validate (pull_request) Failing after 1m21s
2026-04-22 01:16:42 +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
Alexander Whitestone
f8a0556c26 feat: add portal health checks for #1539
Some checks failed
CI / test (pull_request) Failing after 59s
Review Approval Gate / verify-review (pull_request) Failing after 8s
CI / validate (pull_request) Failing after 58s
2026-04-17 02:00:08 -04:00
Alexander Whitestone
9f6cdf4b92 test: define portal health checks for #1539 2026-04-17 01:38:46 -04: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
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
8 changed files with 704 additions and 16 deletions

114
app.js
View File

@@ -83,6 +83,7 @@ let workshopPanelCanvas = null;
let workshopScanMat = null;
let workshopPanelRefreshTimer = 0;
let lastFocusedPortal = null;
let portalHealthTimer = null;
// ═══ VISITOR / OPERATOR MODE ═══
let uiMode = 'visitor'; // 'visitor' | 'operator'
@@ -1536,6 +1537,7 @@ function createPortals(data) {
const portal = createPortal(config);
portals.push(portal);
});
startPortalHealthChecks();
}
function createPortal(config) {
@@ -1674,6 +1676,19 @@ function createPortal(config) {
swirl,
pSystem,
light,
labelCanvas,
labelContext: lctx,
labelTexture: labelTex,
labelMesh,
baseState: window.PortalHealth
? window.PortalHealth.captureBaseState(config)
: {
status: config.status || 'online',
blocked_reason: config.blocked_reason ?? null,
interaction_ready: config.interaction_ready !== false,
action_label: config.destination?.action_label || 'ENTER',
},
health: null,
customElements: {}
};
@@ -1754,6 +1769,86 @@ function createPortal(config) {
return portalObj;
}
function updatePortalLabelTexture(portal, runtimeState) {
if (!portal?.labelContext || !portal?.labelCanvas || !portal?.labelTexture) return;
const ctx = portal.labelContext;
const canvas = portal.labelCanvas;
const portalColor = new THREE.Color(portal.config.color);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = 'bold 32px "Orbitron", sans-serif';
ctx.fillStyle = '#' + portalColor.getHexString();
ctx.textAlign = 'center';
ctx.fillText(`${portal.config.name.toUpperCase()}`, 256, 36);
ctx.font = 'bold 18px "Orbitron", sans-serif';
if (runtimeState && runtimeState.online === false) {
ctx.fillStyle = '#ff4466';
ctx.fillText('OFFLINE', 256, 68);
} else if (portal.config.role) {
const roleColors = { timmy: '#4af0c0', reflex: '#ff4466', pilot: '#ffd700', creative: '#ff00ff' };
ctx.fillStyle = roleColors[portal.config.role] || '#888888';
ctx.fillText(portal.config.role.toUpperCase(), 256, 68);
}
portal.labelTexture.needsUpdate = true;
}
function setPortalVisualIntensity(portal, intensity) {
const factor = Math.max(0.12, Math.min(1, intensity));
portal.group.traverse(obj => {
if (obj.isPointLight) {
if (obj.userData.baseIntensity == null) obj.userData.baseIntensity = obj.intensity;
obj.intensity = obj.userData.baseIntensity * factor;
return;
}
const materials = obj.material ? (Array.isArray(obj.material) ? obj.material : [obj.material]) : [];
materials.forEach(mat => {
if (mat.opacity != null) {
if (mat.userData.baseOpacity == null) mat.userData.baseOpacity = mat.opacity;
mat.opacity = mat.userData.baseOpacity * factor;
}
if (mat.emissiveIntensity != null) {
if (mat.userData.baseEmissiveIntensity == null) mat.userData.baseEmissiveIntensity = mat.emissiveIntensity;
mat.emissiveIntensity = mat.userData.baseEmissiveIntensity * factor;
}
if (mat.transparent != null && factor < 1) mat.transparent = true;
});
});
}
function setPortalRuntimeState(portal, runtimeState) {
portal.health = runtimeState;
portal.config.status = runtimeState.status;
portal.config.blocked_reason = runtimeState.blocked_reason;
portal.config.interaction_ready = runtimeState.interaction_ready;
if (portal.config.destination) {
portal.config.destination.action_label = runtimeState.actionLabel;
}
setPortalVisualIntensity(portal, runtimeState.online === false ? 0.2 : 1);
updatePortalLabelTexture(portal, runtimeState);
}
async function runPortalHealthChecks() {
if (!window.PortalHealth || portals.length === 0) return;
await Promise.all(portals.map(async (portal) => {
const probe = await window.PortalHealth.probePortalHealth(portal.config);
const runtimeState = window.PortalHealth.computePortalRuntimeState(portal.config, portal.baseState, probe);
setPortalRuntimeState(portal, runtimeState);
}));
if (atlasOverlayActive) populateAtlas();
if (activePortal) checkPortalProximity();
}
function startPortalHealthChecks() {
if (!window.PortalHealth || portalHealthTimer) return;
runPortalHealthChecks();
portalHealthTimer = setInterval(runPortalHealthChecks, window.PortalHealth.DEFAULT_HEALTH_INTERVAL_MS);
}
// ═══ PARTICLES ═══
function createParticles() {
const count = particleCount(1500);
@@ -3048,8 +3143,14 @@ function checkPortalProximity() {
activePortal = closest;
const hint = document.getElementById('portal-hint');
const hintLabel = document.getElementById('portal-hint-label');
if (activePortal) {
const runtimeState = activePortal.health || {
online: activePortal.config.status !== 'offline',
tooltipText: `Enter ${activePortal.config.name}`,
};
document.getElementById('portal-hint-name').textContent = activePortal.config.name;
if (hintLabel) hintLabel.textContent = runtimeState.online ? 'Enter' : 'Offline';
hint.style.display = 'flex';
} else {
hint.style.display = 'none';
@@ -3063,8 +3164,13 @@ function activatePortal(portal) {
const descDisplay = document.getElementById('portal-desc-display');
const redirectBox = document.getElementById('portal-redirect-box');
const errorBox = document.getElementById('portal-error-box');
const errorMsg = document.querySelector('#portal-error-box .portal-error-msg');
const timerDisplay = document.getElementById('portal-timer');
const statusDot = document.getElementById('portal-status-dot');
const runtimeState = portal.health || {
online: portal.config.status !== 'offline',
blocked_reason: portal.config.blocked_reason,
};
nameDisplay.textContent = portal.config.name.toUpperCase();
descDisplay.textContent = portal.config.description;
@@ -3073,6 +3179,13 @@ function activatePortal(portal) {
overlay.style.display = 'flex';
if (!runtimeState.online) {
redirectBox.style.display = 'none';
errorBox.style.display = 'block';
if (errorMsg) errorMsg.textContent = runtimeState.blocked_reason || 'OFFLINE';
return;
}
if (portal.config.destination && portal.config.destination.url) {
redirectBox.style.display = 'block';
errorBox.style.display = 'none';
@@ -3091,6 +3204,7 @@ function activatePortal(portal) {
} else {
redirectBox.style.display = 'none';
errorBox.style.display = 'block';
if (errorMsg) errorMsg.textContent = 'DESTINATION NOT YET LINKED';
}
}

View File

@@ -236,7 +236,7 @@
<!-- Portal Hint -->
<div id="portal-hint" class="portal-hint" style="display:none;">
<div class="portal-hint-key">F</div>
<div class="portal-hint-text">Enter <span id="portal-hint-name"></span></div>
<div class="portal-hint-text"><span id="portal-hint-label">Enter</span> <span id="portal-hint-name"></span></div>
</div>
<!-- Vision Hint -->
@@ -394,6 +394,7 @@
<div id="memory-inspect-panel" class="memory-inspect-panel" style="display:none;" aria-label="Memory Inspect Panel"></div>
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
<script src="./portal-health.js"></script>
<script src="./boot.js"></script>
<script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script>

135
portal-health.js Normal file
View File

@@ -0,0 +1,135 @@
(function (global) {
const DEFAULT_HEALTH_INTERVAL_MS = 5 * 60 * 1000;
const DEFAULT_TIMEOUT_MS = 3000;
function derivePortalHealthTarget(config = {}) {
if (typeof config.health_check_url === 'string' && config.health_check_url.trim()) {
return config.health_check_url.trim();
}
const destinationUrl = config?.destination?.url;
if (typeof destinationUrl === 'string' && destinationUrl.trim()) {
return destinationUrl.trim();
}
return null;
}
function shouldProbePortalHealth(config = {}) {
const target = derivePortalHealthTarget(config);
return typeof target === 'string' && /^https?:\/\//i.test(target);
}
function captureBaseState(config = {}) {
return {
status: config.status || 'online',
blocked_reason: config.blocked_reason ?? null,
interaction_ready: config.interaction_ready !== false,
action_label: config?.destination?.action_label || 'ENTER',
};
}
function computePortalRuntimeState(config = {}, baseState = captureBaseState(config), probe = {}) {
const tracked = shouldProbePortalHealth(config);
const portalName = config.name || 'Portal';
if (!tracked) {
return {
tracked: false,
online: true,
status: baseState.status,
blocked_reason: baseState.blocked_reason,
interaction_ready: baseState.interaction_ready,
actionLabel: baseState.action_label,
tooltipText: `Enter ${portalName}`,
};
}
const online = probe.online !== false;
if (!online) {
const reason = probe.reason || 'Offline';
return {
tracked: true,
online: false,
status: 'offline',
blocked_reason: reason,
interaction_ready: false,
actionLabel: 'OFFLINE',
tooltipText: 'Offline',
};
}
return {
tracked: true,
online: true,
status: baseState.status,
blocked_reason: baseState.blocked_reason,
interaction_ready: baseState.interaction_ready,
actionLabel: baseState.action_label,
tooltipText: `Enter ${portalName}`,
};
}
function describeProbeFailure(error) {
if (!error) return 'Offline';
if (error.name === 'AbortError') return 'Offline';
return error.message || 'Offline';
}
async function probePortalHealth(config = {}, { fetchImpl = global.fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const target = derivePortalHealthTarget(config);
if (!shouldProbePortalHealth(config)) {
return { online: true, skipped: true, target };
}
if (typeof fetchImpl !== 'function') {
return { online: false, reason: 'Offline', target };
}
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timeout = controller
? setTimeout(() => controller.abort(), timeoutMs)
: null;
try {
const response = await fetchImpl(target, {
method: 'HEAD',
mode: 'no-cors',
cache: 'no-store',
signal: controller ? controller.signal : undefined,
});
if (timeout) clearTimeout(timeout);
const online = !response || response.type === 'opaque' || response.ok || response.status < 500;
return {
online,
reason: online ? null : `Offline (${response.status || 'unreachable'})`,
target,
};
} catch (error) {
if (timeout) clearTimeout(timeout);
return {
online: false,
reason: describeProbeFailure(error),
target,
};
}
}
const api = {
DEFAULT_HEALTH_INTERVAL_MS,
DEFAULT_TIMEOUT_MS,
derivePortalHealthTarget,
shouldProbePortalHealth,
captureBaseState,
computePortalRuntimeState,
probePortalHealth,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
global.PortalHealth = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -129,13 +129,22 @@
"type": "harness",
"params": {
"mode": "creative"
}
},
"action_label": "Enter Workshop"
},
"agents_present": [
"timmy",
"kimi"
],
"interaction_ready": true
"interaction_ready": true,
"portal_type": "creative-harness",
"world_category": "creative",
"environment": "production",
"access_mode": "visitor",
"readiness_state": "online",
"blocked_reason": null,
"telemetry_source": "workshop",
"owner": "Timmy"
},
{
"id": "archive",
@@ -157,12 +166,21 @@
"type": "harness",
"params": {
"mode": "read"
}
},
"action_label": "Enter Archive"
},
"agents_present": [
"claude"
],
"interaction_ready": true
"interaction_ready": true,
"portal_type": "knowledge-harness",
"world_category": "archive",
"environment": "production",
"access_mode": "visitor",
"readiness_state": "online",
"blocked_reason": null,
"telemetry_source": "archive",
"owner": "Timmy"
},
{
"id": "chapel",
@@ -184,10 +202,19 @@
"type": "harness",
"params": {
"mode": "meditation"
}
},
"action_label": "Enter Chapel"
},
"agents_present": [],
"interaction_ready": true
"interaction_ready": true,
"portal_type": "sanctuary-harness",
"world_category": "sanctuary",
"environment": "production",
"access_mode": "visitor",
"readiness_state": "online",
"blocked_reason": null,
"telemetry_source": "chapel",
"owner": "Timmy"
},
{
"id": "courtyard",
@@ -209,13 +236,22 @@
"type": "harness",
"params": {
"mode": "social"
}
},
"action_label": "Enter Courtyard"
},
"agents_present": [
"timmy",
"perplexity"
],
"interaction_ready": true
"interaction_ready": true,
"portal_type": "social-harness",
"world_category": "social",
"environment": "production",
"access_mode": "visitor",
"readiness_state": "online",
"blocked_reason": null,
"telemetry_source": "courtyard",
"owner": "Reflex"
},
{
"id": "gate",
@@ -237,10 +273,19 @@
"type": "harness",
"params": {
"mode": "transit"
}
},
"action_label": "Enter Gate"
},
"agents_present": [],
"interaction_ready": false
"interaction_ready": false,
"portal_type": "transit-harness",
"world_category": "transit",
"environment": "production",
"access_mode": "operator",
"readiness_state": "standby",
"blocked_reason": null,
"telemetry_source": "gate",
"owner": "Reflex"
},
{
"id": "playground",
@@ -292,4 +337,4 @@
"agents_present": [],
"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()

View File

@@ -0,0 +1,21 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
test('index bootstraps portal-health helper before app startup', () => {
const html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8');
assert.match(html, /portal-health\.js/);
});
test('app wires recurring portal health checks and offline tooltip behavior', () => {
const source = fs.readFileSync(path.join(ROOT, 'app.js'), 'utf8');
assert.match(source, /startPortalHealthChecks\(/);
assert.match(source, /runPortalHealthChecks\(/);
assert.match(source, /PortalHealth\.DEFAULT_HEALTH_INTERVAL_MS/);
assert.match(source, /tooltipText/);
assert.match(source, /Offline/);
assert.match(source, /setPortalRuntimeState\(/);
});

View File

@@ -0,0 +1,69 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const {
DEFAULT_HEALTH_INTERVAL_MS,
derivePortalHealthTarget,
shouldProbePortalHealth,
computePortalRuntimeState,
} = require('../portal-health.js');
test('portal health checks default to five minutes', () => {
assert.equal(DEFAULT_HEALTH_INTERVAL_MS, 5 * 60 * 1000);
});
test('portal health target prefers explicit health URL then destination URL', () => {
assert.equal(
derivePortalHealthTarget({
health_check_url: 'https://status.timmy.foundation/health',
destination: { url: 'https://portal.timmy.foundation' },
}),
'https://status.timmy.foundation/health'
);
assert.equal(
derivePortalHealthTarget({ destination: { url: 'https://portal.timmy.foundation' } }),
'https://portal.timmy.foundation'
);
assert.equal(
derivePortalHealthTarget({ destination: { url: null } }),
null
);
});
test('portal health probe eligibility only tracks portals with external URLs', () => {
assert.equal(shouldProbePortalHealth({ destination: { url: 'https://portal.timmy.foundation' } }), true);
assert.equal(shouldProbePortalHealth({ destination: { url: './local.html' } }), false);
assert.equal(shouldProbePortalHealth({ destination: { url: null } }), false);
});
test('runtime state marks offline portals unavailable and restores base state when healthy', () => {
const config = {
name: 'Workshop',
status: 'online',
blocked_reason: null,
interaction_ready: true,
destination: { url: 'https://workshop.timmy.foundation', action_label: 'Enter Workshop' },
};
const baseState = {
status: 'online',
blocked_reason: null,
interaction_ready: true,
action_label: 'Enter Workshop',
};
const offline = computePortalRuntimeState(config, baseState, { online: false, reason: 'Offline' });
assert.equal(offline.status, 'offline');
assert.equal(offline.interaction_ready, false);
assert.equal(offline.blocked_reason, 'Offline');
assert.equal(offline.tooltipText, 'Offline');
assert.equal(offline.actionLabel, 'OFFLINE');
const restored = computePortalRuntimeState(config, baseState, { online: true });
assert.equal(restored.status, 'online');
assert.equal(restored.interaction_ready, true);
assert.equal(restored.blocked_reason, null);
assert.equal(restored.tooltipText, 'Enter Workshop');
assert.equal(restored.actionLabel, 'Enter Workshop');
});