Files
lutris/tests/util/_test_stall_detection.py
ItsAllAboutTheCode eb72dca3c2 Fixed Unit Test builds when running on CI
Removed the importlib machinery used in `_test_gog_dlcs.py` and `_test_collection_progress.py` to fix issues with due to test module discovery order causing bespoke mocked modules to be placed in the sys.modules.

Instead the builtin Python UnitTest Mock and Patch framework is used to properly patch the methods needed to run the UnitTest instead of manipulating the importlibs and a bespoke manner.

Added missing `__init__.py` to the `lutris/tests/util` directory to allow discovery of those UnitTest by the nose2 and the Python builtin UnitTest framework.
Those test weren't not being run by either `nose2` nor the `UnitTest` framework.

Fixed the UnitTest that imports the pytest module.
Replaced the usage of `pytest` inside of the `lutris/tests/util` directory to use the python UnitTest framework. Lutris does NOT specify a dependency on `pytest `and therefore when those test were actually enabled to be run, they would complain about the missing `pytest` import.
2026-07-21 13:03:26 -05:00

274 lines
11 KiB
Python

"""Tests for stall detection and retry logic in the download system."""
import threading
import time
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import TestCase
from unittest.mock import MagicMock, patch
import requests
from lutris.util.downloader import BaseDownloader, DownloadStallError, SimpleDownloader, StallMonitor, get_time
class TestDownloadStallError(TestCase):
"""Test the DownloadStallError exception class."""
def test_carries_throughput(self):
err = DownloadStallError(throughput=150.5, duration=30.0)
assert err.throughput == 150.5
def test_carries_duration(self):
err = DownloadStallError(throughput=100.0, duration=35.2)
assert err.duration == 35.2
def test_message_contains_throughput(self):
err = DownloadStallError(throughput=42.3, duration=30.0)
assert "42.3" in str(err)
def test_message_contains_duration(self):
err = DownloadStallError(throughput=100.0, duration=31.5)
assert "31.5" in str(err)
def test_is_exception(self):
err = DownloadStallError(throughput=0.0, duration=30.0)
assert isinstance(err, Exception)
class TestCheckStall(TestCase):
"""Test the throughput-window logic in StallMonitor."""
def _make_monitor(self):
return StallMonitor(BaseDownloader.LOW_SPEED_LIMIT, BaseDownloader.LOW_SPEED_TIME)
def test_first_call_initializes_window(self):
"""First call should set the stall window start, not raise."""
monitor = self._make_monitor()
# Should not raise
monitor.check(1000)
assert monitor.window_start is not None
assert monitor.bytes_at_window_start == 1000
def test_good_throughput_resets_window(self):
"""When throughput is above threshold, the stall window resets."""
monitor = self._make_monitor()
monitor.window_start = get_time() - 5.0 # 5 seconds ago
monitor.bytes_at_window_start = 0
# 10000 bytes in 5 seconds = 2000 B/s — well above 200 B/s
monitor.check(10000)
# Window should have been reset
assert monitor.bytes_at_window_start == 10000
def test_low_throughput_does_not_raise_immediately(self):
"""Low throughput within the time window should not raise."""
monitor = self._make_monitor()
monitor.window_start = get_time() - 10.0 # 10 seconds ago
monitor.bytes_at_window_start = 0
# 100 bytes in 10 seconds = 10 B/s — below threshold but only 10s elapsed
monitor.check(100)
# Should not raise because LOW_SPEED_TIME (30s) not exceeded
def test_stall_raises_after_timeout(self):
"""Low throughput exceeding LOW_SPEED_TIME should raise."""
monitor = self._make_monitor()
monitor.window_start = get_time() - 31.0 # 31 seconds ago
monitor.bytes_at_window_start = 0
# 100 bytes in 31 seconds = ~3.2 B/s — below 200 B/s for > 30s
with self.assertRaises(DownloadStallError) as exc_info:
monitor.check(100)
assert exc_info.exception.duration >= 30.0
assert exc_info.exception.throughput < 200.0
def test_zero_bytes_triggers_stall(self):
"""Zero bytes received should eventually trigger stall."""
monitor = self._make_monitor()
monitor.window_start = get_time() - 35.0
monitor.bytes_at_window_start = 0
with self.assertRaises(DownloadStallError):
monitor.check(0)
def test_recovery_resets_timer(self):
"""If throughput recovers within the window, timer resets."""
monitor = self._make_monitor()
monitor.window_start = get_time() - 20.0 # 20 seconds of slow speed
monitor.bytes_at_window_start = 100
# Now deliver a burst: enough bytes to push throughput above threshold
# Need > 200 B/s over 20 seconds = 4000 bytes
monitor.check(100 + 5000)
# Window should have reset (not raised)
assert monitor.bytes_at_window_start == 5100
class TestStallMonitorIsolation(TestCase):
"""Each download stream must own its StallMonitor.
Regression tests for the parallel-download stall race: GOGDownloader runs
several workers concurrently, each with its own byte counter. When they
shared a single window, one worker's counter polluted another's, producing
nonsensical (negative) throughput and spurious stalls on healthy downloads.
"""
def test_separate_monitors_do_not_interfere(self):
"""Two interleaved streams with independent counters never stall."""
fast = StallMonitor(BaseDownloader.LOW_SPEED_LIMIT, BaseDownloader.LOW_SPEED_TIME)
slow = StallMonitor(BaseDownloader.LOW_SPEED_LIMIT, BaseDownloader.LOW_SPEED_TIME)
# 'fast' streams huge byte counts, 'slow' streams small ones. If they
# shared a window, slow.check() would read fast's large start value and
# compute a negative throughput. With separate monitors, both are healthy.
fast_bytes = 0
slow_bytes = 0
for _ in range(50):
fast_bytes += 10_000_000
slow_bytes += 1_000_000
fast.check(fast_bytes) # ~10 MB between calls — healthy
slow.check(slow_bytes) # ~1 MB between calls — healthy
# No DownloadStallError raised: both windows reset on good throughput.
def test_shared_window_would_go_negative(self):
"""Document the bug: one shared window across mismatched counters
yields negative throughput. This is what separate monitors prevent."""
shared = StallMonitor(BaseDownloader.LOW_SPEED_LIMIT, BaseDownloader.LOW_SPEED_TIME)
# Worker A opens the window at a high byte count.
shared.check(8_000_000)
# Worker B then reports its own, smaller counter into the same window.
shared.window_start = get_time() - 1.0 # 1s elapsed
bytes_in_window = 2_000_000 - shared.bytes_at_window_start
assert bytes_in_window < 0 # negative => bogus "stall" on a healthy stream
def test_monitor_raises_on_genuine_stall(self):
"""A single monitor still detects a real stall (feature preserved)."""
monitor = StallMonitor(BaseDownloader.LOW_SPEED_LIMIT, BaseDownloader.LOW_SPEED_TIME)
monitor.check(0) # open window
monitor.window_start = get_time() - 31.0 # 31s elapsed, no progress
with self.assertRaises(DownloadStallError):
monitor.check(100) # ~3 B/s for >30s
class TestDownloaderRetry(TestCase):
"""Test retry logic in SimpleDownloader."""
def setUp(self):
self.tmp_dir = TemporaryDirectory()
self.tmp_path = Path(self.tmp_dir.name)
def _make_downloader(self):
dest = str(self.tmp_path / "test.bin")
dl = SimpleDownloader("https://example.com/file.bin", dest)
dl.stop_request = threading.Event()
return dl
@patch.object(SimpleDownloader, "on_download_completed")
@patch.object(SimpleDownloader, "_do_download")
def test_success_on_first_try(self, mock_do, mock_complete):
"""Successful download should not retry."""
dl = self._make_downloader()
mock_do.return_value = None # Success
dl.async_download()
assert mock_do.call_count == 1
mock_complete.assert_called_once()
@patch.object(SimpleDownloader, "on_download_completed")
@patch.object(SimpleDownloader, "_do_download")
def test_retries_on_stall_error(self, mock_do, mock_complete):
"""DownloadStallError should trigger retry."""
dl = self._make_downloader()
# Fail twice with stall, succeed on third
mock_do.side_effect = [
DownloadStallError(throughput=50.0, duration=31.0),
DownloadStallError(throughput=30.0, duration=32.0),
None, # Success
]
with patch.object(dl, "_prepare_retry"):
with patch("lutris.util.downloader.time") as mock_time:
mock_time.monotonic = time.monotonic
mock_time.sleep = MagicMock()
dl.async_download()
assert mock_do.call_count == 3
mock_complete.assert_called_once()
@patch.object(SimpleDownloader, "_do_download")
def test_fails_after_max_retries(self, mock_do):
"""Should fail after RETRY_ATTEMPTS exhausted."""
dl = self._make_downloader()
stall_err = DownloadStallError(throughput=10.0, duration=30.0)
mock_do.side_effect = stall_err
with patch.object(dl, "_prepare_retry"):
with patch("lutris.util.downloader.time") as mock_time:
mock_time.monotonic = time.monotonic
mock_time.sleep = MagicMock()
dl.async_download()
assert mock_do.call_count == dl.RETRY_ATTEMPTS
assert dl.state == dl.ERROR
assert dl.error is stall_err
@patch.object(SimpleDownloader, "_do_download")
def test_no_retry_on_404(self, mock_do):
"""HTTP 404 should not retry."""
dl = self._make_downloader()
response = MagicMock()
response.status_code = 404
http_err = requests.HTTPError(response=response)
mock_do.side_effect = http_err
dl.async_download()
assert mock_do.call_count == 1
assert dl.state == dl.ERROR
@patch.object(SimpleDownloader, "on_download_completed")
@patch.object(SimpleDownloader, "_do_download")
def test_retry_on_500(self, mock_do, mock_complete):
"""HTTP 500 should trigger retry."""
dl = self._make_downloader()
response = MagicMock()
response.status_code = 500
http_err = requests.HTTPError(response=response)
mock_do.side_effect = [http_err, None]
with patch.object(dl, "_prepare_retry"):
with patch("lutris.util.downloader.time") as mock_time:
mock_time.monotonic = time.monotonic
mock_time.sleep = MagicMock()
dl.async_download()
assert mock_do.call_count == 2
mock_complete.assert_called_once()
@patch.object(SimpleDownloader, "on_download_completed")
@patch.object(SimpleDownloader, "_do_download")
def test_retry_on_429(self, mock_do, mock_complete):
"""HTTP 429 (rate limit) should trigger retry."""
dl = self._make_downloader()
response = MagicMock()
response.status_code = 429
http_err = requests.HTTPError(response=response)
mock_do.side_effect = [http_err, None]
with patch.object(dl, "_prepare_retry"):
with patch("lutris.util.downloader.time") as mock_time:
mock_time.monotonic = time.monotonic
mock_time.sleep = MagicMock()
dl.async_download()
assert mock_do.call_count == 2
mock_complete.assert_called_once()
def test_is_retryable_http_error_no_response(self):
"""HTTP error with no response should be retryable."""
err = requests.HTTPError(response=None)
assert SimpleDownloader._is_retryable_http_error(err) is True
def test_is_retryable_http_error_403(self):
"""HTTP 403 should not be retryable."""
response = MagicMock()
response.status_code = 403
err = requests.HTTPError(response=response)
assert SimpleDownloader._is_retryable_http_error(err) is False