Skip to content

fix(plugins): make update scheduling atomic so update() cannot run twice at once - #437

Merged
ChuckBuilds merged 3 commits into
mainfrom
fix/atomic-plugin-update-scheduling
Aug 5, 2026
Merged

fix(plugins): make update scheduling atomic so update() cannot run twice at once#437
ChuckBuilds merged 3 commits into
mainfrom
fix/atomic-plugin-update-scheduling

Conversation

@ChuckBuilds

@ChuckBuilds ChuckBuilds commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Closes #401.

run_scheduled_updates() decided whether to update a plugin with a check-then-act sequence — can_execute() and set_state(RUNNING) were separate calls with nothing between them, so two scheduler threads could both observe ENABLED and both go on to call the same plugin's update(). update_all_plugins() had the identical pattern.

Two schedulers really do run at once: the render loop calls _tick_plugin_updates(), and Vegas mode fires its own vegas-plugin-tick daemon thread that is never joined when VegasModeCoordinator.play() returns, so a slow update() still in flight overlaps the next tick from the main loop.

What was actually still exposed

Worth stating, because the codebase has moved since the issue was filed. The async path is already safe_enqueue_update() dedups under _pending_lock and a single worker thread runs the updates. The live exposure was:

  • the synchronous kill-switch path (plugin_system.synchronous_updates: true) — set_state(RUNNING) then inline _execute_update_now(), no dedup, no lock
  • update_all_plugins() — same raw pattern, fully inline

Both now claim the plugin through _reserve_for_update(), which holds one lock across the eligibility check, the due-time check and the RUNNING transition — and nothing more. Holding it across execute_update() would serialize slow plugins behind each other and reintroduce the render stall the async worker exists to avoid.

The due-time check moved inside the lock deliberately: left outside, a thread that had already decided "due" could claim the plugin the instant the winner finished, running update() twice within one interval.

Two supporting changes

Both are consequences of introducing a reservation, not drive-by edits:

  • _enqueue_update() no longer sets RUNNING (the reservation did), and hands the reservation back if the pending-dedup ever fires. Otherwise a reserved-but-unqueued plugin sits in RUNNING with nothing left to release it, and can_execute() refuses it forever.
  • _finish() clears the pending entry before flipping the state back to ENABLED. The old order left a window where a scheduler saw ENABLED, reserved the plugin, then had its enqueue silently dropped by the dedup. That was a harmless missed tick before; with a reservation involved it would strand the plugin.

Tests

The overlap tests delay can_execute() to hold every thread inside the check-then-act gap. This matters: the real window is a couple of bytecodes wide, so a plain hammering test passes against the unfixed scheduler and proves nothing — I confirmed that before adding the delay. With it, against main:

AssertionError: update() ran 8x concurrently on the synchronous path
AssertionError: update() ran 8x concurrently via update_all_plugins()
6 failed, 3 passed

and with the fix, 9 passed. Coverage: reservation atomicity (16 threads, exactly one winner), refusal while RUNNING, hand-back, the due check, no overlap on all three dispatch paths, no stranded RUNNING state, and the pending/state ordering invariant.

Also enrolls test_async_plugin_updates.py in CI — it existed but was never run there, which is how a scheduler refactor could regress the async guarantees silently.

63 existing tests across test_async_plugin_updates, test_plugin_system, test_plugin_health, test_vegas_plugin_adapter and test_display_controller_plugin_toggle still pass.

Not addressed

The issue notes the Vegas tick thread is a daemon that is never joined. This PR makes the race harmless rather than removing the overlap; joining that thread on play() return is a separate change with its own shutdown-latency trade-off.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

Summary by CodeRabbit

  • Bug Fixes

    • Prevented duplicate or overlapping plugin updates during concurrent scheduling.
    • Fixed plugin update state handling so plugins do not remain incorrectly marked as running.
    • Improved reliability for immediate, background, and bulk plugin updates, including failed dispatches.
  • Tests

    • Added coverage for configuration keys, asynchronous plugin updates, reservation handling, concurrency, and update state recovery.

