Skip to content

fix(store): plugin updates keep the old install until the new one succeeds - #405

Merged
ChuckBuilds merged 2 commits into
mainfrom
fix/atomic-plugin-update
Jul 13, 2026
Merged

fix(store): plugin updates keep the old install until the new one succeeds#405
ChuckBuilds merged 2 commits into
mainfrom
fix/atomic-plugin-update

Conversation

@ChuckBuilds

@ChuckBuilds ChuckBuilds commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Summary

Data-loss fix. Both reinstall paths in update_plugin — the monorepo-migration remote switch and the routine archive update that every store user hits — did _safe_remove_directory(plugin_path) before install_plugin(). Any mid-update failure (flaky WiFi, DNS outage, registry hiccup) permanently destroyed the plugin.

This isn't theoretical: during testing, a Pi with broken outbound DNS ran an update pass and lost 12 plugins in one go — every plugin whose git remote pointed at the old pre-monorepo repos was deleted and none could be re-downloaded.

The fix (_reinstall_with_rollback): rename the old install aside → install_plugin() → remove the aside on success, restore it on any failure (clearing partial-download debris first). The aside name uses the .standalone-backup- pattern that plugin discovery already excludes, so a crash mid-update never yields a phantom duplicate plugin. A stale aside from a previous crash is cleared before starting.

Verification

6 new unit tests: failed install restores the old version; install exception restores; success removes the aside; partial-download debris is replaced by the old version; stale asides are cleared; the aside is invisible to _scan_directory_for_plugins. Store cache + uninstall/reconcile suites green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam

Summary by CodeRabbit

  • Bug Fixes

    • Plugin updates and reinstalls now protect the existing installation if the operation fails.
    • Failed updates automatically clean up partial files and restore the previous working version.
    • Improved recovery from interrupted or previously failed plugin migrations.
    • Backup directories created during updates remain hidden from plugin discovery.
  • Tests

    • Added coverage for failed, interrupted, successful, and recovery-based plugin updates.

…ceeds

Both reinstall paths in update_plugin — the monorepo-migration remote
switch AND the routine archive update every store user hits — deleted
the installed plugin directory BEFORE downloading its replacement. A
mid-update failure (bad network, registry error) permanently destroyed
the plugin. Seen in the field: a Pi with broken DNS lost 12 plugins in
one update pass during the monorepo migration.

New _reinstall_with_rollback: rename the old install aside (using the
'.standalone-backup-' name pattern plugin discovery already excludes),
run install_plugin, remove the aside on success — restore it on ANY
failure, clearing partial-download debris first. A stale aside from a
previous crash is cleared before starting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@ChuckBuilds, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 234c34f4-0c98-4e14-ab78-7ce4a4ab61dd

📥 Commits

Reviewing files that changed from the base of the PR and between 6412969 and 7827310.

📒 Files selected for processing (2)
  • src/plugin_system/store_manager.py
  • test/test_store_update_rollback.py
📝 Walkthrough

Walkthrough

Plugin reinstall operations now preserve the existing directory during installation, restore it after failures, and clean up temporary artifacts. Two update paths use this helper, with tests covering failures, successful updates, stale backups, and plugin discovery.

Changes

Plugin reinstall rollback

Layer / File(s) Summary
Rollback reinstall helper
src/plugin_system/store_manager.py
Adds _reinstall_with_rollback to move the current installation aside, attempt installation, clean up successful updates, and restore failed updates.
Update path integration
src/plugin_system/store_manager.py
Routes git remote migrations and non-git archive reinstalls through the rollback helper.
Rollback behavior tests
test/test_store_update_rollback.py
Tests failed installs, exceptions, partial downloads, stale asides, successful reinstalls, and backup-directory discovery exclusion.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: keeping the existing plugin install until the replacement succeeds.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/atomic-plugin-update

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

codacy-production Bot commented Jul 12, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 12 complexity · 0 duplication

Metric Results
Complexity 12
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/plugin_system/store_manager.py (2)

2284-2285: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Unchecked _safe_remove_directory return values hide cleanup failures.

Both the stale-aside clear and the post-success aside removal ignore the boolean result of _safe_remove_directory. If either genuinely fails (all three removal strategies exhausted), the function proceeds silently — a stale-clear failure surfaces later only as an opaque rename OSError, and a post-success failure leaves an orphaned .standalone-backup-migrating directory with no log trace. On a Pi's constrained storage, silently accumulating undetected orphaned directories works against the guideline to clean up resources and log clearly for remote debugging.

🛠️ Proposed fix
-        if backup_path.exists():
-            self._safe_remove_directory(backup_path)
+        if backup_path.exists():
+            if not self._safe_remove_directory(backup_path):
+                self.logger.warning(
+                    f"Could not fully clear stale backup {backup_path} before reinstalling {plugin_id}")
         if installed:
-            self._safe_remove_directory(backup_path)
+            if not self._safe_remove_directory(backup_path):
+                self.logger.warning(
+                    f"Reinstall of {plugin_id} succeeded but stale backup {backup_path} could not be removed")
             return True
As per coding guidelines, "Optimize code for Raspberry Pi's limited RAM and CPU capabilities" and "Clean up resources regularly to manage memory effectively."

