Skip to content

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

Description

@ChuckBuilds

Background

Flagged by CodeRabbit during review of #398, deferred as out of scope for that PR (which only fixed a separate plugin_last_update dict-iteration race).

Problem

PluginManager.run_scheduled_updates() (src/plugin_system/plugin_manager.py:706-776) decides whether to update a plugin with a check-then-act sequence that isn't atomic:

if not self.state_manager.can_execute(plugin_id):
    continue
...
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
...
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)

can_execute() and set_state(..., RUNNING) are separate calls with no lock between them, so two threads can both observe ENABLED and both proceed to call plugin_instance.update() for the same plugin concurrently.

This is reachable in practice: Vegas mode (src/vegas_mode/coordinator.py) fires its own background vegas-plugin-tick thread (~every 4s while Vegas is active) that calls run_scheduled_updates() via DisplayController._tick_plugin_updates_for_vegas(). That thread is a daemon thread and is not joined when VegasModeCoordinator.play() returns — if it's still mid-flight (e.g. a slow plugin update() call) when the main render loop's next iteration calls the plain _tick_plugin_updates() (src/display_controller.py:1638), both threads can race into run_scheduled_updates() at once for the same plugin.

Impact

A plugin's update() could execute twice concurrently, which is unsafe for plugins that aren't reentrant (e.g. shared mutable state, non-thread-safe HTTP clients/caches). Low likelihood in practice (requires a slow plugin update overlapping the next tick) but a real correctness gap.

Suggested fix

Reserve the plugin under a per-plugin lock before can_execute()/set_state(RUNNING), holding it only across the eligibility check + state transition (not the full execute_update() call, to avoid serializing slow plugin updates), and roll the state back to ENABLED (or the existing failure state) if execute_update() raises or returns False. Needs the same reservation lock used by every caller of run_scheduled_updates()/update_all_plugins() to be effective.

This is a heavier change than a quick fix — worth its own design pass rather than folding into an unrelated PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions