From 60a647ea50fd36d70ec0f3aaf1e82825e8623468 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 03:01:58 +0000 Subject: [PATCH 1/4] Debounce navigation buttons in review mode to prevent GUI freeze When analysis is running and the user presses navigation buttons rapidly, stop the analysis immediately but defer restarting it until 300ms after the last button press. This avoids repeated blocking engine stop/start cycles that make the GUI unresponsive. Agent-Logs-Url: https://github.com/fsmosca/Python-Easy-Chess-GUI/sessions/71d680e7-f5da-483b-86f3-c7daf8a96f24 Co-authored-by: fsmosca <22366935+fsmosca@users.noreply.github.com> --- python_easy_chess_gui.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/python_easy_chess_gui.py b/python_easy_chess_gui.py index f1c54fd..b39fdb9 100644 --- a/python_easy_chess_gui.py +++ b/python_easy_chess_gui.py @@ -69,7 +69,8 @@ BOX_TITLE = f'{APP_NAME} {APP_VERSION}' REVIEW_MAX_DISPLAY_GAMES = 10000 REVIEW_ANALYSIS_MULTIPV_LINES = 3 -REVIEW_ANALYSIS_PV_MOVES = 7 +REVIEW_ANALYSIS_PV_MOVES = 7 +REVIEW_NAV_DEBOUNCE_SEC = 0.3 REVIEW_MOVE_LIST_HEIGHT = 11 REVIEW_ANALYSIS_BOX_HEIGHT = 4 @@ -829,8 +830,9 @@ def reset_review_state(self): self.review_analysis_lines = [''] * REVIEW_ANALYSIS_MULTIPV_LINES self.review_analysis_enabled = False self.review_analysis_status = 'Analysis stopped' - self.review_analysis_search = None - self.review_analysis_engine = None + self.review_analysis_search = None + self.review_analysis_engine = None + self.review_nav_last_time = 0 def update_game(self, mc: int, user_move: str, time_left: int, user_comment: str): """Saves moves in the game. @@ -3107,9 +3109,17 @@ def start_review_mode(self, window): button, value = review_window.Read(timeout=50) self.poll_review_analysis(review_window) - # Skip timeout events as analysis updates are processed by - # poll_review_analysis() called earlier in the loop. - if button == sg.TIMEOUT_KEY: + # Skip timeout events as analysis updates are processed by + # poll_review_analysis() called earlier in the loop. + if button == sg.TIMEOUT_KEY: + # Restart analysis after debounce delay following navigation. + if (self.review_nav_last_time + and self.review_analysis_enabled + and self.review_analysis_search is None + and time.time() - self.review_nav_last_time + >= REVIEW_NAV_DEBOUNCE_SEC): + self.review_nav_last_time = 0 + self.start_review_analysis(review_window) continue if button is None: @@ -3200,9 +3210,16 @@ def start_review_mode(self, window): self.update_review_window(review_window) continue - if position_changed: - self.update_review_window(review_window) - self.refresh_review_analysis(review_window) + if position_changed: + self.update_review_window(review_window) + if self.review_analysis_enabled: + self.stop_review_analysis() + self.review_nav_last_time = time.time() + self.review_analysis_lines = [''] * REVIEW_ANALYSIS_MULTIPV_LINES + self.review_analysis_status = 'Waiting...' + self.update_review_analysis_panel(review_window) + else: + self.refresh_review_analysis(review_window) self.close_review_analysis() review_window.Close() From 90ab24761ad8fae766f2f68dae9f06401e0fe7ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 03:16:03 +0000 Subject: [PATCH 2/4] Make review mode navigation fully non-blocking Replace blocking stop_review_analysis() on navigation press with non-blocking search.stop() signal. The join+restart is deferred to the debounce handler which fires 300ms after the last button press, by which time the old thread has already exited. Also skip stale analysis messages from the old position during debounce to prevent incorrect UI updates. Agent-Logs-Url: https://github.com/fsmosca/Python-Easy-Chess-GUI/sessions/0e57255b-5a2d-4189-9dc6-ce5b07d5f43f Co-authored-by: fsmosca <22366935+fsmosca@users.noreply.github.com> --- python_easy_chess_gui.py | 87 ++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 39 deletions(-) diff --git a/python_easy_chess_gui.py b/python_easy_chess_gui.py index b39fdb9..edf5b13 100644 --- a/python_easy_chess_gui.py +++ b/python_easy_chess_gui.py @@ -2931,41 +2931,46 @@ def refresh_review_analysis(self, window): return self.start_review_analysis(window) - def poll_review_analysis(self, window): - """Consume engine messages for Review mode analysis.""" - updated = False - while True: - try: - msg = self.review_queue.get_nowait() - except queue.Empty: - break - except Exception: - logging.exception('Failed to read Review mode analysis queue.') - break - - msg_str = str(msg) - if 'multipv_info' in msg_str: - try: - line_no, info_line = msg_str.split(' | ', 1) - line_number = int(line_no.strip()) - if not 1 <= line_number <= REVIEW_ANALYSIS_MULTIPV_LINES: - raise ValueError('Invalid MultiPV line number') - line_index = line_number - 1 - info_line = info_line.rsplit(' multipv_info', 1)[0] - self.review_analysis_lines[line_index] = \ - self.shorten_review_analysis_line(info_line) - updated = True - except Exception: - logging.exception('Failed to parse Review mode analysis info.') - elif 'bestmove' in msg_str: - if self.review_analysis_search is not None: - self.review_analysis_search.join() - self.review_analysis_engine = \ - self.review_analysis_search.get_engine() - self.review_analysis_search = None - if self.review_analysis_enabled: - self.review_analysis_status = \ - 'Analysis ready - {}'.format(self.analysis_id_name) + def poll_review_analysis(self, window): + """Consume engine messages for Review mode analysis.""" + updated = False + is_debouncing = bool(self.review_nav_last_time) + while True: + try: + msg = self.review_queue.get_nowait() + except queue.Empty: + break + except Exception: + logging.exception('Failed to read Review mode analysis queue.') + break + + msg_str = str(msg) + if 'multipv_info' in msg_str: + # Skip stale analysis info from the old position while + # waiting for the debounce to restart analysis. + if is_debouncing: + continue + try: + line_no, info_line = msg_str.split(' | ', 1) + line_number = int(line_no.strip()) + if not 1 <= line_number <= REVIEW_ANALYSIS_MULTIPV_LINES: + raise ValueError('Invalid MultiPV line number') + line_index = line_number - 1 + info_line = info_line.rsplit(' multipv_info', 1)[0] + self.review_analysis_lines[line_index] = \ + self.shorten_review_analysis_line(info_line) + updated = True + except Exception: + logging.exception('Failed to parse Review mode analysis info.') + elif 'bestmove' in msg_str: + if self.review_analysis_search is not None: + self.review_analysis_search.join() + self.review_analysis_engine = \ + self.review_analysis_search.get_engine() + self.review_analysis_search = None + if self.review_analysis_enabled and not is_debouncing: + self.review_analysis_status = \ + 'Analysis ready - {}'.format(self.analysis_id_name) updated = True if updated: @@ -3113,10 +3118,10 @@ def start_review_mode(self, window): # poll_review_analysis() called earlier in the loop. if button == sg.TIMEOUT_KEY: # Restart analysis after debounce delay following navigation. - if (self.review_nav_last_time + nav_time = self.review_nav_last_time + if (nav_time and self.review_analysis_enabled - and self.review_analysis_search is None - and time.time() - self.review_nav_last_time + and time.time() - nav_time >= REVIEW_NAV_DEBOUNCE_SEC): self.review_nav_last_time = 0 self.start_review_analysis(review_window) @@ -3213,7 +3218,11 @@ def start_review_mode(self, window): if position_changed: self.update_review_window(review_window) if self.review_analysis_enabled: - self.stop_review_analysis() + # Signal the analysis thread to stop without blocking. + # The actual join and restart happen in the debounce + # handler after the user stops pressing buttons. + if self.review_analysis_search is not None: + self.review_analysis_search.stop() self.review_nav_last_time = time.time() self.review_analysis_lines = [''] * REVIEW_ANALYSIS_MULTIPV_LINES self.review_analysis_status = 'Waiting...' From c4325861405a658ea5c2ebd806160c0603b9f28a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 03:28:29 +0000 Subject: [PATCH 3/4] Fix RunEngine thread blocking on engine.play() after kill signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the analysis thread is interrupted via _kill.set(), if no bestmove was found yet (bm is None), RunEngine.run() falls through to engine.play(board, Limit(depth=None)) — an unconstrained synchronous call that blocks the thread indefinitely. This makes join() hang and freezes the GUI. Fix: skip the engine.play() fallback when _kill is set, and also make the move delay loop respect the kill signal. Agent-Logs-Url: https://github.com/fsmosca/Python-Easy-Chess-GUI/sessions/225e88fb-0a7d-479d-8208-22d1eb8867e0 Co-authored-by: fsmosca <22366935+fsmosca@users.noreply.github.com> --- python_easy_chess_gui.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/python_easy_chess_gui.py b/python_easy_chess_gui.py index edf5b13..98f1043 100644 --- a/python_easy_chess_gui.py +++ b/python_easy_chess_gui.py @@ -691,13 +691,17 @@ def run(self): # Apply engine move delay if movetime is small if self.is_move_delay: while True: - if time.perf_counter() - start_time >= self.move_delay_sec: + if (self._kill.is_set() + or time.perf_counter() - start_time + >= self.move_delay_sec): break logging.info('Delay sending of best move {}'.format(self.bm)) time.sleep(1.0) # If bm is None, we will use engine.play() - if self.bm is None: + # Skip this fallback when the search was explicitly interrupted + # to avoid blocking the thread with an unconstrained engine call. + if self.bm is None and not self._kill.is_set(): logging.info('bm is none, we will try engine,play().') try: result = self.engine.play(self.board, limit) From 702907cce7ed7c5b22469d26e8aa9effdc7b8a08 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 03:56:47 +0000 Subject: [PATCH 4/4] Fix analysis engine receiving 'go' instead of 'go infinite' python-chess's engine.analysis() checks `if limit:` to decide whether to send 'go infinite'. Since Limit() with all-None fields is a truthy dataclass instance, passing Limit(depth=None) caused python-chess to send bare 'go' without the 'infinite' token. The engine received 'go' with no parameters and sat idle. Fix: pass limit=None (not Limit()) for tc_type='infinite' with no depth constraint, so python-chess takes the else branch and sends 'go infinite'. Also guard the engine.play() fallback against limit=None. Agent-Logs-Url: https://github.com/fsmosca/Python-Easy-Chess-GUI/sessions/d96d5ff9-9d28-43b2-bd39-3600dbd677ec Co-authored-by: fsmosca <22366935+fsmosca@users.noreply.github.com> --- python_easy_chess_gui.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/python_easy_chess_gui.py b/python_easy_chess_gui.py index 98f1043..0cb1564 100644 --- a/python_easy_chess_gui.py +++ b/python_easy_chess_gui.py @@ -556,10 +556,13 @@ def run(self): except Exception: logging.exception('Failed to configure runtime analysis options.') - # Set search limits + # Set search limits. + # For infinite analysis pass limit=None so that python-chess sends + # "go infinite" to the engine (Limit() is truthy and would produce + # a bare "go" without the infinite token). if self.tc_type == 'infinite': - limit = chess.engine.Limit( - depth=self.max_depth if self.max_depth != MAX_DEPTH else None) + limit = (chess.engine.Limit(depth=self.max_depth) + if self.max_depth != MAX_DEPTH else None) elif self.tc_type == 'delay': limit = chess.engine.Limit( depth=self.max_depth if self.max_depth != MAX_DEPTH else None, @@ -701,7 +704,9 @@ def run(self): # If bm is None, we will use engine.play() # Skip this fallback when the search was explicitly interrupted # to avoid blocking the thread with an unconstrained engine call. - if self.bm is None and not self._kill.is_set(): + # Also skip when limit is None (infinite analysis) since + # engine.play() requires a concrete Limit object. + if self.bm is None and not self._kill.is_set() and limit is not None: logging.info('bm is none, we will try engine,play().') try: result = self.engine.play(self.board, limit)