…ice at once

Closes #401.

`run_scheduled_updates()` decided whether to update a plugin with a
check-then-act sequence: `can_execute()` and `set_state(RUNNING)` were
separate calls with nothing between them, so two scheduler threads could
both observe ENABLED and both go on to call the same plugin's `update()`.
`update_all_plugins()` had the identical pattern.

Two schedulers really do run at once. The render loop calls
`_tick_plugin_updates()`, and Vegas mode fires its own `vegas-plugin-tick`
daemon thread that is never joined when `VegasModeCoordinator.play()`
returns — a slow `update()` still in flight overlaps the next tick from
the main loop. A plugin running `update()` twice concurrently is unsafe
unless it happens to be reentrant; shared mutable state, a non-thread-safe
HTTP session or cache all break.

The async path was already covered by the `_pending_lock` dedup in
`_enqueue_update`, so the live exposure was the synchronous kill-switch
path and `update_all_plugins()`. Both now claim the plugin through
`_reserve_for_update()`, which holds one lock across the eligibility
check, the due-time check and the RUNNING transition — and nothing more.
Holding it across `execute_update()` would serialize slow plugins behind
each other and reintroduce the render stall the async worker exists to
avoid.

The due-time check moved inside the lock deliberately. Left outside, a
thread that had already decided "due" could claim the plugin the instant
the winner finished, running `update()` twice within one interval.

Two supporting changes fall out of it:

- `_enqueue_update()` no longer sets RUNNING (the reservation did), and
  hands the reservation back if the pending-dedup ever fires. Otherwise a
  reserved-but-unqueued plugin would sit in RUNNING with nothing left to
  release it, and `can_execute()` would refuse it forever.
- `_finish()` now clears the pending entry *before* flipping the state
  back to ENABLED. The old order left a window where a scheduler saw
  ENABLED, reserved the plugin, then had its enqueue silently dropped by
  the dedup — harmless as a missed tick before, a stuck plugin once a
  reservation is involved.

Regression suite added and enrolled in CI, along with
test_async_plugin_updates.py which was not previously run there. The
overlap tests delay `can_execute()` to hold every thread inside the
check-then-act gap: the real window is a couple of bytecodes wide, so a
plain hammering test passes against the unfixed scheduler and proves
nothing. With that delay the suite reports `update() ran 8x concurrently`
on both affected paths before the fix, and passes after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Plugin update scheduling now reserves eligible plugins atomically. Synchronous, asynchronous, and bulk paths use the reservation mechanism. Completion and dispatch failure paths clear pending bookkeeping and restore plugin state. New concurrency tests and workflow entries validate these paths.

Changes

Plugin update concurrency

Layer / File(s) Summary
Atomic reservation and eligibility checks
src/plugin_system/plugin_manager.py, test/test_plugin_update_reservation.py
A reservation lock and atomic reservation helper coordinate eligibility, due-time, and RUNNING transitions. Tests verify exclusive claims and reservation release.
Update dispatch and state lifecycle
src/plugin_system/plugin_manager.py, test/test_plugin_update_reservation.py, .github/workflows/test.yml
Queued, synchronous, asynchronous, and bulk updates use reservations. Completion clears pending entries before restoring ENABLED. Tests and workflow entries cover non-overlapping execution and state cleanup.
Dispatch failure recovery
src/plugin_system/plugin_manager.py, test/test_plugin_update_reservation.py
Failed worker startup or queueing releases reservations and restores pending state. Tests verify retries and continued processing of later plugins.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Scheduler
  participant PluginManager
  participant UpdateQueue
  participant PluginExecutor
  Scheduler->>PluginManager: request plugin update
  PluginManager->>PluginManager: reserve eligible plugin
  PluginManager->>UpdateQueue: queue reserved update
  UpdateQueue->>PluginExecutor: execute update
  PluginExecutor-->>PluginManager: report completion or dispatch failure
  PluginManager->>PluginManager: clear pending state and restore plugin state
Loading

