From 85cbc378425a78902d3f138b9b7ef81449c9d9ad Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 20:00:50 +0000 Subject: [PATCH] fix(tray): show the tray before the speech model loads (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first model load runs on a background thread after the tray is up. While it loads the tray shows the processing icon and 'Loading model…', and a dictation request gets a 'still loading' notification. A failed load leaves the tray in the error state instead of exiting. Fixes #50 --- src/vocalinux/main.py | 7 + .../speech_recognition/recognition_manager.py | 126 ++++++++++-- src/vocalinux/ui/settings_dialog.py | 4 + src/vocalinux/ui/tray_indicator.py | 37 +++- tests/test_main.py | 64 ++++++ tests/test_model_load_in_background.py | 185 ++++++++++++++++++ tests/test_tray_model_loading.py | 109 +++++++++++ 7 files changed, 516 insertions(+), 16 deletions(-) create mode 100644 tests/test_model_load_in_background.py create mode 100644 tests/test_tray_model_loading.py diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index 3b4a1ab..ab8af7d 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -621,6 +621,8 @@ def main(): remote_api_key=saved_settings.get("remote_api_key", ""), remote_api_endpoint=saved_settings.get("remote_api_endpoint", "/inference"), remote_api_model=saved_settings.get("remote_api_model", "whisper-1"), + # The model loads in the background so the tray can appear first (#50). + defer_model_load=True, ) # Initialize text injection system @@ -1382,6 +1384,11 @@ def main(): dictation_pad=dictation_pad, ) + # Start the model load from the GTK main loop, after the tray's own idle + # callback has put the icon up. The load runs on its own thread, so the + # tray is usable while it loads (#50). + GLib.idle_add(speech_engine.load_model_in_background) + # Start the GTK main loop indicator.run() diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 7cb1efe..c3a8bb5 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -288,6 +288,9 @@ def resolve_language_preference(language: str) -> str: logger = logging.getLogger(__name__) +# Engine names the constructor accepts; checked up front when the load is deferred (#50). +_SUPPORTED_ENGINES = ("vosk", "whisper", "whisper_cpp", "parakeet", "faster_whisper", "remote_api") + _WHISPERCPP_ABORT_CALLBACK_SUPPORT: dict[type, bool] = {} @@ -570,6 +573,12 @@ class SpeechRecognitionManager: # cancel-and-retry cannot stack unaccounted request threads. self._download_openers: set[threading.Thread] = set() self._defer_download = defer_download + # Startup may defer the first model load to a background thread (#50): + # the engine reports model_loading until that load has finished. + self._model_loading = bool(kwargs.get("defer_model_load", False)) + self._model_load_started = False + self._model_load_thread: Optional[threading.Thread] = None + self._model_load_callbacks: list[Callable[[], None]] = [] self._model_initialized = False # True while a configured app/game blocks dictation, including pending unloads self._auto_paused = False @@ -690,21 +699,14 @@ class SpeechRecognitionManager: f"Initializing speech recognition with {engine} engine, {self.language} language and {model_size} model" ) - # Initialize the selected speech recognition engine - if engine == "vosk": - self._init_vosk() - elif engine == "whisper": - self._init_whisper() - elif engine == "whisper_cpp": - self._init_whispercpp() - elif engine == "parakeet": - self._init_parakeet() - elif engine == "faster_whisper": - self._init_faster_whisper() - elif engine == "remote_api": - self._init_remote_api() + # Initialize the selected speech recognition engine. A deferred load is + # started later by load_model_in_background(); the engine name is still + # checked now so a bad name fails at construction, as before. + if self._model_loading: + if engine not in _SUPPORTED_ENGINES: + raise ValueError(f"Unsupported speech recognition engine: {engine}") else: - raise ValueError(f"Unsupported speech recognition engine: {engine}") + self._init_selected_engine() @staticmethod def _normalize_language_candidates(value: str | list[str] | None) -> list[str]: @@ -3300,6 +3302,83 @@ class SpeechRecognitionManager: for callback in self.state_callbacks: callback(new_state) + @property + def model_loading(self) -> bool: + """True from a deferred construction until its background load has finished (#50).""" + return bool(getattr(self, "_model_loading", False)) + + def register_model_load_callback(self, callback: Callable[[], None]) -> None: + """Call ``callback()`` on the loader thread once a deferred load has finished. + + Fires whether the load succeeded, failed or was skipped. Consumers marshal + onto the GTK main loop themselves (#50). + """ + self._model_load_callbacks.append(callback) + + def load_model_in_background(self) -> None: + """Start the deferred first model load on a daemon thread (#50). + + A no-op unless the manager was built with ``defer_model_load=True``, and + only the first call starts a load. The request token is taken here, on + the caller's thread, so an auto-pause that lands before the loader gets + the model lock supersedes the load instead of racing it. + """ + if not self._model_loading or self._model_load_started: + return + self._model_load_started = True + request = self._new_model_request() + self._model_load_thread = threading.Thread( + target=self._run_background_model_load, + args=(request,), + name="voculinux-model-load", + daemon=True, + ) + self._model_load_thread.start() + + def _run_background_model_load(self, request: object) -> None: + """Initialize the configured engine on the loader thread (#50). + + Shares the model lock and request ownership with resume, but skips the + resume-only bookkeeping: a startup load publishes the model and nothing + else. The engine's own loading ladder and fallbacks run unchanged. + """ + started = time.monotonic() + try: + with self._model_lock: + # An auto-pause that is already active (a listed app is running) + # must keep the model out of memory; auto-resume loads it later. + if self._auto_paused or not self._is_current_model_request(request): + logger.info("Speech model load skipped: auto-paused or superseded") + else: + with self._model_request_context(request): + self._init_selected_engine() + logger.info("Speech model loaded in %.2fs", time.monotonic() - started) + except Exception as error: + logger.error("Speech model failed to load", exc_info=True) + if self._is_current_model_request(request): + self._update_state(RecognitionState.ERROR, model_request=request) + _show_notification( + "Speech model failed to load", + f"{error}. Restart Voculinux to try again.", + "dialog-error", + ) + finally: + self._model_loading = False + self._notify_model_load_callbacks() + + def _is_current_model_request(self, request: object) -> bool: + """Whether no auto-pause or reconfigure has superseded ``request``.""" + with getattr(self, "_model_request_lock", nullcontext()): + return self._model_request is request + + def _notify_model_load_callbacks(self) -> None: + """Run each load-state callback; one failing consumer must not stop the rest.""" + for callback in list(self._model_load_callbacks): + try: + callback() + except Exception: + logger.debug("Model load callback failed", exc_info=True) + @property def model_ready(self) -> bool: """Check if the model is initialized and ready for recognition.""" @@ -3501,6 +3580,17 @@ class SpeechRecognitionManager: return False reload_in_background = False + # The first load (#50) is still running. Nothing can be dictated yet, and + # the missing-model offer below would be wrong, so say that instead. + if self.model_loading and not self.model_ready: + logger.info("Cannot start recognition: speech model is still loading") + play_error_sound() + _show_notification( + "Speech model loading", + "Dictation will be available once the speech model has finished loading.", + "dialog-information", + ) + return False # Check if model is ready (lazy-reload after idle keep-alive unload) if not self.model_ready: if self._idle_unloaded: @@ -3932,7 +4022,13 @@ class SpeechRecognitionManager: # select it. self._refuse_language_override(language) return False - if target != self.language and not self._serving_model_supports_language_switch(): + # While the first load runs there is no serving model to judge yet; the + # start itself is refused with the loading notice. + if ( + target != self.language + and not self.model_loading + and not self._serving_model_supports_language_switch() + ): self._refuse_language_override(language) return False diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index d9ea290..905c506 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -9346,6 +9346,10 @@ class SettingsDialog(AboutSectionMixin): "Dictation is paused. Close the listed app or remove it from " "Auto-Pause settings." ) + elif getattr(self.speech_engine, "model_loading", False) is True: + self.test_buffer.set_text( + "The speech model is still loading. Try Test Dictation again in a moment." + ) elif not getattr(self.speech_engine, "model_ready", True): self.test_buffer.set_text( "No speech model downloaded. Open the Speech Model page and " diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 0dbaf67..1328824 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -161,6 +161,9 @@ _INPUT_SETTLE_SECONDS = 2 _INPUT_MONITOR_CAP_SECONDS = 10 _FALLBACK_KEYBOARD_RESTART_SECONDS = 6 +# Tray title while the speech model loads in the background (#50). +LOADING_TITLE = "Loading model…" + class TrayIndicator: """ @@ -282,6 +285,11 @@ class TrayIndicator: # Register for speech recognition state changes self.speech_engine.register_state_callback(self._on_recognition_state_changed) + # The model may still be loading in the background (#50); re-render when + # that load finishes. + register_load_callback = getattr(self.speech_engine, "register_model_load_callback", None) + if callable(register_load_callback): + register_load_callback(self._on_model_load_changed) # Offer the download instead of only telling the user a model is missing self._model_download_active = False @@ -913,6 +921,25 @@ class TrayIndicator: if isinstance(indicator, GMenuIndicator): indicator.refresh_menu() + def _engine_model_loading(self) -> bool: + """True only while the engine reports its first model load in progress (#50).""" + return getattr(self.speech_engine, "model_loading", False) is True + + def _on_model_load_changed(self) -> None: + """Model load finished. Runs on the loader thread, so marshal it (#50).""" + GLib.idle_add(self._apply_model_load_state) + + def _apply_model_load_state(self) -> bool: + """Re-render after a model load changed state (GTK main loop only, #50).""" + if not self._engine_model_loading(): + # Keep-alive refuses to unload while the load runs; restart its + # idle timer now that the model is in memory. + keepalive = getattr(self, "_model_keepalive", None) + if keepalive is not None: + keepalive.bump() + self._update_ui(getattr(self.speech_engine, "state", RecognitionState.IDLE)) + return False # One-shot idle callback + def _on_recognition_state_changed(self, state: RecognitionState): """ Handle changes in the speech recognition state. @@ -1323,7 +1350,12 @@ class TrayIndicator: show_cancel = state == RecognitionState.PROCESSING and transcribing self._set_cancel_menu_item_visible(show_cancel) - if state == RecognitionState.IDLE: + if state == RecognitionState.IDLE and self._engine_model_loading(): + self._set_indicator_title(LOADING_TITLE) + self._set_indicator_icon(self._icon_keys["processing"], "Loading speech model") + self._set_menu_item_enabled("Start Voice Typing", False) + self._set_menu_item_enabled("Stop Voice Typing", False) + elif state == RecognitionState.IDLE: self._set_indicator_title(TRAY_TITLE) self._set_indicator_icon(self._icon_keys["default"], "Microphone off") self._set_menu_item_enabled("Start Voice Typing", True) @@ -1714,6 +1746,9 @@ class TrayIndicator: def _is_safe_for_keepalive_unload(self) -> bool: """Keep-alive may unload only when idle and not under auto-pause.""" + if self._engine_model_loading(): + # The first load (#50) is still putting the model in memory. + return False if getattr(self.speech_engine, "is_auto_paused", False) is True: return False if ( diff --git a/tests/test_main.py b/tests/test_main.py index a93a5aa..b283c8d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -257,6 +257,7 @@ class TestMainModule(unittest.TestCase): remote_api_key="", remote_api_endpoint="/inference", remote_api_model="whisper-1", + defer_model_load=True, ) mock_text.assert_called_once_with(wayland_mode=True) mock_action_handler.assert_called_once_with(mock_text_instance) @@ -286,6 +287,69 @@ class TestMainModule(unittest.TestCase): # Verify the tray indicator was started mock_tray_instance.run.assert_called_once() + @patch("vocalinux.main.check_dependencies") + @patch("vocalinux.ui.dictation_pad.DictationPad") + @patch("vocalinux.ui.action_handler.ActionHandler") + @patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager") + @patch("vocalinux.text_injection.text_injector.TextInjector") + @patch("vocalinux.ui.tray_indicator.TrayIndicator") + @patch("vocalinux.main.logging") + @patch("vocalinux.ui.config_manager.get_shared_config_manager") + @patch("vocalinux.ui.logging_manager.initialize_logging") + def test_tray_is_shown_before_the_model_load_is_scheduled( + self, + mock_init_logging, + mock_config_manager, + mock_logging, + mock_tray, + mock_text, + mock_speech, + mock_action_handler, + mock_dictation_pad, + mock_check_deps, + ): + """The tray is built first, then the model load is queued, then the loop runs (#50).""" + mock_check_deps.return_value = True + mock_config_instance = MagicMock() + mock_config_instance.get_bool.return_value = True + mock_config_instance.get_settings.return_value = { + "speech_recognition": {}, + "general": {"first_run": False}, + } + mock_config_instance.get_model_size_for_engine.return_value = "medium" + mock_config_instance.get_str.return_value = "" + mock_config_instance.is_dictate_to_pad_enabled.return_value = False + mock_config_manager.return_value = mock_config_instance + + mock_speech_instance = MagicMock() + mock_speech.return_value = mock_speech_instance + mock_tray_instance = MagicMock() + mock_tray.return_value = mock_tray_instance + + order = MagicMock() + order.attach_mock(mock_tray, "tray") + order.attach_mock(mock_tray_instance.run, "run") + + with ( + patch("vocalinux.main.parse_arguments") as mock_parse, + patch("gi.repository.GLib.idle_add") as mock_idle_add, + ): + order.attach_mock(mock_idle_add, "idle_add") + mock_args = MagicMock() + mock_args.debug = False + mock_args.model = "medium" + mock_args.engine = "whisper_cpp" + mock_args.language = "en-us" + mock_args.wayland = False + mock_parse.return_value = mock_args + + main() + + mock_speech.assert_called_once() + self.assertIs(mock_speech.call_args.kwargs["defer_model_load"], True) + mock_idle_add.assert_called_once_with(mock_speech_instance.load_model_in_background) + self.assertEqual([c[0] for c in order.mock_calls], ["tray", "idle_add", "run"]) + @patch("vocalinux.main.check_dependencies") @patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager") @patch("vocalinux.text_injection.text_injector.TextInjector") diff --git a/tests/test_model_load_in_background.py b/tests/test_model_load_in_background.py new file mode 100644 index 0000000..e67b716 --- /dev/null +++ b/tests/test_model_load_in_background.py @@ -0,0 +1,185 @@ +"""Startup model load runs on a background thread so the tray can show first (#50). + +Seams covered here, all at the SpeechRecognitionManager public surface: +the deferred-load constructor flag, ``load_model_in_background`` (thread, +loading flag, callbacks, failure reporting), the dictation guard while the +model is loading, supersession by an auto-pause, and the language-shortcut +path while loading. +""" + +import threading +from unittest.mock import patch + +import pytest + +MANAGER_MODULE = "vocalinux.speech_recognition.recognition_manager" + + +@pytest.fixture +def init_whispercpp(): + """Stand in for the whisper.cpp load so no model file is read.""" + from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager + + with patch.object(SpeechRecognitionManager, "_init_whispercpp") as init: + yield init + + +def _deferred_manager(): + from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager + + return SpeechRecognitionManager( + engine="whisper_cpp", + model_size="small", + language="en-us", + defer_model_load=True, + ) + + +class TestDeferredConstruction: + def test_construction_does_not_load_the_engine(self, init_whispercpp): + manager = _deferred_manager() + + init_whispercpp.assert_not_called() + assert manager.model_loading is True + assert manager.model_ready is False + + def test_default_construction_still_loads_synchronously(self, init_whispercpp): + from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager + + manager = SpeechRecognitionManager( + engine="whisper_cpp", model_size="small", language="en-us" + ) + + init_whispercpp.assert_called_once_with() + assert manager.model_loading is False + + def test_unknown_engine_still_fails_at_construction(self, init_whispercpp): + from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager + + with pytest.raises(ValueError, match="Unsupported speech recognition engine"): + SpeechRecognitionManager( + engine="no-such-engine", + model_size="small", + language="en-us", + defer_model_load=True, + ) + + +class TestBackgroundLoad: + def test_load_runs_on_a_daemon_thread_and_returns_at_once(self, init_whispercpp): + entered = threading.Event() + release = threading.Event() + seen_threads = [] + + def slow_load(*_args, **_kwargs): + seen_threads.append(threading.current_thread()) + entered.set() + release.wait(5) + + init_whispercpp.side_effect = slow_load + manager = _deferred_manager() + + manager.load_model_in_background() + + assert entered.wait(5), "the load never started" + assert manager.model_loading is True + assert seen_threads[0] is not threading.main_thread() + assert seen_threads[0].daemon is True + release.set() + manager._model_load_thread.join(5) + + def test_loading_flag_clears_and_callbacks_fire_after_success(self, init_whispercpp): + manager = _deferred_manager() + flags_seen_by_callback = [] + manager.register_model_load_callback( + lambda: flags_seen_by_callback.append(manager.model_loading) + ) + + manager.load_model_in_background() + manager._model_load_thread.join(5) + + init_whispercpp.assert_called_once_with() + assert manager.model_loading is False + assert flags_seen_by_callback, "the load-finished callback never ran" + assert flags_seen_by_callback[-1] is False + + def test_failed_load_reports_error_instead_of_raising(self, init_whispercpp): + from vocalinux.common_types import RecognitionState + + init_whispercpp.side_effect = RuntimeError("model file is corrupt") + manager = _deferred_manager() + states = [] + manager.register_state_callback(states.append) + + with patch(f"{MANAGER_MODULE}._show_notification") as notify: + manager.load_model_in_background() + manager._model_load_thread.join(5) + + assert manager.model_loading is False + assert manager.state == RecognitionState.ERROR + assert states[-1] == RecognitionState.ERROR + notify.assert_called_once() + assert "failed to load" in notify.call_args.args[0].lower() + + def test_auto_pause_already_active_at_startup_skips_the_load(self, init_whispercpp): + manager = _deferred_manager() + # A configured app is already running when the tray comes up. Its + # auto-pause runs before the load is scheduled, so the load must not + # keep the model resident; auto-resume loads it when the app exits. + manager._new_model_request(auto_pause=True) + + manager.load_model_in_background() + manager._model_load_thread.join(5) + + init_whispercpp.assert_not_called() + assert manager.is_auto_paused is True + assert manager.model_loading is False + + def test_auto_pause_arriving_before_the_load_supersedes_it(self, init_whispercpp): + manager = _deferred_manager() + + # The loader must wait for the model lock; an auto-pause takes a newer + # request while the lock is still held, so the load must not run. + with manager._model_lock: + manager.load_model_in_background() + manager._new_model_request(auto_pause=True) + manager._model_load_thread.join(5) + + init_whispercpp.assert_not_called() + assert manager.is_auto_paused is True + assert manager.model_loading is False + + +class TestDictationWhileLoading: + def test_start_is_refused_with_a_loading_notice(self, init_whispercpp): + from vocalinux.common_types import RecognitionState + + manager = _deferred_manager() + + with ( + patch(f"{MANAGER_MODULE}._show_notification") as notify, + patch(f"{MANAGER_MODULE}.play_error_sound") as error_sound, + patch.object(manager, "_notify_model_missing") as model_missing_offer, + ): + started = manager.start_recognition() + + assert started is False + assert manager.state == RecognitionState.IDLE + model_missing_offer.assert_not_called() + error_sound.assert_called_once() + assert "loading" in notify.call_args.args[0].lower() + init_whispercpp.assert_not_called() + + def test_language_shortcut_during_load_reports_loading_not_unsupported(self, init_whispercpp): + manager = _deferred_manager() + + with ( + patch.object(manager, "_refuse_language_override") as refuse, + patch(f"{MANAGER_MODULE}._show_notification") as notify, + patch(f"{MANAGER_MODULE}.play_error_sound"), + ): + started = manager.start_recognition_with_language("de") + + assert started is False + refuse.assert_not_called() + assert "loading" in notify.call_args.args[0].lower() diff --git a/tests/test_tray_model_loading.py b/tests/test_tray_model_loading.py new file mode 100644 index 0000000..171eb51 --- /dev/null +++ b/tests/test_tray_model_loading.py @@ -0,0 +1,109 @@ +"""Tray rendering while the speech model loads in the background (#50). + +The tray is built before the model load starts, so it must show a loading +state, re-render on the GTK main loop when the load finishes, and keep the +idle keep-alive from unloading a model that is still being loaded. +""" + +from unittest.mock import MagicMock, patch + +from vocalinux.common_types import RecognitionState +from vocalinux.ui import tray_indicator + +LOADING_TITLE = "Loading model…" +ICONS = { + "default": "default-icon", + "active": "active-icon", + "processing": "processing-icon", +} + + +def _engine(state=RecognitionState.IDLE, loading=False): + engine = MagicMock() + engine.state = state + engine.model_loading = loading + engine.is_auto_paused = False + return engine + + +def _make_tray(engine): + """Build a TrayIndicator without running GTK setup; only rendering helpers are real.""" + tray = tray_indicator.TrayIndicator.__new__(tray_indicator.TrayIndicator) + tray.speech_engine = engine + tray.indicator = MagicMock() + tray._icon_keys = dict(ICONS) + tray._indicator_title = tray_indicator.TRAY_TITLE + tray._language_shortcuts_refresh_pending = False + tray.overlay = None + tray.shortcut_manager = MagicMock() + tray._model_keepalive = MagicMock() + tray._auto_pause_monitor = MagicMock(paused=False) + tray._set_indicator_icon = MagicMock() + tray._set_indicator_title = MagicMock() + tray._set_menu_item_enabled = MagicMock() + tray._set_cancel_menu_item_visible = MagicMock() + return tray + + +def test_loading_shows_processing_icon_and_loading_title_while_idle(): + tray = _make_tray(_engine(loading=True)) + + tray._update_ui(RecognitionState.IDLE) + + tray._set_indicator_icon.assert_called_with(ICONS["processing"], "Loading speech model") + tray._set_indicator_title.assert_called_with(LOADING_TITLE) + tray._set_menu_item_enabled.assert_any_call("Start Voice Typing", False) + tray._set_menu_item_enabled.assert_any_call("Stop Voice Typing", False) + + +def test_finished_load_returns_to_idle_icon_and_enables_start(): + tray = _make_tray(_engine(loading=False)) + + tray._update_ui(RecognitionState.IDLE) + + tray._set_indicator_icon.assert_called_with(ICONS["default"], "Microphone off") + tray._set_menu_item_enabled.assert_any_call("Start Voice Typing", True) + + +def test_loading_state_does_not_mask_an_active_session(): + tray = _make_tray(_engine(state=RecognitionState.LISTENING, loading=True)) + + tray._update_ui(RecognitionState.LISTENING) + + tray._set_indicator_icon.assert_called_with(ICONS["active"], "Microphone on") + + +def test_load_change_is_marshalled_onto_the_gtk_main_loop(): + tray = _make_tray(_engine(loading=False)) + + with patch.object(tray_indicator, "GLib") as glib: + tray._on_model_load_changed() + + glib.idle_add.assert_called_once_with(tray._apply_model_load_state) + tray._set_indicator_icon.assert_not_called() + + +def test_finishing_the_load_restarts_the_keep_alive_timer(): + engine = _engine(loading=False) + tray = _make_tray(engine) + + assert tray._apply_model_load_state() is False + + tray._model_keepalive.bump.assert_called_once_with() + tray._set_indicator_icon.assert_called_with(ICONS["default"], "Microphone off") + + +def test_loading_in_progress_does_not_restart_the_keep_alive_timer(): + tray = _make_tray(_engine(loading=True)) + + tray._apply_model_load_state() + + tray._model_keepalive.bump.assert_not_called() + + +def test_keep_alive_never_unloads_while_the_model_is_loading(): + loading = _make_tray(_engine(loading=True)) + loaded = _make_tray(_engine(loading=False)) + + assert loading._is_safe_for_keepalive_unload() is False + assert loaded._is_safe_for_keepalive_unload() is True