Files
voculinux/tests/test_vosk_memory_guard.py
T
xavierk 80a3792576
CI / security (pull_request) Failing after 1m25s
CI / lint (pull_request) Failing after 34s
CI / ai-review (pull_request) Successful in 25s
chore: import vocalinux project
Import the vocalinux source tree (tray app, Next.js site in web/,
packaging, installers, docs and tests) on top of the Gitea template
commit. The project README replaces the template README, and the
template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
2026-11-06 11:51:44 +05:30

202 lines
8.2 KiB
Python

"""
Tests for the VOSK model-load memory guard (issue #676).
vosk.Model() allocates a model's full footprint (plus, for models that ship
an RNNLM rescoring component, a further multi-GB spike) in one shot with no
way to query the requirement up front or load incrementally. These tests
exercise the guard that refuses the load rather than letting the kernel OOM
killer take the process down mid-load.
"""
import sys
from unittest.mock import MagicMock, patch
import pytest
if "gi" not in sys.modules:
sys.modules["gi"] = MagicMock()
if "gi.repository" not in sys.modules:
sys.modules["gi.repository"] = MagicMock()
@pytest.fixture(autouse=True)
def _restore_sys_modules():
saved = dict(sys.modules)
yield
added = set(sys.modules.keys()) - set(saved.keys())
for k in added:
del sys.modules[k]
for k, v in saved.items():
if k not in sys.modules or sys.modules[k] is not v:
sys.modules[k] = v
def _make_manager(**kw):
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
with (
patch.object(SpeechRecognitionManager, "_init_vosk"),
patch.object(SpeechRecognitionManager, "_init_whisper"),
patch.object(SpeechRecognitionManager, "_init_whispercpp"),
patch.object(SpeechRecognitionManager, "_init_parakeet"),
patch.object(SpeechRecognitionManager, "_init_faster_whisper"),
):
return SpeechRecognitionManager(
engine="whisper_cpp",
model_size="small",
language="en-us",
defer_download=True,
**kw,
)
def _write_file_of_size(path: str, size_bytes: int) -> None:
with open(path, "wb") as f:
f.write(b"\0" * size_bytes)
class TestVoskModelSizeBytes:
"""_vosk_model_size_bytes walks the model directory on disk."""
def test_sums_all_files_recursively(self, tmp_path):
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
model_dir = tmp_path / "vosk-model-ru-0.22"
(model_dir / "am").mkdir(parents=True)
(model_dir / "rnnlm").mkdir()
_write_file_of_size(str(model_dir / "am" / "final.mdl"), 1000)
_write_file_of_size(str(model_dir / "rnnlm" / "final.raw"), 2000)
total = SpeechRecognitionManager._vosk_model_size_bytes(str(model_dir))
assert total == 3000
def test_nonexistent_directory_is_zero(self, tmp_path):
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
total = SpeechRecognitionManager._vosk_model_size_bytes(str(tmp_path / "does-not-exist"))
assert total == 0
def test_unreadable_entry_is_skipped_not_raised(self, tmp_path):
# os.walk lists a directory's entries up front; a broken symlink (or a
# file removed by a concurrent process) still shows up in that list
# but os.path.getsize() on it raises OSError. The walk must keep
# summing the rest of the model rather than dying on one bad entry.
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
model_dir = tmp_path / "vosk-model-ru-0.22"
model_dir.mkdir()
_write_file_of_size(str(model_dir / "final.mdl"), 1000)
(model_dir / "broken-link").symlink_to(tmp_path / "does-not-exist-target")
total = SpeechRecognitionManager._vosk_model_size_bytes(str(model_dir))
assert total == 1000
class TestCheckVoskModelMemory:
"""_check_vosk_model_memory raises before the native loader is ever called."""
def test_raises_when_available_memory_is_below_the_safety_margin(self, tmp_path):
model_dir = tmp_path / "vosk-model-ru-0.22"
model_dir.mkdir()
# 100MB on disk; guard requires 2.0x = 200MB available.
_write_file_of_size(str(model_dir / "final.raw"), 100 * 1024 * 1024)
manager = _make_manager()
with patch("psutil.virtual_memory") as mock_mem:
mock_mem.return_value.available = 120 * 1024 * 1024 # below the 200MB requirement
with pytest.raises(RuntimeError, match="Not enough memory"):
manager._check_vosk_model_memory(str(model_dir))
def test_passes_when_available_memory_clears_the_safety_margin(self, tmp_path):
model_dir = tmp_path / "vosk-model-small-en-us-0.15"
model_dir.mkdir()
_write_file_of_size(str(model_dir / "final.mdl"), 40 * 1024 * 1024)
manager = _make_manager()
with patch("psutil.virtual_memory") as mock_mem:
mock_mem.return_value.available = 16 * 1024 * 1024 * 1024 # 16GB, plenty
manager._check_vosk_model_memory(str(model_dir)) # must not raise
def test_empty_model_directory_is_never_guarded(self, tmp_path):
# A directory with no files (or one os.walk cannot see) sizes to 0.
# There is nothing on disk to compare, so the guard is a no-op here
# by design, not a claim that zero bytes always fits in memory.
model_dir = tmp_path / "empty-model"
model_dir.mkdir()
manager = _make_manager()
with patch("psutil.virtual_memory") as mock_mem:
mock_mem.return_value.available = 0
manager._check_vosk_model_memory(str(model_dir)) # must not raise
class TestInitVoskMemoryGuardIntegration:
"""The guard sits in front of vosk.Model(), inside the real _init_vosk."""
def test_insufficient_memory_blocks_the_native_load(self, tmp_path):
model_dir = tmp_path / "vosk-model-ru-0.22"
model_dir.mkdir()
_write_file_of_size(str(model_dir / "final.raw"), 100 * 1024 * 1024)
vosk_mock = MagicMock()
sys.modules["vosk"] = vosk_mock
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
with (
patch.object(
SpeechRecognitionManager, "_get_vosk_model_path", return_value=str(model_dir)
),
patch("os.path.exists", return_value=True),
patch.object(SpeechRecognitionManager, "_init_whisper"),
patch.object(SpeechRecognitionManager, "_init_whispercpp"),
patch.object(SpeechRecognitionManager, "_init_parakeet"),
patch.object(SpeechRecognitionManager, "_init_faster_whisper"),
patch("psutil.virtual_memory") as mock_mem,
):
mock_mem.return_value.available = 1 * 1024 * 1024 # far below the requirement
with pytest.raises(RuntimeError, match="Not enough memory"):
SpeechRecognitionManager(
engine="vosk",
model_size="large",
language="ru",
defer_download=True,
)
# The control: the same fixture with ample memory reaches vosk.Model().
# If this assertion were dropped, a guard that (by bug) always raises
# would pass the test above for the wrong reason.
vosk_mock.Model.assert_not_called()
def test_sufficient_memory_reaches_the_native_load(self, tmp_path):
model_dir = tmp_path / "vosk-model-small-en-us-0.15"
model_dir.mkdir()
_write_file_of_size(str(model_dir / "final.mdl"), 40 * 1024 * 1024)
vosk_mock = MagicMock()
sys.modules["vosk"] = vosk_mock
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
with (
patch.object(
SpeechRecognitionManager, "_get_vosk_model_path", return_value=str(model_dir)
),
patch("os.path.exists", return_value=True),
patch.object(SpeechRecognitionManager, "_init_whisper"),
patch.object(SpeechRecognitionManager, "_init_whispercpp"),
patch.object(SpeechRecognitionManager, "_init_parakeet"),
patch.object(SpeechRecognitionManager, "_init_faster_whisper"),
patch("psutil.virtual_memory") as mock_mem,
):
mock_mem.return_value.available = 16 * 1024 * 1024 * 1024
manager = SpeechRecognitionManager(
engine="vosk",
model_size="small",
language="en-us",
defer_download=True,
)
vosk_mock.Model.assert_called_once_with(str(model_dir))
assert manager._model_initialized is True