diff --git a/.jules/bolt.md b/.jules/bolt.md index 870f3c7..8aa68bc 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -124,3 +124,20 @@ Inside the `_is_ignored_impl` hot path in `watchdog`, calling `os.path.relpath` Action: Pre-compute `_base_prefix` during initialization (`os.path.join(self.base_path, '')`) and use it in `startswith()` alongside `_abs_base_path` for fast string slicing. Also removed the blind `.removeprefix('./')` behavior to improve robustness. + +## 2026-04-29 — Reliability Fix for SIGTERM + +Learning: +Command-line file watchers and daemon tools usually listen for KeyboardInterrupt (SIGINT) to clean up subprocesses gracefully. However, they often ignore SIGTERM, which is the standard termination signal sent by containers (Docker/K8s) and process managers. Ignoring SIGTERM causes the main watcher to die instantly, leaking running child processes in the background indefinitely and causing resource exhaustion. + +Action: +Always register a SIGTERM handler on POSIX systems (`if platform.system() != "Windows"`) that performs the same graceful shutdown and subprocess termination steps as the KeyboardInterrupt handler. + +## 2026-04-29 — Ignore Filter Relpath & Compound Loop Overhead + +Learning: +Inside the `_is_ignored_impl` hot path, `os.path.relpath` is computationally expensive because it inherently resolves absolute paths. While optimizations existed for exact prefix matching, simple relative paths (e.g., `src/file.py`) against a `.` base path would fall through and trigger a `relpath` call, slowing down high-volume events. Additionally, reconstructing cumulative directory prefixes (`foo`, `foo/bar`) to test against exact/wildcard ignores consumes significant CPU cycles and is entirely unnecessary if the user specified no compound ignore patterns (i.e., no slashes in any pattern). + +Action: +In `watchdog` event path normalization, bypass the computationally expensive `os.path.relpath` for the common case where `base_path` is `.` and the path is already relative by adding a fast-path condition: `elif self.base_path == "." and not os.path.isabs(path) and not path.startswith(".."): pass`. +To optimize ignore pattern matching in hot loops, pre-compute a flag during initialization (e.g., `self._has_compound_ignores = any('/' in p for p in self.ignore_patterns)`) and use it to short-circuit the evaluation of compound directory paths if no slash-based ignore patterns exist. diff --git a/.jules/warden.md b/.jules/warden.md index 391542f..9fff46b 100644 --- a/.jules/warden.md +++ b/.jules/warden.md @@ -160,3 +160,19 @@ Observed the preceding agent optimized the ignore file watcher hot path by pre-c Alignment / Deferred: Version bumped to `0.1.21` as a patch release. Updated CHANGELOG.md. No dead code or dependency upgrades required. + +## 2026-04-29 — Assessment & Lifecycle + +Observation / Pruned: +Observed the preceding agent optimized process lifecycle management by adding a POSIX SIGTERM signal handler. This prevents child process leaks when the application is terminated by process managers or containers. Verified test execution, linting, and dead code pruning without issues. No unused imports or variables were found. + +Alignment / Deferred: +Version bumped to `0.1.22` as a patch release. Updated CHANGELOG.md. No heavy pruning or major dependency updates required. + +## 2026-04-30 — Assessment & Lifecycle + +Observation / Pruned: +Observed the preceding agent optimized the ignore file watcher hot paths by explicitly bypassing `os.path.relpath` for the common case, and short-circuiting compound directory evaluations when no slash-based ignore patterns exist. Verified test execution, linting, and dead code pruning without issues. No unused imports or variables were found. No heavy pruning required. + +Alignment / Deferred: +Version bumped to `0.1.23` as a patch release. Updated CHANGELOG.md. diff --git a/CHANGELOG.md b/CHANGELOG.md index 68b48de..8fbc4e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.1.23] - 2026-04-30 + +### Changed +* **[Performance]:** Optimized ignore file filtering in hot paths by fast-tracking common relative paths and avoiding compound loop iterations when unnecessary, significantly reducing CPU cycles on burst saves. + +## [0.1.22] - 2026-04-29 + +### Changed +* **[Reliability]:** Added a SIGTERM signal handler to ensure proper cleanup of subprocesses during graceful shutdowns initiated by containers and process managers. + ## [0.1.21] - 2026-04-28 ### Changed diff --git a/fix_merge.py b/fix_merge.py new file mode 100644 index 0000000..490cd41 --- /dev/null +++ b/fix_merge.py @@ -0,0 +1,45 @@ +import sys + +content = open("src/echo/watcher.py").read() + +conflict = """<<<<<<< HEAD + def handle_sigterm(signum, frame): + observer.stop() + console.print("\\n[magenta]Echo shutting down. Peace ✨[/magenta]") + event_handler.shutdown() + sys.exit(0) + + if platform.system() != "Windows": + if signal.getsignal(signal.SIGTERM) != handle_sigterm: + signal.signal(signal.SIGTERM, handle_sigterm) +======= + def handle_sigterm(_signum, _frame): + try: + observer.stop() + console.print("\\n[magenta]Echo shutting down. Peace ✨[/magenta]") + event_handler.shutdown() + except Exception: + pass + sys.exit(0) + + if platform.system() != "Windows": + signal.signal(signal.SIGTERM, handle_sigterm) +>>>>>>> origin/main""" + +resolution = """ def handle_sigterm(_signum, _frame): + try: + observer.stop() + console.print("\\n[magenta]Echo shutting down. Peace ✨[/magenta]") + event_handler.shutdown() + except Exception: + pass + sys.exit(0) + + if platform.system() != "Windows": + if signal.getsignal(signal.SIGTERM) != handle_sigterm: + signal.signal(signal.SIGTERM, handle_sigterm)""" + +content = content.replace(conflict, resolution) + +with open("src/echo/watcher.py", "w") as f: + f.write(content) diff --git a/pyproject.toml b/pyproject.toml index d12b603..0b75ece 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "echo-watcher" -version = "0.1.21" +version = "0.1.23" description = "📡 Lightweight file watcher. Trigger commands on changes. <5MB RAM, single binary." authors = [ { name = "shenald-dev", email = "bot@shenald.dev" } diff --git a/src/echo/watcher.py b/src/echo/watcher.py index a5d703b..1d178c2 100644 --- a/src/echo/watcher.py +++ b/src/echo/watcher.py @@ -33,6 +33,7 @@ def __init__(self, command: str, base_path: str = ".", ignore_patterns: list[str self.exact_ignores = {p for p in self.ignore_patterns if not any(c in p for c in ('*', '?', '['))} wildcard_ignores = [p for p in self.ignore_patterns if any(c in p for c in ('*', '?', '['))] self.wildcard_regex = None + self._has_compound_ignores = any('/' in p for p in self.ignore_patterns) if wildcard_ignores: regex_str = "|".join(f"(?:{fnmatch.translate(p)})" for p in wildcard_ignores) self.wildcard_regex = re.compile(regex_str) @@ -171,6 +172,8 @@ def _is_ignored_impl(self, path: str) -> bool: path = path[len(self._base_prefix):] elif path == self.base_path or path == self._abs_base_path.rstrip(os.sep): path = "." + elif self.base_path == "." and not os.path.isabs(path) and not path.startswith(".."): + pass else: try: path = os.path.relpath(path, self.base_path) @@ -191,7 +194,7 @@ def _is_ignored_impl(self, path: str) -> bool: return True # Check for exact and wildcard ignore patterns matching cumulative prefix directories - if len(parts) > 1: + if self._has_compound_ignores and len(parts) > 1: prefix = parts[0] # Prefix for parts[0] is already evaluated via earlier exact match `isdisjoint()` # and wildcard matching, so we start accumulating from the second part. @@ -266,6 +269,19 @@ def main(): sys.exit(1) raise + def handle_sigterm(_signum, _frame): + try: + observer.stop() + console.print("\n[magenta]Echo shutting down. Peace ✨[/magenta]") + event_handler.shutdown() + except Exception: + pass + sys.exit(0) + + if platform.system() != "Windows": + if signal.getsignal(signal.SIGTERM) != handle_sigterm: + signal.signal(signal.SIGTERM, handle_sigterm) + try: while True: time.sleep(1)