Skip to content

Commit 7384546

Browse files
ericchansenCopilot
andcommitted
Use native file system observer for livereload
Replace the hardcoded PollingObserver with watchdog's platform-default Observer, which automatically selects the best native backend per platform (inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows). This fixes live reload on Linux and WSL2, where PollingObserver silently fails to detect file changes on ext4 filesystems. To preserve symlink support that PollingObserver provided via stat(), the watch() method now resolves symlink paths with os.path.realpath() and adds filtered watches for symlink targets outside the watched tree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cf1d532 commit 7384546

File tree

1 file changed

+80
-4
lines changed

1 file changed

+80
-4
lines changed

properdocs/livereload/__init__.py

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from typing import Any, BinaryIO
2727

2828
import watchdog.events
29-
import watchdog.observers.polling
29+
import watchdog.observers
3030

3131
_SCRIPT_TEMPLATE_STR = """
3232
var livereload = function(epoch, requestId) {
@@ -132,15 +132,17 @@ def __init__(
132132
self._rebuild_cond = threading.Condition() # Must be held when accessing _want_rebuild.
133133

134134
self._shutdown = False
135+
self._building = False
135136
self.serve_thread = threading.Thread(target=lambda: self.serve_forever(shutdown_delay))
136-
self.observer = watchdog.observers.polling.PollingObserver(timeout=polling_interval)
137+
self.observer = watchdog.observers.Observer(timeout=polling_interval)
137138

138139
self._watched_paths: dict[str, int] = {}
139140
self._watch_refs: dict[str, Any] = {}
141+
self._extra_watch_refs: dict[str, list[Any]] = {}
140142

141143
def watch(self, path: str, func: None = None, *, recursive: bool = True) -> None:
142144
"""Add the 'path' to watched paths, call the function and reload when any file changes under it."""
143-
path = os.path.abspath(path)
145+
path = os.path.realpath(path)
144146
if not (func is None or func is self.builder): # type: ignore[unreachable]
145147
raise TypeError("Plugins can no longer pass a 'func' parameter to watch().")
146148

@@ -152,6 +154,8 @@ def watch(self, path: str, func: None = None, *, recursive: bool = True) -> None
152154
def callback(event):
153155
if event.is_directory:
154156
return
157+
if self._building:
158+
return
155159
log.debug(str(event))
156160
with self._rebuild_cond:
157161
self._want_rebuild = True
@@ -162,14 +166,79 @@ def callback(event):
162166
log.debug(f"Watching '{path}'")
163167
self._watch_refs[path] = self.observer.schedule(handler, path, recursive=recursive)
164168

169+
# Watch symlink targets outside the watched tree so that native file system
170+
# observers (inotify, FSEvents) detect changes to symlinked files.
171+
if recursive and os.path.isdir(path):
172+
self._watch_symlink_targets(path, callback)
173+
174+
def _watch_symlink_targets(self, root: str, callback: Callable) -> None:
175+
file_targets: set[str] = set()
176+
dir_targets: set[str] = set()
177+
self._collect_symlink_targets(root, root, file_targets, dir_targets)
178+
if not file_targets and not dir_targets:
179+
return
180+
181+
def filtered_callback(event: Any) -> None:
182+
if event.is_directory:
183+
return None
184+
src = os.path.realpath(event.src_path)
185+
if src in file_targets:
186+
return callback(event)
187+
for d in dir_targets:
188+
if src.startswith(d + os.sep):
189+
return callback(event)
190+
return None
191+
192+
filtered_handler = watchdog.events.FileSystemEventHandler()
193+
filtered_handler.on_any_event = filtered_callback # type: ignore[method-assign]
194+
195+
extra_refs: list[Any] = []
196+
watched_dirs: set[str] = set()
197+
for target in file_targets:
198+
parent = os.path.dirname(target)
199+
if parent not in watched_dirs:
200+
watched_dirs.add(parent)
201+
extra_refs.append(self.observer.schedule(filtered_handler, parent, recursive=False))
202+
for d in dir_targets:
203+
if d not in watched_dirs:
204+
watched_dirs.add(d)
205+
extra_refs.append(self.observer.schedule(filtered_handler, d, recursive=True))
206+
if extra_refs:
207+
self._extra_watch_refs[root] = extra_refs
208+
209+
def _collect_symlink_targets(
210+
self, scan_dir: str, root: str, file_targets: set[str], dir_targets: set[str]
211+
) -> None:
212+
try:
213+
entries = list(os.scandir(scan_dir))
214+
except OSError:
215+
return
216+
for entry in entries:
217+
if entry.is_symlink():
218+
try:
219+
target = os.path.realpath(entry.path)
220+
except OSError:
221+
continue
222+
if target == root or target.startswith(root + os.sep):
223+
continue
224+
if os.path.isdir(target):
225+
dir_targets.add(target)
226+
self._collect_symlink_targets(target, root, file_targets, dir_targets)
227+
elif os.path.isfile(target):
228+
file_targets.add(target)
229+
elif entry.is_dir(follow_symlinks=False):
230+
self._collect_symlink_targets(entry.path, root, file_targets, dir_targets)
231+
165232
def unwatch(self, path: str) -> None:
166233
"""Stop watching file changes for path. Raises if there was no corresponding `watch` call."""
167-
path = os.path.abspath(path)
234+
path = os.path.realpath(path)
168235

169236
self._watched_paths[path] -= 1
170237
if self._watched_paths[path] <= 0:
171238
self._watched_paths.pop(path)
172239
self.observer.unschedule(self._watch_refs.pop(path))
240+
for ref in self._extra_watch_refs.pop(path, []):
241+
self.observer.unschedule(ref)
173242

174243
def serve(self, *, open_in_browser=False):
175244
self.server_bind()
@@ -210,6 +279,7 @@ def _build_loop(self):
210279
self._want_rebuild = False
211280

212281
try:
282+
self._building = True
213283
self.builder()
214284
except Exception as e:
215285
if isinstance(e, SystemExit):
@@ -220,6 +290,12 @@ def _build_loop(self):
220290
"An error happened during the rebuild. The server will appear stuck until build errors are resolved."
221291
)
222292
continue
293+
finally:
294+
self._building = False
295+
# Discard any file change events generated by the build itself
296+
# (e.g. from plugins that write into the docs directory).
297+
with self._rebuild_cond:
298+
self._want_rebuild = False
223299

224300
with self._epoch_cond:
225301
log.info("Reloading browsers")

0 commit comments

Comments
 (0)