fix(settings): stop freezing when applying an already-downloaded model (#790)
* fix(settings): apply already-downloaded models off the GTK main loop Changing Model Size, Specialization, or Language froze Settings when the model was already on disk, because reconfigure ran in the changed handler. Move that apply onto a worker thread (same shape as the download path) and keep the apply-guard held until GLib.idle_add clears it so a second pick cannot start a parallel load. Fixes #789. * fix(settings): guard Test/apply while downloaded-model worker runs Block apply_settings and Test Dictation while _applying_settings is held so a concurrent Test click cannot race the shared engine. Annotate the new worker/helpers per AGENTS.md and cover the no-op/block behavior. * fix(settings): resync pickers when a second pick lands mid-apply While an already-downloaded model worker was running, Model Size / Specialization / Language could still move. The apply guard blocked saving the new pick, then finish only cleared the flag, so the UI could show B with dictation still on A. On finish, if the selection no longer matches the saved config, put the pickers back. Idle resyncs from workers now skip a destroyed dialog the same way update check does. * fix(settings): always resync pickers after model apply finishes A second Model Size pick on whisper.cpp could move the size combo while the apply guard skipped rebuilding specialization, so selected settings still looked like the saved variant and finish skipped the resync. Always put the pickers back on the saved config when the worker ends, and restore language from that config too. * test(settings): prove mid-apply picker handlers round-trip on finish Call the real size, specialization, and language handlers while the apply guard is held, and run finish through the real resync path so whisper.cpp size and language cannot silently stick on the second pick.
This commit is contained in:
@@ -5186,6 +5186,8 @@ class SettingsDialog(Gtk.Dialog):
|
||||
"""Handle changes in the selected model."""
|
||||
if self._populating_models:
|
||||
return
|
||||
if self._initializing or self._applying_settings:
|
||||
return
|
||||
|
||||
if self._get_selected_engine() == "whisper_cpp":
|
||||
model_size = self.model_combo.get_active_id()
|
||||
@@ -5205,6 +5207,8 @@ class SettingsDialog(Gtk.Dialog):
|
||||
"""Handle changes in the selected whisper.cpp specialization."""
|
||||
if self._populating_models:
|
||||
return
|
||||
if self._initializing or self._applying_settings:
|
||||
return
|
||||
|
||||
self._sync_language_options_for_selected_model()
|
||||
self._update_model_info()
|
||||
@@ -5291,6 +5295,8 @@ class SettingsDialog(Gtk.Dialog):
|
||||
"""Handle language selection change."""
|
||||
if self._processing_language_change:
|
||||
return
|
||||
if self._initializing or self._applying_settings:
|
||||
return
|
||||
|
||||
lang_code = self.language_combo.get_active_id()
|
||||
if not lang_code:
|
||||
@@ -5480,6 +5486,9 @@ class SettingsDialog(Gtk.Dialog):
|
||||
return
|
||||
|
||||
self._applying_settings = True
|
||||
# Already-downloaded apply is handed to a worker that clears this flag
|
||||
# via GLib.idle_add. Download still holds it for the modal run() below.
|
||||
worker_holds_guard = False
|
||||
try:
|
||||
settings = self.get_selected_settings()
|
||||
engine = settings.get("engine", "vosk")
|
||||
@@ -5539,7 +5548,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
# False return must not be read as success: it
|
||||
# means nothing was saved and the pickers still
|
||||
# show settings the engine never took.
|
||||
GLib.idle_add(self._resync_model_ui_from_config)
|
||||
GLib.idle_add(self._idle_resync_model_ui_from_config)
|
||||
GLib.idle_add(
|
||||
download_dialog.set_complete,
|
||||
False,
|
||||
@@ -5555,7 +5564,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
# The settings were never saved, so the previously working
|
||||
# model is still the configured one; put the pickers back on
|
||||
# it so the UI matches the config and a retry is possible.
|
||||
GLib.idle_add(self._resync_model_ui_from_config)
|
||||
GLib.idle_add(self._idle_resync_model_ui_from_config)
|
||||
if "cancelled" in error_msg.lower():
|
||||
GLib.idle_add(
|
||||
download_dialog.set_complete,
|
||||
@@ -5582,18 +5591,43 @@ class SettingsDialog(Gtk.Dialog):
|
||||
|
||||
logger.info(f"Auto-applying settings: {settings}")
|
||||
|
||||
was_running = self.speech_engine.state != RecognitionState.IDLE
|
||||
if was_running:
|
||||
self.speech_engine.stop_recognition()
|
||||
def apply_already_downloaded() -> None:
|
||||
try:
|
||||
self._apply_settings_internal(settings, raise_errors=True)
|
||||
logger.info("Settings auto-applied successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-apply settings: {e}")
|
||||
GLib.idle_add(self._idle_resync_model_ui_from_config)
|
||||
finally:
|
||||
GLib.idle_add(self._finish_auto_apply)
|
||||
|
||||
self.speech_engine.reconfigure(**settings)
|
||||
self._save_selected_settings(settings)
|
||||
logger.info("Settings auto-applied successfully")
|
||||
threading.Thread(target=apply_already_downloaded, daemon=True).start()
|
||||
worker_holds_guard = True
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-apply settings: {e}")
|
||||
self._resync_model_ui_from_config()
|
||||
finally:
|
||||
self._applying_settings = False
|
||||
if not worker_holds_guard:
|
||||
self._applying_settings = False
|
||||
|
||||
def _finish_auto_apply(self) -> bool:
|
||||
"""Release the apply-guard after an already-downloaded worker finishes.
|
||||
|
||||
Always resync while the dialog is alive: handlers early-return during
|
||||
apply, so selected settings can still match the saved config even when
|
||||
a combo (whisper.cpp size) has already moved.
|
||||
"""
|
||||
self._applying_settings = False
|
||||
if self._dialog_is_alive():
|
||||
self._resync_model_ui_from_config()
|
||||
return False
|
||||
|
||||
def _idle_resync_model_ui_from_config(self) -> bool:
|
||||
"""Main-loop resync from a worker thread; no-op if the dialog is gone."""
|
||||
if self._dialog_is_alive():
|
||||
self._resync_model_ui_from_config()
|
||||
return False
|
||||
|
||||
def _resync_model_ui_from_config(self):
|
||||
"""Put the pickers back on the settings that are actually saved.
|
||||
@@ -5604,9 +5638,8 @@ class SettingsDialog(Gtk.Dialog):
|
||||
signal.
|
||||
"""
|
||||
try:
|
||||
saved_engine = (
|
||||
self.config_manager.get_settings().get("speech_recognition", {}).get("engine")
|
||||
)
|
||||
sr_config = self.config_manager.get_settings().get("speech_recognition", {})
|
||||
saved_engine = sr_config.get("engine")
|
||||
if saved_engine:
|
||||
display = _engine_display_name(saved_engine)
|
||||
if self.engine_combo.get_active_text() != display:
|
||||
@@ -5620,6 +5653,9 @@ class SettingsDialog(Gtk.Dialog):
|
||||
finally:
|
||||
self._applying_settings = was_applying
|
||||
self._populate_model_options()
|
||||
saved_language = sr_config.get("language")
|
||||
if saved_language:
|
||||
self._sync_language_options_for_selected_model(saved_language)
|
||||
self._update_model_info()
|
||||
except Exception as e: # pragma: no cover - UI resync must never mask the original error
|
||||
logger.debug(f"Could not resync model pickers: {e}")
|
||||
@@ -5734,6 +5770,13 @@ class SettingsDialog(Gtk.Dialog):
|
||||
logger.warning("Test already in progress.")
|
||||
return
|
||||
|
||||
if self._applying_settings:
|
||||
# The live engine may still be mid-reconfigure even when the UI
|
||||
# already matches the saved config. Do not apply or start a test.
|
||||
self.test_output_revealer.set_reveal_child(True)
|
||||
self.test_buffer.set_text("Settings are still applying. Try Test again in a moment.")
|
||||
return
|
||||
|
||||
current_config = self.config_manager.get_settings().get("speech_recognition", {})
|
||||
selected_settings = self.get_selected_settings()
|
||||
selected_engine = selected_settings.get("engine")
|
||||
@@ -5893,8 +5936,12 @@ For now, the engine has been reverted to VOSK."""
|
||||
self._populate_model_options()
|
||||
self._update_engine_specific_ui()
|
||||
|
||||
def apply_settings(self):
|
||||
def apply_settings(self) -> bool:
|
||||
"""Apply the selected settings."""
|
||||
if self._applying_settings:
|
||||
logger.warning("Ignoring apply_settings(); another apply is already in progress")
|
||||
return False
|
||||
|
||||
settings = self.get_selected_settings()
|
||||
logger.info(f"Applying settings: {settings}")
|
||||
|
||||
@@ -5918,7 +5965,7 @@ For now, the engine has been reverted to VOSK."""
|
||||
# Same guard as in _auto_apply_settings: one download at a time.
|
||||
self._show_download_busy_dialog()
|
||||
self._resync_model_ui_from_config()
|
||||
return
|
||||
return False
|
||||
download_dialog = ModelDownloadDialog(
|
||||
self,
|
||||
model_name,
|
||||
@@ -5946,7 +5993,7 @@ For now, the engine has been reverted to VOSK."""
|
||||
if applied:
|
||||
GLib.idle_add(download_dialog.set_complete, True, "")
|
||||
else:
|
||||
GLib.idle_add(self._resync_model_ui_from_config)
|
||||
GLib.idle_add(self._idle_resync_model_ui_from_config)
|
||||
GLib.idle_add(
|
||||
download_dialog.set_complete,
|
||||
False,
|
||||
@@ -5961,7 +6008,7 @@ For now, the engine has been reverted to VOSK."""
|
||||
error_msg = str(e)
|
||||
# Nothing was saved, so the config still names the previous
|
||||
# engine and model; put the pickers back on them.
|
||||
GLib.idle_add(self._resync_model_ui_from_config)
|
||||
GLib.idle_add(self._idle_resync_model_ui_from_config)
|
||||
if "cancelled" in error_msg.lower():
|
||||
GLib.idle_add(download_dialog.set_complete, False, "Download cancelled")
|
||||
elif engine == "whisper" and "no module named" in error_msg.lower():
|
||||
|
||||
Reference in New Issue
Block a user