Skip to content

PR 1/2: in-process module reload (@reload)#233

Open
spierenburg wants to merge 8 commits into
mainfrom
feat/module-reload
Open

PR 1/2: in-process module reload (@reload)#233
spierenburg wants to merge 8 commits into
mainfrom
feat/module-reload

Conversation

@spierenburg
Copy link
Copy Markdown
Collaborator

@spierenburg spierenburg commented May 15, 2026

Summary

  • Adds @reload / !reload (admin-gated): re-discovers and reloads commands/* into the running bot with no restart and no dropped websocket, working under tmux/screen, daemonized, or direct. Discover-only — operators git pull on the host themselves; the bot never pulls from chat (no remote-code path via Mattermost).
  • Extracts the duplicated inline __init__ loader into a unit-tested command_loader.load_command_table(). This also fixes two latent loader bugs: the per-module HELP variable leak, and stale binds for on-disk-removed modules. Per-module import failure is non-fatal — a broken module is reported and skipped, its last-good version retained, the bot stays up.
  • reload_commands() is asyncio.Lock-serialized, offloads the walk/import to the command thread-pool, and atomically swaps the command table (in-flight commands keep their already-submitted callable).
  • New lifecyclecmds config list (["!reload", "@reload"]); README documents the feature and the honest importlib.reload limitation (cannot evict code from held references / module-global state — restart for deep changes).

This is PR 1 of 2. PR 2 (systemd supervision + @restart/@update) is sequenced after this and extends the same lifecyclecmds list and dispatch branch — it must land second.

10 unit tests (command_loader ×8, format_reload_report ×2); test harness made interpreter-independent (conftest stubs configargparse/mattermostdriver only when absent so the suite collects regardless of the pytest interpreter).

Test Plan

Automated (passing): pytest -q → 10 passed; matterbot.py & matterfeed.py compile; import matterbot clean; loader smoke loads the real commands/ tree (76 modules / 136 binds).

Manual — requires a live Mattermost plus one admin (in Matterbot.botadmins) and one non-admin account, and lifecyclecmds present in the deployed config.yaml:

  • Admin @reload → summary posted (refreshed/added/removed/failed), websocket stays connected
  • Non-admin @reload → permission-denied message, no reload
  • Edit a loaded module's process() → admin @reload → command returns new behaviour, no process restart
  • Add a new commands/<x>/ (command.py + defaults.py) → @reloadadded=1, new bind works
  • Delete a module dir → @reloadremoved=1, bind no longer triggers
  • Syntax error in a module → @reloadfailed=1 (named), bot stays up, other commands work, last-good version retained
  • Repeat admin @reload under tmux/screen → identical behaviour (run-mode parity)

Known follow-up (non-blocking)

command.lstrip('!@') in the dispatch branch strips a character set rather than a prefix; harmless given config-controlled tokens, noted for a later cleanup.

Importable, unit-tested command-table builder extracted from the inline
__init__ loader. Fixes the per-module HELP leak and stale-binds for
removed modules; preserves runtime binds/chans; per-module failure is
non-fatal. invalidate_caches() so a git-pulled new module dir is
discoverable. 7 tests.
Adds Matterbot.lifecyclecmds (!reload/@reload). PR 2 will append
restart/update tokens to the same list.
Replaces the duplicated two-pass inline command loader in
MattermostManager.__init__ with a single load_command_table() call;
adds non-mutating bindmap_serializable() and simplifies update_bindmap()
to use it. Removes now-dead fnmatch / importlib.util imports (only the
deleted walker used them). Behaviour-preserving for well-formed modules;
inherits the HELP-leak and stale-binds fixes from command_loader.
Adds async reload_commands(): serialized by an asyncio.Lock, offloads
the synchronous load_command_table to the command thread-pool, then
atomically rebinds self.commands/self.binds and persists the bindmap.
In-flight commands keep their already-submitted process ref. Adds a
table-independence test and documents the (pre-existing) bind/unbind
chans-mutation race that reload now makes reachable.
Adds MattermostManager.format_reload_report(report) — the human summary
posted by @reload (refreshed/added/removed/failed counts + per-failure
bullets; newlines in error text flattened so one failure stays one
line). conftest.py now stubs configargparse/mattermostdriver only when
absent, so `import matterbot` (and thus the suite) collects on any test
interpreter regardless of installed deps. Strengthened report tests.
Adds a "Reloading modules without a restart" subsection under
matterbot.py: how @reload/!reload works, that a broken module is
skipped (not fatal), run-mode agnostic, and the honest importlib.reload
limitation (no eviction of held refs / module-global state; restart for
deep changes).
Adds a lifecyclecmds branch to the built-in command dispatch chain:
@reload/!reload, gated by isadmin, awaits reload_commands() and posts
format_reload_report(); failures are caught and surfaced (no escape to
the event loop). Non-reload lifecyclecmds tokens are inert here so PR 2
can add restart/update without touching this branch's shape.
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