Skip to content

Commit 1406463

Browse files
authored
fix(conversation_tab): add resource cleanup when closing conversations (#1057)
* fix(conversation_tab): add resource cleanup when closing conversations Add proper cleanup of all running resources when closing conversation tabs or the application to prevent resource leaks, orphaned processes, and RuntimeErrors from accessing destroyed widgets. Resource cleanup improvements: - Add cleanup_resources() method to ConversationTab for comprehensive cleanup - Clean up completion handlers with skip_callbacks to prevent widget access - Abort recording threads (instead of just stopping) to prevent transcription callbacks - Stop progress sounds when closing tabs - Terminate OCR processes gracefully with timeout and kill fallback - Terminate background processes with proper error handling - Add _is_destroying flag to prevent callbacks from accessing destroyed widgets - Factorize cleanup logic with _cleanup_all_tabs() helper in MainFrame - Call cleanup_resources() in on_close_conversation() and on_quit() - Create _terminate_process() helper to factorize process termination logic Defensive callback handling: - Make all completion callbacks defensive (_on_completion_start, _on_completion_end, _on_completion_error, _on_stream_chunk, _on_stream_start, _on_stream_finish, _on_non_stream_finish) - check _is_destroying flag and widget existence - Make all recording callbacks defensive (on_recording_started, on_recording_stopped, on_transcription_started, on_transcription_received, on_transcription_error) - Make OCR callbacks defensive (_handle_ocr_completion_message, _cleanup_ocr_process) - Update RecordingThread to check _want_abort before scheduling callbacks - Add skip_callbacks parameter to CompletionHandler.stop_completion() - Improve stop_recording() with abort parameter and defensive UI updates * fix(conversation_tab): address review comments add stack traces to exception-related logs Clear the recording_thread reference after aborting and joining the thread in cleanup_resources() to allow proper garbage collection of the RecordingThread and its held references (provider_engine, conversation_tab). Fix _is_widget_valid() method to properly detect destroyed wx widgets by removing the ineffective __class__ fallback and directly calling GetParent() to trigger RuntimeError when the C++ peer has been destroyed. Remove exc_info=True from log calls outside exception handlers - Remove exc_info=True from log.debug/log.error calls that are not inside exception handlers to prevent "NoneType: None" noise in logs - Fixed in on_transcription_error(), _on_completion_error(), _handle_ocr_completion_message(), and _enable_ocr_button() - All remaining exc_info=True usages are correctly inside exception handlers Add join() after kill() in process termination - Add process.join(timeout=0.5) after process.kill() in _terminate_process() to ensure killed processes are reaped before returning - Prevents zombie processes from accumulating - Guarded with hasattr and is_alive checks for safety - Uses short timeout to avoid blocking Add error handling to _cleanup_all_tabs
1 parent 183809e commit 1406463

File tree

5 files changed

+291
-25
lines changed

5 files changed

+291
-25
lines changed

basilisk/completion_handler.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,14 +120,19 @@ def start_completion(
120120
self.task.start()
121121
logger.debug("Completion task %s started", self.task.ident)
122122

123-
def stop_completion(self):
124-
"""Stop the current completion if running."""
123+
def stop_completion(self, skip_callbacks: bool = False):
124+
"""Stop the current completion if running.
125+
126+
Args:
127+
skip_callbacks: If True, skip calling completion end callbacks.
128+
Useful when cleaning up resources before destroying the tab.
129+
"""
125130
if self.is_running():
126131
self._stop_completion = True
127132
logger.debug("Stopping completion task: %s", self.task.ident)
128133
self.task.join(timeout=0.05)
129134
self.task = None
130-
if self.on_completion_end:
135+
if self.on_completion_end and not skip_callbacks:
131136
wx.CallAfter(self.on_completion_end, False)
132137

133138
def is_running(self) -> bool:

basilisk/gui/conversation_tab.py

Lines changed: 157 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ def __init__(
152152
self.conv_storage_path = conv_storage_path or self.conv_storage_path()
153153
self.conversation = conversation or Conversation()
154154
self.recording_thread: Optional[RecordingThread] = None
155+
self._is_destroying = False
155156

156157
# Initialize variables for error recovery
157158
self._stored_prompt_text: Optional[str] = None
@@ -495,18 +496,62 @@ def on_transcribe_audio_file(self):
495496
else:
496497
dlg.Destroy()
497498

499+
def _is_widget_valid(self, widget_name: Optional[str] = None) -> bool:
500+
"""Check if the tab and its widgets are still valid.
501+
502+
This method verifies that the tab object and optionally a specific widget
503+
are still valid (not being destroyed and accessible). Works with any
504+
wx.Window-derived object.
505+
506+
Args:
507+
widget_name: Optional specific widget name to check. If None, only
508+
checks if the tab itself is valid.
509+
510+
Returns:
511+
True if widgets are valid, False otherwise
512+
"""
513+
if self._is_destroying:
514+
return False
515+
516+
if widget_name and not hasattr(self, widget_name):
517+
return False
518+
519+
try:
520+
_ = self.GetParent()
521+
if widget_name:
522+
getattr(self, widget_name).GetParent()
523+
return True
524+
except RuntimeError:
525+
log.debug(
526+
"Widget validation failed: tab or widget is being destroyed",
527+
exc_info=True,
528+
)
529+
return False
530+
except AttributeError:
531+
log.debug(
532+
"Widget validation: object missing GetParent method",
533+
exc_info=True,
534+
)
535+
return False
536+
498537
def on_recording_started(self):
499538
"""Handle the start of audio recording."""
539+
if not self._is_widget_valid():
540+
return
500541
play_sound("recording_started")
501542
self.SetStatusText(_("Recording..."))
502543

503544
def on_recording_stopped(self):
504545
"""Handle the end of audio recording."""
546+
if not self._is_widget_valid():
547+
return
505548
play_sound("recording_stopped")
506549
self.SetStatusText(_("Recording stopped"))
507550

508551
def on_transcription_started(self):
509552
"""Handle the start of audio transcription."""
553+
if not self._is_widget_valid():
554+
return
510555
play_sound("progress", loop=True)
511556
self.SetStatusText(_("Transcribing..."))
512557

@@ -516,6 +561,8 @@ def on_transcription_received(self, transcription):
516561
Args:
517562
transcription: The transcription result
518563
"""
564+
if not self._is_widget_valid("prompt_panel"):
565+
return
519566
stop_sound()
520567
self.SetStatusText(_("Ready"))
521568
self.prompt_panel.prompt.AppendText(transcription.text)
@@ -533,6 +580,11 @@ def on_transcription_error(self, error):
533580
Args:
534581
error: The error that occurred
535582
"""
583+
if not self._is_widget_valid():
584+
log.debug(
585+
"Skipping transcription error dialog: tab is being destroyed"
586+
)
587+
return
536588
stop_sound()
537589
self.SetStatusText(_("Ready"))
538590
show_enhanced_error_dialog(
@@ -567,11 +619,24 @@ def start_recording(self):
567619
self.submit_btn.Disable()
568620
self.transcribe_audio_file()
569621

570-
def stop_recording(self):
571-
"""Stop audio recording."""
572-
self.recording_thread.stop()
573-
self.toggle_record_btn.SetLabel(_("Record") + " (Ctrl+R)")
574-
self.submit_btn.Enable()
622+
def stop_recording(self, abort: bool = False):
623+
"""Stop audio recording.
624+
625+
Args:
626+
abort: If True, abort the recording and transcription process entirely.
627+
If False, stop recording but allow transcription to complete.
628+
"""
629+
if not self.recording_thread:
630+
return
631+
632+
if abort:
633+
self.recording_thread.abort()
634+
else:
635+
self.recording_thread.stop()
636+
637+
if self._is_widget_valid("toggle_record_btn"):
638+
self.toggle_record_btn.SetLabel(_("Record") + " (Ctrl+R)")
639+
self.submit_btn.Enable()
575640

576641
def get_system_message(self) -> SystemMessage | None:
577642
"""Get the system message from the system prompt input.
@@ -803,6 +868,8 @@ def get_conversation_block_index(self, block: MessageBlock) -> int | None:
803868

804869
def _on_completion_start(self):
805870
"""Called when completion starts."""
871+
if not self._is_widget_valid("submit_btn"):
872+
return
806873
self.submit_btn.Disable()
807874
self.stop_completion_btn.Show()
808875
if config.conf().conversation.focus_history_after_send:
@@ -814,6 +881,8 @@ def _on_completion_end(self, success: bool):
814881
Args:
815882
success: Whether the completion was successful
816883
"""
884+
if not self._is_widget_valid("stop_completion_btn"):
885+
return
817886
self.stop_completion_btn.Hide()
818887
self.submit_btn.Enable()
819888

@@ -822,14 +891,17 @@ def _on_completion_end(self, success: bool):
822891
self._clear_stored_content()
823892

824893
if success and config.conf().conversation.focus_history_after_send:
825-
self.messages.SetFocus()
894+
if self._is_widget_valid("messages"):
895+
self.messages.SetFocus()
826896

827897
def _on_stream_chunk(self, chunk: str):
828898
"""Called for each streaming chunk.
829899
830900
Args:
831901
chunk: The streaming chunk content
832902
"""
903+
if not self._is_widget_valid("messages"):
904+
return
833905
self.messages.append_stream_chunk(chunk)
834906

835907
def _on_stream_start(
@@ -841,6 +913,8 @@ def _on_stream_start(
841913
new_block: The message block being completed
842914
system_message: Optional system message
843915
"""
916+
if not self._is_widget_valid("messages"):
917+
return
844918
self.conversation.add_block(new_block, system_message)
845919
self.messages.display_new_block(new_block, streaming=True)
846920
self.messages.SetInsertionPointEnd()
@@ -851,6 +925,8 @@ def _on_stream_finish(self, new_block: MessageBlock):
851925
Args:
852926
new_block: The completed message block
853927
"""
928+
if not self._is_widget_valid("messages"):
929+
return
854930
self.messages.a_output.handle_stream_buffer()
855931
self.messages.update_last_segment_length()
856932

@@ -863,6 +939,8 @@ def _on_non_stream_finish(
863939
new_block: The completed message block
864940
system_message: Optional system message
865941
"""
942+
if not self._is_widget_valid("messages"):
943+
return
866944
self.conversation.add_block(new_block, system_message)
867945
self.messages.display_new_block(new_block)
868946
if self.messages.should_speak_response:
@@ -894,13 +972,85 @@ def _on_completion_error(self, error_message: str):
894972
Args:
895973
error_message: The error message
896974
"""
975+
if not self._is_widget_valid("prompt_panel"):
976+
log.debug(
977+
"Skipping completion error dialog: tab is being destroyed"
978+
)
979+
return
897980
self._restore_prompt_content()
898981
self._clear_stored_content()
899-
900982
show_enhanced_error_dialog(
901983
parent=self,
902984
message=_("An error occurred during completion: %s")
903985
% error_message,
904986
title=_("Completion Error"),
905987
is_completion_error=True,
906988
)
989+
990+
def _terminate_process(
991+
self, process: Any, process_name: str, timeout: float = 1.0
992+
) -> None:
993+
"""Terminate a process gracefully, with fallback to kill if needed.
994+
995+
Args:
996+
process: The process to terminate (must have is_alive, terminate, join, kill methods)
997+
process_name: Name of the process for logging purposes
998+
timeout: Timeout in seconds to wait for graceful termination
999+
"""
1000+
if not process or not hasattr(process, "is_alive"):
1001+
return
1002+
if not process.is_alive():
1003+
return
1004+
1005+
log.debug("Terminating %s before closing tab", process_name)
1006+
try:
1007+
process.terminate()
1008+
process.join(timeout=timeout)
1009+
if process.is_alive():
1010+
log.warning("%s did not terminate, killing it", process_name)
1011+
process.kill()
1012+
if process.is_alive():
1013+
process.join(timeout=0.5)
1014+
except Exception as e:
1015+
log.error(
1016+
"Error terminating %s: %s", process_name, e, exc_info=True
1017+
)
1018+
1019+
def cleanup_resources(self):
1020+
"""Clean up all running resources before closing the conversation tab.
1021+
1022+
This method stops:
1023+
- Running completion tasks
1024+
- Active audio recording
1025+
- Progress sounds
1026+
- OCR processes
1027+
- Any other background processes
1028+
"""
1029+
self._is_destroying = True
1030+
1031+
if self.completion_handler.is_running():
1032+
log.debug("Stopping completion handler before closing tab")
1033+
self.completion_handler.stop_completion(skip_callbacks=True)
1034+
1035+
# Abort recording if active (abort prevents transcription callbacks)
1036+
if self.recording_thread:
1037+
if self.recording_thread.is_alive():
1038+
log.debug("Aborting recording thread before closing tab")
1039+
try:
1040+
self.recording_thread.abort()
1041+
self.recording_thread.join(timeout=0.5)
1042+
except Exception as e:
1043+
log.error(
1044+
"Error aborting recording thread: %s", e, exc_info=True
1045+
)
1046+
self.recording_thread = None
1047+
1048+
stop_sound()
1049+
1050+
self._terminate_process(self.ocr_handler.process, "OCR process")
1051+
if self.ocr_handler.process:
1052+
self.ocr_handler.process = None
1053+
1054+
self._terminate_process(self.process, "background process")
1055+
if self.process:
1056+
self.process = None

basilisk/gui/main_frame.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,27 @@ def on_restore(self, event: wx.Event | None):
403403
self.Show(True)
404404
self.Raise()
405405

406+
def _cleanup_all_tabs(self):
407+
"""Clean up resources for all conversation tabs.
408+
409+
This helper method is used by on_quit to clean up all tabs when
410+
closing the application. For closing a single tab, use
411+
tab.cleanup_resources() directly.
412+
413+
Each tab's cleanup is wrapped in try/except to ensure all tabs
414+
get cleaned up even if one fails.
415+
"""
416+
for index, tab in enumerate(self.tabs_panels):
417+
try:
418+
tab.cleanup_resources()
419+
except Exception as e:
420+
log.error(
421+
"Error cleaning up resources for tab at index %d: %s",
422+
index,
423+
e,
424+
exc_info=True,
425+
)
426+
406427
def on_close(self, event: wx.Event | None):
407428
"""Handle the close event for the main application frame.
408429
@@ -419,7 +440,7 @@ def on_quit(self, event: wx.Event | None):
419440
420441
This method performs the following actions:
421442
- Sets a global flag to indicate the application should exit
422-
- Waits for all active conversation tasks to complete
443+
- Cleans up all resources for all conversation tabs (completions, recordings, sounds, processes)
423444
- Unregisters global hotkeys on Windows platforms
424445
- Removes and destroys the system tray icon
425446
- Destroys the main application window
@@ -430,15 +451,16 @@ def on_quit(self, event: wx.Event | None):
430451
"""
431452
log.info("Closing application")
432453
global_vars.app_should_exit = True
433-
# ensure all conversation tasks are stopped
434-
for tab in self.tabs_panels:
435-
tab.completion_handler.stop_completion()
454+
455+
self._cleanup_all_tabs()
456+
436457
if sys.platform == "win32":
437458
self.UnregisterHotKey(HotkeyAction.TOGGLE_VISIBILITY.value)
438459
self.UnregisterHotKey(HotkeyAction.CAPTURE_WINDOW.value)
439460
self.UnregisterHotKey(HotkeyAction.CAPTURE_FULL.value)
440461
self.tray_icon.RemoveIcon()
441462
self.tray_icon.Destroy()
463+
442464
self.Destroy()
443465
wx.GetApp().ExitMainLoop()
444466

@@ -611,14 +633,31 @@ def on_close_conversation(self, event: wx.Event | None):
611633
This method removes the current tab from the notebook and the tabs_panels list. If no tabs remain,
612634
a new default conversation is created. Otherwise, the last tab is selected and the frame title is refreshed.
613635
636+
Before closing, it cleans up all running resources for this specific tab only
637+
(completion tasks, recordings, sounds, processes).
638+
614639
Args:
615640
event: The event that triggered the tab closure. Can be None.
616641
"""
617642
current_tab_index = self.notebook.GetSelection()
618643
if current_tab_index == wx.NOT_FOUND:
619644
return
645+
646+
current_tab = self.tabs_panels[current_tab_index]
647+
648+
try:
649+
current_tab.cleanup_resources()
650+
except Exception as e:
651+
log.error(
652+
"Error cleaning up resources for tab at index %d: %s",
653+
current_tab_index,
654+
e,
655+
exc_info=True,
656+
)
657+
620658
self.notebook.DeletePage(current_tab_index)
621659
self.tabs_panels.pop(current_tab_index)
660+
622661
current_tab_count = self.notebook.GetPageCount()
623662
if current_tab_count == 0:
624663
self.on_new_default_conversation(None)

0 commit comments

Comments
 (0)