diff --git a/PQEnalyzer/apps/app.py b/PQEnalyzer/apps/app.py index 43986c7..f55a33a 100644 --- a/PQEnalyzer/apps/app.py +++ b/PQEnalyzer/apps/app.py @@ -214,7 +214,10 @@ def __auto_refresh_control_event(self): if self.auto_refresh.get(): if self.__start_file_watcher(): - self.__set_auto_refresh_status("Watching for file changes") + status = "Watching for file changes" + if getattr(self.__file_watcher, "mode", None) == "polling": + status += " (polling)" + self.__set_auto_refresh_status(status) else: self.__stop_file_watcher() self.__set_auto_refresh_status("Auto-refresh paused") diff --git a/PQEnalyzer/apps/file_watcher.py b/PQEnalyzer/apps/file_watcher.py index 6876dfb..ae066b6 100644 --- a/PQEnalyzer/apps/file_watcher.py +++ b/PQEnalyzer/apps/file_watcher.py @@ -2,6 +2,7 @@ File-change watcher used by the GUI auto-refresh flow. """ +import contextlib from pathlib import Path from .._logging import get_logger @@ -9,9 +10,11 @@ try: from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer + from watchdog.observers.polling import PollingObserver except ImportError: # pragma: no cover - dependency is installed at runtime FileSystemEventHandler = object Observer = None + PollingObserver = None logger = get_logger(__name__) @@ -45,6 +48,7 @@ def __init__(self, filenames, callback): self.filenames = {Path(filename).resolve() for filename in filenames} self.callback = callback self.observer = None + self.mode = None def start(self) -> bool: """ @@ -55,12 +59,33 @@ def start(self) -> bool: logger.warning("Auto-refresh unavailable: watchdog is not installed.") return False - self.observer = Observer() handler = _InputFileEventHandler(self) - for directory in sorted({filename.parent for filename in self.filenames}): - self.observer.schedule(handler, str(directory), recursive=False) + directories = sorted({ + filename.parent for filename in self.filenames + }) + try: + self.__start_observer(Observer, handler, directories) + except OSError as error: + self.__stop_observer() + logger.warning( + "Native auto-refresh unavailable (%s); using polling.", + error, + ) + else: + self.mode = "native" + return True + + if PollingObserver is None: + return False - self.observer.start() + try: + self.__start_observer(PollingObserver, handler, directories) + except OSError as error: + self.__stop_observer() + logger.warning("Auto-refresh unavailable: %s", error) + return False + + self.mode = "polling" return True def stop(self) -> None: @@ -68,12 +93,7 @@ def stop(self) -> None: Stop the background observer if it is running. """ - if self.observer is None: - return - - self.observer.stop() - self.observer.join(timeout=1.0) - self.observer = None + self.__stop_observer() def notify(self, event) -> None: """ @@ -103,3 +123,28 @@ def __matches_loaded_file(self, event) -> bool: return True return False + + def __start_observer(self, observer_type, handler, directories): + """ + Start one watchdog observer implementation. + """ + + self.observer = observer_type() + for directory in directories: + self.observer.schedule(handler, str(directory), recursive=False) + self.observer.start() + + def __stop_observer(self): + """ + Stop a complete or partially started observer. + """ + + observer = self.observer + self.observer = None + self.mode = None + if observer is None: + return + + observer.stop() + with contextlib.suppress(RuntimeError): + observer.join(timeout=1.0) diff --git a/README.md b/README.md index 58c24d3..24d8e26 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,10 @@ overview with one panel per parameter. `Auto-Refresh` is enabled by default. It watches the loaded input files and refreshes open plots when new simulation output is written. Disable it to pause -file watching. Plot controls apply to the selected focused plot, so different -plot windows can use different statistics and overlays at the same time. +file watching. If the native file watcher is unavailable, PQEnalyzer falls back +to polling and marks that mode in the GUI status. Plot controls apply to the +selected focused plot, so different plot windows can use different statistics +and overlays at the same time. Available statistics and overlays are: diff --git a/tests/apps/test_app.py b/tests/apps/test_app.py index aa5b79a..0a7b8c2 100644 --- a/tests/apps/test_app.py +++ b/tests/apps/test_app.py @@ -724,6 +724,29 @@ def start(self): assert status.configure_kwargs["text"] == "Auto-refresh unavailable" +def test_auto_refresh_control_reports_polling_fallback(monkeypatch): + app = make_app(auto_refresh=True) + status = FakeWidget() + app.auto_refresh_status_label = status + + class FakeWatcher: + mode = "polling" + + def __init__(self, filenames, callback): + pass + + def start(self): + return True + + monkeypatch.setattr(app_module, "FileChangeWatcher", FakeWatcher) + + app_module.App._App__auto_refresh_control_event(app) + + assert status.configure_kwargs["text"] == ( + "Watching for file changes (polling)" + ) + + def test_auto_refresh_debounces_file_events(): app = make_app(auto_refresh=True) calls = [] diff --git a/tests/apps/test_file_watcher.py b/tests/apps/test_file_watcher.py index dbf158d..1ef99ae 100644 --- a/tests/apps/test_file_watcher.py +++ b/tests/apps/test_file_watcher.py @@ -1,3 +1,4 @@ +import errno from types import SimpleNamespace from PQEnalyzer.apps import file_watcher @@ -78,6 +79,7 @@ def join(self, timeout=None): assert watcher.start() is True watcher.stop() + assert watcher.mode is None assert [entry[1] for entry in scheduled[:2]] == [ str(tmp_path / "a"), str(tmp_path / "b"), @@ -94,3 +96,93 @@ def test_file_change_watcher_reports_unavailable_observer(monkeypatch): watcher = FileChangeWatcher(["run.en"], lambda: None) assert watcher.start() is False + + +def test_file_change_watcher_falls_back_after_native_resource_error( + tmp_path, monkeypatch, caplog): + energy_file = tmp_path / "run.en" + calls = [] + + class FailingNativeObserver: + + def schedule(self, handler, directory, recursive): + calls.append(("native-schedule", directory, recursive)) + + def start(self): + raise OSError(errno.EMFILE, "inotify instance limit reached") + + def stop(self): + calls.append(("native-stop",)) + + def join(self, timeout=None): + calls.append(("native-join", timeout)) + raise RuntimeError("observer thread was not started") + + class FakePollingObserver: + + def schedule(self, handler, directory, recursive): + calls.append(("polling-schedule", directory, recursive)) + + def start(self): + calls.append(("polling-start",)) + + def stop(self): + calls.append(("polling-stop",)) + + def join(self, timeout=None): + calls.append(("polling-join", timeout)) + + monkeypatch.setattr( + file_watcher, + "Observer", + FailingNativeObserver, + ) + monkeypatch.setattr( + file_watcher, + "PollingObserver", + FakePollingObserver, + ) + watcher = FileChangeWatcher([energy_file], lambda: None) + + assert watcher.start() is True + assert watcher.mode == "polling" + assert ("native-stop",) in calls + assert ("native-join", 1.0) in calls + assert ("polling-start",) in calls + assert "inotify instance limit reached" in caplog.text + assert "using polling" in caplog.text + + watcher.stop() + + assert watcher.mode is None + assert calls[-2:] == [ + ("polling-stop",), + ("polling-join", 1.0), + ] + + +def test_file_change_watcher_reports_failed_polling_fallback( + tmp_path, monkeypatch, caplog): + + class FailingObserver: + + def schedule(self, handler, directory, recursive): + return None + + def start(self): + raise OSError(errno.EMFILE, "watch limit reached") + + def stop(self): + return None + + def join(self, timeout=None): + return None + + monkeypatch.setattr(file_watcher, "Observer", FailingObserver) + monkeypatch.setattr(file_watcher, "PollingObserver", FailingObserver) + watcher = FileChangeWatcher([tmp_path / "run.en"], lambda: None) + + assert watcher.start() is False + assert watcher.observer is None + assert watcher.mode is None + assert "Auto-refresh unavailable" in caplog.text