Skip to content

fix(plugins): sub-package reload, symlinked dev plugins, BaseException in update(), config callbacks outside the lock - #741

Merged
ChuckBuilds merged 5 commits into
mainfrom
fix/plugin-system-lifecycle
Oct 4, 2026
Merged

ChuckBuilds merged 5 commits into
mainfrom
fix/plugin-system-lifecycle

Conversation

@ChuckBuilds

Copy link
Copy Markdown
Owner

Summary

Five plugin-lifecycle bugs, one commit each.

  1. A reloaded plugin ran its old sub-package code (plugin_loader.py).
    • Cause: _iter_plugin_bare_modules skipped any dotted name. So providers.feed (elections), enrichment.* (flights) and data/, renderers/ (olympics) stayed in sys.modules after unload. The reloaded manager.py then ran against the old helpers.
    • Fix: dotted modules whose file (or namespace __path__) is inside the plugin folder are now recorded. They are dropped on unload, only while sys.modules still holds that plugin's object, and on a failed load.
    • Bare-name namespacing is unchanged.
  2. Removing a symlinked dev plugin chmod-ed the developer's checkout (store_manager._safe_remove_directory).
    • Cause: rmtree refuses a symlink, so it fell through to the chmod stage. That walked through the link and set the linked repo's files to 0700. The sudo stage was then refused.
    • Fix: a link (including a dangling one) is now unlinked before anything else.
  3. A dev plugin linked under a different name never loaded (contained_plugin_dir).
    • Example: link-github foo <url> clones to ledmatrix-foo and links it as plugins/foo.
    • Cause: resolving the link and then looking up the target's name in the plugins folder found nothing. install_dependencies returned False with no requirements.txt at all.
    • Fix: the link's own entry is now looked up first. The result is still always a name os.scandir() returned for the plugins folder, and a path outside it is still refused.
  4. A BaseException from update() left the plugin stuck until restart.
    • Cause: _target_update caught only Exception. A CancelledError or SystemExit skipped _finish, so the lock stayed held and the state stayed RUNNING. The plugin was never rescheduled, and every display() was skipped.
    • Fix: it now catches BaseException, finishes, and re-raises.
    • PluginExecutor's thread catches it too, so an instant failure is reported as a failure, not as a 30 s timeout.
  5. ConfigService ran change callbacks while holding its lock, so disabling a busy plugin could stall rendering.
    • Cause: a per-plugin callback can wait up to 5 s for a busy plugin, and the render thread's get_config() waited behind it.
    • Fix: the config is swapped under _lock, and subscribers are notified afterwards. A separate _notify_lock keeps one reload's notifications ahead of the next.
    • The existing promise still holds: once unsubscribe() returns, that callback is neither running nor will run.

Tests

  • New:
    • test_plugin_loader_reload_isolation.py (4)
    • test_store_symlinked_plugin.py (4)
    • test_plugin_loader_symlinked_dir.py (4)
    • test_config_service_notify.py (6)
    • BaseException cases in test_async_plugin_updates.py and test_plugin_system.py
  • Each fix's tests fail on main; guard tests pass both before and after.
  • Symlink tests skip on Windows without symlink privilege. Under WSL Ubuntu: 4 failed → 4 passed for Stocks #2, and 3 failed → 4 passed for Stable #3.
  • Related suites: 421 passed, 3 skipped. scripts/check_types.py: no issues.

Part of a bug sweep

This is one of 10 independent fix PRs from one sweep, all based on main ef69201.

  • Merge order: any. 45 pairwise test merges gave 0 conflicts, and each PR also merges cleanly with fix(ipc): ticks carry the volatile timestamps, so current-status stays known over the socket #737.
  • CHANGELOG: each PR adds its bullet at a different place in Unreleased → Fixes, so squash-merging them one after another needs no conflict fixing.
  • Full suite (Windows), all 10 merged together vs plain main: the same 62 failures and 6 errors on both. These are the known Windows path and file-locking tests. 191 more tests pass.
    • One extra failure in that run, test_backup_manager.py::test_create_backup_contents (os.replace → WinError 5 on a temp zip), was a Windows file-lock flake. It passes on rerun, and nothing here touches create_backup.
  • ledpi: all 10 together ran on ledpi (Pi 4) on top of main, with a clean start and no errors or render stalls in the journal. ledpi is back on plain main.

🤖 Generated with Claude Code

