Skip to content

Commit c0f9f79

Browse files
committed
v1.2.6: Fix memory leak, run.sh parsing & jar detection
- Parse run.sh/run.bat to extract Java command instead of skipping scripts. Executes Java directly (not via sh), fixing readline crash on Gentoo Linux/AppImage (Issue #11). - Cap binary read buffer at 1MB to prevent unbounded memory 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. - Libraries search with priority scoring (server jars preferred). - Use full jar path (not basename). - Use bytearray for buffer to reduce fragmentation.
1 parent 9df2e2a commit c0f9f79

3 files changed

Lines changed: 176 additions & 59 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: 152 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -581,71 +581,113 @@ def _get_start_command(self):
581581
custom_env["PATH"] = f"{java_bin_dir}{path_sep}{custom_env.get('PATH', '')}"
582582
logging.info(f"Handler: Injected Environment - JAVA_HOME={java_root}")
583583

584-
# JAR-based startup (skipping run.sh/run.bat scripts to avoid
585-
# readline/library conflicts on Linux/AppImage environments)
584+
# Try to parse run.sh/run.bat to extract the java command
585+
# (avoids executing sh/bat directly, which can cause readline/library crashes)
586+
run_script = None
587+
script_names = (
588+
["run.bat", "start.bat", "launch.bat"]
589+
if sys.platform == "win32"
590+
else ["run.sh", "start.sh", "launch.sh"]
591+
)
592+
for name in script_names:
593+
p = os.path.join(self.server_path, name)
594+
if os.path.exists(p):
595+
run_script = p
596+
break
597+
598+
if run_script:
599+
parsed = self._parse_startup_script(run_script)
600+
if parsed:
601+
if "nogui" not in parsed:
602+
parsed.append("nogui")
603+
command = [java_path] + parsed
604+
self.output_callback(
605+
"Parsed startup script: using direct Java launch.\n", "info"
606+
)
607+
return command, custom_env
608+
609+
# Fallback: JAR-based startup
586610
self.output_callback(
587-
"Starting server via JAR-based method (skipping startup scripts).\n", "info"
611+
"Starting server via JAR-based method.\n", "info"
588612
)
589613
all_jars = glob.glob(os.path.join(self.server_path, "*.jar"))
590614
server_jar_path = None
591615

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",
616+
# Check for forge/neoforge server JAR in libraries/ first
617+
lib_fallback = None
618+
if self.server_type in ["forge", "neoforge"]:
619+
lib_path = os.path.join(self.server_path, "libraries")
620+
if os.path.exists(lib_path):
621+
candidates = []
622+
forge_path_marker = "neoforged/neoforge" if self.server_type == "neoforge" else "minecraftforge/forge"
623+
for root, dirs, files in os.walk(lib_path):
624+
for file in files:
625+
if file.endswith(".jar") and "installer" not in file.lower():
626+
full_path = os.path.join(root, file)
627+
score = 0
628+
if forge_path_marker in root.replace("\\", "/"):
629+
score = 10
630+
elif self.server_type in file.lower():
631+
score = 5
632+
if "server" in file.lower():
633+
score += 2
634+
candidates.append((score, full_path))
635+
if candidates:
636+
candidates.sort(key=lambda x: x[0], reverse=True)
637+
best = candidates[0]
638+
lib_fallback = best[1]
639+
# Only use library jar if it has "server" in the name
640+
if "server" in os.path.basename(best[1]).lower():
641+
server_jar_path = best[1]
642+
643+
# Fallback to root-level jars
644+
if not server_jar_path:
645+
non_installer_jars = [
646+
j for j in all_jars if "installer" not in os.path.basename(j).lower()
604647
]
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
648+
if non_installer_jars:
649+
preferred_names = [
650+
"server.jar",
651+
"minecraft_server.jar",
652+
"paper.jar",
653+
"spigot.jar",
654+
"fabric-server-launch.jar",
655+
]
656+
for name in preferred_names:
657+
for jar in non_installer_jars:
658+
if os.path.basename(jar).lower() == name:
659+
server_jar_path = jar
660+
break
661+
if server_jar_path:
609662
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]
619-
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")
663+
if not server_jar_path:
664+
server_jar_path = non_installer_jars[0]
665+
elif all_jars:
666+
server_jar_path = all_jars[0]
667+
668+
# If nothing found in root, fall back to best library candidate
669+
if not server_jar_path and lib_fallback:
670+
server_jar_path = lib_fallback
671+
self.output_callback(
672+
"Using library JAR as fallback. Consider reinstalling the server.\n",
673+
"warning",
674+
)
638675

