Skip to content

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

Open
ChuckBuilds wants to merge 1 commit into
mainfrom
fix/atomic-plugin-update
Open

fix(store): plugin updates keep the old install until the new one succeeds#405
ChuckBuilds wants to merge 1 commit 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

Review Change Stack

📝 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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 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

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 6 complexity · 0 duplication

Metric Results
Complexity 6
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

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 on lines +93 to +101
def test_stale_aside_from_previous_crash_is_cleared(self, store):
mgr, plugin_dir = store
stale = plugin_dir.parent / f"{PLUGIN_ID}.standalone-backup-migrating"
stale.mkdir()
(stale / "old.txt").write_text("stale")
with patch.object(mgr, "install_plugin", return_value=False):
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
assert ok is False
assert plugin_dir.exists()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assertions don't actually prove the stale aside was cleared.

If the stale-clear at the top of _reinstall_with_rollback silently failed, plugin_path.rename(backup_path) would raise (destination non-empty) and return False before ever touching plugin_dir — leaving plugin_dir.exists() true and ok is False, which is exactly what this test asserts today. The test would pass identically whether or not the stale-clear logic works, so it doesn't actually cover the regression its name claims to guard against.

✅ Proposed strengthening
-        with patch.object(mgr, "install_plugin", return_value=False):
+        with patch.object(mgr, "install_plugin", return_value=False) as mock_install:
             ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
+        mock_install.assert_called_once()
         assert ok is False
         assert plugin_dir.exists()
+        assert "old version marker" in (plugin_dir / "manager.py").read_text()
+        assert not (plugin_dir / "old.txt").exists()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_stale_aside_from_previous_crash_is_cleared(self, store):
mgr, plugin_dir = store
stale = plugin_dir.parent / f"{PLUGIN_ID}.standalone-backup-migrating"
stale.mkdir()
(stale / "old.txt").write_text("stale")
with patch.object(mgr, "install_plugin", return_value=False):
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
assert ok is False
assert plugin_dir.exists()
def test_stale_aside_from_previous_crash_is_cleared(self, store):
mgr, plugin_dir = store
stale = plugin_dir.parent / f"{PLUGIN_ID}.standalone-backup-migrating"
stale.mkdir()
(stale / "old.txt").write_text("stale")
with patch.object(mgr, "install_plugin", return_value=False) as mock_install:
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
mock_install.assert_called_once()
assert ok is False
assert plugin_dir.exists()
assert "old version marker" in (plugin_dir / "manager.py").read_text()
assert not (plugin_dir / "old.txt").exists()
🤖 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 `@test/test_store_update_rollback.py` around lines 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.

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