From e85681b775b2981426e61aadecf55f368160b336 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Wed, 18 Mar 2026 13:10:27 +0000 Subject: [PATCH] Fix WebSocket drops: add bidirectional keepalive pings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend now sends client→server ping every 15s during analysis to keep the GCP LB idle timeout alive from both directions. Backend responds with pong. Previously only server→client heartbeats were sent, which didn't reset the proxy's client-side idle timer. Also updates favicon to Oliver brand mark (gold M). Co-Authored-By: Claude Sonnet 4.6 --- backend/app/main.py | 3 ++ frontend/public/favicon.svg | 16 +++++++--- frontend/services/geminiService.ts | 49 ++++++++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 265b4d0..3eaf173 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -240,6 +240,9 @@ async def websocket_analyze(websocket: WebSocket): ) finally: heartbeat_task.cancel() + elif data.get("type") == "ping": + # Client keepalive ping — respond with pong + await manager.send_message(client_id, {"type": "pong"}) else: logger.warning(f"[MAIN] Unknown message type: {data.get('type')}") await manager.send_message(client_id, { diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 8150704..76f688d 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,4 +1,12 @@ - - - M - + + + + + + + + \ No newline at end of file diff --git a/frontend/services/geminiService.ts b/frontend/services/geminiService.ts index 5b65cb8..c3d1132 100755 --- a/frontend/services/geminiService.ts +++ b/frontend/services/geminiService.ts @@ -53,7 +53,24 @@ export const analyzeProof = async ( const ws = new WebSocket(WS_URL); let resolved = false; + // Send client→server pings every 15s to keep proxy idle timers alive + let pingInterval: ReturnType | null = null; + const startPing = () => { + pingInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })); + } + }, 15000); + }; + const stopPing = () => { + if (pingInterval !== null) { + clearInterval(pingInterval); + pingInterval = null; + } + }; + ws.onopen = () => { + startPing(); // Convert file to base64 and send const reader = new FileReader(); reader.onloadend = () => { @@ -114,9 +131,14 @@ export const analyzeProof = async ( onAgentUpdate('Summary'); break; + case 'pong': + // Response to our ping — ignore + break; + case 'complete': // Analysis complete - resolve with full result resolved = true; + stopPing(); ws.close(); resolve({ review: message.result as AgentReview, @@ -138,6 +160,7 @@ export const analyzeProof = async ( case 'error': // Error occurred resolved = true; + stopPing(); ws.close(); reject(new Error(message.message || 'Analysis failed')); break; @@ -148,6 +171,7 @@ export const analyzeProof = async ( }; ws.onerror = () => { + stopPing(); if (!resolved) { resolved = true; reject(new Error('WebSocket connection error. Is the backend running?')); @@ -155,6 +179,7 @@ export const analyzeProof = async ( }; ws.onclose = (event) => { + stopPing(); if (!resolved && !event.wasClean) { resolved = true; reject(new Error('WebSocket connection closed unexpectedly')); @@ -182,7 +207,23 @@ export const analyzeWIPProof = async ( const ws = new WebSocket(WS_URL); let resolved = false; + let wipPingInterval: ReturnType | null = null; + const startWipPing = () => { + wipPingInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })); + } + }, 15000); + }; + const stopWipPing = () => { + if (wipPingInterval !== null) { + clearInterval(wipPingInterval); + wipPingInterval = null; + } + }; + ws.onopen = () => { + startWipPing(); const reader = new FileReader(); reader.onloadend = () => { const base64Data = (reader.result as string).split(',')[1]; @@ -205,18 +246,20 @@ export const analyzeWIPProof = async ( try { const message = JSON.parse(event.data); - if (message.type === 'heartbeat') { - // Server keepalive — ignore silently + if (message.type === 'heartbeat' || message.type === 'pong') { + // Keepalive — ignore silently } else if (message.type === 'agent_completed') { onAgentUpdate(message.agent_name as AgentName, message.review); } else if (message.type === 'summary') { onAgentUpdate('Summary'); } else if (message.type === 'complete') { resolved = true; + stopWipPing(); ws.close(); resolve(message.result?.leadAgentSummary || 'Analysis complete.'); } else if (message.type === 'error') { resolved = true; + stopWipPing(); ws.close(); reject(new Error(message.message || 'Analysis failed')); } @@ -226,6 +269,7 @@ export const analyzeWIPProof = async ( }; ws.onerror = () => { + stopWipPing(); if (!resolved) { resolved = true; reject(new Error('WebSocket connection error')); @@ -233,6 +277,7 @@ export const analyzeWIPProof = async ( }; ws.onclose = (event) => { + stopWipPing(); if (!resolved && !event.wasClean) { resolved = true; reject(new Error('Connection closed unexpectedly'));