Also applies to: 2299-2300

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/plugin_system/store_manager.py` around lines 2284 - 2285, Check the
boolean result of _safe_remove_directory in both the stale-aside cleanup and
post-success backup removal paths. When removal fails, explicitly log the
cleanup failure with the affected backup_path and preserve the appropriate
failure behavior instead of continuing silently. Ensure successful removals
retain the existing flow.

Source: Coding guidelines


2266-2318: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Serialize _reinstall_with_rollback() per plugin (src/plugin_system/store_manager.py:2266-2318) — there’s no per-plugin lock around the rename/install/restore sequence, so concurrent updates for the same plugin_id can race and turn into a spurious failed reinstall. A small lock around this path would make the rollback flow deterministic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/plugin_system/store_manager.py` around lines 2266 - 2318, Serialize
_reinstall_with_rollback per plugin_id by acquiring a lock before the backup
rename and holding it through install_plugin, cleanup, and rollback, releasing
it on every return or exception. Reuse the store manager’s existing lock pattern
if available; ensure different plugin IDs can still proceed concurrently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/test_store_update_rollback.py`:
- Around line 93-101: Strengthen test_stale_aside_from_previous_crash_is_cleared
to directly verify the stale backup directory is removed and the reinstall
proceeds far enough to invoke the mocked install_plugin. Assert the stale path
no longer exists after _reinstall_with_rollback, while preserving the expected
failed result and plugin directory assertions.

---

Nitpick comments:
In `@src/plugin_system/store_manager.py`:
- Around line 2284-2285: Check the boolean result of _safe_remove_directory in
both the stale-aside cleanup and post-success backup removal paths. When removal
fails, explicitly log the cleanup failure with the affected backup_path and
preserve the appropriate failure behavior instead of continuing silently. Ensure
successful removals retain the existing flow.
- Around line 2266-2318: Serialize _reinstall_with_rollback per plugin_id by
acquiring a lock before the backup rename and holding it through install_plugin,
cleanup, and rollback, releasing it on every return or exception. Reuse the
store manager’s existing lock pattern if available; ensure different plugin IDs
can still proceed concurrently.
🪄 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: e7620ae9-256a-400a-906e-76e4098fc4a0

📥 Commits

Reviewing files that changed from the base of the PR and between 6edd80d and 6412969.

📒 Files selected for processing (2)
  • src/plugin_system/store_manager.py
  • test/test_store_update_rollback.py

Comment thread test/test_store_update_rollback.py
…sults

CodeRabbit review on #405 flagged two things in
_reinstall_with_rollback, both verified against current code:

- Real race: the web UI runs Flask with threaded=True and there's a
  single update route, so two overlapping requests for the same
  plugin_id (double-click, two tabs) can interleave. The loser could
  rename the winner's in-progress install aside mid-download, deleting
  its own rollback safety net — worse than the bug this function
  exists to fix. Added a lazy per-plugin_id lock dict (mirrors the
  plugin_manager per-plugin lock pattern) held for the whole function.
- _safe_remove_directory's return value was ignored at both call
  sites. Stale-aside cleanup failure now aborts cleanly instead of
  falling through to a rename that would fail anyway with a less
  useful error; post-success backup-removal failure now logs instead
  of failing silently (still returns True — the update itself
  succeeded, and the next update self-heals the leftover aside).

Left the third nitpick (test_stale_aside_from_previous_crash_is_cleared)
addressed by asserting the stale dir is actually gone and that
install_plugin was reached, rather than just the end-to-end result.

Added a concurrency regression test asserting install_plugin never
runs for the same plugin_id while another call is in flight.
@ChuckBuilds

Copy link
Copy Markdown
Owner Author

Addressed all three review items:

Locking (real issue, not a nitpick): confirmed app.run(..., threaded=True) and a single /plugins/update route with no dedup — two overlapping requests for the same plugin_id (double-click, two tabs) really can interleave. Worst case: the loser renames the winner's in-progress install aside mid-download, deleting the winner's own rollback safety net — which would have been worse than the delete-then-install bug this PR fixes. Added a lazy per-plugin_id lock dict (_reinstall_locks + _get_reinstall_lock, mirroring the existing per-plugin lock pattern already used in plugin_manager), held for the entire _reinstall_with_rollback body. Different plugin IDs still proceed concurrently. Added test_concurrent_updates_for_same_plugin_are_serialized, which asserts install_plugin is never active for the same plugin from two threads at once.

_safe_remove_directory return value: now checked at both sites. Stale-aside cleanup failure now returns early with a clear log message instead of falling through to a rename() that would fail anyway with a less useful "dest already exists" error. Post-success backup-removal failure now logs a warning (still returns True — the update itself succeeded; the leftover aside self-heals on the next update via the same stale-cleanup path).

Test strengthening: test_stale_aside_from_previous_crash_is_cleared now asserts the stale directory is actually gone and that install_plugin was reached (assert_called_once_with), not just the end-to-end result.

All 7 tests in test_store_update_rollback.py pass, plus the full -k store suite (53 passed).

@ChuckBuilds
ChuckBuilds merged commit c1fa509 into main Jul 13, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant