Files
LEDMatrix/src/startup_validator.py
T
7171e6c022 fix(cache): one cleanup thread per cache directory, not per manager (#453)
The display process ran three cleanup threads over one directory:

    14:22:59.954  display_controller        (the real manager)
    14:22:59.973  startup validation, run 1 (discarded)
    14:23:01.055  startup validation, run 2 (discarded)

Two of those managers existed only to read a directory path.
StartupValidator._validate_cache_directory built a whole CacheManager to
call get_cache_dir(), and validation runs twice -- once before the
plugin manager exists and again after. Each construction also probes
writability by writing and deleting .writetest on the card.

The discarded ones never went away. cleanup_loop closes over `self`, so
the thread keeps its manager alive: two objects that could never be
collected, waking every 24 hours to re-scan the same 9,000-file
directory. Nothing stopped them either -- stop_cleanup_thread had no
callers anywhere in the tree.

Two changes. The validator now takes the CacheManager the application
actually uses, which is also the more correct thing to validate; when
no caller supplies one it still builds its own, but stops the thread
afterwards. And CacheManager now tracks which directory it is sweeping,
so the second manager over a directory skips starting a thread at all.
That is the right granularity regardless of call sites: the sweep lists
a directory and deletes from it, so a second thread only duplicates the
scan. Ownership is released on stop, so a survivor can take over rather
than leaving the directory permanently unclaimed by a dead owner.

Measured directly, three managers over one directory: 3 threads before,
1 after.


Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 08:42:56 -04:00

234 lines
9.3 KiB
Python

"""
Startup Validator
Validates system configuration, plugins, and dependencies on startup.
Fails fast with clear error messages to prevent runtime issues.
"""
import os
from typing import Any, List, Optional, Tuple
from pathlib import Path
from src.exceptions import ConfigError, PluginError, CacheError
from src.logging_config import get_logger
class StartupValidator:
"""Validates system state on startup."""
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None,
cache_manager: Optional[Any] = None) -> None:
"""
Initialize the startup validator.
Args:
config_manager: ConfigManager instance
plugin_manager: Optional PluginManager instance
cache_manager: The CacheManager the application will actually use.
Pass it. Without one this validator builds its own just to read
a directory path, which reports on a cache the app does not
use and leaves behind a cleanup thread that nothing stops --
validation runs twice per startup, so that was two of them.
"""
self.config_manager = config_manager
self.plugin_manager = plugin_manager
self.cache_manager = cache_manager
self.logger = get_logger(__name__)
self.errors: List[str] = []
self.warnings: List[str] = []
def validate_all(self) -> Tuple[bool, List[str], List[str]]:
"""
Run all validation checks.
Returns:
Tuple of (is_valid, errors, warnings)
"""
self.logger.info("Starting startup validation...")
# Fresh lists each run — without this, calling validate_all() twice
# duplicated every message.
self.errors = []
self.warnings = []
# Validate configuration
self._validate_config()
# Validate cache directory
self._validate_cache_directory()
# Validate display configuration
self._validate_display_config()
# Validate plugins if plugin manager is available
if self.plugin_manager:
self._validate_plugins()
is_valid = len(self.errors) == 0
if is_valid:
self.logger.info("Startup validation passed")
if self.warnings:
self.logger.warning(f"Startup validation completed with {len(self.warnings)} warning(s)")
else:
self.logger.error(f"Startup validation failed with {len(self.errors)} error(s)")
return (is_valid, self.errors.copy(), self.warnings.copy())
def _validate_config(self) -> None:
"""Validate configuration files."""
try:
config = self.config_manager.load_config()
# Check for required top-level keys
required_keys = ['display', 'timezone']
for key in required_keys:
if key not in config:
self.errors.append(f"Missing required configuration key: {key}")
# Validate display configuration
display_config = config.get('display', {})
if not display_config:
self.errors.append("Display configuration is missing or empty")
except ConfigError as e:
self.errors.append(f"Configuration error: {e}")
except Exception as e:
self.errors.append(f"Unexpected error validating configuration: {e}")
def _validate_cache_directory(self) -> None:
"""Validate cache directory permissions."""
try:
cache_manager = self.cache_manager
if cache_manager is None:
# No caller supplied one (older embedders, direct use in a
# script). Build one, but do not leave its cleanup thread
# running behind us -- this instance is discarded on the next
# line but the thread is a closure over it, so it would never
# be collected.
from src.cache_manager import CacheManager
cache_manager = CacheManager()
try:
cache_dir = cache_manager.get_cache_dir()
finally:
cache_manager.stop_cleanup_thread()
else:
cache_dir = cache_manager.get_cache_dir()
if not cache_dir:
self.warnings.append("Cache directory not available - caching will be disabled")
return
# Check if directory exists and is writable
if not os.path.exists(cache_dir):
self.errors.append(f"Cache directory does not exist: {cache_dir}")
return
if not os.access(cache_dir, os.W_OK):
self.errors.append(f"Cache directory is not writable: {cache_dir}")
return
# Test write access
test_file = os.path.join(cache_dir, '.startup_test')
try:
with open(test_file, 'w') as f:
f.write('test')
os.remove(test_file)
except (IOError, OSError) as e:
self.errors.append(f"Cannot write to cache directory {cache_dir}: {e}")
except Exception as e:
self.warnings.append(f"Could not validate cache directory: {e}")
def _validate_display_config(self) -> None:
"""Validate display configuration."""
try:
config = self.config_manager.get_config()
display_config = config.get('display', {})
if not display_config:
self.errors.append("Display configuration is missing")
return
hardware_config = display_config.get('hardware', {})
if not hardware_config:
self.errors.append("Display hardware configuration is missing")
return
# Check required hardware settings
required_hardware = ['rows', 'cols']
for key in required_hardware:
if key not in hardware_config:
self.warnings.append(f"Display hardware setting '{key}' not specified, using default")
except Exception as e:
self.warnings.append(f"Could not validate display configuration: {e}")
def _validate_plugins(self) -> None:
"""Validate plugin configurations and dependencies."""
if not self.plugin_manager:
return
try:
# Get enabled plugins from config
config = self.config_manager.get_config()
discovered_plugins = self.plugin_manager.discover_plugins()
# Check for enabled plugins that don't exist
for plugin_id, plugin_config in config.items():
# Skip non-plugin config sections
if plugin_id in ['display', 'schedule', 'timezone', 'plugin_system']:
continue
if not isinstance(plugin_config, dict):
continue
if plugin_config.get('enabled', False):
if plugin_id not in discovered_plugins:
self.warnings.append(f"Plugin '{plugin_id}' is enabled but not found in plugins directory")
# Validate plugin configurations
for plugin_id in discovered_plugins:
plugin_config = config.get(plugin_id, {})
if plugin_config.get('enabled', False):
# Check if plugin can be loaded (without actually loading it)
plugin_dir = self.plugin_manager.get_plugin_directory(plugin_id)
if plugin_dir:
manifest_path = Path(plugin_dir) / "manifest.json"
if not manifest_path.exists():
self.errors.append(f"Plugin '{plugin_id}' manifest.json not found")
except Exception as e:
self.warnings.append(f"Could not validate plugins: {e}")
def raise_on_errors(self) -> None:
"""
Raise exceptions if validation errors exist.
Raises:
ConfigError: If configuration validation fails
CacheError: If cache validation fails
PluginError: If plugin validation fails
"""
if not self.errors:
return
# Group errors by type
config_errors = [e for e in self.errors if 'configuration' in e.lower() or 'config' in e.lower()]
cache_errors = [e for e in self.errors if 'cache' in e.lower()]
plugin_errors = [e for e in self.errors if 'plugin' in e.lower()]
other_errors = [e for e in self.errors if e not in config_errors + cache_errors + plugin_errors]
# Raise appropriate exceptions
if config_errors:
raise ConfigError("Configuration validation failed", context={'errors': config_errors})
if cache_errors:
raise CacheError("Cache validation failed", context={'errors': cache_errors})
if plugin_errors:
raise PluginError("Plugin validation failed", context={'errors': plugin_errors})
if other_errors:
raise ConfigError("Startup validation failed", context={'errors': other_errors})