mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
GET /plugins/installed called get_registry_info() per plugin. Despite the "no network call" comment, a cold or expired cache made that download plugins.json (10 s timeout, three attempts), and with no cached copy each plugin's lookup repeated it -- offline, every load waited out the timeouts. The route now reads the registry copy already in memory, however old, via get_cached_registry_info(). A missing or expired copy starts a single background refresh (backing off after an offline failure), so a later load gets update and verified badges. Store, install and update paths still fetch. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
6.9 KiB
Python
189 lines
6.9 KiB
Python
"""GET /api/v3/plugins/installed never waits on the network for registry data.
|
|
|
|
The route used `get_registry_info`, which goes through `fetch_registry`: on a
|
|
cold (or expired) cache that downloads plugins.json from GitHub with a 10s
|
|
timeout and three attempts -- and, with no registry to fall back on, every
|
|
plugin's lookup repeated the whole cycle. The first plugin-list load after a
|
|
restart waited on GitHub, and offline it waited out every timeout.
|
|
|
|
Now the route reads the registry copy already in memory, however old, and a
|
|
missing or expired copy only starts a background refresh. These tests block
|
|
the network at the socket layer (DNS lookups hang, then fail) and assert the
|
|
request returns quickly without a single network attempt on the request path.
|
|
"""
|
|
|
|
import functools
|
|
import socket
|
|
import threading
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
|
|
api_v3_client, api_v3_module,
|
|
)
|
|
from src.plugin_system.store_manager import PluginStoreManager
|
|
|
|
# How long a blocked lookup hangs before failing: long enough that a single
|
|
# one on the request path blows the response budget below.
|
|
HANG_SECONDS = 1.5
|
|
FAST_SECONDS = 1.0
|
|
|
|
REGISTRY = {'plugins': [
|
|
{'id': 'weather', 'name': 'Weather', 'verified': True, 'latest_version': '1.2.0'},
|
|
]}
|
|
|
|
|
|
@pytest.fixture
|
|
def blocked_network(monkeypatch):
|
|
"""Every DNS lookup hangs, then fails; records the thread it came from."""
|
|
attempts = []
|
|
|
|
def hang_then_fail(host, *args, **kwargs):
|
|
attempts.append((host, threading.current_thread().name))
|
|
time.sleep(HANG_SECONDS)
|
|
raise socket.gaierror(-3, 'Temporary failure in name resolution (blocked by test)')
|
|
|
|
monkeypatch.setattr(socket, 'getaddrinfo', hang_then_fail)
|
|
return attempts
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path, monkeypatch):
|
|
store = PluginStoreManager(plugins_dir=str(tmp_path / 'plugins'))
|
|
# One attempt, no pause between attempts, so a background refresh against
|
|
# the blocked network ends within the test (teardown joins it).
|
|
monkeypatch.setattr(store, '_http_get_with_retries', functools.partial(
|
|
PluginStoreManager._http_get_with_retries, store, max_retries=1))
|
|
yield store
|
|
thread = getattr(store, '_registry_refresh_thread', None)
|
|
if thread is not None:
|
|
thread.join(timeout=30)
|
|
|
|
|
|
@pytest.fixture
|
|
def get_installed(api_v3_module, api_v3_client, store, tmp_path):
|
|
api = api_v3_module.api_v3
|
|
api.plugin_store_manager = store
|
|
api.plugin_catalog.plugins_dir = str(tmp_path / 'plugins')
|
|
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[
|
|
{'id': 'weather', 'name': 'Weather', 'version': '1.0.0'},
|
|
{'id': 'clock', 'name': 'Clock', 'version': '2.0.0'},
|
|
])
|
|
api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=[])
|
|
api.config_manager.load_config = MagicMock(return_value={})
|
|
|
|
def _get():
|
|
start = time.perf_counter()
|
|
response = api_v3_client.get('/api/v3/plugins/installed')
|
|
elapsed = time.perf_counter() - start
|
|
assert response.status_code == 200
|
|
plugins = {p['id']: p for p in response.get_json()['data']['plugins']}
|
|
return plugins, elapsed
|
|
return _get
|
|
|
|
|
|
def _request_path_attempts(attempts):
|
|
return [a for a in attempts if a[1] != 'registry-refresh']
|
|
|
|
|
|
def test_a_cold_cache_offline_returns_fast_without_registry_info(get_installed, blocked_network):
|
|
plugins, elapsed = get_installed()
|
|
|
|
assert _request_path_attempts(blocked_network) == []
|
|
assert elapsed < FAST_SECONDS, f"installed list took {elapsed:.2f}s with the network blocked"
|
|
weather = plugins['weather']
|
|
assert weather['latest_version'] == ''
|
|
assert weather['update_available'] is False
|
|
assert weather['verified'] is False
|
|
|
|
|
|
def test_a_stale_cache_is_used_as_is_without_a_fetch(get_installed, store, blocked_network):
|
|
store.registry_cache = REGISTRY
|
|
store.registry_cache_time = time.time() - store.registry_cache_timeout - 3600
|
|
|
|
plugins, elapsed = get_installed()
|
|
|
|
assert _request_path_attempts(blocked_network) == []
|
|
assert elapsed < FAST_SECONDS
|
|
weather = plugins['weather']
|
|
assert weather['latest_version'] == '1.2.0'
|
|
assert weather['update_available'] is True
|
|
assert weather['verified'] is True
|
|
assert plugins['clock']['latest_version'] == ''
|
|
|
|
|
|
def test_a_fresh_cache_starts_no_refresh(get_installed, store, blocked_network):
|
|
store.registry_cache = REGISTRY
|
|
store.registry_cache_time = time.time()
|
|
|
|
plugins, _ = get_installed()
|
|
|
|
assert blocked_network == []
|
|
assert store._registry_refresh_thread is None
|
|
assert plugins['weather']['update_available'] is True
|
|
|
|
|
|
def test_a_cold_cache_is_filled_in_the_background_for_the_next_load(get_installed, store, monkeypatch):
|
|
response = MagicMock()
|
|
response.json.return_value = REGISTRY
|
|
fetched_on = []
|
|
|
|
def fake_get(url, **kwargs):
|
|
fetched_on.append(threading.current_thread().name)
|
|
return response
|
|
|
|
monkeypatch.setattr(store, '_http_get_with_retries', fake_get)
|
|
|
|
first, _ = get_installed()
|
|
assert first['weather']['update_available'] is False
|
|
store._registry_refresh_thread.join(timeout=10)
|
|
|
|
second, _ = get_installed()
|
|
assert second['weather']['latest_version'] == '1.2.0'
|
|
assert second['weather']['update_available'] is True
|
|
# One background fetch for the whole listing, none on the request path.
|
|
assert fetched_on == ['registry-refresh']
|
|
|
|
|
|
def test_an_offline_background_refresh_backs_off(store, monkeypatch):
|
|
def offline(url, **kwargs):
|
|
raise requests.ConnectionError('blocked by test')
|
|
|
|
monkeypatch.setattr(store, '_http_get_with_retries', offline)
|
|
|
|
assert store.refresh_registry_in_background() is True
|
|
store._registry_refresh_thread.join(timeout=10)
|
|
assert store.registry_cache is None
|
|
# Offline: the next page load does not start another attempt straight away.
|
|
assert store.refresh_registry_in_background() is False
|
|
store._registry_refresh_retry_after = 0.0
|
|
assert store.refresh_registry_in_background() is True
|
|
|
|
|
|
def test_only_one_background_refresh_runs_at_a_time(store, monkeypatch):
|
|
release = threading.Event()
|
|
|
|
def slow(url, **kwargs):
|
|
release.wait(10)
|
|
raise requests.ConnectionError('blocked by test')
|
|
|
|
monkeypatch.setattr(store, '_http_get_with_retries', slow)
|
|
try:
|
|
assert store.refresh_registry_in_background() is True
|
|
assert store.refresh_registry_in_background() is False
|
|
finally:
|
|
release.set()
|
|
|
|
|
|
def test_get_registry_info_still_fetches_for_the_store(store, monkeypatch):
|
|
"""The store, install and update paths keep fetching a cold registry."""
|
|
response = MagicMock()
|
|
response.json.return_value = REGISTRY
|
|
monkeypatch.setattr(store, '_http_get_with_retries', MagicMock(return_value=response))
|
|
|
|
assert store.get_registry_info('weather')['latest_version'] == '1.2.0'
|
|
store._http_get_with_retries.assert_called_once()
|