Add graceful shutdown, reconnect handling and config file integration

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
This commit is contained in:
Debian
2026-09-13 14:00:16 +02:00
co-authored by Sisyphus
parent 3213bb38b4
commit 0613d380a4
+50 -18
View File
@@ -49,22 +49,32 @@ unknown_json_logger.setLevel(logging.DEBUG)
quit_confirm_time = None quit_confirm_time = None
quit_confirm_lock = threading.Lock() quit_confirm_lock = threading.Lock()
# Configurable timeouts (set from config in __main__)
_QUIT_CONFIRM_TIMEOUT = QUIT_CONFIRM_TIMEOUT
_RESPAWN_DELAY = RESPAWN_DELAY
# Global shutdown flag (set by signal_handler, checked by main_loop)
shutdown_requested = False
def signal_handler(sig, frame): def signal_handler(sig, frame):
"""Handle Ctrl+C with confirmation""" """Handle Ctrl+C with confirmation"""
global quit_confirm_time global quit_confirm_time, shutdown_requested
current_time = time.time() current_time = time.time()
with quit_confirm_lock: with quit_confirm_lock:
if quit_confirm_time is None or (current_time - quit_confirm_time) > QUIT_CONFIRM_TIMEOUT: if shutdown_requested:
# Third Ctrl-C: last-resort forced exit
curses.endwin()
os._exit(0)
elif quit_confirm_time is None or (current_time - quit_confirm_time) > _QUIT_CONFIRM_TIMEOUT:
# First Ctrl-C or timeout expired # First Ctrl-C or timeout expired
logger.warning(f"^1^8Press Ctrl-C again within {QUIT_CONFIRM_TIMEOUT:.0f} seconds to quit^0") logger.warning(f"^1^8Press Ctrl-C again within {_QUIT_CONFIRM_TIMEOUT:.0f} seconds to quit^0")
quit_confirm_time = current_time quit_confirm_time = current_time
else: else:
# Second Ctrl-C within timeout # Second Ctrl-C within timeout: request graceful shutdown
logger.warning("^1^8Quittin'^0") logger.warning("^1^8Quittin'^0")
curses.endwin() # Restore terminal before force exit shutdown_requested = True
os._exit(0)
def parse_cvar_response(message, game_state, ui): def parse_cvar_response(message, game_state, ui):
""" """
@@ -91,7 +101,7 @@ def parse_cvar_response(message, game_state, ui):
return False return False
def handle_stats_connection(message, rcon, ui, game_state): def handle_stats_connection(message):
""" """
Handle stats connection info extraction Handle stats connection info extraction
Returns (stats_port, stats_password) or (None, None) Returns (stats_port, stats_password) or (None, None)
@@ -163,7 +173,7 @@ def handle_player_respawns(game_state, ui):
if game_state.server_info.gametype == 'Clan Arena': if game_state.server_info.gametype == 'Clan Arena':
# CA: revive all players after round end # CA: revive all players after round end
if game_state.server_info.round_end_time: if game_state.server_info.round_end_time:
if time.time() - game_state.server_info.round_end_time >= RESPAWN_DELAY: if time.time() - game_state.server_info.round_end_time >= _RESPAWN_DELAY:
game_state.server_info.dead_players.clear() game_state.server_info.dead_players.clear()
game_state.server_info.round_end_time = None game_state.server_info.round_end_time = None
ui.update_server_info(game_state) ui.update_server_info(game_state)
@@ -172,7 +182,7 @@ def handle_player_respawns(game_state, ui):
current_time = time.time() current_time = time.time()
players_to_revive = [ players_to_revive = [
name for name, death_time in game_state.server_info.dead_players.items() name for name, death_time in game_state.server_info.dead_players.items()
if current_time - death_time >= RESPAWN_DELAY if current_time - death_time >= _RESPAWN_DELAY
] ]
if players_to_revive: if players_to_revive:
for name in players_to_revive: for name in players_to_revive:
@@ -307,7 +317,7 @@ def main_loop(screen, args):
# Set logging level # Set logging level
if args.verbose == 0: if args.verbose == 0:
logger.setLevel(logging.WARNING) logger.setLevel(args.config_log_level if args.config_log_level is not None else logging.WARNING)
elif args.verbose == 1: elif args.verbose == 1:
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
else: else:
@@ -321,15 +331,22 @@ def main_loop(screen, args):
unknown_json_logger.propagate = False unknown_json_logger.propagate = False
# Initialize components # Initialize components
ui = UIManager(screen, args.host) ui = UIManager(screen, args.host, args.max_history)
game_state = GameState() game_state = GameState()
# Setup logging to output window # Setup logging to output window
log_handler = ui.setup_logging() log_handler = ui.setup_logging()
logger.addHandler(log_handler) logger.addHandler(log_handler)
# Attach UI log handler to lib module loggers
for lib_name in ('network', 'parser', 'state'):
lib_logger = logging.getLogger(lib_name)
if log_handler not in lib_logger.handlers:
lib_logger.addHandler(log_handler)
lib_logger.setLevel(logger.level)
# Setup input queue # Setup input queue
input_queue = ui.setup_input_queue() input_queue = ui.setup_input_queue(player_names_provider=lambda: game_state.player_tracker.get_player_names())
# Display startup messages # Display startup messages
ui.print_message(f"*** QL pyCon Version {VERSION} starting ***\n") ui.print_message(f"*** QL pyCon Version {VERSION} starting ***\n")
@@ -345,9 +362,6 @@ def main_loop(screen, args):
stats_password = None stats_password = None
stats_check_counter = 0 stats_check_counter = 0
# Shutdown flag
shutdown = False
# Timer refresh tracking (update UI once per second) # Timer refresh tracking (update UI once per second)
last_ui_update = 0 last_ui_update = 0
@@ -366,7 +380,7 @@ def main_loop(screen, args):
# Main event loop with resource cleanup # Main event loop with resource cleanup
try: try:
while not shutdown: while not shutdown_requested:
# Poll RCON socket # Poll RCON socket
event = rcon.poll(POLL_TIMEOUT) event = rcon.poll(POLL_TIMEOUT)
@@ -380,6 +394,14 @@ def main_loop(screen, args):
ui.print_message("Requesting connection info...\n") ui.print_message("Requesting connection info...\n")
rcon.send_command(b'zmq_stats_password') rcon.send_command(b'zmq_stats_password')
rcon.send_command(b'net_port') rcon.send_command(b'net_port')
elif monitor_event and monitor_event[0] == zmq.EVENT_DISCONNECTED:
timestamp = time.strftime('%H:%M:%S')
ui.print_message(f"^3[^7{timestamp}^3] ^8^1Disconnected from server - waiting for reconnect...^0^7\n")
if stats_conn is not None:
stats_conn.close()
stats_conn = None
stats_port = None
stats_password = None
# Handle user input # Handle user input
handle_user_input(input_queue, rcon, ui) handle_user_input(input_queue, rcon, ui)
@@ -414,7 +436,7 @@ def main_loop(screen, args):
if parse_player_events(message, game_state, ui): if parse_player_events(message, game_state, ui):
continue continue
if '------- Game Initialization -------' in message or 'Game Initialization' in message: if 'Game Initialization' in message:
logger.info('Game initialization detected - refreshing server info') logger.info('Game initialization detected - refreshing server info')
timestamp = time.strftime('%H:%M:%S') timestamp = time.strftime('%H:%M:%S')
@@ -432,6 +454,8 @@ def main_loop(screen, args):
# Clear player dict since map changed # Clear player dict since map changed
game_state.server_info.players = {} game_state.server_info.players = {}
game_state.player_tracker.player_teams = {} game_state.player_tracker.player_teams = {}
game_state.server_info.reset_round_scores()
game_state.server_info.dead_players.clear()
ui.update_server_info(game_state) ui.update_server_info(game_state)
# Try to parse as cvar response # Try to parse as cvar response
@@ -440,7 +464,7 @@ def main_loop(screen, args):
continue continue
# Check for stats connection info # Check for stats connection info
port, password = handle_stats_connection(message, rcon, ui, game_state) port, password = handle_stats_connection(message)
if port: if port:
stats_port = port stats_port = port
if password: if password:
@@ -531,6 +555,10 @@ if __name__ == '__main__':
config = ConfigLoader() config = ConfigLoader()
config.load() config.load()
# Read settings from config ([logging]/[ui]/[behavior])
_QUIT_CONFIRM_TIMEOUT = config.get_quit_timeout(QUIT_CONFIRM_TIMEOUT)
_RESPAWN_DELAY = config.get_respawn_delay(RESPAWN_DELAY)
# Parse arguments # Parse arguments
parser = argparse.ArgumentParser(description='Quake Live Python Console') parser = argparse.ArgumentParser(description='Quake Live Python Console')
parser.add_argument('server', nargs='?', default=None, parser.add_argument('server', nargs='?', default=None,
@@ -551,6 +579,10 @@ if __name__ == '__main__':
help='File to log all JSON events') help='File to log all JSON events')
args = parser.parse_args() args = parser.parse_args()
# Apply config settings to args
args.config_log_level = config.get_log_level()
args.max_history = config.get_max_history(MAX_COMMAND_HISTORY)
# Handle --list # Handle --list
if args.list: if args.list:
servers = config.get_servers() servers = config.get_servers()