@@ -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