Fix WebSocket drops: add bidirectional keepalive pings

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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-18 13:10:27 +00:00
parent e98143de55
commit e85681b775
3 changed files with 62 additions and 6 deletions

View file

@ -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, {

View file

@ -1,4 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#0487B6"/>
<text x="16" y="22" font-family="Arial,sans-serif" font-size="18" font-weight="bold" fill="white" text-anchor="middle">M</text>
</svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 100 100">
<!-- Generator: Adobe Illustrator 30.2.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 1) -->
<defs>
<style>
.st0 {
fill: #ffcb05;
}
</style>
</defs>
<path class="st0" d="M75.85,88.28l-.12-45.97-21.94,37.88h-7.78l-21.82-36.87v45H7.98V11.68h14.28l27.9,47.63,27.47-47.63h14.16l.23,76.61h-16.17Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 265 B

After

Width:  |  Height:  |  Size: 469 B

View file

@ -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<typeof setInterval> | 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<typeof setInterval> | 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'));