Skip to content

Commit 3818962

Browse files
Copilotfsmosca
andauthored
Fix analysis engine receiving bare 'go' instead of 'go infinite' (#63)
* 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> * 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> * Fix RunEngine thread blocking on engine.play() after kill signal 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> * 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> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: fsmosca <22366935+fsmosca@users.noreply.github.com>
1 parent 8541edb commit 3818962

1 file changed

Lines changed: 84 additions & 49 deletions

File tree

python_easy_chess_gui.py

Lines changed: 84 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@
6969
BOX_TITLE = f'{APP_NAME} {APP_VERSION}'
7070
REVIEW_MAX_DISPLAY_GAMES = 10000
7171
REVIEW_ANALYSIS_MULTIPV_LINES = 3
72-
REVIEW_ANALYSIS_PV_MOVES = 7
72+
REVIEW_ANALYSIS_PV_MOVES = 7
73+
REVIEW_NAV_DEBOUNCE_SEC = 0.3
7374
REVIEW_MOVE_LIST_HEIGHT = 11
7475
REVIEW_ANALYSIS_BOX_HEIGHT = 4
7576

@@ -555,10 +556,13 @@ def run(self):
555556
except Exception:
556557
logging.exception('Failed to configure runtime analysis options.')
557558

558-
# Set search limits
559+
# Set search limits.
560+
# For infinite analysis pass limit=None so that python-chess sends
561+
# "go infinite" to the engine (Limit() is truthy and would produce
562+
# a bare "go" without the infinite token).
559563
if self.tc_type == 'infinite':
560-
limit = chess.engine.Limit(
561-
depth=self.max_depth if self.max_depth != MAX_DEPTH else None)
564+
limit = (chess.engine.Limit(depth=self.max_depth)
565+
if self.max_depth != MAX_DEPTH else None)
562566
elif self.tc_type == 'delay':
563567
limit = chess.engine.Limit(
564568
depth=self.max_depth if self.max_depth != MAX_DEPTH else None,
@@ -690,13 +694,19 @@ def run(self):
690694
# Apply engine move delay if movetime is small
691695
if self.is_move_delay:
692696
while True:
693-
if time.perf_counter() - start_time >= self.move_delay_sec:
697+
if (self._kill.is_set()
698+
or time.perf_counter() - start_time
699+
>= self.move_delay_sec):
694700
break
695701
logging.info('Delay sending of best move {}'.format(self.bm))
696702
time.sleep(1.0)
697703

698704
# If bm is None, we will use engine.play()
699-
if self.bm is None:
705+
# Skip this fallback when the search was explicitly interrupted
706+
# to avoid blocking the thread with an unconstrained engine call.
707+
# Also skip when limit is None (infinite analysis) since
708+
# engine.play() requires a concrete Limit object.
709+
if self.bm is None and not self._kill.is_set() and limit is not None:
700710
logging.info('bm is none, we will try engine,play().')
701711
try:
702712
result = self.engine.play(self.board, limit)
@@ -829,8 +839,9 @@ def reset_review_state(self):
829839
self.review_analysis_lines = [''] * REVIEW_ANALYSIS_MULTIPV_LINES
830840
self.review_analysis_enabled = False
831841
self.review_analysis_status = 'Analysis stopped'
832-
self.review_analysis_search = None
833-
self.review_analysis_engine = None
842+
self.review_analysis_search = None
843+
self.review_analysis_engine = None
844+
self.review_nav_last_time = 0
834845

835846
def update_game(self, mc: int, user_move: str, time_left: int, user_comment: str):
836847
"""Saves moves in the game.
@@ -2929,41 +2940,46 @@ def refresh_review_analysis(self, window):
29292940
return
29302941
self.start_review_analysis(window)
29312942

2932-
def poll_review_analysis(self, window):
2933-
"""Consume engine messages for Review mode analysis."""
2934-
updated = False
2935-
while True:
2936-
try:
2937-
msg = self.review_queue.get_nowait()
2938-
except queue.Empty:
2939-
break
2940-
except Exception:
2941-
logging.exception('Failed to read Review mode analysis queue.')
2942-
break
2943-
2944-
msg_str = str(msg)
2945-
if 'multipv_info' in msg_str:
2946-
try:
2947-
line_no, info_line = msg_str.split(' | ', 1)
2948-
line_number = int(line_no.strip())
2949-
if not 1 <= line_number <= REVIEW_ANALYSIS_MULTIPV_LINES:
2950-
raise ValueError('Invalid MultiPV line number')
2951-
line_index = line_number - 1
2952-
info_line = info_line.rsplit(' multipv_info', 1)[0]
2953-
self.review_analysis_lines[line_index] = \
2954-
self.shorten_review_analysis_line(info_line)
2955-
updated = True
2956-
except Exception:
2957-
logging.exception('Failed to parse Review mode analysis info.')
2958-
elif 'bestmove' in msg_str:
2959-
if self.review_analysis_search is not None:
2960-
self.review_analysis_search.join()
2961-
self.review_analysis_engine = \
2962-
self.review_analysis_search.get_engine()
2963-
self.review_analysis_search = None
2964-
if self.review_analysis_enabled:
2965-
self.review_analysis_status = \
2966-
'Analysis ready - {}'.format(self.analysis_id_name)
2943+
def poll_review_analysis(self, window):
2944+
"""Consume engine messages for Review mode analysis."""
2945+
updated = False
2946+
is_debouncing = bool(self.review_nav_last_time)
2947+
while True:
2948+
try:
2949+
msg = self.review_queue.get_nowait()
2950+
except queue.Empty:
2951+
break
2952+
except Exception:
2953+
logging.exception('Failed to read Review mode analysis queue.')
2954+
break
2955+
2956+
msg_str = str(msg)
2957+
if 'multipv_info' in msg_str:
2958+
# Skip stale analysis info from the old position while
2959+
# waiting for the debounce to restart analysis.
2960+
if is_debouncing:
2961+
continue
2962+
try:
2963+
line_no, info_line = msg_str.split(' | ', 1)
2964+
line_number = int(line_no.strip())
2965+
if not 1 <= line_number <= REVIEW_ANALYSIS_MULTIPV_LINES:
2966+
raise ValueError('Invalid MultiPV line number')
2967+
line_index = line_number - 1
2968+
info_line = info_line.rsplit(' multipv_info', 1)[0]
2969+
self.review_analysis_lines[line_index] = \
2970+
self.shorten_review_analysis_line(info_line)
2971+
updated = True
2972+
except Exception:
2973+
logging.exception('Failed to parse Review mode analysis info.')
2974+
elif 'bestmove' in msg_str:
2975+
if self.review_analysis_search is not None:
2976+
self.review_analysis_search.join()
2977+
self.review_analysis_engine = \
2978+
self.review_analysis_search.get_engine()
2979+
self.review_analysis_search = None
2980+
if self.review_analysis_enabled and not is_debouncing:
2981+
self.review_analysis_status = \
2982+
'Analysis ready - {}'.format(self.analysis_id_name)
29672983
updated = True
29682984

29692985
if updated:
@@ -3107,9 +3123,17 @@ def start_review_mode(self, window):
31073123
button, value = review_window.Read(timeout=50)
31083124
self.poll_review_analysis(review_window)
31093125

3110-
# Skip timeout events as analysis updates are processed by
3111-
# poll_review_analysis() called earlier in the loop.
3112-
if button == sg.TIMEOUT_KEY:
3126+
# Skip timeout events as analysis updates are processed by
3127+
# poll_review_analysis() called earlier in the loop.
3128+
if button == sg.TIMEOUT_KEY:
3129+
# Restart analysis after debounce delay following navigation.
3130+
nav_time = self.review_nav_last_time
3131+
if (nav_time
3132+
and self.review_analysis_enabled
3133+
and time.time() - nav_time
3134+
>= REVIEW_NAV_DEBOUNCE_SEC):
3135+
self.review_nav_last_time = 0
3136+
self.start_review_analysis(review_window)
31133137
continue
31143138

31153139
if button is None:
@@ -3200,9 +3224,20 @@ def start_review_mode(self, window):
32003224
self.update_review_window(review_window)
32013225
continue
32023226

3203-
if position_changed:
3204-
self.update_review_window(review_window)
3205-
self.refresh_review_analysis(review_window)
3227+
if position_changed:
3228+
self.update_review_window(review_window)
3229+
if self.review_analysis_enabled:
3230+
# Signal the analysis thread to stop without blocking.
3231+
# The actual join and restart happen in the debounce
3232+
# handler after the user stops pressing buttons.
3233+
if self.review_analysis_search is not None:
3234+
self.review_analysis_search.stop()
3235+
self.review_nav_last_time = time.time()
3236+
self.review_analysis_lines = [''] * REVIEW_ANALYSIS_MULTIPV_LINES
3237+
self.review_analysis_status = 'Waiting...'
3238+
self.update_review_analysis_panel(review_window)
3239+
else:
3240+
self.refresh_review_analysis(review_window)
32063241

32073242
self.close_review_analysis()
32083243
review_window.Close()

0 commit comments

Comments
 (0)