config reload: fix feedback loop — react to modified/created/moved only, not open/close (v3.12.1 spun cpu on read-opens)

This commit is contained in:
Oleksandr Kozachuk
2026-07-14 21:30:43 +02:00
parent ef62cb41a5
commit da50395dc7
3 changed files with 30 additions and 10 deletions
+13 -1
View File
@@ -88,10 +88,22 @@ class ConfigFileHandler(FileSystemEventHandler):
return True return True
return False return False
def on_any_event(self, event): def _dispatch(self, event):
if not event.is_directory and self._hits_config(event): if not event.is_directory and self._hits_config(event):
self._on_change() self._on_change()
# Only write/rename events — NOT on_opened/on_closed, whose read-opens
# (our own load_config re-reads the file) would otherwise feed back into
# a reload loop (CFG-05).
def on_modified(self, event):
self._dispatch(event)
def on_created(self, event):
self._dispatch(event)
def on_moved(self, event):
self._dispatch(event)
class FjerkroaBot(commands.Bot): class FjerkroaBot(commands.Bot):
def __init__(self, config_file: str): def __init__(self, config_file: str):
+6 -3
View File
@@ -35,9 +35,12 @@ applies directly — there are no concurrent readers yet.
### CFG-05 — Hot-reload is rename-safe (coverage: test) ### CFG-05 — Hot-reload is rename-safe (coverage: test)
The watcher observes the config file's **directory**, not the file, and The watcher observes the config file's **directory**, not the file, and
reacts to any event (modified, created, moved) whose source or reacts to a **modified, created, or moved** event whose source or
destination path is the config file. This catches atomic saves — write destination path is the config file. This catches atomic saves — write
a temp file, then rename it over the target — which replace the inode a temp file, then rename it over the target — which replace the inode
and fire a move/create rather than a modify; watching the file directly and fire a move/create rather than a modify; watching the file directly
would go deaf after the first such save. Events for other files in the would go deaf after the first such save. Open/close events are
directory, and directory events themselves, are ignored. deliberately not handled: reloading re-opens the file to read it, so
reacting to opens would feed back into an endless reload loop. Events
for other files in the directory, and directory events themselves, are
ignored.
+11 -6
View File
@@ -157,10 +157,15 @@ class TestConfigReloadRenameSafe(unittest.TestCase):
event.dest_path = dest if dest is not None else "" event.dest_path = dest if dest is not None else ""
return event return event
handler.on_any_event(evt(src=str(config))) # in-place modify handler.on_modified(evt(src=str(config))) # in-place modify
handler.on_any_event(evt(src=str(Path(tmp) / "kroa.toml.tmp"), dest=str(config))) # atomic rename over handler.on_moved(evt(src=str(Path(tmp) / "kroa.toml.tmp"), dest=str(config))) # atomic rename over
self.assertEqual(len(hits), 2) handler.on_created(evt(src=str(config))) # write-new
self.assertEqual(len(hits), 3)
handler.on_any_event(evt(src=str(Path(tmp) / "other.txt"))) # unrelated file handler.on_modified(evt(src=str(Path(tmp) / "other.txt"))) # unrelated file
handler.on_any_event(evt(is_dir=True, src=str(config))) # directory event handler.on_modified(evt(is_dir=True, src=str(config))) # directory event
self.assertEqual(len(hits), 2) # neither fired self.assertEqual(len(hits), 3) # neither fired
# open/close of the config (our own load_config re-reads) must NOT be handled — else a reload loop.
self.assertNotIn("on_opened", vars(ConfigFileHandler))
self.assertNotIn("on_closed", vars(ConfigFileHandler))