Skip to content

Plugins loaded by location are not registered in sys.modules, so importing one elsewhere creates a second instance #360

Description

@MartinEbner

Describe the bug

_initPythonPlugin in src/plugin.py has two load paths, and only one of them registers the
module:

if location is None:
    mod = importlib.import_module(name)          # registers in sys.modules
else:
    ...
    spec = importlib.util.spec_from_file_location(name, modpath)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)                 # does NOT register in sys.modules

Every plugin in config/plugins.yaml carries a location, so in a default install they all take
the second path. The module object then lives only in _plugins[name]. Any other code that does
import <name> does not find it in sys.modules and imports the file a second time, getting
a separate module object with its own globals.

For a channel plugin that is not a cosmetic issue: module-level state is duplicated. A second copy
of channels/telegram.py has its own outbox and its own polling state, and nothing polls it, so
sends written into it are never delivered and nothing reports an error.

The two paths also disagree with each other — the same plugin is a shared module when found on
PYTHONPATH and a private one when found by location.

To Reproduce

  1. Run with a plugin that has a location, e.g. the default telegram.
  2. From any other module in the process, import telegram.
  3. telegram is not in sys.modules beforehand, so the import executes the file again. The
    object obtained is not the one the plugin loader started: state set during
    loadOmegaPlugin() is absent, and id() differs from plugin._plugins["telegram"].mod.

We hit this on our own deployment: a helper module that had previously done a plain
import telegram kept working by accident until the plugin API landed, then silently began
addressing a second, never-polled copy. The symptom was outbound messages disappearing with no
error line.

Expected behavior

A plugin loaded by location is the same module object that import <name> yields, as it is on the
PYTHONPATH path.

Possible fix

The standard importlib recipe, registering before execution so the module is also visible to
imports made while it is initialising:

spec = importlib.util.spec_from_file_location(name, modpath)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)

That makes the two load paths behave alike.

One thing to weigh before doing it that way: registering under the bare name puts the plugin in
the same namespace as installed packages, so a plugin called telegram would shadow a PyPI
telegram for anything importing it later. The PYTHONPATH path already has that property, so
this would make the behaviour consistent rather than introduce it — but if isolation is the point,
a namespaced key such as plugins.telegram gives a single shared object without touching the
top-level namespace.

If the isolation is deliberate as it stands, then it is worth stating in
docs/reference-plugin-api.md, since a plugin author has no way to discover it except by losing
state.

Environment

Code path above is current main.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions