Skip to content

Commit 89c2542

Browse files
committed
v1.2.6: Fix memory leak, jar detection & startup scripts crash
- Search libraries/ with priority scoring: correct forge/neoforge path gets highest priority, then filename match, then any non-installer jar. Fixes picking random libraries (e.g. lz4-java) instead of server jar. - Cap binary read buffer at 1MB to prevent unbounded growth - Close pipes only if reader threads are still alive (zombie prevention) - Clean up active_handlers on server delete - Evict oldest entries from world_size_cache - Use full jar path instead of basename - Use bytearray for buffer to reduce fragmentation - Skip run.sh/run.bat scripts (fixes readline crash on Gentoo/AppImage)
1 parent 9df2e2a commit 89c2542

3 files changed

Lines changed: 112 additions & 56 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: 88 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -589,63 +589,73 @@ def _get_start_command(self):
589589
all_jars = glob.glob(os.path.join(self.server_path, "*.jar"))
590590
server_jar_path = None
591591

592-
non_installer_jars = [
593-
j for j in all_jars if "installer" not in os.path.basename(j).lower()
594-
]
595-
596-
if non_installer_jars:
597-
# Look for common server jar names first
598-
preferred_names = [
599-
"server.jar",
600-
"minecraft_server.jar",
601-
"paper.jar",
602-
"spigot.jar",
603-
"fabric-server-launch.jar",
592+
# Check for forge/neoforge server JAR in libraries/ first
593+
lib_fallback = None
594+
if self.server_type in ["forge", "neoforge"]:
595+
lib_path = os.path.join(self.server_path, "libraries")
596+
if os.path.exists(lib_path):
597+
candidates = []
598+
forge_path_marker = "neoforged/neoforge" if self.server_type == "neoforge" else "minecraftforge/forge"
599+
for root, dirs, files in os.walk(lib_path):
600+
for file in files:
601+
if file.endswith(".jar") and "installer" not in file.lower():
602+
full_path = os.path.join(root, file)
603+
score = 0
604+
if forge_path_marker in root.replace("\\", "/"):
605+
score = 10
606+
elif self.server_type in file.lower():
607+
score = 5
608+
if "server" in file.lower():
609+
score += 2
610+
candidates.append((score, full_path))
611+
if candidates:
612+
candidates.sort(key=lambda x: x[0], reverse=True)
613+
best = candidates[0]
614+
lib_fallback = best[1]
615+
# Only use library jar if it has "server" in the name
616+
if "server" in os.path.basename(best[1]).lower():
617+
server_jar_path = best[1]
618+
619+
# Fallback to root-level jars
620+
if not server_jar_path:
621+
non_installer_jars = [
622+
j for j in all_jars if "installer" not in os.path.basename(j).lower()
604623
]
605-
for name in preferred_names:
606-
for jar in non_installer_jars:
607-
if os.path.basename(jar).lower() == name:
608-
server_jar_path = jar
624+
if non_installer_jars:
625+
preferred_names = [
626+
"server.jar",
627+
"minecraft_server.jar",
628+
"paper.jar",
629+
"spigot.jar",
630+
"fabric-server-launch.jar",
631+
]
632+
for name in preferred_names:
633+
for jar in non_installer_jars:
634+
if os.path.basename(jar).lower() == name:
635+
server_jar_path = jar
636+
break
637+
if server_jar_path:
609638
break
610-
if server_jar_path:
611-
break
612-
613-
# If no preferred name is found, take the first non-installer jar
614-
if not server_jar_path:
615-
server_jar_path = non_installer_jars[0]
616-
617-
elif all_jars: # Fallback if only installer jars are present for some reason
618-
server_jar_path = all_jars[0]
639+
if not server_jar_path:
640+
server_jar_path = non_installer_jars[0]
641+
elif all_jars:
642+
server_jar_path = all_jars[0]
619643

620-
if not server_jar_path:
621-
# Special check for Forge/NeoForge libraries if run.bat is missing
622-
if self.server_type in ["forge", "neoforge"]:
623-
self.output_callback("Startup script missing. Checking libraries for server JAR...\n", "info")
624-
lib_path = os.path.join(self.server_path, "libraries")
625-
if os.path.exists(lib_path):
626-
# Search for any jar that looks like a forge/neoforge server jar
627-
for root, dirs, files in os.walk(lib_path):
628-
for file in files:
629-
if file.endswith(".jar") and ("forge" in file.lower() or "neoforge" in file.lower()) and "server" in file.lower():
630-
server_jar_path = os.path.join(root, file)
631-
break
632-
if server_jar_path: break
633-
634-
if server_jar_path:
635-
self.output_callback(f"Found server JAR in libraries: {os.path.basename(server_jar_path)}\n", "success")
636-
else:
637-
self.output_callback("Could not find server JAR in libraries. Installation might be incomplete.\n", "warning")
638-
639-
if not server_jar_path:
644+
if server_jar_path:
645+
lib_jar = "libraries" in server_jar_path
646+
self.output_callback(
647+
f"Found server JAR: {os.path.basename(server_jar_path)}{' (in libraries/)' if lib_jar else ''}\n",
648+
"success",
649+
)
650+
else:
640651
# Audit directory before failing
641652
try:
642653
files = os.listdir(self.server_path)
643654
logging.info(f"Handler: Server Directory Audit: {files}")
644655
except Exception as ae:
645656
logging.error(f"Handler: Directory Audit FAILED: {ae}")
646-
647657
self.output_callback(
648-
"Error: No server .jar file or run script found in the directory.\n",
658+
"Error: No server .jar file found in the directory.\n",
649659
"error",
650660
)
651661
return None, None
@@ -723,9 +733,9 @@ def _get_start_command(self):
723733
)
724734
# Try to run it without nogui if it's an installer,
725735
# though it probably won't start the server.
726-
command.extend(["-jar", os.path.basename(server_jar_path)])
736+
command.extend(["-jar", server_jar_path])
727737
else:
728-
command.extend(["-jar", os.path.basename(server_jar_path), "--nogui"])
738+
command.extend(["-jar", server_jar_path, "--nogui"])
729739

