Skip to content

Commit caf77b7

Browse files
committed
v1.2.6: Fix memory leak, run.sh parsing, jar detection, detailed view, java path
- Parse run.sh/run.bat to extract Java command (fixes readline crash, Issue #11) - Fix detailed view button: add viewMode, onClick, grid/list toggle (Issue #13) - Fix Java path not applied: /app-settings updates handlers, _setup_java_for_minecraft no longer overrides user-set paths (Issue #14) - Add Browse button in Settings to select Java executable via file dialog - Cap binary read buffer at 1MB to prevent memory leak - Close pipes only if reader threads are still alive - Clean up active_handlers on server delete - Libraries search with priority scoring - Use full jar path and bytearray for buffer
1 parent 9df2e2a commit caf77b7

12 files changed

Lines changed: 239 additions & 70 deletions

File tree

backend/api_server.py

Lines changed: 19 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
@@ -516,6 +517,12 @@ async def update_app_settings(request: Request):
516517
state.config_manager.config["app_settings"] = {}
517518
state.config_manager.config["app_settings"].update(body)
518519
state.config_manager.save()
520+
# Also update active handlers' java_path
521+
if "java_path" in body:
522+
for handler in state.active_handlers.values():
523+
handler.java_path = body["java_path"]
524+
if state.server_handler:
525+
state.server_handler.java_path = body["java_path"]
519526
return state.config_manager.config["app_settings"]
520527

521528

@@ -567,8 +574,14 @@ async def delete_server(server_id: str, delete_files: bool = False):
567574

568575
# Delete the profile
569576
state.config_manager.delete_server(server_id)
577+
# Clean up handler from active_handlers to prevent memory leaks
578+
if server_id in state.active_handlers:
579+
handler = state.active_handlers.pop(server_id)
580+
try:
581+
handler.stop()
582+
except:
583+
pass
570584
if state.selected_server_id == server_id:
571-
state.server_handler = None
572585
state.selected_server_id = None
573586

574587
# Optionally delete files
@@ -1580,6 +1593,11 @@ def _compute_world_size(
15801593
"size": f"{size_mb} MB",
15811594
"folder_mtime": expected_folder_mtime,
15821595
}
1596+
# Evict oldest entries if cache exceeds limit
1597+
if len(state.world_size_cache) > state.world_size_cache_max:
1598+
excess = sorted(state.world_size_cache.keys())[:-state.world_size_cache_max]
1599+
for k in excess:
1600+
del state.world_size_cache[k]
15831601
finally:
15841602
if state:
15851603
with state.world_size_lock:

backend/server/server_handler.py

Lines changed: 155 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ def _log(self, message, level="normal"):
114114

115115
def _setup_java_for_minecraft(self, minecraft_version):
116116
"""Configura automáticamente la versión correcta de Java para Minecraft."""
117+
# Skip auto-setup if user has explicitly set a custom Java path
118+
if self.java_path and self.java_path != "java":
119+
return
117120
try:
118121
self._log(
119122
f"Configuring Java for Minecraft {minecraft_version}...\n", "info"
@@ -581,71 +584,113 @@ def _get_start_command(self):
581584
custom_env["PATH"] = f"{java_bin_dir}{path_sep}{custom_env.get('PATH', '')}"
582585
logging.info(f"Handler: Injected Environment - JAVA_HOME={java_root}")
583586

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

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

639-
if not server_jar_path:
679+
if server_jar_path:
680+
lib_jar = "libraries" in server_jar_path
681+
self.output_callback(
682+
f"Found server JAR: {os.path.basename(server_jar_path)}{' (in libraries/)' if lib_jar else ''}\n",
683+
"success",
684+
)
685+
else:
640686
# Audit directory before failing
641687
try:
642688
files = os.listdir(self.server_path)
643689
logging.info(f"Handler: Server Directory Audit: {files}")
644690
except Exception as ae:
645691
logging.error(f"Handler: Directory Audit FAILED: {ae}")
646-
647692
self.output_callback(
648-
"Error: No server .jar file or run script found in the directory.\n",
693+
"Error: No server .jar file found in the directory.\n",
649694
"error",
650695
)
651696
return None, None
@@ -723,12 +768,41 @@ def _get_start_command(self):
723768
)
724769
# Try to run it without nogui if it's an installer,
725770
# though it probably won't start the server.
726-
command.extend(["-jar", os.path.basename(server_jar_path)])
771+
command.extend(["-jar", server_jar_path])
727772
else:
728-
command.extend(["-jar", os.path.basename(server_jar_path), "--nogui"])
773+
command.extend(["-jar", server_jar_path, "--nogui"])
729774

730775
return command, custom_env
731776

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

823907
# --- CRITICAL FIX: ACTUALIZAR ESTADO INTERNO PRIMERO ---
824908
# Guardamos el estado previo para el log
@@ -1012,7 +1096,8 @@ def _read_output(self, pipe, level):
10121096
f"Handler: Log reader thread started for {level} (Chunked Binary mode)"
10131097
)
10141098

1015-
buffer = b""
1099+
buffer = bytearray()
1100+
MAX_BUFFER = 1_048_576 # 1 MB cap to prevent memory leak
10161101
while True:
10171102
chunk = pipe.read(4096)
10181103
if not chunk:
@@ -1029,7 +1114,20 @@ def _read_output(self, pipe, level):
10291114
)
10301115
break
10311116

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

10541152
line_bytes = buffer[:idx]
1055-
buffer = buffer[idx + sep_len :]
1153+
buffer = bytearray(buffer[idx + sep_len :])
10561154

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

electron-app/electron/main.cjs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,19 @@ ipcMain.handle('dialog:openDirectory', async () => {
110110
}
111111
});
112112

113+
ipcMain.handle('dialog:openFile', async () => {
114+
const filters = [];
115+
if (process.platform === 'win32') {
116+
filters.push({ name: 'Executable', extensions: ['exe'] });
117+
}
118+
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
119+
properties: ['openFile'],
120+
filters: filters.length > 0 ? filters : undefined
121+
});
122+
if (canceled) return null;
123+
return filePaths[0];
124+
});
125+
113126
// Old handler removed - shutdown is now main-process driven
114127

115128

electron-app/electron/preload.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const { contextBridge, ipcRenderer } = require('electron');
22

33
contextBridge.exposeInMainWorld('electron', {
44
openDirectory: () => ipcRenderer.invoke('dialog:openDirectory'),
5+
openFile: () => ipcRenderer.invoke('dialog:openFile'),
56
onCloseRequested: (callback) => {
67
ipcRenderer.on('app-close-requested', (event, ...args) => {
78
callback(event, ...args);

electron-app/src/api.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,12 @@ export const api = {
260260
}
261261
return null;
262262
},
263+
openFilePicker: async () => {
264+
if (window.electron && window.electron.openFile) {
265+
return await window.electron.openFile();
266+
}
267+
return null;
268+
},
263269
openServerFolder: async () => {
264270
return await fetchJson(`${API_URL}/server/open-folder`, { method: 'POST' });
265271
},

0 commit comments

Comments
 (0)