fix(install): prevent weather and music from auto-installing on fresh install - #317
fix(install): prevent weather and music from auto-installing on fresh install#317ChuckBuilds wants to merge 6 commits into
Conversation
When execute_update() fails (timeout or unhandled exception), the plugin state was set to ERROR with no recovery path. can_execute() returns False for ERROR state, so the plugin's update() was never called again, leaving it showing stale data indefinitely. Instead, update plugin_last_update so the plugin waits one configured interval before retrying, and keep the state ENABLED so recovery is automatic on the next cycle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…context - Use time.time() at the point of failure instead of reusing current_time (captured before execution), so the full retry interval always elapses after a timeout rather than one execution-duration shorter - Add PluginStateManager.set_error_info() to persist structured error context without changing plugin state; call it in both failure branches so get_error_info() / get_state_info() surface recoverable errors alongside ERROR-state errors - Add warning log on the success=False branch (was previously silent) - Pass a descriptive Exception (not a generic "Plugin execution failed") to health_tracker.record_failure() in the timeout/executor-error path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The two-step set_state() / set_error_info() sequence left a window where readers could observe ENABLED state without the accompanying error context. Add threading.RLock to PluginStateManager and a new set_state_with_error() method that holds the lock for both the state-transition write and the _error_info write together. The method inlines the state-transition logic rather than calling set_state() internally to intentionally skip the "clear _error_info for non-ERROR states" side effect — the recoverable error dict is exactly what we want stored. Replace both paired set_state / set_error_info call sites in run_scheduled_updates() with the single atomic method. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three verified issues: - set_error_info wrote _error_info without holding _lock and stored the caller's dict by reference, allowing races and post-write mutation - set_state_with_error stored error_info by reference (lock was already held) - get_error_info read _error_info without _lock and returned the live reference, letting callers mutate the stored snapshot Implicit fourth fix: set_state also wrote _error_info without _lock; locking get_error_info while leaving that writer unguarded would have created a new race, so set_state is now wrapped in _lock too for consistency. Changes: - set_state: wrap entire body in self._lock (covers _states, _state_history, and _error_info writes atomically; ERROR-path _error_info value was already a fresh dict literal so no copy needed) - set_error_info: acquire self._lock + store dict(error_info) shallow copy - set_state_with_error: store dict(error_info) shallow copy (lock already held) - get_error_info: acquire self._lock + return dict(info) copy or None All stored values are flat dicts of strings/floats/bools, so shallow copy is sufficient — deepcopy is not needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…act helper update_all_plugins still set PluginState.ERROR on both failure paths, leaving it inconsistent with the run_scheduled_updates fix from the same PR. Extract _record_update_failure(plugin_id, exc=None) to hold all shared failure logic: capture actual failure time, build structured error_info, log the retry warning, stamp plugin_last_update, call set_state_with_error(ENABLED), and forward to health_tracker. Replace all four failure sites (two in run_scheduled_updates, two in update_all_plugins) with calls to this helper. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…template config_secrets.template.json shipped ledmatrix-weather and music as top-level keys; config_manager deep-merges secrets into the main config on load, so the reconciler treated them as plugin config entries and auto-installed both plugins on first web UI visit after a fresh install. Remove both keys from the template and clear the inline fallback block in first_time_install.sh so new installs start clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Important Review skippedNo new commits to review since the last review. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughRemoves credential sections from the configuration template and updates the initialization script to write empty JSON. Introduces a standardized failure-recovery mechanism in the plugin system that records update failures with structured error context while keeping plugins in Changes
Sequence DiagramsequenceDiagram
participant PM as Plugin Manager
participant PSM as Plugin State Manager
participant HT as Health Tracker
PM->>PM: Attempt plugin update
PM->>PM: Update fails (exception)
PM->>PSM: set_state_with_error(ENABLED, error_info)
activate PSM
PSM->>PSM: Acquire re-entrant lock
PSM->>PSM: Record error context
PSM->>PSM: Update state to ENABLED
PSM->>PSM: Add state transition to history
PSM->>PSM: Release lock
deactivate PSM
PM->>PM: Stamp plugin_last_update with failure time
PM->>HT: Record failure event
PM->>PM: Log retry warning
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 5 |
| Duplication | 0 |
AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Pull Request Overview
While this PR successfully addresses the goal of preventing auto-installation of specific plugins, it introduces significant, undocumented architectural changes to the plugin system. The failure recovery logic has been modified to keep plugins in an ENABLED state rather than transitioning to ERROR, and a thread-safety layer (RLock) has been partially implemented.
Codacy analysis shows the PR is up to standards, but the new complexity in plugin_manager.py and plugin_state.py is not fully covered by tests or properly documented. The most critical concerns are the incomplete thread-safety implementation and the risk of infinite retry loops or broken monitoring due to the lifecycle changes.
About this PR
- The change in plugin failure behavior—where a plugin remains
ENABLEDrather than moving to anERRORstate—is a significant lifecycle modification. This may impact external dashboards or monitoring tools that rely on theERRORstate to flag health issues. - The PR title and description focus exclusively on installation fixes, but the code contains significant architectural changes to plugin error handling, retry logic, and thread-safety in
PluginStateManager. These changes should be acknowledged and justified in the PR description to ensure reviewers and future maintainers understand the rationale.
1 comment outside of the diff
src/plugin_system/plugin_manager.py
line 760⚪ LOW RISK
Suggestion: This dynamic class generation usingtype()inside a loop is unconventional and may interfere with thePluginExecutorif it performs introspection on class names or attributes. Consider using a standard wrapper class or passing monitoring logic directly.
Test suggestions
- Verify config_secrets.json is generated without weather or music keys on fresh install.
- Verify plugin update failures now transition to ENABLED state with error info (recoverable path) instead of ERROR state.
- Verify thread-safety of PluginStateManager under concurrent state transitions and queries.
- Verify the atomic 'set_state_with_error' method correctly persists both state and error context.
Prompt proposal for missing tests
Consider implementing these tests if applicable:
1. Verify plugin update failures now transition to ENABLED state with error info (recoverable path) instead of ERROR state.
2. Verify thread-safety of PluginStateManager under concurrent state transitions and queries.
3. Verify the atomic 'set_state_with_error' method correctly persists both state and error context.
TIP Improve review quality by adding custom instructions
TIP How was this review? Give us feedback
| error_info = { | ||
| 'error': str(err), | ||
| 'error_type': error_type, | ||
| 'timestamp': failure_time, |
There was a problem hiding this comment.
🟡 MEDIUM RISK
Inconsistent timestamp type. The rest of the PluginStateManager uses datetime.now() for timestamps, but this dictionary uses a float from time.time(). Use datetime.now() to ensure consistency for UI/JSON serialization components.
| logger: Optional logger instance | ||
| """ | ||
| self.logger = logger or get_logger(__name__) | ||
| self._lock = threading.RLock() |
There was a problem hiding this comment.
🟡 MEDIUM RISK
The thread-safety implementation is incomplete. To avoid race conditions or inconsistent snapshots, all methods reading from or writing to internal state must acquire self._lock. Specifically, apply the lock to: record_update, record_display, get_state, is_loaded, is_enabled, is_running, is_error, can_execute, get_state_history, and get_state_info.
| self.state_manager.set_state_with_error(plugin_id, PluginState.ENABLED, error_info, error=err) | ||
| if self.health_tracker: | ||
| self.health_tracker.record_failure(plugin_id, err) |
There was a problem hiding this comment.
🟡 MEDIUM RISK
Suggestion: This logic transitions failing plugins back to PluginState.ENABLED for automatic recovery instead of PluginState.ERROR. While this enables retries, ensure it does not lead to infinite retry loops or log spam for non-transient failures. Additionally, ensure that UI or monitoring components are updated to inspect get_error_info(), as a plugin can now be 'Enabled' while actively failing.
| with self._lock: | ||
| self._error_info[plugin_id] = dict(error_info) | ||
|
|
||
| def set_state_with_error( |
There was a problem hiding this comment.
⚪ LOW RISK
Suggestion: The logic for recording state transitions is duplicated across set_state and set_state_with_error. Refactor this into a private _record_transition method to improve maintainability.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/plugin_system/plugin_manager.py (1)
799-809:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
update_all_plugins()still leaves recovered plugins marked unhealthy.Failures on this path now call
health_tracker.record_failure()through_record_update_failure()at Lines 715-716, but the success branch here never mirrorsrun_scheduled_updates()Lines 772-773 with arecord_success(). A plugin can recover toENABLEDwhile the health tracker and any circuit-breaker logic stay stuck in failure mode.Suggested fix
if success: self.plugin_last_update[plugin_id] = time.time() self.state_manager.record_update(plugin_id) self.state_manager.set_state(plugin_id, PluginState.ENABLED) + if self.health_tracker: + self.health_tracker.record_success(plugin_id) else: self._record_update_failure(plugin_id)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/plugin_system/plugin_manager.py` around lines 799 - 809, The success branch in update_all_plugins sets plugin_last_update, calls state_manager.record_update and sets state_manager.set_state(…, PluginState.ENABLED) but never notifies the health tracker of recovery; mirror the behavior in run_scheduled_updates by calling the health success path when execute_update returns True (e.g., invoke health_tracker.record_success(plugin_id) or the equivalent success-recording method) right after state_manager.set_state so recovered plugins are removed from failure/circuit-breaker state; leave the existing _record_update_failure(exception) call in the except/false paths unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/plugin_system/plugin_manager.py`:
- Around line 706-710: The error_info dictionary is storing 'timestamp' as a
Unix float while PluginStateManager.set_state() uses a datetime for
PluginState.ERROR, causing inconsistent shapes; change the code that builds
error_info (the variable error_info in plugin_manager) to normalize failure_time
into the same datetime type used by PluginStateManager.set_state() (e.g.,
convert the float/ts to a datetime via datetime.fromtimestamp() or use
datetime.utcnow() at the failure site) so get_error_info()/get_state_info()
always return the same timestamp type; update any callers that construct
error_info to use the same datetime normalization and ensure serialization code
expects that unified format.
In `@src/plugin_system/plugin_state.py`:
- Around line 155-216: The docstring promises atomic writes in
set_state_with_error (which holds self._lock) but readers like get_state() and
get_state_info() read shared structures without locking, letting a reader see
the new state before _error_info is written; fix by making all readers that
access self._states, self._error_info or self._state_history (e.g., get_state(),
get_state_info(), any get_state_history/get_state_info variants) acquire
self._lock around their reads and return shallow copies as needed so callers
cannot mutate internal maps, ensuring the state+error atomicity guaranteed by
set_state_with_error.
---
Outside diff comments:
In `@src/plugin_system/plugin_manager.py`:
- Around line 799-809: The success branch in update_all_plugins sets
plugin_last_update, calls state_manager.record_update and sets
state_manager.set_state(…, PluginState.ENABLED) but never notifies the health
tracker of recovery; mirror the behavior in run_scheduled_updates by calling the
health success path when execute_update returns True (e.g., invoke
health_tracker.record_success(plugin_id) or the equivalent success-recording
method) right after state_manager.set_state so recovered plugins are removed
from failure/circuit-breaker state; leave the existing
_record_update_failure(exception) call in the except/false paths unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 0891d654-753d-4a35-b6d3-86848f8a4b39
📒 Files selected for processing (4)
config/config_secrets.template.jsonfirst_time_install.shsrc/plugin_system/plugin_manager.pysrc/plugin_system/plugin_state.py
| error_info = { | ||
| 'error': str(err), | ||
| 'error_type': error_type, | ||
| 'timestamp': failure_time, | ||
| 'recoverable': True, |
There was a problem hiding this comment.
Normalize error_info["timestamp"] across failure paths.
This helper writes a Unix float, but PluginStateManager.set_state() still writes a datetime object for PluginState.ERROR in src/plugin_system/plugin_state.py Lines 76-79. get_error_info()/get_state_info() can now return two different shapes for the same field, which is easy to break in JSON serialization and UI formatting.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/plugin_system/plugin_manager.py` around lines 706 - 710, The error_info
dictionary is storing 'timestamp' as a Unix float while
PluginStateManager.set_state() uses a datetime for PluginState.ERROR, causing
inconsistent shapes; change the code that builds error_info (the variable
error_info in plugin_manager) to normalize failure_time into the same datetime
type used by PluginStateManager.set_state() (e.g., convert the float/ts to a
datetime via datetime.fromtimestamp() or use datetime.utcnow() at the failure
site) so get_error_info()/get_state_info() always return the same timestamp
type; update any callers that construct error_info to use the same datetime
normalization and ensure serialization code expects that unified format.
| def set_state_with_error( | ||
| self, | ||
| plugin_id: str, | ||
| state: PluginState, | ||
| error_info: Dict[str, Any], | ||
| error: Optional[Exception] = None, | ||
| ) -> None: | ||
| """Set plugin state and persist error context atomically. | ||
|
|
||
| Unlike calling set_state() then set_error_info() separately, this | ||
| method holds ``_lock`` for both writes so no reader can observe the | ||
| new state without the accompanying error context. | ||
|
|
||
| Intentionally does not clear ``_error_info`` the way set_state() does | ||
| for non-ERROR transitions — this is the recoverable-failure path where | ||
| the error dict is the entire point. | ||
|
|
||
| Args: | ||
| plugin_id: Plugin identifier | ||
| state: New state | ||
| error_info: Structured error dict to persist alongside the state | ||
| error: Optional exception recorded in the transition history | ||
| """ | ||
| with self._lock: | ||
| old_state = self._states.get(plugin_id, PluginState.UNLOADED) | ||
| self._states[plugin_id] = state | ||
|
|
||
| if plugin_id not in self._state_history: | ||
| self._state_history[plugin_id] = [] | ||
| self._state_history[plugin_id].append({ | ||
| 'timestamp': datetime.now(), | ||
| 'from': old_state.value, | ||
| 'to': state.value, | ||
| 'error': str(error) if error else None, | ||
| }) | ||
|
|
||
| self._error_info[plugin_id] = dict(error_info) | ||
|
|
||
| self.logger.debug( | ||
| "Plugin %s state transition: %s → %s (recoverable error stored)", | ||
| plugin_id, | ||
| old_state.value, | ||
| state.value, | ||
| ) | ||
|
|
||
| def get_error_info(self, plugin_id: str) -> Optional[Dict[str, Any]]: | ||
| """ | ||
| Get error information for a plugin in ERROR state. | ||
|
|
||
| Get error information for a plugin. | ||
|
|
||
| Returns the stored error dict whether the plugin is in ERROR state or | ||
| still ENABLED after a recoverable failure. Returns a shallow copy so | ||
| callers cannot mutate the stored snapshot. | ||
|
|
||
| Args: | ||
| plugin_id: Plugin identifier | ||
|
|
||
| Returns: | ||
| Error information dict or None | ||
| Copy of the error information dict, or None | ||
| """ | ||
| return self._error_info.get(plugin_id) | ||
| with self._lock: | ||
| info = self._error_info.get(plugin_id) | ||
| return dict(info) if info is not None else None |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, check if the files exist and examine the plugin_state.py implementation
find . -name "plugin_state.py" -type fRepository: ChuckBuilds/LEDMatrix
Length of output: 101
🏁 Script executed:
# Get the full implementation of plugin_state.py to understand lock usage
cat -n src/plugin_system/plugin_state.pyRepository: ChuckBuilds/LEDMatrix
Length of output: 11280
🏁 Script executed:
# Check the plugin_manager.py file at the mentioned lines to see how get_state_info() is called
sed -n '560,570p' src/plugin_system/plugin_manager.py
sed -n '820,830p' src/plugin_system/plugin_manager.py
sed -n '710,720p' src/plugin_system/plugin_manager.pyRepository: ChuckBuilds/LEDMatrix
Length of output: 1363
🏁 Script executed:
# Verify the actual method implementations and lock usage one more time with clearer focus
rg -A 2 "def get_state\(" src/plugin_system/plugin_state.py
rg -A 15 "def get_state_info" src/plugin_system/plugin_state.py | head -20Repository: ChuckBuilds/LEDMatrix
Length of output: 711
🏁 Script executed:
# Check what callers depend on get_state_info() atomicity
rg "get_state_info\|get_state\(" src/plugin_system/plugin_manager.py | head -20Repository: ChuckBuilds/LEDMatrix
Length of output: 47
🏁 Script executed:
# Let me check plugin_manager.py line 714 to see the actual usage
sed -n '710,720p' src/plugin_system/plugin_manager.pyRepository: ChuckBuilds/LEDMatrix
Length of output: 627
🏁 Script executed:
# And confirm that get_state_info is called in places that would be affected
grep -n "get_state_info\|\.get_state(" src/plugin_system/plugin_manager.py | head -10Repository: ChuckBuilds/LEDMatrix
Length of output: 350
Partial locking breaks the atomicity guarantee: readers can observe state changes without error context.
set_state_with_error() acquires _lock to write both _states and _error_info atomically, but get_state(), get_state_info(), and other readers do not acquire the lock. A concurrent caller can read the new state from _states (via unlocked get_state()) before _error_info is written, defeating the atomicity contract described in the docstring. This directly undermines the recoverable-failure recovery mechanism used at src/plugin_system/plugin_manager.py line 714.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/plugin_system/plugin_state.py` around lines 155 - 216, The docstring
promises atomic writes in set_state_with_error (which holds self._lock) but
readers like get_state() and get_state_info() read shared structures without
locking, letting a reader see the new state before _error_info is written; fix
by making all readers that access self._states, self._error_info or
self._state_history (e.g., get_state(), get_state_info(), any
get_state_history/get_state_info variants) acquire self._lock around their reads
and return shallow copies as needed so callers cannot mutate internal maps,
ensuring the state+error atomicity guaranteed by set_state_with_error.
|
Closing — our change got bundled with unrelated commits. Re-opening as a focused single-commit PR. |
|
Summary
ledmatrix-weatherandmusiccredential stubs fromconfig_secrets.template.jsonweatherkey from the inline fallback block infirst_time_install.shRoot cause
config_managerdeep-mergesconfig_secrets.jsoninto the main config on every load. Because the secrets template shippedledmatrix-weatherandmusicas top-level keys, they appeared as plugin config entries in the merged config. The state reconciler treated them as plugins referenced in config but not installed on disk, and auto-downloaded and installed both on the first web UI visit after a fresh install.Test plan
one-shot-install.sh— confirm onlystarlark-apps,web-ui-infoappear inplugin-repos/after bootledmatrix-weathernorledmatrix-musicare auto-installedledmatrix-weatherorledmatrix-musicvia plugin store — confirm credentials can still be added toconfig_secrets.jsonand are picked up correctly🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Chores