Possibly related PRs

🚥 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: atomic plugin update scheduling that prevents concurrent update() execution.
Linked Issues check ✅ Passed The PR addresses issue #401 with atomic reservations, shared protection for scheduled and bulk updates, state recovery, and regression tests.
Out of Scope Changes check ✅ Passed The code and CI test changes directly support issue #401 and the stated objective of preventing overlapping plugin updates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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-scheduling

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 Aug 5, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 9 complexity · 0 duplication

Metric Results
Complexity 9
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: 2

🧹 Nitpick comments (1)
src/plugin_system/plugin_manager.py (1)

876-882: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Only hand back a reservation that is still RUNNING.

_release_reservation writes ENABLED unconditionally. If unload_plugin runs between the reservation and the release, it already set UNLOADED and called clear_state. The release then recreates an ENABLED state entry for a plugin that is no longer in self.plugins, and get_plugin_state reports that stale entry to the web interface. Guard the transition with the reservation lock and a RUNNING check.

♻️ Proposed guard
     def _release_reservation(self, plugin_id: str) -> None:
         """Hand a claimed plugin back when it never got dispatched.
 
         Without this a plugin reserved but not queued would sit in RUNNING
         forever, and can_execute() would refuse it on every later tick.
+
+        Only a plugin still in RUNNING is handed back; a plugin unloaded
+        while reserved keeps the state unload_plugin() left it in.
         """
-        self.state_manager.set_state(plugin_id, PluginState.ENABLED)
+        with self._reservation_lock:
+            if self.state_manager.is_running(plugin_id):
+                self.state_manager.set_state(plugin_id, PluginState.ENABLED)
🤖 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/plugin_manager.py` around lines 876 - 882, Update
_release_reservation to acquire the reservation lock and transition the plugin
to ENABLED only when its current state is still RUNNING; otherwise leave the
state untouched so unload_plugin cannot recreate a stale state entry.
🤖 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 `@src/plugin_system/plugin_manager.py`:
- Around line 907-916: Update _enqueue_update to handle failures from
_ensure_update_worker or _update_queue.put by removing plugin_id from
_pending_updates, releasing its reservation via _release_reservation, and
logging the exception cause with self.logger; contain the failure so
run_scheduled_updates can continue processing remaining plugins.

In `@test/test_plugin_update_reservation.py`:
- Around line 203-209: Update the async wait loop around plugin._active to wait
until the update has started and the pending work has drained, rather than
exiting while _active is still false. Use the existing pending/update queue
state alongside plugin._active, retain the timeout, and assert that
plugin.max_concurrent confirms update() actually ran before checking its
concurrency value.

---

Nitpick comments:
In `@src/plugin_system/plugin_manager.py`:
- Around line 876-882: Update _release_reservation to acquire the reservation
lock and transition the plugin to ENABLED only when its current state is still
RUNNING; otherwise leave the state untouched so unload_plugin cannot recreate a
stale state entry.
🪄 Autofix

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 Plus

Run ID: b0e9f73c-2b4b-4551-a09b-851716898103

📥 Commits

Reviewing files that changed from the base of the PR and between 5b81cca and 4b3f191.

📒 Files selected for processing (3)
  • .github/workflows/test.yml
  • src/plugin_system/plugin_manager.py
  • test/test_plugin_update_reservation.py

Comment thread src/plugin_system/plugin_manager.py Outdated
Comment thread test/test_plugin_update_reservation.py Outdated
ChuckBuilds and others added 2 commits August 5, 2026 17:21
`test_async_path_never_overlaps` failed in CI with "update() ran 0x
concurrently" — the test's bug, not the scheduler's. It polled
`plugin._active` to wait for the update to finish, but before the worker
picks the item up nothing is active yet, so the loop fell straight
through and asserted on a plugin that had never run.

Both async waits now key on `update_calls >= 1` as well, so they wait for
an update to have started *and* finished. The stranded-state test gets
the same guard for a second reason: ENABLED is also the starting state,
so without it that assertion passes vacuously on a plugin that was never
scheduled.

Verified over 12 consecutive local runs, 12 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
_enqueue_update() reserved the plugin and added it to the pending set,
then started the worker and queued the item. Thread.start() raises
RuntimeError when the OS refuses a new thread — not hypothetical on a Pi
under memory or thread pressure — and nothing is queued at that point to
release the plugin. It stayed RUNNING with a stale pending entry, so
can_execute() refused it for the rest of the process, and the exception
escaped run_scheduled_updates() and skipped every remaining plugin in
that tick.

That is the same stranded-RUNNING failure the reservation was introduced
to prevent, just reached through the dispatch rather than the dedup, so
it is handled the same way: discard the pending entry, hand the
reservation back, log the cause. Swallowed rather than raised so one
plugin failing to queue cannot abort the others' turn.

Both new tests fail against the un-rolled-back version — the second on
the escaping RuntimeError itself — and pass with it. 74 tests across the
reservation, async-update, plugin-system, health, Vegas-adapter and
controller-toggle suites still pass.

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

@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.

🧹 Nitpick comments (1)
test/test_plugin_update_reservation.py (1)

290-325: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the required type annotations.

Annotate pm and -> None on both test methods. Add -> None to refuse_to_start and fail_first_only. Confirm the available PluginManager import or type alias before annotating pm.

As per coding guidelines, “Use type hints for function parameters and return values.”

🤖 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_plugin_update_reservation.py` around lines 290 - 325, Update the
two test methods test_reservation_released_when_the_worker_cannot_start and
test_dispatch_failure_does_not_abort_the_rest_of_the_tick with the appropriate
PluginManager type annotation for pm and a -> None return annotation. Also
annotate the nested refuse_to_start and fail_first_only helpers with -> None,
reusing the available PluginManager import or type alias.