639-
if not server_jar_path:
676+
if server_jar_path:
677+
lib_jar = "libraries" in server_jar_path
678+
self.output_callback(
679+
f"Found server JAR: {os.path.basename(server_jar_path)}{' (in libraries/)' if lib_jar else ''}\n",
680+
"success",
681+
)
682+
else:
640683
# Audit directory before failing
641684
try:
642685
files = os.listdir(self.server_path)
643686
logging.info(f"Handler: Server Directory Audit: {files}")
644687
except Exception as ae:
645688
logging.error(f"Handler: Directory Audit FAILED: {ae}")
646-
647689
self.output_callback(
648-
"Error: No server .jar file or run script found in the directory.\n",
690+
"Error: No server .jar file found in the directory.\n",
649691
"error",
650692
)
651693
return None, None
@@ -723,12 +765,41 @@ def _get_start_command(self):
723765
)
724766
# Try to run it without nogui if it's an installer,
725767
# though it probably won't start the server.
726-
command.extend(["-jar", os.path.basename(server_jar_path)])
768+
command.extend(["-jar", server_jar_path])
727769
else:
728-
command.extend(["-jar", os.path.basename(server_jar_path), "--nogui"])
770+
command.extend(["-jar", server_jar_path, "--nogui"])
729771

730772
return command, custom_env
731773

774+
def _parse_startup_script(self, script_path):
775+
"""Extract Java command args from run.sh/run.bat without executing sh/bat."""
776+
try:
777+
with open(script_path, "r") as f:
778+
content = f.read()
779+
for line in content.splitlines():
780+
stripped = line.strip()
781+
if stripped.startswith("#"):
782+
continue
783+
# Find the java invocation line
784+
# Matches: "java @args.txt ... nogui" or "java @args.txt ..."
785+
if re.match(r'^\s*java\s', stripped):
786+
# Extract tokens after 'java'
787+
# Handle quotes and @file args
788+
import shlex
789+
tokens = shlex.split(stripped, posix=sys.platform != "win32")
790+
# Find index of 'java' and take everything after it
791+
for i, token in enumerate(tokens):
792+
if token == "java":
793+
args = tokens[i + 1:]
794+
# Remove trailing shell variables like "$@" or "%*"
795+
args = [a for a in args if a not in ('"$@"', '$@', '%*')]
796+
if args:
797+
return args
798+
return None
799+
except Exception as e:
800+
logging.warning(f"Handler: Could not parse startup script {script_path}: {e}")
801+
return None
802+
732803
def _is_java_version_compatible(
733804
self, installed_major: int, required_version: int
734805
) -> bool:
@@ -810,15 +881,25 @@ def _run_server(self, command, env):
810881
logging.error(f"Handler: CRASH in _run_server thread:\n{error_details}")
811882
self._log(f"Server start failed: {e}\n", "error")
812883
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.
884+
# Wait for reader threads to finish naturally first
815885
try:
816886
if stdout_thread:
817887
stdout_thread.join(timeout=2)
818888
if stderr_thread:
819889
stderr_thread.join(timeout=2)
820890
except:
821891
pass
892+
# Force-close pipes only if threads are still alive (zombie prevention)
893+
try:
894+
if self.server_process:
895+
if stdout_thread and stdout_thread.is_alive():
896+
if self.server_process.stdout and not self.server_process.stdout.closed:
897+
self.server_process.stdout.close()
898+
if stderr_thread and stderr_thread.is_alive():
899+
if self.server_process.stderr and not self.server_process.stderr.closed:
900+
self.server_process.stderr.close()
901+
except:
902+
pass
822903

823904
# --- CRITICAL FIX: ACTUALIZAR ESTADO INTERNO PRIMERO ---
824905
# Guardamos el estado previo para el log
@@ -1012,7 +1093,8 @@ def _read_output(self, pipe, level):
10121093
f"Handler: Log reader thread started for {level} (Chunked Binary mode)"
10131094
)
10141095

1015-
buffer = b""
1096+
buffer = bytearray()
1097+
MAX_BUFFER = 1_048_576 # 1 MB cap to prevent memory leak
10161098
while True:
10171099
chunk = pipe.read(4096)
10181100
if not chunk:
@@ -1029,7 +1111,20 @@ def _read_output(self, pipe, level):
10291111
)
10301112
break
10311113

1032-
buffer += chunk
1114+
buffer.extend(chunk)
1115+
# Force-flush if buffer exceeds cap (prevents unbounded growth)
1116+
if len(buffer) > MAX_BUFFER:
1117+
self._process_log_line(
1118+
buffer.decode("utf-8", errors="replace"),
1119+
level,
1120+
re,
1121+
join_pattern,
1122+
leave_pattern,
1123+
list_inline_pattern,
1124+
list_header_pattern,
1125+
)
1126+
buffer = bytearray()
1127+
continue
10331128
logging.debug(
10341129
f"Handler: Received chunk of {len(chunk)} bytes from {level}"
10351130
)
@@ -1052,7 +1147,7 @@ def _read_output(self, pipe, level):
10521147
sep_len = 2 # Handle \r\n
10531148

10541149
line_bytes = buffer[:idx]
1055-
buffer = buffer[idx + sep_len :]
1150+
buffer = bytearray(buffer[idx + sep_len :])
10561151

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