Skip to content

fix: unregister plugin web APIs on plugin unload - #9915

Open
lxfight wants to merge 1 commit into
AstrBotDevs:masterfrom
lxfight:fix/plugin-webapi-unregister
Open

fix: unregister plugin web APIs on plugin unload#9915
lxfight wants to merge 1 commit into
AstrBotDevs:masterfrom
lxfight:fix/plugin-webapi-unregister

Conversation

@lxfight

@lxfight lxfight commented Sep 2, 2026

Copy link
Copy Markdown
Member

Motivation / 动机

Context.registered_web_apis never released entries registered by plugins. After a plugin was uninstalled, disabled or reloaded, its registered web API handlers stayed in the list forever:

  • Memory leak: the list holds bound methods of plugin instances, so purged modules cannot be garbage collected. Repeated install/uninstall cycles keep growing the list.
  • Ghost routes: requests to /api/v1/plugins/extensions/<uninstalled-plugin>/... still match the stale handler and execute it, instead of returning 404.
  • Reload residue: register_web_api only replaces entries with the exact same route and methods; routes removed or changed in a new plugin version stay forever.

PluginManager._unbind_plugin() already cleans up event handlers, LLM tools, platform adapters and sys.modules — this PR closes the gap by also releasing registered web APIs.

Modifications / 改动点

  • astrbot/core/star/context.py: add Context.unregister_web_apis(module_path) classmethod that removes all web APIs whose handler is owned by the given plugin module path prefix (checked via the handler function module and, for bound methods, the owner class module).

  • astrbot/core/star/star_manager.py:

    • _unbind_plugin(): unregister web APIs owned by the plugin (shared cleanup path for uninstall, disable and single-plugin reload).
    • _cleanup_plugin_state(): unregister web APIs for plugins that failed to load.
    • reload(all): clear all registered web APIs alongside the existing registry clears, since every plugin is re-loaded afterwards.
  • tests/test_plugin_manager.py: unit tests for ownership-prefixed removal (other plugins and core routes are preserved) and an integration test for _unbind_plugin().

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

$ uv run pytest tests/test_plugin_manager.py tests/test_dashboard.py tests/test_fastapi_v1_dashboard.py -q
64 passed (plugin manager, includes 2 new tests)
172 passed (dashboard regression)

Verification steps:

  1. Install a plugin that calls context.register_web_api(...).
  2. Uninstall it, then request its extension endpoint — it now returns 未找到该路由 instead of executing the stale handler.
  3. Check logs for Removed N registered web API(s) from plugin ....

Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Ensure plugin web APIs are fully removed whenever plugin state is cleaned up so unloaded plugins no longer retain memory or serve ghost routes.

Bug Fixes:

  • Remove plugin-owned web API registrations when plugins are unloaded, disabled, reloaded, or fail to load, preventing stale routes, memory leaks, and handlers from removed plugin versions from persisting.

Enhancements:

  • Preserve web APIs belonging to other plugins and core components while identifying ownership across plugin submodules and bound methods.

Tests:

  • Add unit and integration coverage for selective web API cleanup during plugin unbinding.

Context.registered_web_apis kept handlers of uninstalled, disabled and
reloaded plugins forever, leaking plugin instances via bound method
references and leaving ghost routes callable after unload.

- Add Context.unregister_web_apis() to drop web APIs owned by a plugin
  module path prefix (function module and owner class module).
- Call it from PluginManager._unbind_plugin() and
  _cleanup_plugin_state().
- Clear all registered web APIs on reload(all), matching the existing
  registry clear semantics.

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

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="astrbot/core/star/star_manager.py" line_range="1941-1942" />
<code_context>
                 )

+            # Unregister web APIs registered by this plugin to avoid ghost
+            # routes and memory leaks after unloading.
+            removed_web_apis = Context.unregister_web_apis(module_prefix)
+            if removed_web_apis:
+                logger.info(
</code_context>
<issue_to_address>
**issue (broader_impact):** Disabling a plugin through `turn_off_plugin()` does not call `_unbind_plugin()`, so this new cleanup path is never reached and the plugin's registered web APIs remain routable after the plugin is disabled. Requests continue invoking the disabled plugin's handlers instead of returning 404.

**Triggers:** When a loaded plugin is disabled without being uninstalled or reloaded.

**Suggested fix:** Invoke the web API cleanup from the disable path, or route disabling through `_unbind_plugin()` while preserving the plugin metadata needed for re-enabling.
</issue_to_address>

Sourcery assessment

Approval pending. 1 finding to address first.

Blocking findings: astrbot/core/star/star_manager.py:1942


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +1941 to +1942
# routes and memory leaks after unloading.
removed_web_apis = Context.unregister_web_apis(module_prefix)

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.

issue (broader_impact): Disabling a plugin through turn_off_plugin() does not call _unbind_plugin(), so this new cleanup path is never reached and the plugin's registered web APIs remain routable after the plugin is disabled. Requests continue invoking the disabled plugin's handlers instead of returning 404.

Triggers: When a loaded plugin is disabled without being uninstalled or reloaded.

Suggested fix: Invoke the web API cleanup from the disable path, or route disabling through _unbind_plugin() while preserving the plugin metadata needed for re-enabling.

@lxfight

lxfight commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Regarding the Sourcery finding about turn_off_plugin() not calling _unbind_plugin(): this is an intentional split across two PRs, not an oversight.

  • Disable keeps entries registered by design. Disabling is a lightweight toggle: the plugin module, handlers and instance stay loaded so re-enabling is cheap. Symmetrically, static Plugin Pages also keep files on disk when a plugin is disabled and gate them at request time via the activated check (403). Unregistering web APIs on disable would change the response semantics from "plugin disabled" to "route not found", which is less accurate, and would make the activation gate dead code.
  • The request-time gate lands in fix: reject extension API calls for disabled plugins #9916: _call_plugin_extension() resolves the owning plugin from the handler module and rejects calls when it is disabled, aligning the dynamic API with the static pages behavior.
  • Memory-wise there is no leak in the disabled state: the plugin instance is intentionally kept alive while disabled (same as its handlers); fix: unregister plugin web APIs on plugin unload #9915 fixes the case where the plugin is gone (uninstalled/reloaded) while its bound handlers were retained forever.

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