ChuckBuilds and others added 5 commits October 3, 2026 20:52
A plugin that keeps helpers in a package (providers/feed.py, imported as
`from providers.feed import ...`) leaves dotted entries in sys.modules.
PluginLoader only tracked bare names: `providers` was namespaced and
dropped on unload, `providers.feed` stayed. A reload after a store update
imported a fresh `providers`, then got the old `feed` back from the module
cache, so the new manager.py ran against the old helpers until the display
restarted. A load that failed part-way left them behind the same way.
Elections (providers/), flights (enrichment/) and olympics (data/,
renderers/) ship packages.

The loader now records the dotted modules whose file (or, for a namespace
package, every __path__ entry) lies inside the plugin directory. They keep
their names while the plugin runs, as before, and unregister_plugin_modules()
drops them, only while sys.modules still holds that plugin's module. The
failed-load cleanup in load_module() drops them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PluginStoreManager._safe_remove_directory, behind uninstall and behind
discarding the set-aside copy after an install or update, handed a
symlinked dev plugin (scripts/dev/dev_plugin_setup.sh) to shutil.rmtree,
which refuses a symlink. The chmod fallback then walked through the link
and set every directory and file in the linked checkout to 0700, and the
sudo stage refused the resolved path as outside the plugins directory. The
removal failed, the link stayed, and the developer's checkout lost its
group/other permissions. A dangling link read as already removed, because
exists() follows it, and was left behind.

A symlink is now unlinked before any other stage runs, and before the
exists() check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
contained_plugin_dir(), the containment check before a plugin's
dependencies are installed, resolved the plugin directory and looked for
the resolved folder's name among the plugins directory's entries. A dev
plugin symlinked in under its id by a name its checkout does not share --
`dev_plugin_setup.sh link-github foo <url>` clones ledmatrix-foo, the
repository naming convention, and links it as plugins/foo -- has no such
entry, so install_dependencies() returned False and the load failed with
"Dependency installation failed", even with no requirements.txt.

When the path sits directly in the plugins directory, the entry it names
(the link) is looked up first; anything else is resolved and matched by
name as before. The answer is still always rebuilt from a name os.scandir()
returned for the plugins directory, so a path outside it is still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the async update worker, the wrapped update() finished its bookkeeping
(_finish: release the plugin lock, drop the pending slot, state back to
ENABLED) only for an Exception. asyncio.CancelledError and SystemExit
derive from BaseException, so one raised from update() skipped _finish:
the plugin kept its lock and stayed RUNNING for the life of the process,
never rescheduled, with every display() skipped as busy. PluginExecutor
caught only Exception as well, so its thread died with the call never
marked complete and an immediate failure was logged and recorded as a
timeout.

_target_update now runs _finish for any BaseException and re-raises it,
and the executor's thread stores it like any other exception, so it is
reported as the operation's failure (PluginError) on both the async and
the synchronous path. _finish and _record_update_failure take a
BaseException.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ConfigService._load_config ran every subscriber while holding _lock. The
display's per-plugin subscriber calls PluginManager.apply_config_change,
which waits up to PLUGIN_LOCK_TIMEOUT (5 s) for a plugin busy in update().
A save that enables or disables a plugin also flags a reconcile, which the
render thread runs: its get_config(), and the unsubscribe() of a plugin it
disables, both take _lock, so the panel froze behind every slow callback,
up to 5 s per busy plugin.

The config is now swapped under _lock and the subscribers are called after
it is released, from a copy of the subscriber lists. A separate
_notify_lock is held across a whole reload (read, swap, notify), so one
reload's notifications still finish before the next one's start. Each
callback is checked against the live lists just before it runs, and
unsubscribe() waits only for a call of that same callback already in
progress (unless it is that callback's own thread), so a callback it
removed is not running and will not run once it returns, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 29 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c7103fd4-8685-4bb8-8620-fe0c86c6e00b
📥 Commits

Reviewing files that changed from the base of the PR and between ef69201 and 98ff58b.

📒 Files selected for processing (12)
  • CHANGELOG.md
  • src/config_service.py
  • src/plugin_system/plugin_executor.py
  • src/plugin_system/plugin_loader.py
  • src/plugin_system/plugin_manager.py
  • src/plugin_system/store_manager.py
  • test/test_async_plugin_updates.py
  • test/test_config_service_notify.py
  • test/test_plugin_loader_reload_isolation.py
  • test/test_plugin_loader_symlinked_dir.py
  • test/test_plugin_system.py
  • test/test_store_symlinked_plugin.py
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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 21 complexity

Metric Results
Complexity 21

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.

@ChuckBuilds
ChuckBuilds merged commit d18e4d3 into main Oct 4, 2026
15 checks passed
@ChuckBuilds
ChuckBuilds deleted the fix/plugin-system-lifecycle branch October 4, 2026 02:18
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