UNPKG

ble-mcp-test

Version:

Complete BLE testing stack: WebSocket bridge server, MCP observability layer, and Web Bluetooth API mock. Test real BLE devices in Playwright/E2E tests without browser support.

201 lines (200 loc) 8.29 kB
import { BleSession } from './ble-session.js'; import { WebSocketHandler } from './ws-handler.js'; /** * SessionManager - Manages BLE session lifecycle and WebSocket routing * * Responsibilities: * - Maintain registry of active BLE sessions * - Route WebSocket connections to appropriate sessions * - Handle session cleanup and eviction * - Provide session status information */ export class SessionManager { sharedState; sessions = new Map(); cleanupInterval = null; constructor(sharedState) { this.sharedState = sharedState; // Start periodic cleanup check this.startCleanupTimer(); } /** * Get or create a BLE session */ getOrCreateSession(sessionId, config) { let session = this.sessions.get(sessionId); if (!session) { // Check if any other session has a BLE transport (connected or in grace period) const activeSessions = Array.from(this.sessions.values()); const sessionWithTransport = activeSessions.find(s => s.getStatus().hasTransport); if (sessionWithTransport && sessionWithTransport.sessionId !== sessionId) { // Reject new session - device is busy with a different session const status = sessionWithTransport.getStatus(); console.log(`[SessionManager] Rejecting new session ${sessionId} - device busy with session ${sessionWithTransport.sessionId} (grace period: ${status.hasGracePeriod})`); // Enhanced logging for debugging console.log(`[SessionManager] Active sessions: ${activeSessions.length}`); activeSessions.forEach(s => { const st = s.getStatus(); console.log(` - Session ${st.sessionId}: transport=${st.hasTransport}, grace=${st.hasGracePeriod}, websockets=${st.activeWebSockets}`); }); return null; } console.log(`[SessionManager] Creating new session: ${sessionId}`); session = new BleSession(sessionId, config, this.sharedState); session.sessionManager = this; // Set reference for cleanup commands this.sessions.set(sessionId, session); // Auto-cleanup on session cleanup event session.once('cleanup', (info) => { console.log(`[SessionManager] Session ${info.sessionId} cleanup: ${info.reason}`); this.sessions.delete(sessionId); this.updateSharedState(); }); this.updateSharedState(); } else { console.log(`[SessionManager] Reusing existing session: ${sessionId}`); // If session is in grace period, log that we're reconnecting const status = session.getStatus(); if (status.hasGracePeriod) { console.log(`[SessionManager] Reconnecting to session ${sessionId} during grace period`); } } return session; } /** * Attach a WebSocket to a session */ attachWebSocket(session, ws) { const handler = new WebSocketHandler(ws, session, this.sharedState); // Update shared state when WebSocket closes handler.once('close', () => { this.updateSharedState(); }); return handler; } /** * Get all active sessions */ getAllSessions() { return Array.from(this.sessions.values()); } /** * Get session by ID */ getSession(sessionId) { return this.sessions.get(sessionId); } /** * Update shared state with session information */ updateSharedState() { if (!this.sharedState) return; // Update connection state based on active sessions const activeSessions = Array.from(this.sessions.values()); const connectedSession = activeSessions.find(s => s.getStatus().connected); if (connectedSession) { const status = connectedSession.getStatus(); this.sharedState.setConnectionState({ connected: true, deviceName: status.deviceName }); } else { // No connected sessions - update state to disconnected this.sharedState.setConnectionState({ connected: false, deviceName: null }); } } /** * Start periodic cleanup timer */ startCleanupTimer() { // Check for stale sessions every 30 seconds this.cleanupInterval = setInterval(async () => { try { await this.checkStaleSessions(); } catch (e) { console.error('[SessionManager] Error during stale session check:', e); } }, 30000); } /** * Check for and clean up stale/zombie sessions */ async checkStaleSessions() { for (const [sessionId, session] of this.sessions) { const status = session.getStatus(); // Log session status for monitoring if (status.hasGracePeriod || status.idleTime > 60) { console.log(`[SessionManager] Session ${sessionId} - ` + `WebSockets: ${status.activeWebSockets}, ` + `Idle: ${status.idleTime}s, ` + `Grace: ${status.hasGracePeriod}, ` + `Connected: ${status.connected}, ` + `HasTransport: ${status.hasTransport}`); } let shouldCleanup = false; let reason = ''; // Detect zombie sessions: has transport but not properly connected // Give new connections at least 30 seconds to complete before considering them zombies if (status.hasTransport && !status.connected && !status.hasGracePeriod && status.idleTime > 30) { shouldCleanup = true; reason = `zombie session - has transport but not connected after ${status.idleTime}s`; } // Force cleanup sessions that are idle too long without grace period if (!status.hasGracePeriod && status.activeWebSockets === 0 && status.idleTime > status.idleTimeoutSec + 60) { shouldCleanup = true; reason = `stale session - idle for ${status.idleTime}s`; } if (shouldCleanup) { console.log(`[SessionManager] Cleaning up session ${sessionId}: ${reason}`); try { // Use force cleanup for zombie/stale sessions (includes resource verification) await session.forceCleanup(reason); } catch (e) { console.error(`[SessionManager] Failed to clean up session ${sessionId}: ${e}`); } } } } /** * Force cleanup all sessions (for admin/testing) */ async forceCleanupAll(reason = 'admin cleanup') { console.log(`[SessionManager] Force cleanup all sessions: ${reason}`); const sessions = Array.from(this.sessions.values()); const cleanupPromises = sessions.map(session => session.forceCleanup(reason)); await Promise.all(cleanupPromises); this.sessions.clear(); } /** * Force cleanup sessions for a specific device */ async forceCleanupDevice(deviceName, reason = 'device cleanup') { console.log(`[SessionManager] Force cleanup sessions for device ${deviceName}: ${reason}`); const sessions = Array.from(this.sessions.values()) .filter(s => s.getStatus().deviceName === deviceName); const cleanupPromises = sessions.map(session => session.forceCleanup(reason)); await Promise.all(cleanupPromises); sessions.forEach(s => this.sessions.delete(s.sessionId)); } /** * Stop the session manager */ async stop() { console.log('[SessionManager] Stopping...'); // Clear cleanup timer if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } // Clean up all sessions await this.forceCleanupAll('manager stopping'); } }