From d8a29364a7601d1b48244cb4e424fe6ddc4eabf4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 01:45:43 +0000 Subject: [PATCH 1/8] chore(release): v0.1.26 Co-authored-by: shenald-dev <245350826+shenald-dev@users.noreply.github.com> --- .jules/warden.md | 8 ++++++++ CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- src/echo/watcher.py | 14 ++++++-------- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/.jules/warden.md b/.jules/warden.md index 774d07b..19378ff 100644 --- a/.jules/warden.md +++ b/.jules/warden.md @@ -192,3 +192,11 @@ Observed the preceding agent optimized the exact ignore pattern matching by spli Alignment / Deferred: Version bumped to `0.1.25` as a patch release reflecting the performance optimization. Updated CHANGELOG.md. + +## 2026-05-13 — Assessment & Lifecycle + +Observation / Pruned: +Observed the preceding agent optimized the event loop lock contention but introduced a race condition by removing the `timer_lock` around concurrent variable assignments (`last_event_time` and `last_event_path`) in `on_any_event`. This could lead to incorrect debounce timeouts or paths executing due to unsafe reads across threads. + +Alignment / Deferred: +Reverted the lock removal around the variable assignments to assure atomic visibility and thread safety across watchdog's multi-threaded event handlers. Passed all QA tests. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f0e66d..fa7e842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ # Changelog +## [0.1.26] - 2026-05-13 + +### Changed +* **[Reliability]:** Restored thread-safe locking around event time and path assignments in `on_any_event` to resolve a race condition that could cause incorrect file executions or missed debounces during high-frequency events. + ## [0.1.25] - 2026-05-08 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 395384d..ad48d11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "echo-watcher" -version = "0.1.25" +version = "0.1.26" 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 b87c065..c0b2bc3 100644 --- a/src/echo/watcher.py +++ b/src/echo/watcher.py @@ -246,14 +246,12 @@ def on_any_event(self, event): if not event_path: return - self.last_event_time = time.monotonic() - self.last_event_path = event_path - - if self.debounce_thread is None: - with self.timer_lock: - if self.debounce_thread is None: - self.debounce_thread = threading.Thread(target=self._debounce_worker, daemon=True) - self.debounce_thread.start() + with self.timer_lock: + self.last_event_time = time.monotonic() + self.last_event_path = event_path + if self.debounce_thread is None: + self.debounce_thread = threading.Thread(target=self._debounce_worker, daemon=True) + self.debounce_thread.start() def main(): parser = argparse.ArgumentParser(description="📡 Echo File Watcher") From 5897b52ef710d39745ba04ee8bdcd2c44161a311 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 07:29:13 +0000 Subject: [PATCH 2/8] chore: fix merge conflict on jules-10442985920554078393 Co-authored-by: shenald-dev <245350826+shenald-dev@users.noreply.github.com> From a9d22cfcd41c547f3fd1420794394d086a160d87 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 10:17:49 +0000 Subject: [PATCH 3/8] chore: fix merge conflict on jules-10442985920554078393 Co-authored-by: shenald-dev <245350826+shenald-dev@users.noreply.github.com> From 773249c59d77c74428852cb50cd49991a5ada364 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 12:46:57 +0000 Subject: [PATCH 4/8] test: add debounce worker thread safety test Co-authored-by: shenald-dev <245350826+shenald-dev@users.noreply.github.com> --- tests/test_debounce_thread_safety.py | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/test_debounce_thread_safety.py diff --git a/tests/test_debounce_thread_safety.py b/tests/test_debounce_thread_safety.py new file mode 100644 index 0000000..c82196a --- /dev/null +++ b/tests/test_debounce_thread_safety.py @@ -0,0 +1,35 @@ +import time +import threading +from echo.watcher import CommandRunnerHandler +from unittest.mock import MagicMock + +def test_debounce_thread_safety(): + handler = CommandRunnerHandler("echo test") + + # Create multiple concurrent events to trigger race condition + def trigger_event(path): + mock_event = MagicMock() + mock_event.is_directory = False + mock_event.src_path = path + mock_event.event_type = "modified" + handler.on_any_event(mock_event) + + threads = [] + for i in range(20): + t = threading.Thread(target=trigger_event, args=(f"test{i}.txt",)) + threads.append(t) + t.start() + + for t in threads: + t.join() + + # Wait for the debounce worker to pick up the final event + start = time.time() + while handler.current_process is None and time.time() - start < 2: + time.sleep(0.05) + + if handler.current_process: + handler.current_process.wait() + + # Ensure it didn't crash and actually processed an event + assert handler.last_event_path is not None From 40559e5630164a464c6888449bdbe31f0931d6e6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 13:58:29 +0000 Subject: [PATCH 5/8] chore: fix merge conflict on jules-10442985920554078393 Co-authored-by: shenald-dev <245350826+shenald-dev@users.noreply.github.com> From 6204e09b950805c0f2a82f4b60b34eec07211eaa Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 14:50:24 +0000 Subject: [PATCH 6/8] chore: resolve merge conflicts and integrate main optimizations into release branch Co-authored-by: shenald-dev <245350826+shenald-dev@users.noreply.github.com> --- .jules/bolt.md | 16 ++++++++++++++++ .jules/warden.md | 16 ++++++++++++++++ CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- src/echo/watcher.py | 39 +++++++++++++++++++++++---------------- 5 files changed, 66 insertions(+), 17 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index b1fe398..1fbb1b9 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -165,3 +165,19 @@ Acquiring a thread lock (`self.timer_lock`) on every file system event just to u Action: Prefer direct attribute access for guaranteed attributes (`self.is_shutting_down`). Use double-checked locking when spawning background threads (`if thread is None: with lock: if thread is None: start_thread()`) to avoid acquiring locks on every event, and update thread-safe variables like `time.monotonic()` outside the lock. + +## 2026-05-16 — Generator Expression Overhead in Hot Paths + +Learning: +In high-frequency Python hot paths (like checking path parts against a regex), using `any()` with a generator expression (e.g., `any(match(p) for p in parts)`) introduces generator overhead that makes it slower than a simple, explicit `for` loop. Additionally, redundant property accesses (`getattr`) and redundant loop-invariant truthiness checks (`if self.compound_wildcard_regex:`) inside loops cause measurable performance regressions. + +Action: +Prefer explicit `for` loops with early returns over `any()` generators in hot paths. Lift loop-invariant checks and expensive builtins (like `len()`) outside of tight loops. Use direct attribute access over `getattr` when the attribute's existence is guaranteed. + +## 2026-05-20 — Generator Expression Overhead in Object Initialization + +Learning: +Using `any()` with a generator expression inside a list comprehension (e.g., `[p for p in patterns if not any(c in p for c in ('*', '?', '['))]`) creates significant generator evaluation overhead, which is magnified when iterating over items. While this was previously addressed in the hot path, it remained in the object initialization, causing minor startup latency. + +Action: +Prefer explicit logical string conditions (`if '*' not in p and '?' not in p and '[' not in p`) over `any()` generator expressions for simple string character checks to avoid generator creation overhead, even outside of hot paths. diff --git a/.jules/warden.md b/.jules/warden.md index 19378ff..bbfe62b 100644 --- a/.jules/warden.md +++ b/.jules/warden.md @@ -195,6 +195,22 @@ Version bumped to `0.1.25` as a patch release reflecting the performance optimiz ## 2026-05-13 — Assessment & Lifecycle +Observation / Pruned: +Observed the preceding agent optimized event loop thread lock contention by preferring direct attribute access, using double-checked locking for thread spawning, and moving thread-safe variable updates outside the lock. I verified this via the test suite and confirmed structural soundness. Static analysis tools reported no dead code or linting issues. + +Alignment / Deferred: +Version bumped to `0.1.26` as a patch release reflecting the performance optimization. Updated CHANGELOG.md. + +## 2026-05-21 — Assessment & Lifecycle + +Observation / Pruned: +Observed the preceding agent optimized event loop lock contention by streamlining logic and variable assignments around `debounce_worker` and `Timer` threads. Verified this logic handles multi-threaded execution properly and confirmed zero loss in structural soundness or logic through tests. Vulture confirmed the codebase remains at zero dead code. No further entropy pruning was required. + +Alignment / Deferred: +Version bumped to `0.1.27` as a patch release. No dependency adjustments or complex refactors were deferred. + +## 2026-05-13 — Assessment & Lifecycle (Amended) + Observation / Pruned: Observed the preceding agent optimized the event loop lock contention but introduced a race condition by removing the `timer_lock` around concurrent variable assignments (`last_event_time` and `last_event_path`) in `on_any_event`. This could lead to incorrect debounce timeouts or paths executing due to unsafe reads across threads. diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7e842..2ffbf37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,14 @@ # Changelog +## [0.1.27] - 2026-05-21 + +### Changed +* **[Performance]:** Assured the event loop lock contention optimizations, validating thread safety and structure without introducing new regressions. + +## [0.1.26] - 2026-05-13 + +### Changed +* **[Performance]:** Optimized event loop lock contention by implementing double-checked locking for debounce thread spawning and moving non-critical state assignments outside the thread lock, reducing overhead in high-frequency event loops. + ## [0.1.26] - 2026-05-13 ### Changed diff --git a/pyproject.toml b/pyproject.toml index ad48d11..d731ecc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "echo-watcher" -version = "0.1.26" +version = "0.1.28" 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 c0b2bc3..6540b1e 100644 --- a/src/echo/watcher.py +++ b/src/echo/watcher.py @@ -22,6 +22,8 @@ def __init__(self, command: str, base_path: str = ".", ignore_patterns: list[str self.base_path = base_path self._abs_base_path = os.path.join(os.path.abspath(base_path), '') self._base_prefix = os.path.join(self.base_path, '') + self._abs_base_path_len = len(self._abs_base_path) + self._base_prefix_len = len(self._base_prefix) # Default ignore patterns default_ignores = [".git", "__pycache__", ".pytest_cache", ".ruff_cache", "node_modules", ".venv", "venv"] @@ -30,8 +32,8 @@ def __init__(self, command: str, base_path: str = ".", ignore_patterns: list[str self.ignore_patterns = [p.replace('\\', '/').rstrip('/').removeprefix('./') for p in default_ignores] # Pre-compute exact vs wildcard patterns for faster matching - 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 ('*', '?', '['))] + exact_ignores = [p for p in self.ignore_patterns if '*' not in p and '?' not in p and '[' not in p] + wildcard_ignores = [p for p in self.ignore_patterns if '*' in p or '?' in p or '[' in p] self.simple_exact_ignores = frozenset(p for p in exact_ignores if '/' not in p) self.compound_exact_ignores = frozenset(p for p in exact_ignores if '/' in p) @@ -177,9 +179,9 @@ def _run_command(self, event_path): def _is_ignored_impl(self, path: str) -> bool: if path.startswith(self._abs_base_path): - path = path[len(self._abs_base_path):] + path = path[self._abs_base_path_len:] elif path.startswith(self._base_prefix): - path = path[len(self._base_prefix):] + path = path[self._base_prefix_len:] 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(".."): @@ -207,16 +209,21 @@ def _is_ignored_impl(self, path: str) -> bool: # Check for exact and wildcard ignore patterns matching cumulative prefix directories 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. - - match = self.compound_wildcard_regex.match if self.compound_wildcard_regex else None - for part in parts[1:]: - prefix = f"{prefix}/{part}" - if prefix in self.compound_exact_ignores: - return True - if match and match(prefix): - return True + compound_exact_ignores = self.compound_exact_ignores + + if self.compound_wildcard_regex: + match = self.compound_wildcard_regex.match + for part in parts[1:]: + prefix = f"{prefix}/{part}" + if prefix in compound_exact_ignores: + return True + if match(prefix): + return True + else: + for part in parts[1:]: + prefix = f"{prefix}/{part}" + if prefix in compound_exact_ignores: + return True return False @@ -228,11 +235,11 @@ def on_any_event(self, event): return # Ignore read-only events to prevent redundant executions - if getattr(event, 'event_type', '') in ('opened', 'closed_no_write'): + if event.event_type in ('opened', 'closed_no_write'): return # Fast-path ignore filter to prevent infinite loops from test/build artifacts - event_path = getattr(event, 'src_path', None) + event_path = event.src_path is_src_ignored = event_path and self._is_ignored(event_path) dest_path = getattr(event, 'dest_path', None) From d10f12d3de8b5a2a706f12ef33e8dd19a23b4cf8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 17:36:16 +0000 Subject: [PATCH 7/8] chore: resolve merge conflicts and integrate main optimizations into release branch Co-authored-by: shenald-dev <245350826+shenald-dev@users.noreply.github.com> From e3213ad70e263b93857ef8947c6fb7364f5c5ae9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 00:30:24 +0000 Subject: [PATCH 8/8] chore: integrate main optimizations into release branch and update changelog Co-authored-by: shenald-dev <245350826+shenald-dev@users.noreply.github.com>