Skip to content

Commit d56e56b

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 d56e56b

3 files changed

Lines changed: 105 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: 81 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -589,63 +589,66 @@ 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+
if self.server_type in ["forge", "neoforge"]:
594+
lib_path = os.path.join(self.server_path, "libraries")
595+
if os.path.exists(lib_path):
596+
candidates = []
597+
forge_path_marker = "neoforged/neoforge" if self.server_type == "neoforge" else "minecraftforge/forge"
598+
for root, dirs, files in os.walk(lib_path):
599+
for file in files:
600+
if file.endswith(".jar") and "installer" not in file.lower():
601+
full_path = os.path.join(root, file)
602+
score = 0
603+
if forge_path_marker in root.replace("\\", "/"):
604+
score = 10 # Highest priority: in correct forge/neoforge path
605+
elif self.server_type in file.lower():
606+
score = 5 # Medium: has forge/neoforge in filename
607+
candidates.append((score, full_path))
608+
if candidates:
609+
candidates.sort(key=lambda x: x[0], reverse=True)
610+
server_jar_path = candidates[0][1]
611+
612+
# Fallback to root-level jars if nothing found in libraries
613+
if not server_jar_path:
614+
non_installer_jars = [
615+
j for j in all_jars if "installer" not in os.path.basename(j).lower()
604616
]
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
617+
if non_installer_jars:
618+
preferred_names = [
619+
"server.jar",
620+
"minecraft_server.jar",
621+
"paper.jar",
622+
"spigot.jar",
623+
"fabric-server-launch.jar",
624+
]
625+
for name in preferred_names:
626+
for jar in non_installer_jars:
627+
if os.path.basename(jar).lower() == name:
628+
server_jar_path = jar
629+
break
630+
if server_jar_path:
609631
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]
632+
if not server_jar_path:
633+
server_jar_path = non_installer_jars[0]
634+
elif all_jars:
635+
server_jar_path = all_jars[0]
619636

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:
637+
if server_jar_path:
638+
lib_jar = "libraries" in server_jar_path
639+
self.output_callback(
640+
f"Found server JAR: {os.path.basename(server_jar_path)}{' (in libraries/)' if lib_jar else ''}\n",
641+
"success",
642+
)
643+
else:
640644
# Audit directory before failing
641645
try:
642646
files = os.listdir(self.server_path)
643647
logging.info(f"Handler: Server Directory Audit: {files}")
644648
except Exception as ae:
645649
logging.error(f"Handler: Directory Audit FAILED: {ae}")
646-
647650
self.output_callback(
648-
"Error: No server .jar file or run script found in the directory.\n",
651+
"Error: No server .jar file found in the directory.\n",
649652
"error",
650653
)
651654
return None, None
@@ -723,9 +726,9 @@ def _get_start_command(self):
723726
)
724727
# Try to run it without nogui if it's an installer,
725728
# though it probably won't start the server.
726-
command.extend(["-jar", os.path.basename(server_jar_path)])
729+
command.extend(["-jar", server_jar_path])
727730
else:
728-
command.extend(["-jar", os.path.basename(server_jar_path), "--nogui"])
731+
command.extend(["-jar", server_jar_path, "--nogui"])
729732

730733
return command, custom_env
731734

@@ -810,15 +813,25 @@ def _run_server(self, command, env):
810813
logging.error(f"Handler: CRASH in _run_server thread:\n{error_details}")
811814
self._log(f"Server start failed: {e}\n", "error")
812815
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.
816+
# Wait for reader threads to finish naturally first
815817
try:
816818
if stdout_thread:
817819
stdout_thread.join(timeout=2)
818820
if stderr_thread:
819821
stderr_thread.join(timeout=2)
820822
except:
821823
pass
824+
# Force-close pipes only if threads are still alive (zombie prevention)
825+
try:
826+
if self.server_process:
827+
if stdout_thread and stdout_thread.is_alive():
828+
if self.server_process.stdout and not self.server_process.stdout.closed:
829+
self.server_process.stdout.close()
830+
if stderr_thread and stderr_thread.is_alive():
831+
if self.server_process.stderr and not self.server_process.stderr.closed:
832+
self.server_process.stderr.close()
833+
except:
834+
pass
822835

823836
# --- CRITICAL FIX: ACTUALIZAR ESTADO INTERNO PRIMERO ---
824837
# Guardamos el estado previo para el log
@@ -1012,7 +1025,8 @@ def _read_output(self, pipe, level):
10121025
f"Handler: Log reader thread started for {level} (Chunked Binary mode)"
10131026
)
10141027

1015-
buffer = b""
1028+
buffer = bytearray()
1029+
MAX_BUFFER = 1_048_576 # 1 MB cap to prevent memory leak
10161030
while True:
10171031
chunk = pipe.read(4096)
10181032
if not chunk:
@@ -1029,7 +1043,20 @@ def _read_output(self, pipe, level):
10291043
)
10301044
break
10311045

1032-
buffer += chunk
1046+
buffer.extend(chunk)
1047+
# Force-flush if buffer exceeds cap (prevents unbounded growth)
1048+
if len(buffer) > MAX_BUFFER:
1049+
self._process_log_line(
1050+
buffer.decode("utf-8", errors="replace"),
1051+
level,
1052+
re,
1053+
join_pattern,
1054+
leave_pattern,
1055+
list_inline_pattern,
1056+
list_header_pattern,
1057+
)
1058+
buffer = bytearray()
1059+
continue
10331060
logging.debug(
10341061
f"Handler: Received chunk of {len(chunk)} bytes from {level}"
10351062
)
@@ -1052,7 +1079,7 @@ def _read_output(self, pipe, level):
10521079
sep_len = 2 # Handle \r\n
10531080

10541081
line_bytes = buffer[:idx]
1055-
buffer = buffer[idx + sep_len :]
1082+
buffer = bytearray(buffer[idx + sep_len :])
10561083

10571084
line = line_bytes.decode("utf-8", errors="replace")
10581085
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)