Skip to content

Commit d96f5a4

Browse files
committed
Hotfix: Use chunked non-blocking pumping to solve console hangs (v1.1.9)
1 parent f7d4bcb commit d96f5a4

File tree

1 file changed

+113
-97
lines changed

1 file changed

+113
-97
lines changed

backend/server/server_handler.py

Lines changed: 113 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,91 @@ def _run_server(self, command, env):
540540
"server_id": self.server_id
541541
})
542542

543+
def _process_log_line(self, line, level, re, join_pattern, leave_pattern, list_inline_pattern, list_header_pattern):
544+
line_no_ansi = re.sub(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])', '', line)
545+
546+
# Check for standard server start completion messages
547+
is_done = False
548+
if 'Done' in line_no_ansi and 'For help' in line_no_ansi:
549+
is_done = True
550+
elif 'Done (' in line_no_ansi and ')!' in line_no_ansi:
551+
is_done = True
552+
elif 'started on port' in line_no_ansi.lower():
553+
is_done = True
554+
555+
if is_done:
556+
self.server_fully_started = True
557+
self.server_stopping = False
558+
# Broadcast explicit status change to online
559+
if self.output_callback:
560+
self.output_callback({
561+
"type": "status_change",
562+
"status": "online",
563+
"server_id": self.server_id
564+
})
565+
elif 'Stopping the server' in line_no_ansi or 'Stopping server' in line_no_ansi:
566+
self.server_stopping = True
567+
elif 'All dimensions are saved' in line_no_ansi or 'All chunks are saved' in line_no_ansi:
568+
if self.server_stopping:
569+
# Detect that the server HAS saved everything.
570+
# If it doesn't close in 7 seconds, we force it.
571+
def delayed_kill():
572+
time.sleep(7)
573+
if self.get_status() == 'stopping':
574+
self._log("Server finished saving but hung. Forcing termination.\n", "warning")
575+
self._kill_process_tree()
576+
577+
threading.Thread(target=delayed_kill, daemon=True).start()
578+
579+
# Detect player join/leave
580+
join_match = join_pattern.search(line_no_ansi)
581+
if join_match:
582+
player_name = join_match.group(1)
583+
self.tracked_players.add(player_name)
584+
585+
leave_match = leave_pattern.search(line_no_ansi)
586+
if leave_match:
587+
player_name = leave_match.group(1)
588+
self.tracked_players.discard(player_name)
589+
590+
clean_line = line_no_ansi.strip()
591+
suppress_from_console = False
592+
if clean_line:
593+
if self._expecting_player_list_next_line:
594+
# Only accept the expected next line if it actually contains a players list.
595+
if "players online:" not in clean_line.lower():
596+
self._expecting_player_list_next_line = False
597+
else:
598+
# Parse the content after 'players online:'
599+
names_line = clean_line.split("players online:", 1)[1].strip()
600+
if names_line:
601+
self.tracked_players = {p.strip() for p in names_line.split(",") if p.strip()}
602+
else:
603+
self.tracked_players = set()
604+
self._expecting_player_list_next_line = False
605+
suppress_from_console = True
606+
607+
elif "there are no players online" in clean_line.lower():
608+
self.tracked_players = set()
609+
self._expecting_player_list_next_line = False
610+
suppress_from_console = True
611+
612+
else:
613+
inline_match = list_inline_pattern.search(clean_line)
614+
if inline_match is not None:
615+
names_part = (inline_match.group(1) or "").strip()
616+
if names_part:
617+
self.tracked_players = {p.strip() for p in names_part.split(",") if p.strip()}
618+
else:
619+
self._expecting_player_list_next_line = True
620+
suppress_from_console = True
621+
elif list_header_pattern.search(clean_line) is not None:
622+
self._expecting_player_list_next_line = True
623+
suppress_from_console = True
624+
625+
if not suppress_from_console:
626+
self._log(line + '\n', level)
627+
543628
def _read_output(self, pipe, level):
544629
import re
545630
join_pattern = re.compile(r'\b([A-Za-z0-9_]{1,16})\b\s+joined the game', re.IGNORECASE)
@@ -549,107 +634,38 @@ def _read_output(self, pipe, level):
549634
list_header_pattern = re.compile(r".*There are\s+\d+\s+of\s+a\s+max\s+of\s+\d+\s+players\s+online:\s*$", re.IGNORECASE)
550635

551636
try:
552-
logging.info(f"Handler: Log reader thread started for {level} (Binary mode)")
553-
# In binary mode, readline returns bytes
554-
for line_bytes in iter(pipe.readline, b''):
555-
try:
556-
line = line_bytes.decode('utf-8', errors='replace')
557-
except Exception as de:
558-
logging.error(f"Handler: Decode error in {level}: {de}")
559-
continue
560-
561-
line_no_ansi = re.sub(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])', '', line)
637+
logging.info(f"Handler: Log reader thread started for {level} (Chunked Binary mode)")
638+
639+
buffer = b""
640+
while True:
641+
chunk = pipe.read(4096)
642+
if not chunk:
643+
# End of stream
644+
if buffer:
645+
self._process_log_line(buffer.decode('utf-8', errors='replace'), level, re, join_pattern, leave_pattern, list_inline_pattern, list_header_pattern)
646+
break
562647

563-
# Check for standard server start completion messages
564-
is_done = False
565-
if 'Done' in line_no_ansi and 'For help' in line_no_ansi:
566-
is_done = True
567-
elif 'Done (' in line_no_ansi and ')!' in line_no_ansi:
568-
is_done = True
569-
elif 'started on port' in line_no_ansi.lower():
570-
is_done = True
648+
buffer += chunk
649+
while b'\n' in buffer or b'\r' in buffer:
650+
# Find the first line break
651+
idx_n = buffer.find(b'\n')
652+
idx_r = buffer.find(b'\r')
571653

572-
if is_done:
573-
self.server_fully_started = True
574-
self.server_stopping = False
575-
# Broadcast explicit status change to online
576-
if self.output_callback:
577-
self.output_callback({
578-
"type": "status_change",
579-
"status": "online",
580-
"server_id": self.server_id
581-
})
582-
elif 'Stopping the server' in line_no_ansi or 'Stopping server' in line_no_ansi:
583-
self.server_stopping = True
584-
elif 'All dimensions are saved' in line_no_ansi or 'All chunks are saved' in line_no_ansi:
585-
if self.server_stopping:
586-
# Detect that the server HAS saved everything.
587-
# If it doesn't close in 7 seconds, we force it.
588-
def delayed_kill():
589-
time.sleep(7)
590-
if self.get_status() == 'stopping':
591-
self._log("Server finished saving but hung. Forcing termination.\n", "warning")
592-
self._kill_process_tree()
593-
594-
threading.Thread(target=delayed_kill, daemon=True).start()
595-
596-
# Detect player join/leave
597-
join_match = join_pattern.search(line_no_ansi)
598-
if join_match:
599-
player_name = join_match.group(1)
600-
self.tracked_players.add(player_name)
654+
if idx_n != -1 and (idx_r == -1 or idx_n < idx_r):
655+
idx = idx_n
656+
sep_len = 1
657+
else:
658+
idx = idx_r
659+
sep_len = 1
660+
if idx != -1 and idx + 1 < len(buffer) and buffer[idx+1] == ord('\n'):
661+
sep_len = 2 # Handle \r\n
601662

602-
leave_match = leave_pattern.search(line_no_ansi)
603-
if leave_match:
604-
player_name = leave_match.group(1)
605-
self.tracked_players.discard(player_name)
606-
607-
clean_line = line_no_ansi.strip()
608-
suppress_from_console = False
609-
if clean_line:
610-
if self._expecting_player_list_next_line:
611-
# Only accept the expected next line if it actually contains a players list.
612-
# Some servers output only the header line when there are 0 players.
613-
if "players online:" not in clean_line.lower():
614-
self._expecting_player_list_next_line = False
615-
else:
616-
# Parse the content after 'players online:' (avoid issues with timestamp prefixes)
617-
names_line = clean_line.split("players online:", 1)[1].strip()
618-
if names_line:
619-
self.tracked_players = {p.strip() for p in names_line.split(",") if p.strip()}
620-
else:
621-
self.tracked_players = set()
622-
self._expecting_player_list_next_line = False
623-
624-
# Hide list output from console, but keep parsed data
625-
suppress_from_console = True
626-
627-
elif "there are no players online" in clean_line.lower():
628-
self.tracked_players = set()
629-
self._expecting_player_list_next_line = False
630-
631-
# Hide list output from console
632-
suppress_from_console = True
663+
line_bytes = buffer[:idx]
664+
buffer = buffer[idx + sep_len:]
665+
666+
line = line_bytes.decode('utf-8', errors='replace')
667+
self._process_log_line(line, level, re, join_pattern, leave_pattern, list_inline_pattern, list_header_pattern)
633668

634-
else:
635-
inline_match = list_inline_pattern.search(clean_line)
636-
if inline_match is not None:
637-
names_part = (inline_match.group(1) or "").strip()
638-
if names_part:
639-
self.tracked_players = {p.strip() for p in names_part.split(",") if p.strip()}
640-
else:
641-
self._expecting_player_list_next_line = True
642-
643-
# Hide list output from console
644-
suppress_from_console = True
645-
elif list_header_pattern.search(clean_line) is not None:
646-
self._expecting_player_list_next_line = True
647-
648-
# Hide list output from console
649-
suppress_from_console = True
650-
651-
if not suppress_from_console:
652-
self._log(line_no_ansi, level)
653669
logging.info(f"Handler: Log reader thread {level} finished normally")
654670
except Exception as e:
655671
error_details = traceback.format_exc()

0 commit comments

Comments
 (0)