Skip to content

Commit aa0ce5f

Browse files
committed
v1.2.7: Fix massive memory leak in api_server process
- Cap binary read buffer at 1MB to prevent unbounded growth - Close pipes before joining reader threads to prevent zombie threads - Clean up active_handlers on server delete - Evict oldest entries from world_size_cache - Use bytearray for buffer to reduce fragmentation
1 parent 9df2e2a commit aa0ce5f

4 files changed

Lines changed: 52 additions & 7 deletions

File tree

backend/api_server.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ def __init__(self):
209209
self.world_size_cache = {}
210210
self.world_size_inflight = set()
211211
self.world_size_lock = threading.Lock()
212+
self.world_size_cache_max = 100
212213

213214
# Multi-server management
214215
self.active_handlers = {} # server_id -> ServerHandler
@@ -567,8 +568,14 @@ async def delete_server(server_id: str, delete_files: bool = False):
567568

568569
# Delete the profile
569570
state.config_manager.delete_server(server_id)
571+
# Clean up handler from active_handlers to prevent memory leaks
572+
if server_id in state.active_handlers:
573+
handler = state.active_handlers.pop(server_id)
574+
try:
575+
handler.stop()
576+
except:
577+
pass
570578
if state.selected_server_id == server_id:
571-
state.server_handler = None
572579
state.selected_server_id = None
573580

574581
# Optionally delete files
@@ -1580,6 +1587,11 @@ def _compute_world_size(
15801587
"size": f"{size_mb} MB",
15811588
"folder_mtime": expected_folder_mtime,
15821589
}
1590+
# Evict oldest entries if cache exceeds limit
1591+
if len(state.world_size_cache) > state.world_size_cache_max:
1592+
excess = sorted(state.world_size_cache.keys())[:-state.world_size_cache_max]
1593+
for k in excess:
1594+
del state.world_size_cache[k]
15831595
finally:
15841596
if state:
15851597
with state.world_size_lock:

backend/server/server_handler.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -810,8 +810,16 @@ def _run_server(self, command, env):
810810
logging.error(f"Handler: CRASH in _run_server thread:\n{error_details}")
811811
self._log(f"Server start failed: {e}\n", "error")
812812
finally:
813-
# Esperar a que los hilos de salida terminen de procesar los últimos logs
814-
# Esto evita que el estado se quede en 'stopping' si el log llega justo al final.
813+
# Close pipes first so reader threads can unblock and terminate
814+
try:
815+
if self.server_process:
816+
for pipe_name in ["stdout", "stderr"]:
817+
pipe = getattr(self.server_process, pipe_name, None)
818+
if pipe and not pipe.closed:
819+
pipe.close()
820+
except:
821+
pass
822+
# Then wait for reader threads to finish (they hold references to this handler)
815823
try:
816824
if stdout_thread:
817825
stdout_thread.join(timeout=2)
@@ -1012,7 +1020,8 @@ def _read_output(self, pipe, level):
10121020
f"Handler: Log reader thread started for {level} (Chunked Binary mode)"
10131021
)
10141022

1015-
buffer = b""
1023+
buffer = bytearray()
1024+
MAX_BUFFER = 1_048_576 # 1 MB cap to prevent memory leak
10161025
while True:
10171026
chunk = pipe.read(4096)
10181027
if not chunk:
@@ -1029,7 +1038,20 @@ def _read_output(self, pipe, level):
10291038
)
10301039
break
10311040

1032-
buffer += chunk
1041+
buffer.extend(chunk)
1042+
# Force-flush if buffer exceeds cap (prevents unbounded growth)
1043+
if len(buffer) > MAX_BUFFER:
1044+
self._process_log_line(
1045+
buffer.decode("utf-8", errors="replace"),
1046+
level,
1047+
re,
1048+
join_pattern,
1049+
leave_pattern,
1050+
list_inline_pattern,
1051+
list_header_pattern,
1052+
)
1053+
buffer = bytearray()
1054+
continue
10331055
logging.debug(
10341056
f"Handler: Received chunk of {len(chunk)} bytes from {level}"
10351057
)
@@ -1052,7 +1074,7 @@ def _read_output(self, pipe, level):
10521074
sep_len = 2 # Handle \r\n
10531075

10541076
line_bytes = buffer[:idx]
1055-
buffer = buffer[idx + sep_len :]
1077+
buffer = bytearray(buffer[idx + sep_len :])
10561078

10571079
line = line_bytes.decode("utf-8", errors="replace")
10581080
self._process_log_line(

changelog.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Minecraft Server GUI - Changelog
22

3+
## Version 1.2.7 - 2026-06-02 MEMORY LEAK FIX
4+
5+
### CRITICAL FIX:
6+
- **Fixed massive memory leak in `api_server`** — The binary read buffer in log reader threads could grow unboundedly when output had no newlines. Now capped at 1MB and force-flushed.
7+
- **Fixed zombie reader threads** — Pipes are now closed before joining threads, preventing stuck daemon threads from holding references to old `ServerHandler` instances and their log history.
8+
- **Fixed `active_handlers` leak** — Deleting a server now properly removes its handler from memory.
9+
- **Fixed `world_size_cache` unbounded growth** — Cache now evicts oldest entries when exceeding 100 items.
10+
- **Reduced memory fragmentation** — Changed `bytes +=` concatenation to `bytearray.extend()` in log reader.
11+
12+
---
13+
314
## Version 1.2.6 - 2026-06-02 READLINE / STARTUP SCRIPTS FIX
415

516
### FIXED:

electron-app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "minecraft-server-gui",
33
"private": true,
4-
"version": "1.2.6",
4+
"version": "1.2.7",
55
"repository": {
66
"type": "git",
77
"url": "https://github.com/CalaKuad1/Minecraft-Local-Server-GUI.git"

0 commit comments

Comments
 (0)