730740
return command, custom_env
731741

@@ -810,15 +820,25 @@ def _run_server(self, command, env):
810820
logging.error(f"Handler: CRASH in _run_server thread:\n{error_details}")
811821
self._log(f"Server start failed: {e}\n", "error")
812822
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.
823+
# Wait for reader threads to finish naturally first
815824
try:
816825
if stdout_thread:
817826
stdout_thread.join(timeout=2)
818827
if stderr_thread:
819828
stderr_thread.join(timeout=2)
820829
except:
821830
pass
831+
# Force-close pipes only if threads are still alive (zombie prevention)
832+
try:
833+
if self.server_process:
834+
if stdout_thread and stdout_thread.is_alive():
835+
if self.server_process.stdout and not self.server_process.stdout.closed:
836+
self.server_process.stdout.close()
837+
if stderr_thread and stderr_thread.is_alive():
838+
if self.server_process.stderr and not self.server_process.stderr.closed:
839+
self.server_process.stderr.close()
840+
except:
841+
pass
822842

823843
# --- CRITICAL FIX: ACTUALIZAR ESTADO INTERNO PRIMERO ---
824844
# Guardamos el estado previo para el log
@@ -1012,7 +1032,8 @@ def _read_output(self, pipe, level):
10121032
f"Handler: Log reader thread started for {level} (Chunked Binary mode)"
10131033
)
10141034

1015-
buffer = b""
1035+
buffer = bytearray()
1036+
MAX_BUFFER = 1_048_576 # 1 MB cap to prevent memory leak
10161037
while True:
10171038
chunk = pipe.read(4096)
10181039
if not chunk:
@@ -1029,7 +1050,20 @@ def _read_output(self, pipe, level):
10291050
)
10301051
break
10311052

1032-
buffer += chunk
1053+
buffer.extend(chunk)
1054+
# Force-flush if buffer exceeds cap (prevents unbounded growth)
1055+
if len(buffer) > MAX_BUFFER:
1056+
self._process_log_line(
1057+
buffer.decode("utf-8", errors="replace"),
1058+
level,
1059+
re,
1060+
join_pattern,
1061+
leave_pattern,
1062+
list_inline_pattern,
1063+
list_header_pattern,
1064+
)
1065+
buffer = bytearray()
1066+
continue
10331067
logging.debug(
10341068
f"Handler: Received chunk of {len(chunk)} bytes from {level}"
10351069
)
@@ -1052,7 +1086,7 @@ def _read_output(self, pipe, level):
10521086
sep_len = 2 # Handle \r\n
10531087

10541088
line_bytes = buffer[:idx]
1055-
buffer = buffer[idx + sep_len :]
1089+
buffer = bytearray(buffer[idx + sep_len :])
10561090

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

changelog.txt

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

3-
## Version 1.2.6 - 2026-06-02 READLINE / STARTUP SCRIPTS FIX
3+
## Version 1.2.6 - 2026-06-02 MEMORY LEAK & STARTUP SCRIPTS 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+
### FIXED:
13+
- **Startup scripts (run.sh/run.bat) no longer executed** — The app now uses direct JAR-based startup instead of running `run.sh`/`run.bat` scripts. Fixes `undefined symbol: rl_macro_display_hook` crash on Gentoo Linux / AppImage environments (Issue #11)
414

515
### FIXED:
616
- **Startup scripts (run.sh/run.bat) no longer executed** — The app now uses direct JAR-based startup instead of running `run.sh`/`run.bat` scripts. Fixes `undefined symbol: rl_macro_display_hook` crash on Gentoo Linux / AppImage environments (Issue #11)

0 commit comments

Comments
 (0)