Skip to content

Commit 34c8bbc

Browse files
committed
fix: memory leak in Console.jsx setInterval, Dashboard WS reconnect, and WinError 10054 loop crash
1 parent 8f8f4f4 commit 34c8bbc

File tree

4 files changed

+146
-117
lines changed

4 files changed

+146
-117
lines changed

backend/api_server.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from fastapi.middleware.cors import CORSMiddleware
2828
from pydantic import BaseModel
2929
from typing import Optional, List, Dict
30+
from contextlib import asynccontextmanager
3031
import json
3132
import logging
3233
from queue import Queue
@@ -74,7 +75,24 @@
7475
)
7576
from utils.mods_manager import ModsManager
7677

77-
app = FastAPI()
78+
79+
@asynccontextmanager
80+
async def lifespan(app: FastAPI):
81+
# Suppress WinError 10054 on Windows asyncio loop
82+
if sys.platform == "win32":
83+
loop = asyncio.get_running_loop()
84+
85+
def custom_exception_handler(loop, context):
86+
if "WinError 10054" in str(context.get("exception", "")):
87+
return
88+
loop.default_exception_handler(context)
89+
90+
loop.set_exception_handler(custom_exception_handler)
91+
92+
yield
93+
94+
95+
app = FastAPI(lifespan=lifespan)
7896

7997
# Enable CORS
8098
app.add_middleware(
@@ -931,6 +949,9 @@ async def websocket_console(websocket: WebSocket):
931949
# logging.info("WebSocket disconnected")
932950
if state and websocket in state.active_websockets:
933951
state.active_websockets.remove(websocket)
952+
except ConnectionResetError:
953+
if state and websocket in state.active_websockets:
954+
state.active_websockets.remove(websocket)
934955
except Exception as e:
935956
logging.error(f"WebSocket error: {e}")
936957
if state and websocket in state.active_websockets:

electron-app/src/api.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const api = {
4242
return await fetchJson(`${API_URL}/status`, {}, 15000);
4343
} catch (e) {
4444
console.error("Status check failed:", e);
45-
throw e; // Throw so App.jsx knows it failed and doesn't override with offline!
45+
return { status: 'offline', cpu: 0, ram: 0, players: 0 };
4646
}
4747
},
4848
start: async () => {

electron-app/src/components/Console.jsx

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -73,44 +73,47 @@ export default function Console() {
7373
console.error('WS Error:', err);
7474
// The onerror event usually precedes onclose, so we let onclose handle the retry
7575
};
76+
};
7677

77-
// Polling Fallback (Every 1s)
78-
// This ensures logs appear even if WS fails (common on Windows with Reset errors)
79-
const intervalId = setInterval(async () => {
80-
try {
81-
const status = await api.getStatus();
82-
if (status.recent_logs && Array.isArray(status.recent_logs)) {
83-
setLogs((prev) => {
84-
// Simple merge strategy to avoid flicker
85-
if (status.recent_logs.length === 0) return prev;
86-
87-
const lastPoll = status.recent_logs[status.recent_logs.length - 1];
88-
const lastLocal = prev.length > 0 ? prev[prev.length - 1] : null;
89-
90-
// Update if local is empty OR last message differs OR poll has more logs
91-
if (!lastLocal || lastLocal.message !== lastPoll.message || status.recent_logs.length > prev.length) {
92-
return status.recent_logs;
93-
}
94-
return prev;
95-
});
96-
97-
// Also update connection status heuristic
98-
if (status.status === 'online' || status.status === 'starting') {
99-
if (isMounted) setIsConnected(true);
78+
// Polling Fallback (Every 1s)
79+
// This ensures logs appear even if WS fails (common on Windows with Reset errors)
80+
const intervalId = setInterval(async () => {
81+
try {
82+
// If WS is perfectly connected, we can skip updating from polling to prevent flickering
83+
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
84+
return;
85+
}
86+
const status = await api.getStatus();
87+
if (status.recent_logs && Array.isArray(status.recent_logs)) {
88+
setLogs((prev) => {
89+
// Simple merge strategy to avoid flicker
90+
if (status.recent_logs.length === 0) return prev;
91+
92+
const lastPoll = status.recent_logs[status.recent_logs.length - 1];
93+
const lastLocal = prev.length > 0 ? prev[prev.length - 1] : null;
94+
95+
// Update if local is empty OR last message differs OR poll has more logs
96+
if (!lastLocal || lastLocal.message !== lastPoll.message || status.recent_logs.length > prev.length) {
97+
return status.recent_logs;
10098
}
99+
return prev;
100+
});
101+
102+
// Also update connection status heuristic
103+
if (status.status === 'online' || status.status === 'starting') {
104+
if (isMounted) setIsConnected(true);
101105
}
102-
} catch (e) {
103-
// Ignore polling errors
104106
}
105-
}, 1000);
106-
107-
return () => clearInterval(intervalId);
108-
};
107+
} catch (e) {
108+
// Ignore polling errors
109+
}
110+
}, 1000);
109111

110112
connect();
111113

112114
return () => {
113115
isMounted = false;
116+
clearInterval(intervalId);
114117
// Clear reconnection timeout
115118
if (timeoutId) clearTimeout(timeoutId);
116119

electron-app/src/components/Dashboard.jsx

Lines changed: 91 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -262,24 +262,16 @@ export default function Dashboard({ status: serverStatus, onRefresh }) {
262262
}
263263

264264
// Polling Fallback for Logs (If WS is dead/unstable)
265-
if (serverStatus.recent_logs && Array.isArray(serverStatus.recent_logs) && serverStatus.recent_logs.length > 0) {
265+
const isWsConnected = ws.current && ws.current.readyState === WebSocket.OPEN;
266+
if (!isWsConnected && serverStatus.recent_logs && Array.isArray(serverStatus.recent_logs) && serverStatus.recent_logs.length > 0) {
266267
setLocalLogs(prev => {
267-
// Update if local is empty
268-
if (prev.length === 0) return serverStatus.recent_logs.slice(-100);
269-
270-
// If the last log from polling isn't in our local logs (or differs from our last log),
271-
// it means we missed something or are out of sync.
268+
if (prev.length === 0) return serverStatus.recent_logs.slice(-50);
272269
const lastPoll = serverStatus.recent_logs[serverStatus.recent_logs.length - 1];
273270
const lastLocal = prev[prev.length - 1];
274271

275272
if (lastPoll && (!lastLocal || lastPoll.message !== lastLocal.message)) {
276-
// Check if it's really new (isn't already in the last few local logs)
277-
const isRepetition = prev.slice(-5).some(l => l.message === lastPoll.message && l.time === lastPoll.time);
278-
if (!isRepetition) {
279-
return serverStatus.recent_logs.slice(-100);
280-
}
273+
return serverStatus.recent_logs.slice(-50);
281274
}
282-
283275
return prev;
284276
});
285277
}
@@ -323,96 +315,109 @@ export default function Dashboard({ status: serverStatus, onRefresh }) {
323315

324316
// --- WebSocket for Real-time Logs & Status Updates ---
325317
useEffect(() => {
326-
// Clear previous logs on mount or server switch
327-
setLocalLogs([]);
318+
let timeoutId;
319+
let isMounted = true;
328320

329-
ws.current = new WebSocket('ws://127.0.0.1:8000/ws/console');
321+
const connect = () => {
322+
if (ws.current) {
323+
ws.current.onclose = null;
324+
ws.current.close();
325+
}
330326

331-
ws.current.onopen = () => {
332-
// console.log("Dashboard WS Connected");
333-
};
327+
ws.current = new WebSocket('ws://127.0.0.1:8000/ws/console');
334328

335-
ws.current.onmessage = (event) => {
336-
try {
337-
const data = JSON.parse(event.data);
338-
339-
const processItem = (item, isHistory = false) => {
340-
// 1. Handle explicit status change events from Backend (Fixes stuck on Stopping)
341-
if (item.type === 'status_change') {
342-
setLocalStatus(item.status);
343-
setLoading(false);
344-
if (item.status === 'offline') {
345-
isStoppingRef.current = false;
346-
if (onRefresh) onRefresh();
347-
} else if (item.status === 'online') {
348-
if (onRefresh) onRefresh();
349-
}
350-
return;
351-
}
329+
ws.current.onopen = () => {};
352330

353-
// 2. Handle Tunnel Events
354-
if (item.type === 'tunnel_connected') {
355-
setTunnelAddress(item.address);
356-
setTunnelConnecting(false);
357-
return;
358-
}
359-
if (item.type === 'tunnel_disconnected') {
360-
setTunnelAddress(null);
361-
setTunnelConnecting(false);
362-
return;
363-
}
331+
ws.current.onmessage = (event) => {
332+
if (!isMounted) return;
333+
try {
334+
const data = JSON.parse(event.data);
364335

365-
// Logs - ensure message is a string
366-
if (item.message !== undefined || item.level) {
367-
const msgText = typeof item.message === 'string' ? item.message : JSON.stringify(item.message || '');
368-
369-
setLocalLogs(prev => {
370-
// OPTIMIZACIÓN: Reducir buffer visual en Dashboard
371-
// En PCs lentos, renderizar 100 elementos complejos cuesta mucho.
372-
// Bajamos a 50 para la vista rápida (mini consola).
373-
const newLogs = [...prev, { ...item, message: msgText }];
374-
if (newLogs.length > 50) {
375-
return newLogs.slice(newLogs.length - 50);
336+
const processItem = (item, isHistory = false) => {
337+
if (item.type === 'status_change') {
338+
setLocalStatus(item.status);
339+
setLoading(false);
340+
if (item.status === 'offline') {
341+
isStoppingRef.current = false;
342+
if (onRefresh) onRefresh();
343+
} else if (item.status === 'online') {
344+
if (onRefresh) onRefresh();
376345
}
377-
return newLogs;
378-
});
346+
return;
347+
}
379348

380-
// Fallback: Detect status from log text (Skip for historical logs)
381-
if (isHistory) return;
349+
if (item.type === 'tunnel_connected') {
350+
setTunnelAddress(item.address);
351+
setTunnelConnecting(false);
352+
return;
353+
}
354+
if (item.type === 'tunnel_disconnected') {
355+
setTunnelAddress(null);
356+
setTunnelConnecting(false);
357+
return;
358+
}
382359

383-
const msg = msgText.toString();
384-
if (msg.includes("Done") && msg.includes("For help")) {
385-
setLocalStatus('online');
386-
setLoading(false);
387-
isStoppingRef.current = false;
388-
if (onRefresh) onRefresh();
389-
} else if (msg.includes("Stopping server") || msg.includes("Stopping the server")) {
390-
setLocalStatus('stopping');
391-
isStoppingRef.current = true;
360+
if (item.message !== undefined || item.level) {
361+
const msgText = typeof item.message === 'string' ? item.message : JSON.stringify(item.message || '');
362+
363+
setLocalLogs(prev => {
364+
const newLogs = [...prev, { ...item, message: msgText }];
365+
return newLogs.length > 50 ? newLogs.slice(newLogs.length - 50) : newLogs;
366+
});
367+
368+
if (isHistory) return;
369+
370+
const msg = msgText.toString();
371+
if (msg.includes("Done") && msg.includes("For help")) {
372+
setLocalStatus('online');
373+
setLoading(false);
374+
isStoppingRef.current = false;
375+
if (onRefresh) onRefresh();
376+
} else if (msg.includes("Stopping server") || msg.includes("Stopping the server")) {
377+
setLocalStatus('stopping');
378+
isStoppingRef.current = true;
379+
}
392380
}
393-
}
394-
};
395-
396-
if (data.type === 'batch' && Array.isArray(data.items)) {
397-
const logsBatch = data.items.filter(i => i.message !== undefined || i.level).map(i => {
398-
return { ...i, message: typeof i.message === 'string' ? i.message : JSON.stringify(i.message || '') };
399-
});
400-
if (logsBatch.length > 0) {
401-
setLocalLogs(prev => {
402-
const newLogs = [...prev, ...logsBatch];
403-
return newLogs.length > 50 ? newLogs.slice(-50) : newLogs;
381+
};
382+
383+
if (data.type === 'batch' && Array.isArray(data.items)) {
384+
const logsBatch = data.items.filter(i => i.message !== undefined || i.level).map(i => {
385+
return { ...i, message: typeof i.message === 'string' ? i.message : JSON.stringify(i.message || '') };
404386
});
387+
if (logsBatch.length > 0) {
388+
setLocalLogs(prev => {
389+
const newLogs = [...prev, ...logsBatch];
390+
return newLogs.length > 50 ? newLogs.slice(-50) : newLogs;
391+
});
392+
}
393+
} else {
394+
processItem(data, false);
405395
}
406-
} else {
407-
processItem(data, false);
396+
} catch (e) { }
397+
};
398+
399+
ws.current.onclose = () => {
400+
if (isMounted) {
401+
timeoutId = setTimeout(connect, 3000);
408402
}
409-
} catch (e) { }
403+
};
404+
405+
ws.current.onerror = (err) => {
406+
console.error('WS Error:', err);
407+
};
410408
};
411409

410+
connect();
411+
412412
return () => {
413-
if (ws.current) ws.current.close();
413+
isMounted = false;
414+
clearTimeout(timeoutId);
415+
if (ws.current) {
416+
ws.current.onclose = null;
417+
ws.current.close();
418+
}
414419
};
415-
}, []); // Empty dependency array ensures this runs once per mount (which happens on server switch due to App.jsx key)
420+
}, []);
416421

417422
// Robust Auto-scroll logs
418423
useEffect(() => {

0 commit comments

Comments
 (0)