diff --git a/ldclient/impl/integrations/files/filedata.py b/ldclient/impl/integrations/files/filedata.py index 0f1ef576..29d67492 100644 --- a/ldclient/impl/integrations/files/filedata.py +++ b/ldclient/impl/integrations/files/filedata.py @@ -61,7 +61,15 @@ # The watchdog event types that can change a file's content or presence. Opening or reading a # file also produces events, and a reload reads the files, so those must not count as changes. -_CHANGE_EVENT_TYPES = frozenset(["created", "modified", "moved", "deleted", "closed"]) +# watchdog 7 reports a change of a file's metadata, such as a new modification time from a +# touch, as "attrib", where earlier versions reported "modified". A read does not produce it. +_CHANGE_EVENT_TYPES = frozenset(["created", "modified", "moved", "deleted", "closed", "attrib"]) + +# How long, in seconds, the watchdog observer waits for further notifications before it delivers +# the ones it has. watchdog 7 on Windows keeps waiting while notifications arrive, so a change is +# reported this late, and not at all while another file in the same directory changes more often +# than this. The watchdog default is one second. +_OBSERVER_TIMEOUT = 0.1 class DuplicateKeysHandling(str, Enum): @@ -984,7 +992,7 @@ def on_any_event(self, event): watcher._handle_event(event) self._handler = _Handler() - self._observer = watchdog.observers.Observer() + self._observer = watchdog.observers.Observer(timeout=_OBSERVER_TIMEOUT) # Guards the state below. It is never held while the observer is called, because the # observer holds its own lock while it dispatches an event to the handler. @@ -1068,6 +1076,11 @@ def _schedule(self, directory: str) -> Optional[Any]: except Exception as e: self._log_unwatchable(directory, 'Cannot watch directory "%s" for changes yet: %s' % (directory, e)) return None + # watchdog 6 on Windows starts a watch's reader thread and returns before the thread has + # asked for its first notification, and a change made before then is not reported. + # Yielding lets the thread make that request before the setup goes on, for example to + # the initial load. + time.sleep(0) with self._lock: self._unwatchable_messages.pop(directory, None) return watch diff --git a/ldclient/testing/integrations/test_filedata.py b/ldclient/testing/integrations/test_filedata.py index 2b2cbd23..0852c025 100644 --- a/ldclient/testing/integrations/test_filedata.py +++ b/ldclient/testing/integrations/test_filedata.py @@ -2456,6 +2456,60 @@ def test_watcher_detects_file_deletion(tmp_path, make_watcher): w.require_change() +@watchdog_required +def test_watcher_reports_a_change_within_a_fraction_of_a_second(tmp_path, make_watcher): + # watchdog 7 on Windows holds each notification for the observer's timeout, one second by + # default, before it delivers it. The watcher sets a shorter timeout. + path = os.path.join(str(tmp_path), 'data.json') + write_file(path, 'a') + w = make_watcher([path]) + w.require_no_change(0.15) + write_file(path, 'bb') + take(w.changes, timeout=0.5) + + +@watchdog_required +def test_watcher_reports_a_change_while_another_file_in_the_directory_keeps_changing(tmp_path, make_watcher): + # watchdog 7 on Windows delivers nothing while notifications for the directory keep arriving + # within the observer's timeout, so a file appended to five times a second, such as a log, + # would hold back the change to the watched file for as long as it is written. + path = os.path.join(str(tmp_path), 'data.json') + other = os.path.join(str(tmp_path), 'app.log') + write_file(path, 'a') + write_file(other, '') + w = make_watcher([path]) + stop = threading.Event() + + def append_to_other(): + while not stop.is_set(): + with open(other, 'a') as f: + f.write('line\n') + stop.wait(0.2) + + writer = threading.Thread(target=append_to_other, daemon=True) + writer.start() + try: + w.require_no_change(0.5) + write_file(path, 'bb') + take(w.changes, timeout=2.0) + finally: + stop.set() + writer.join() + + +@watchdog_required +def test_watcher_reports_a_new_modification_time(tmp_path, make_watcher): + # A touch changes only the modification time. watchdog 7 reports it as an "attrib" event, + # where earlier versions report a modification. + path = os.path.join(str(tmp_path), 'data.json') + write_file(path, 'a') + w = make_watcher([path]) + w.require_no_change(0.15) + info = os.stat(path) + os.utime(path, ns=(info.st_atime_ns, info.st_mtime_ns + 1_000_000_000)) + w.require_change() + + def make_symlink(target: str, link: str, directory: bool = False) -> None: # Windows needs to know whether a link leads to a directory when the link is created. try: