Skip to content

Commit 51a9796

Browse files
committed
v1.2.7: Fix Paper install (#15), Link-folder button (#15), Linux memory leak (#4)
Issue #15 (cant install paper server): - download_server_jar now lowercases server_type; mcutils returns HTTP 500 for capitalized types (e.g. 'Paper'), so versions loaded but download failed with a generic 'Failed to download Server JAR.' error. - download_file_from_url: retry with backoff on transient CDN/Cloudflare 5xx, browser User-Agent to avoid bot-blocking, and surface the real failure reason (URL + status) to the UI. - install_server dispatch + saved profile type normalized to lowercase. - Fixed 'Link Project' button doing nothing: api.importServer was missing from the frontend API client (silent TypeError). It now detects the engine/version from the folder, registers the profile, and shows loading/error feedback in the Setup Wizard. Issue #4 (Massive RAM leak on Linux - 32GB gone in 50s): - Truncate log lines to 8KB before storing in the in-memory deques (log_history 1000, app_log_history 500) and async log queue (2000). A force-flushed 1MB newline-less chunk could otherwise be retained per entry, allowing gigabytes to pile up. - Reuse the binary read buffer (del/clear) instead of allocating a new bytearray per flushed line, reducing allocator churn and glibc arena growth that kept RSS high after a server stopped. - malloc_trim(0) on Linux after server stop/kill so freed heap is returned to the OS instead of held in the malloc arena. - Stop calling logging.basicConfig() in api_client.py and java_manager.py at import time (ran before api_server configured the root logger, so the backend_debug.log FileHandler was never installed). Root logger level lowered DEBUG->INFO, removing huge per-chunk debug log garbage. - Cap Server List Ping JSON payload at 1MB and bound the Varint reader.
1 parent caf77b7 commit 51a9796

11 files changed

Lines changed: 274 additions & 66 deletions

File tree

README.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,23 @@
2828

2929
---
3030

31-
## What's New in v1.2.5
31+
## What's New in v1.2.7
32+
33+
### Fixed Server Installation (Paper/Spigot/Fabric)
34+
- **Paper install fixed** — Installing Paper (and Spigot/Fabric) no longer fails with "Failed to download Server JAR." The server type is now lowercased before hitting the mcutils API, which returned HTTP 500 for capitalized types.
35+
- **Reliable downloads** — Server JAR downloads now retry on transient CDN/Cloudflare errors with backoff, and the real failure reason (URL + status) is shown in the UI instead of a generic message.
36+
37+
### Fixed "Link to folder"
38+
- **Import existing server works again** — The "Link Project" button in the Setup Wizard was silently broken (missing API method). It now detects the engine/version from your folder and registers the server, with loading and error feedback.
39+
40+
### Linux Memory Leak Fix
41+
- **Massive RAM leak resolved** — On Linux, `api_server` could hold gigabytes after a server stopped. Log lines are now truncated before being stored in memory, the read buffer is reused, freed heap is returned to the OS via `malloc_trim`, and the backend no longer generates huge volumes of per-chunk debug log garbage.
42+
- **Real log file now works**`backend_debug.log` is now actually written (a logging setup bug previously sent everything to stderr).
43+
44+
<details>
45+
<summary><strong>Earlier versions</strong></summary>
46+
47+
### What's New in v1.2.5
3248

3349
### Fixed Server Addresses (DNS)
3450
- **Permanent domain** — Your server gets a permanent address like `survival.play.tudominio.app` that never changes
@@ -48,9 +64,6 @@
4864
- **Auto-Tunnel** — Tunnel starts automatically with the server (toggle in Advanced)
4965
- **Boot from library** — "Boot" button on server cards in the library
5066

51-
<details>
52-
<summary><strong>Earlier versions</strong></summary>
53-
5467
### Auto-Restart on Crash
5568
- Server automatically restarts after unexpected shutdowns — toggle it on/off from the Dashboard header
5669
- Up to 3 restart attempts with a 3-second delay between retries
@@ -81,6 +94,8 @@
8194

8295
</details>
8396

97+
</details>
98+
8499
---
85100

86101
## Download & Install

backend/api_server.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252

5353
logging.basicConfig(
5454
filename=os.path.join(log_dir, "backend_debug.log"),
55-
level=logging.DEBUG,
55+
level=logging.INFO,
5656
format="%(asctime)s - %(levelname)s - %(message)s",
5757
)
5858
logging.info("Backend starting up...")
@@ -74,10 +74,17 @@
7474
get_forge_versions,
7575
get_neoforge_versions,
7676
download_server_jar,
77+
download_file_from_url,
7778
)
7879
from utils.mods_manager import ModsManager
7980

8081

82+
# Maximum length of a single log line retained in memory (deques / async queue).
83+
# Matches server_handler.MAX_LOG_LINE_LEN — kept duplicated here to avoid a
84+
# circular import. Bounds retained memory when server output has no newlines.
85+
MAX_LOG_LINE_LEN = 8000
86+
87+
8188
@asynccontextmanager
8289
async def lifespan(app: FastAPI):
8390
global state
@@ -372,6 +379,15 @@ def broadcast_log_sync(self, message, level="normal", server_id=None):
372379
message = message.replace("\r", "")
373380
msg_obj = {"message": message, "level": level, "server_id": server_id}
374381

382+
# Bound memory: truncate huge messages (e.g. a 1MB force-flushed log
383+
# chunk) before they enter app_log_history (deque 500) or the async
384+
# log queue (maxsize 2000). Without this, a chatty/idle server with
385+
# newline-less output could retain gigabytes in these structures.
386+
msg_text_check = msg_obj.get("message") if isinstance(msg_obj, dict) else None
387+
if isinstance(msg_text_check, str) and len(msg_text_check) > MAX_LOG_LINE_LEN:
388+
msg_obj = dict(msg_obj)
389+
msg_obj["message"] = msg_text_check[:MAX_LOG_LINE_LEN] + " …[truncated]"
390+
375391
# Store in app-level history for Dashboard polling
376392
# Skip verbose installer logs (recipe files, etc.) to avoid spam
377393
msg_text = (
@@ -883,6 +899,12 @@ def install_server(req: InstallRequest):
883899
def run_install():
884900
logging.info("Installation thread started")
885901
try:
902+
# Normalize server type to lowercase so the mcutils download URL and
903+
# the dispatch below behave consistently regardless of the casing the
904+
# frontend sends (e.g. "Paper" vs "paper"). mcutils returns HTTP 500
905+
# for capitalized types, which previously broke Paper installs.
906+
server_type = (req.server_type or "").lower()
907+
886908
# Helper to send structured progress
887909
def send_progress(pct, msg, **kwargs):
888910
state.install_progress = pct
@@ -951,7 +973,7 @@ def progress_callback(p):
951973
send_progress(scaled, "Downloading server files...")
952974

953975
# 3. Instalación del Servidor
954-
if req.server_type.lower() == "forge":
976+
if server_type == "forge":
955977
# Crear un handler temporal con la ruta de Java CORRECTA explícita
956978
temp_handler = ServerHandler(
957979
install_path,
@@ -994,7 +1016,7 @@ def forge_progress(p):
9941016
forge_ver, req.version, forge_progress
9951017
)
9961018

997-
elif req.server_type.lower() == "neoforge":
1019+
elif server_type == "neoforge":
9981020
# NeoForge logic
9991021
temp_handler = ServerHandler(
10001022
install_path,
@@ -1035,13 +1057,18 @@ def neoforge_progress(p):
10351057
temp_handler.install_neoforge_server(neoforge_ver, neoforge_progress)
10361058

10371059
else:
1038-
# Vanilla / Paper / Fabric logic
1060+
# Vanilla / Paper / Spigot / Fabric logic
10391061
jar_path = os.path.join(install_path, "server.jar")
10401062
success = download_server_jar(
1041-
req.server_type, req.version, jar_path, progress_callback
1063+
server_type, req.version, jar_path, progress_callback
10421064
)
10431065
if not success:
1044-
raise Exception("Failed to download Server JAR.")
1066+
# Surface the real failure reason (URL/status/exception) instead
1067+
# of a generic message so users can diagnose Paper/Spigot issues.
1068+
reason = getattr(
1069+
download_file_from_url, "last_error", None
1070+
) or "Unknown error"
1071+
raise Exception(f"Failed to download Server JAR: {reason}")
10451072

10461073
# 4. Configuración Final
10471074
send_progress(95, "Finalizing configuration...")
@@ -1050,7 +1077,7 @@ def neoforge_progress(p):
10501077
new_server_data = {
10511078
"name": req.folder_name,
10521079
"path": install_path,
1053-
"type": req.server_type,
1080+
"type": server_type,
10541081
"version": req.version,
10551082
"ram_min": req.ram_min,
10561083
"ram_max": req.ram_max,

backend/server/server_handler.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,31 @@
1919
import time
2020
from typing import Optional
2121

22+
# Cap the length of a single log line kept in memory. Server output that contains
23+
# no newlines (e.g. progress bars, binary-ish blobs) can be force-flushed as a
24+
# single ~1MB chunk; without truncation each bounded deque would retain megabytes
25+
# per entry (1000 + 500 + 2000 entries => multiple GB). 8KB is plenty for any
26+
# real log line while keeping worst-case retained memory small.
27+
MAX_LOG_LINE_LEN = 8000
28+
29+
30+
def _malloc_trim():
31+
"""Return freed heap memory to the OS on Linux (no-op elsewhere).
32+
33+
glibc's malloc keeps freed allocations in its arena for reuse instead of
34+
releasing them back to the kernel, so RSS can stay high (and even appear to
35+
"leak") after a server stops. Calling malloc_trim(0) after a server exits
36+
makes the backend give back that memory.
37+
"""
38+
if sys.platform == "linux":
39+
try:
40+
import ctypes
41+
libc = ctypes.CDLL("libc.so.6", use_errno=True)
42+
if hasattr(libc, "malloc_trim"):
43+
libc.malloc_trim(0)
44+
except Exception:
45+
pass
46+
2247

2348
class ServerHandler:
2449
def __init__(
@@ -100,6 +125,10 @@ def _log(self, message, level="normal"):
100125
message = message.rstrip()
101126
if not message:
102127
return
128+
# Bound memory: truncate force-flushed chunks (up to 1MB with no
129+
# newlines) so the bounded deques don't retain megabytes per entry.
130+
if len(message) > MAX_LOG_LINE_LEN:
131+
message = message[:MAX_LOG_LINE_LEN] + " …[truncated]"
103132
msg_obj = {"message": message, "level": level, "server_id": self.server_id}
104133
else:
105134
msg_obj = message
@@ -954,6 +983,11 @@ def _run_server(self, command, env):
954983
}
955984
)
956985

986+
# Return freed memory to the OS on Linux (glibc arena trimming).
987+
# This is what makes the backend's RSS actually drop after a server
988+
# stops instead of holding onto peak usage indefinitely.
989+
_malloc_trim()
990+
957991
def _process_log_line(
958992
self,
959993
line,
@@ -1126,11 +1160,11 @@ def _read_output(self, pipe, level):
11261160
list_inline_pattern,
11271161
list_header_pattern,
11281162
)
1129-
buffer = bytearray()
1163+
# Reuse the same bytearray object (clear() may free the
1164+
# underlying storage) instead of allocating a new one every
1165+
# flush — reduces allocator churn and glibc arena growth.
1166+
buffer.clear()
11301167
continue
1131-
logging.debug(
1132-
f"Handler: Received chunk of {len(chunk)} bytes from {level}"
1133-
)
11341168
while b"\n" in buffer or b"\r" in buffer:
11351169
# Find the first line break
11361170
idx_n = buffer.find(b"\n")
@@ -1150,7 +1184,9 @@ def _read_output(self, pipe, level):
11501184
sep_len = 2 # Handle \r\n
11511185

11521186
line_bytes = buffer[:idx]
1153-
buffer = bytearray(buffer[idx + sep_len :])
1187+
# Remove the consumed line in-place instead of creating a new
1188+
# bytearray each iteration (avoids per-line allocation).
1189+
del buffer[: idx + sep_len]
11541190

11551191
line = line_bytes.decode("utf-8", errors="replace")
11561192
self._process_log_line(
@@ -1250,6 +1286,7 @@ def _kill_process_tree(self):
12501286
"server_id": self.server_id,
12511287
}
12521288
)
1289+
_malloc_trim()
12531290

12541291
def wait_for_stop(self, timeout=30):
12551292
"""Blocks until the server process has exited or the timeout is reached."""

backend/utils/api_client.py

Lines changed: 81 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@
44
import logging
55
import json
66
import os
7+
import time
78
import xml.etree.ElementTree as ET
89
from collections import defaultdict
910
import re
1011
import zipfile
1112

12-
# Configure logging
13-
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
13+
# NOTE: Do NOT call logging.basicConfig() here. This module is imported by
14+
# api_server.py BEFORE api_server configures the root logger, and basicConfig is
15+
# a no-op once the root logger already has handlers. Configuring the root logger
16+
# here would prevent api_server's FileHandler from ever being installed (logs
17+
# would silently go to stderr instead of backend_debug.log). Let api_server own
18+
# the root logger configuration; module-level logging.* calls propagate to it.
1419

1520
def get_server_versions(server_type):
1621
"""Fetches available server versions for a given type from mcutils.com API."""
@@ -186,48 +191,84 @@ def fetch_username_from_uuid(uuid_str):
186191

187192
def download_server_jar(server_type, server_version, save_path, progress_callback):
188193
"""Downloads the server.jar file for a given type and version with progress."""
194+
# CRITICAL: mcutils.com API is case-sensitive and returns HTTP 500 for
195+
# capitalized server types (e.g. "Paper"). Always normalize to lowercase.
196+
server_type = (server_type or "").lower()
189197
download_url = f"https://mcutils.com/api/server-jars/{server_type}/{server_version}/download"
190198
return download_file_from_url(download_url, save_path, progress_callback)
191199

192-
def download_file_from_url(download_url, save_path, progress_callback):
193-
"""Downloads a file from a specific URL with progress."""
200+
# A browser-like User-Agent avoids bot-blocking by Cloudflare-fronted CDNs
201+
# (mcutils.com, fill-data.papermc.io, piston-data.mojang.com).
202+
_DOWNLOAD_HEADERS = {
203+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
204+
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
205+
}
206+
207+
def download_file_from_url(download_url, save_path, progress_callback, retries=3):
208+
"""Downloads a file from a specific URL with progress.
209+
210+
Retries on transient failures (Cloudflare 500/429, connection resets) and
211+
returns False on failure. The last error is logged with the URL + status so
212+
callers can surface a useful message instead of a generic one.
213+
"""
194214
os.makedirs(os.path.dirname(save_path), exist_ok=True)
195-
try:
196-
with requests.get(download_url, stream=True, timeout=30) as r:
197-
r.raise_for_status()
198-
total_size_raw = r.headers.get('content-length')
199-
total_size = int(total_size_raw) if total_size_raw else 0
200-
bytes_downloaded = 0
201-
last_reported_mb = 0
202-
203-
logging.info(f"Downloading {download_url} - Total size: {total_size if total_size > 0 else 'unknown'}")
204-
205-
with open(save_path, 'wb') as f:
206-
for chunk in r.iter_content(chunk_size=65536): # 64KB chunks for better throughput
207-
if not chunk:
208-
continue
209-
f.write(chunk)
210-
bytes_downloaded += len(chunk)
211-
212-
if total_size > 0:
213-
progress = (bytes_downloaded / total_size) * 100
214-
progress_callback(progress)
215-
else:
216-
# Fallback: Report "activity" every 1MB
217-
current_mb = bytes_downloaded // (1024 * 1024)
218-
if current_mb > last_reported_mb:
219-
last_reported_mb = current_mb
220-
# Send a small incremental progress or just trigger the callback
221-
# to keep the UI alive. We'll send a "mock" slow progress
222-
mock_progress = min(current_mb * 5, 95) # Caps at 95 until real finish
223-
progress_callback(mock_progress)
224-
logging.debug(f"Downloaded {current_mb}MB (unknown total size)")
225-
226-
progress_callback(100)
227-
return True
228-
except Exception as e:
229-
logging.error(f"Failed to download file: {e}")
230-
return False
215+
last_error = None
216+
for attempt in range(1, retries + 1):
217+
try:
218+
with requests.get(
219+
download_url, stream=True, timeout=30, headers=_DOWNLOAD_HEADERS
220+
) as r:
221+
if r.status_code >= 500:
222+
# Transient server/CDN error — retry with backoff
223+
last_error = f"HTTP {r.status_code} from {download_url}"
224+
logging.warning(
225+
f"Download attempt {attempt}/{retries} failed: {last_error}"
226+
)
227+
if attempt < retries:
228+
time.sleep(2 * attempt)
229+
continue
230+
r.raise_for_status()
231+
total_size_raw = r.headers.get('content-length')
232+
total_size = int(total_size_raw) if total_size_raw else 0
233+
bytes_downloaded = 0
234+
last_reported_mb = 0
235+
236+
logging.info(f"Downloading {download_url} - Total size: {total_size if total_size > 0 else 'unknown'}")
237+
238+
with open(save_path, 'wb') as f:
239+
for chunk in r.iter_content(chunk_size=65536): # 64KB chunks
240+
if not chunk:
241+
continue
242+
f.write(chunk)
243+
bytes_downloaded += len(chunk)
244+
245+
if total_size > 0:
246+
progress = (bytes_downloaded / total_size) * 100
247+
progress_callback(progress)
248+
else:
249+
# Fallback: Report "activity" every 1MB
250+
current_mb = bytes_downloaded // (1024 * 1024)
251+
if current_mb > last_reported_mb:
252+
last_reported_mb = current_mb
253+
mock_progress = min(current_mb * 5, 95)
254+
progress_callback(mock_progress)
255+
logging.debug(f"Downloaded {current_mb}MB (unknown total size)")
256+
257+
progress_callback(100)
258+
return True
259+
except requests.RequestException as e:
260+
last_error = f"{e} ({download_url})"
261+
logging.warning(f"Download attempt {attempt}/{retries} failed: {last_error}")
262+
if attempt < retries:
263+
time.sleep(2 * attempt)
264+
except Exception as e:
265+
last_error = f"{e} ({download_url})"
266+
logging.error(f"Failed to download file: {last_error}")
267+
return False
268+
logging.error(f"Download failed after {retries} attempts: {last_error}")
269+
# Stash the last error on the function so callers can surface a useful message
270+
download_file_from_url.last_error = last_error
271+
return False
231272

232273
def download_and_extract_zip(url, extract_to_dir, progress_callback, contains_single_folder=True):
233274
"""

backend/utils/java_manager.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
import logging
1212
from typing import Optional, Dict, Tuple, Callable
1313

14-
logging.basicConfig(level=logging.INFO)
14+
# NOTE: Do NOT call logging.basicConfig() here — this module is imported by
15+
# api_server.py before api_server configures the root logger, and basicConfig is
16+
# a no-op once the root logger already has handlers. Configuring root here would
17+
# prevent api_server's FileHandler (backend_debug.log) from being installed.
18+
# Use a module logger; it propagates to the root logger api_server sets up.
1519
logger = logging.getLogger(__name__)
1620

1721

0 commit comments

Comments
 (0)