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.
This commit is contained in:
padreug 2025-09-18 11:28:56 +02:00
parent 37a07c0c12
commit ed92c7ba87
2 changed files with 307 additions and 19 deletions

View file

@ -78,7 +78,9 @@ export const appConfig: AppConfig = {
websocket: { websocket: {
enabled: import.meta.env.VITE_WEBSOCKET_ENABLED !== 'false', // Can be disabled via env var 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) 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
} }
} }
} }

View file

@ -9,6 +9,8 @@ interface WebSocketConfig {
enabled: boolean enabled: boolean
reconnectDelay: number reconnectDelay: number
maxReconnectAttempts: number maxReconnectAttempts: number
fallbackToPolling: boolean
pollingInterval: number
} }
export class WalletWebSocketService extends BaseService { export class WalletWebSocketService extends BaseService {
@ -21,10 +23,16 @@ export class WalletWebSocketService extends BaseService {
private ws: WebSocket | null = null private ws: WebSocket | null = null
private reconnectTimer: NodeJS.Timeout | null = null private reconnectTimer: NodeJS.Timeout | null = null
private reconnectAttempts = 0 private reconnectAttempts = 0
private pollingTimer: NodeJS.Timeout | null = null
private stabilityTimer: NodeJS.Timeout | null = null
private usingPollingFallback = false
private consecutiveFailures = 0
private config: WebSocketConfig = { private config: WebSocketConfig = {
enabled: true, enabled: true,
reconnectDelay: 1000, reconnectDelay: 1000,
maxReconnectAttempts: 5 maxReconnectAttempts: 5,
fallbackToPolling: true,
pollingInterval: 10000 // 10 seconds
} }
// Service dependencies (auto-injected by BaseService) // Service dependencies (auto-injected by BaseService)
@ -143,14 +151,22 @@ export class WalletWebSocketService extends BaseService {
this.connectionStatus.value = 'connecting' this.connectionStatus.value = 'connecting'
// Create WebSocket connection // Create WebSocket connection with detailed monitoring
this.ws = new WebSocket(wsUrl) this.ws = new WebSocket(wsUrl)
// Set up event handlers // Log initial WebSocket state
this.ws.onopen = this.handleOpen.bind(this) console.log('WalletWebSocketService: WebSocket created', {
this.ws.onmessage = this.handleMessage.bind(this) readyState: this.ws.readyState,
this.ws.onclose = this.handleClose.bind(this) url: this.ws.url,
this.ws.onerror = this.handleWebSocketError.bind(this) 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...') console.log('WalletWebSocketService: WebSocket created, waiting for connection...')
@ -165,10 +181,12 @@ export class WalletWebSocketService extends BaseService {
* Handle WebSocket connection opened * Handle WebSocket connection opened
*/ */
private handleOpen(_event: Event): void { private handleOpen(_event: Event): void {
// Record connection start time for duration tracking
this.connectionStartTime = Date.now()
console.log('WalletWebSocketService: Connected successfully') console.log('WalletWebSocketService: Connected successfully')
this.isConnected.value = true this.isConnected.value = true
this.connectionStatus.value = 'connected' this.connectionStatus.value = 'connected'
this.reconnectAttempts = 0
// Clear any pending reconnect timer // Clear any pending reconnect timer
if (this.reconnectTimer) { if (this.reconnectTimer) {
@ -176,6 +194,26 @@ export class WalletWebSocketService extends BaseService {
this.reconnectTimer = null 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 // Send a ping to test connection stability
this.sendPing() this.sendPing()
} }
@ -285,6 +323,12 @@ export class WalletWebSocketService extends BaseService {
this.connectionStatus.value = 'disconnected' this.connectionStatus.value = 'disconnected'
this.ws = null this.ws = null
// Clear stability timer since connection closed
if (this.stabilityTimer) {
clearTimeout(this.stabilityTimer)
this.stabilityTimer = null
}
// Handle specific close codes // Handle specific close codes
if (event.code === 1006) { if (event.code === 1006) {
console.warn('WalletWebSocketService: Abnormal closure detected - possible server issue') console.warn('WalletWebSocketService: Abnormal closure detected - possible server issue')
@ -321,17 +365,25 @@ export class WalletWebSocketService extends BaseService {
* Schedule a reconnection attempt * Schedule a reconnection attempt
*/ */
private scheduleReconnect(isAbnormalClosure = false): void { private scheduleReconnect(isAbnormalClosure = false): void {
this.consecutiveFailures++
// Don't reconnect if we've exceeded max attempts // Don't reconnect if we've exceeded max attempts
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) { 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' this.connectionStatus.value = 'failed'
// 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 // Show user notification about WebSocket issues
if (this.toast) { if (this.toast) {
this.toast.info('Real-time balance updates temporarily unavailable', { this.toast.info('Real-time balance updates temporarily unavailable', {
description: 'WebSocket connection failed. Balance will update on page refresh.' description: 'WebSocket connection failed. Balance will update on page refresh.'
}) })
} }
}
return return
} }
@ -367,12 +419,20 @@ export class WalletWebSocketService extends BaseService {
this.reconnectTimer = null this.reconnectTimer = null
} }
if (this.stabilityTimer) {
clearTimeout(this.stabilityTimer)
this.stabilityTimer = null
}
if (this.ws) { if (this.ws) {
// Close with normal closure code // Close with normal closure code
this.ws.close(1000, 'Client disconnect') this.ws.close(1000, 'Client disconnect')
this.ws = null this.ws = null
} }
// Stop polling fallback
this.stopPollingFallback()
this.isConnected.value = false this.isConnected.value = false
this.connectionStatus.value = 'disconnected' this.connectionStatus.value = 'disconnected'
} }
@ -383,10 +443,13 @@ export class WalletWebSocketService extends BaseService {
private async onResume(): Promise<void> { private async onResume(): Promise<void> {
console.log('WalletWebSocketService: Resuming connection') console.log('WalletWebSocketService: Resuming connection')
// Reconnect if not connected // Try WebSocket first, then fallback to polling if needed
if (!this.isConnected.value) { if (!this.isConnected.value && !this.usingPollingFallback) {
this.reconnectAttempts = 0 this.reconnectAttempts = 0
await this.connectIfNeeded() 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<void> { private async onPause(): Promise<void> {
console.log('WalletWebSocketService: Pausing connection') console.log('WalletWebSocketService: Pausing connection')
// Disconnect to save battery // Disconnect WebSocket to save battery
this.disconnect() 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<void> { public async cleanup(): Promise<void> {
this.disconnect() 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<void> {
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 export default WalletWebSocketService