Files
voculinux/tests/test_download_error_reporting.py
ba721779c3 fix(downloads): abort blocked model fetches on cancel (#888)
* fix(downloads): abort blocked model fetches on cancel

requests.get() has no way to abort while it is still resolving,
connecting, or waiting for response headers — which is what an
unreachable download server looks like. The cancel flag was only
checked between response chunks, so Cancel sat until the request
timed out. The blocking open now runs on a helper thread the caller
polls every 200 ms; on cancel it raises at once while the abandoned
attempt stays bounded by the existing timeouts.

The settings dialog's download workers also reported "Model ready to
use" whenever the apply returned True, but reconfigure() can no-op
when the selection matches live state — success without a download.
They now pass force_reinit so a reported success means the engine
actually initialized the downloaded model.

Fixes #679

* fix(downloads): bound cancel openers, close late responses

- Track blocked request opener threads and give each attempt its own stop
  event so a cancel-and-retry cannot stack unaccounted requests and a
  response landing after cancel is closed instead of leaked
- Catch requests.exceptions.RequestException in the opener instead of a
  bare Exception
- Close the streaming response on the mid-stream cancel path
- Make the blocked-open test helper cancellable and reap tracked openers;
  check each download worker call site for force_reinit separately; add
  missing return annotations on new test helpers and methods

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:52:12 -07:00

154 lines
5.8 KiB
Python

"""Tests for reporting failed model downloads instead of swallowing them."""
import importlib
import sys
from unittest.mock import MagicMock, Mock, patch
import pytest
def _load_settings_dialog():
"""Import settings_dialog with real base classes for its GTK subclasses.
conftest swaps gi for a MagicMock, which leaves every ``class X(Gtk.Y)`` in
the module as a mock and makes its methods unreachable. Handing the three
bases the module subclasses a real class keeps the classes intact, while
the rest of GTK stays mocked.
Restore both sys.modules and the vocalinux.ui.settings_dialog attribute so
3.9/3.10 mock and later imports see the same module object.
"""
import vocalinux.ui as ui_pkg
repository = sys.modules["gi.repository"]
bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")}
saved = sys.modules.pop("vocalinux.ui.settings_dialog", None)
try:
with patch.object(repository, "Gtk", MagicMock(**bases)):
module = importlib.import_module("vocalinux.ui.settings_dialog")
finally:
sys.modules.pop("vocalinux.ui.settings_dialog", None)
if saved is not None:
sys.modules["vocalinux.ui.settings_dialog"] = saved
ui_pkg.settings_dialog = saved
else:
ui_pkg.__dict__.pop("settings_dialog", None)
return module
settings_dialog = _load_settings_dialog()
SettingsDialog = settings_dialog.SettingsDialog
def _dialog_stub():
dialog = Mock()
dialog.speech_engine.state = "idle"
return dialog
def test_download_threads_get_the_failure():
"""With raise_errors the caller's handler sees the exception.
The download threads rely on this: their except blocks turn the failure
into set_complete(False, reason) on the progress dialog. Without it they
reported "Complete!" for downloads that were cancelled or never finished.
"""
dialog = _dialog_stub()
dialog.speech_engine.reconfigure.side_effect = RuntimeError("Download cancelled")
with pytest.raises(RuntimeError, match="Download cancelled"):
SettingsDialog._apply_settings_internal(dialog, {"engine": "vosk"}, raise_errors=True)
def test_no_gtk_dialog_is_built_when_errors_are_raised():
"""The failing thread path must not touch Gtk — that is the caller's job.
Building and running a Gtk.MessageDialog off the main loop is what the
swallowed-error path used to do from the download thread.
"""
dialog = _dialog_stub()
dialog.speech_engine.reconfigure.side_effect = RuntimeError("boom")
with patch.object(settings_dialog, "Gtk") as gtk:
with pytest.raises(RuntimeError):
SettingsDialog._apply_settings_internal(dialog, {"engine": "vosk"}, raise_errors=True)
gtk.MessageDialog.assert_not_called()
def test_main_loop_callers_keep_the_dialog_and_the_false_return():
"""Without the flag the old contract stays: swallow, show, return False."""
dialog = _dialog_stub()
dialog.speech_engine.reconfigure.side_effect = RuntimeError("boom")
with patch.object(settings_dialog, "Gtk") as gtk:
result = SettingsDialog._apply_settings_internal(dialog, {"engine": "vosk"})
assert result is False
gtk.MessageDialog.assert_called_once()
def test_success_still_returns_true():
dialog = _dialog_stub()
assert SettingsDialog._apply_settings_internal(dialog, {"engine": "vosk"}) is True
assert (
SettingsDialog._apply_settings_internal(dialog, {"engine": "vosk"}, raise_errors=True)
is True
)
def test_both_download_threads_ask_for_the_failure() -> None:
"""Source guard: every _apply_settings_internal call made off the main loop
passes raise_errors=True, so a regression cannot silently reintroduce the
swallowed-error path on a worker thread. The two download workers must
also force the engine re-init — their apply exists because a model is
missing, and a no-op reconfigure would report the green "Model ready to
use" for a download that never ran. Each call site is checked on its own
worker: a bare count would still pass if the flag moved between call
sites, and the already-downloaded worker must never force it."""
import ast
import inspect
def worker_kwargs(worker: ast.FunctionDef) -> dict:
calls = [
node
for node in ast.walk(worker)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "_apply_settings_internal"
]
assert len(calls) == 1, "each worker applies settings exactly once"
return {
kw.arg: kw.value.value if isinstance(kw.value, ast.Constant) else None
for kw in calls[0].keywords
if kw.arg is not None
}
tree = ast.parse(inspect.getsource(settings_dialog))
functions = [node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)]
download_workers = [node for node in functions if node.name == "download_and_apply"]
assert len(download_workers) == 2
for worker in download_workers:
kwargs = worker_kwargs(worker)
assert kwargs.get("raise_errors") is True
assert kwargs.get("force_reinit") is True
already_downloaded = [node for node in functions if node.name == "apply_already_downloaded"]
assert len(already_downloaded) == 1
kwargs = worker_kwargs(already_downloaded[0])
assert kwargs.get("raise_errors") is True
assert "force_reinit" not in kwargs
def test_force_reinit_reaches_reconfigure() -> None:
"""The flag exists so a needed download cannot no-op its way to green."""
dialog = _dialog_stub()
assert (
SettingsDialog._apply_settings_internal(dialog, {"engine": "vosk"}, force_reinit=True)
is True
)
dialog.speech_engine.reconfigure.assert_called_once_with(force_reinit=True, engine="vosk")