From ed92c7ba873e1d8e2112fe74f0c1bdc87882b66d Mon Sep 17 00:00:00 2001 From: padreug Date: Thu, 18 Sep 2025 11:28:56 +0200 Subject: [PATCH] Enhance WebSocket service with polling fallback and improved diagnostics - Added configuration options for polling fallback and polling interval to the WebSocket service, enabling a backup mechanism for real-time updates when WebSocket connections fail. - Implemented detailed logging for WebSocket events, including connection status, message handling, and error diagnostics, to improve monitoring and debugging capabilities. - Updated connection handling to manage polling effectively, ensuring users receive timely updates even during WebSocket disruptions. These changes enhance the reliability and transparency of the WalletWebSocketService, contributing to a better user experience. --- src/app.config.ts | 4 +- .../wallet/services/WalletWebSocketService.ts | 322 +++++++++++++++++- 2 files changed, 307 insertions(+), 19 deletions(-) diff --git a/src/app.config.ts b/src/app.config.ts index 1b5b262..d49b876 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -78,7 +78,9 @@ export const appConfig: AppConfig = { websocket: { enabled: import.meta.env.VITE_WEBSOCKET_ENABLED !== 'false', // Can be disabled via env var reconnectDelay: 2000, // 2 seconds (increased from 1s to reduce server load) - maxReconnectAttempts: 3 // Reduced from 5 to avoid overwhelming server + maxReconnectAttempts: 3, // Reduced from 5 to avoid overwhelming server + fallbackToPolling: true, // Enable polling fallback when WebSocket fails + pollingInterval: 10000 // 10 seconds for polling updates } } } diff --git a/src/modules/wallet/services/WalletWebSocketService.ts b/src/modules/wallet/services/WalletWebSocketService.ts index 31518be..2c62be9 100644 --- a/src/modules/wallet/services/WalletWebSocketService.ts +++ b/src/modules/wallet/services/WalletWebSocketService.ts @@ -9,6 +9,8 @@ interface WebSocketConfig { enabled: boolean reconnectDelay: number maxReconnectAttempts: number + fallbackToPolling: boolean + pollingInterval: number } export class WalletWebSocketService extends BaseService { @@ -21,10 +23,16 @@ export class WalletWebSocketService extends BaseService { private ws: WebSocket | null = null private reconnectTimer: NodeJS.Timeout | null = null private reconnectAttempts = 0 + private pollingTimer: NodeJS.Timeout | null = null + private stabilityTimer: NodeJS.Timeout | null = null + private usingPollingFallback = false + private consecutiveFailures = 0 private config: WebSocketConfig = { enabled: true, reconnectDelay: 1000, - maxReconnectAttempts: 5 + maxReconnectAttempts: 5, + fallbackToPolling: true, + pollingInterval: 10000 // 10 seconds } // Service dependencies (auto-injected by BaseService) @@ -143,14 +151,22 @@ export class WalletWebSocketService extends BaseService { this.connectionStatus.value = 'connecting' - // Create WebSocket connection + // Create WebSocket connection with detailed monitoring this.ws = new WebSocket(wsUrl) - // Set up event handlers - this.ws.onopen = this.handleOpen.bind(this) - this.ws.onmessage = this.handleMessage.bind(this) - this.ws.onclose = this.handleClose.bind(this) - this.ws.onerror = this.handleWebSocketError.bind(this) + // Log initial WebSocket state + console.log('WalletWebSocketService: WebSocket created', { + readyState: this.ws.readyState, + url: this.ws.url, + protocol: this.ws.protocol, + extensions: this.ws.extensions + }) + + // Set up event handlers with enhanced diagnostics + this.ws.onopen = (event) => this.handleOpenWithDiagnostics(event) + this.ws.onmessage = (event) => this.handleMessageWithDiagnostics(event) + this.ws.onclose = (event) => this.handleCloseWithDiagnostics(event) + this.ws.onerror = (event) => this.handleWebSocketErrorWithDiagnostics(event) console.log('WalletWebSocketService: WebSocket created, waiting for connection...') @@ -165,10 +181,12 @@ export class WalletWebSocketService extends BaseService { * Handle WebSocket connection opened */ private handleOpen(_event: Event): void { + // Record connection start time for duration tracking + this.connectionStartTime = Date.now() + console.log('WalletWebSocketService: Connected successfully') this.isConnected.value = true this.connectionStatus.value = 'connected' - this.reconnectAttempts = 0 // Clear any pending reconnect timer if (this.reconnectTimer) { @@ -176,6 +194,26 @@ export class WalletWebSocketService extends BaseService { this.reconnectTimer = null } + // Only reset counters after connection has been stable for 10 seconds + this.stabilityTimer = setTimeout(() => { + if (this.isConnected.value) { + console.log('WalletWebSocketService: Connection stable, resetting retry counters') + this.reconnectAttempts = 0 + this.consecutiveFailures = 0 + + // Stop polling fallback if we have a stable WebSocket connection + if (this.usingPollingFallback) { + this.stopPollingFallback() + if (this.toast) { + this.toast.success('WebSocket reconnected', { + description: 'Real-time updates restored via WebSocket connection.' + }) + } + } + } + this.stabilityTimer = null + }, 10000) + // Send a ping to test connection stability this.sendPing() } @@ -285,6 +323,12 @@ export class WalletWebSocketService extends BaseService { this.connectionStatus.value = 'disconnected' this.ws = null + // Clear stability timer since connection closed + if (this.stabilityTimer) { + clearTimeout(this.stabilityTimer) + this.stabilityTimer = null + } + // Handle specific close codes if (event.code === 1006) { console.warn('WalletWebSocketService: Abnormal closure detected - possible server issue') @@ -321,16 +365,24 @@ export class WalletWebSocketService extends BaseService { * Schedule a reconnection attempt */ private scheduleReconnect(isAbnormalClosure = false): void { + this.consecutiveFailures++ + // Don't reconnect if we've exceeded max attempts if (this.reconnectAttempts >= this.config.maxReconnectAttempts) { - console.log('WalletWebSocketService: Max reconnection attempts reached - disabling WebSocket') + console.log('WalletWebSocketService: Max reconnection attempts reached') this.connectionStatus.value = 'failed' - // Show user notification about WebSocket issues - if (this.toast) { - this.toast.info('Real-time balance updates temporarily unavailable', { - description: 'WebSocket connection failed. Balance will update on page refresh.' - }) + // Check if we should fallback to polling + if (this.config.fallbackToPolling && !this.usingPollingFallback) { + console.log('WalletWebSocketService: Falling back to polling for balance updates') + this.startPollingFallback() + } else { + // Show user notification about WebSocket issues + if (this.toast) { + this.toast.info('Real-time balance updates temporarily unavailable', { + description: 'WebSocket connection failed. Balance will update on page refresh.' + }) + } } return } @@ -367,12 +419,20 @@ export class WalletWebSocketService extends BaseService { this.reconnectTimer = null } + if (this.stabilityTimer) { + clearTimeout(this.stabilityTimer) + this.stabilityTimer = null + } + if (this.ws) { // Close with normal closure code this.ws.close(1000, 'Client disconnect') this.ws = null } + // Stop polling fallback + this.stopPollingFallback() + this.isConnected.value = false this.connectionStatus.value = 'disconnected' } @@ -383,10 +443,13 @@ export class WalletWebSocketService extends BaseService { private async onResume(): Promise { console.log('WalletWebSocketService: Resuming connection') - // Reconnect if not connected - if (!this.isConnected.value) { + // Try WebSocket first, then fallback to polling if needed + if (!this.isConnected.value && !this.usingPollingFallback) { this.reconnectAttempts = 0 await this.connectIfNeeded() + } else if (this.usingPollingFallback) { + // Resume polling if we were using fallback + this.schedulePolling() } } @@ -396,8 +459,24 @@ export class WalletWebSocketService extends BaseService { private async onPause(): Promise { console.log('WalletWebSocketService: Pausing connection') - // Disconnect to save battery - this.disconnect() + // Disconnect WebSocket to save battery + if (this.ws) { + this.ws.close(1000, 'App paused') + this.ws = null + this.isConnected.value = false + } + + // Pause polling to save battery + if (this.pollingTimer) { + clearTimeout(this.pollingTimer) + this.pollingTimer = null + } + + // Clear reconnect timers + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } } /** @@ -437,6 +516,213 @@ export class WalletWebSocketService extends BaseService { public async cleanup(): Promise { this.disconnect() } + + /** + * Start polling fallback when WebSocket fails persistently + */ + private startPollingFallback(): void { + if (this.usingPollingFallback) return + + this.usingPollingFallback = true + this.connectionStatus.value = 'polling-fallback' + + console.log(`WalletWebSocketService: Starting polling fallback (${this.config.pollingInterval}ms interval)`) + + // Show user notification + if (this.toast) { + this.toast.info('Using backup connection', { + description: 'Real-time updates temporarily using polling instead of WebSocket.' + }) + } + + this.schedulePolling() + } + + /** + * Stop polling fallback + */ + private stopPollingFallback(): void { + if (!this.usingPollingFallback) return + + this.usingPollingFallback = false + + if (this.pollingTimer) { + clearTimeout(this.pollingTimer) + this.pollingTimer = null + } + + console.log('WalletWebSocketService: Stopped polling fallback') + } + + /** + * Schedule next polling attempt + */ + private schedulePolling(): void { + if (!this.usingPollingFallback) return + + this.pollingTimer = setTimeout(() => { + this.performPollingUpdate() + }, this.config.pollingInterval) + } + + /** + * Perform a polling-based balance update + */ + private async performPollingUpdate(): Promise { + try { + const wallet = this.paymentService?.getPreferredWallet?.() + if (!wallet?.inkey) { + console.log('WalletWebSocketService: No wallet available for polling') + this.schedulePolling() // Continue polling + return + } + + // Fetch balance from LNbits API + const baseUrl = import.meta.env.VITE_LNBITS_BASE_URL || import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000' + const response = await fetch(`${baseUrl}/api/v1/wallet`, { + headers: { + 'X-Api-Key': wallet.inkey + } + }) + + if (!response.ok) { + console.error('WalletWebSocketService: Polling failed', response.status, response.statusText) + this.schedulePolling() // Continue polling despite error + return + } + + const data = await response.json() + + if (data.balance !== undefined) { + // LNbits API returns balance in millisats + const balanceMsat = parseInt(data.balance) + console.log('WalletWebSocketService: Polling update - balance:', balanceMsat, 'msat') + + // Update balance via PaymentService + if (this.paymentService?.updateWalletBalance) { + this.paymentService.updateWalletBalance(balanceMsat, wallet.id) + } + } + + // Schedule next polling + this.schedulePolling() + + } catch (error) { + console.error('WalletWebSocketService: Polling error', error) + this.schedulePolling() // Continue polling despite error + } + } + + /** + * Enhanced diagnostic handlers for WebSocket events + */ + private handleOpenWithDiagnostics(event: Event): void { + const now = Date.now() + console.log('WalletWebSocketService: WebSocket OPEN event', { + timestamp: now, + readyState: this.ws?.readyState, + url: this.ws?.url, + protocol: this.ws?.protocol, + extensions: this.ws?.extensions, + event: event + }) + + // Call original handler + this.handleOpen(event) + + // Schedule a delayed check to see if connection stays open + setTimeout(() => { + if (this.ws) { + console.log('WalletWebSocketService: Connection health check (5s after open)', { + readyState: this.ws.readyState, + readyStateText: this.getReadyStateText(this.ws.readyState), + isConnected: this.isConnected.value, + status: this.connectionStatus.value + }) + } + }, 5000) + } + + private handleMessageWithDiagnostics(event: MessageEvent): void { + console.log('WalletWebSocketService: WebSocket MESSAGE event', { + timestamp: Date.now(), + dataLength: event.data?.length || 0, + dataType: typeof event.data, + readyState: this.ws?.readyState, + preview: event.data?.substring ? event.data.substring(0, 100) + '...' : event.data + }) + + // Call original handler + this.handleMessage(event) + } + + private handleCloseWithDiagnostics(event: CloseEvent): void { + const now = Date.now() + console.log('WalletWebSocketService: WebSocket CLOSE event', { + timestamp: now, + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + readyState: this.ws?.readyState, + readyStateText: this.ws ? this.getReadyStateText(this.ws.readyState) : 'null', + closeCodeMeaning: this.getCloseCodeMeaning(event.code), + connectionDuration: this.getConnectionDuration(now) + }) + + // Call original handler + this.handleClose(event) + } + + private handleWebSocketErrorWithDiagnostics(event: Event): void { + console.error('WalletWebSocketService: WebSocket ERROR event', { + timestamp: Date.now(), + readyState: this.ws?.readyState, + readyStateText: this.ws ? this.getReadyStateText(this.ws.readyState) : 'null', + url: this.ws?.url, + event: event, + networkStatus: navigator.onLine ? 'online' : 'offline', + userAgent: navigator.userAgent + }) + + // Call original handler + this.handleWebSocketError(event) + } + + private getReadyStateText(readyState: number): string { + switch (readyState) { + case WebSocket.CONNECTING: return 'CONNECTING (0)' + case WebSocket.OPEN: return 'OPEN (1)' + case WebSocket.CLOSING: return 'CLOSING (2)' + case WebSocket.CLOSED: return 'CLOSED (3)' + default: return `UNKNOWN (${readyState})` + } + } + + private getCloseCodeMeaning(code: number): string { + switch (code) { + case 1000: return 'Normal Closure' + case 1001: return 'Going Away' + case 1002: return 'Protocol Error' + case 1003: return 'Unsupported Data' + case 1005: return 'No Status Rcvd' + case 1006: return 'Abnormal Closure' + case 1007: return 'Invalid frame payload data' + case 1008: return 'Policy Violation' + case 1009: return 'Message Too Big' + case 1010: return 'Mandatory Extension' + case 1011: return 'Internal Server Error' + case 1015: return 'TLS Handshake' + default: return `Unknown Code (${code})` + } + } + + private connectionStartTime = 0 + + private getConnectionDuration(closeTime: number): string { + if (this.connectionStartTime === 0) return 'Unknown' + const duration = closeTime - this.connectionStartTime + return `${duration}ms` + } } export default WalletWebSocketService \ No newline at end of file