Source: Coding guidelines

🤖 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.

Nitpick comments:
In `@test/test_plugin_update_reservation.py`:
- Around line 290-325: Update the two test methods
test_reservation_released_when_the_worker_cannot_start and
test_dispatch_failure_does_not_abort_the_rest_of_the_tick with the appropriate
PluginManager type annotation for pm and a -> None return annotation. Also
annotate the nested refuse_to_start and fail_first_only helpers with -> None,
reusing the available PluginManager import or type alias.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 64225d9c-1690-41b1-998f-71fc2b63aa86

📥 Commits

Reviewing files that changed from the base of the PR and between 4b3f191 and c90c8f2.

📒 Files selected for processing (2)
  • src/plugin_system/plugin_manager.py
  • test/test_plugin_update_reservation.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/plugin_system/plugin_manager.py

Copy link
Copy Markdown
Owner Author

On the remaining nitpick (type annotations on the two new TestDispatchFailure methods) — skipping this one deliberately, because applying it would make the new tests inconsistent with every other test in the repo.

The convention here is split, and the numbers are unambiguous:

annotated
src/plugin_system/*.py 210 / 235 defs
test/*.py test defs 0 / 1546

Not one test function in the suite carries -> None, including the nine already in this file — four of which this PR added and CodeRabbit reviewed without raising it. Annotating only these two would leave them as the sole annotated tests in a 1546-test suite.

The one written guideline I can find is docs/PLUGIN_REGISTRY_SETUP_GUIDE.md: "Has type hints where appropriate" — which the codebase evidently reads as "production code yes, tests no." The _enqueue_update change itself is in src/ and keeps its existing annotated signature, so it's already on the right side of that line.

Happy to annotate the whole test/ tree as its own cleanup if you'd rather standardise the other way — it just isn't something to do to two functions in a bug-fix PR.


Generated by Claude Code

@ChuckBuilds
ChuckBuilds merged commit 2af41c5 into main Aug 5, 2026
14 checks passed
@ChuckBuilds
ChuckBuilds deleted the fix/atomic-plugin-update-scheduling branch August 5, 2026 23:38
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.

Plugin update scheduling isn't atomic — same plugin's update() can run concurrently on two threads

1 participant