Skip to content

Commit 1fbe613

Browse files
authored
Merge branch 'gorakhargosh:master' into master
2 parents a3bf6fe + 6a4f1cf commit 1fbe613

14 files changed

+189
-45
lines changed

changelog.rst

+13-5
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,21 @@
33
Changelog
44
---------
55

6-
5.0.3 (dev)
7-
~~~~~~~~~~~
6+
5.0.4-dev
7+
~~~~~~~~~
88

9-
2024-xx-xx • `full history <https://github.com/gorakhargosh/watchdog/compare/v5.0.2...HEAD>`__
9+
2024-xx-xx • `full history <https://github.com/gorakhargosh/watchdog/compare/v5.0.3...HEAD>`__
1010

11-
-
12-
- Thanks to our beloved contributors: @BoboTiG
11+
-
12+
- Thanks to our beloved contributors: @BoboTiG, @
13+
14+
5.0.3
15+
~~~~~
16+
17+
2024-09-27 • `full history <https://github.com/gorakhargosh/watchdog/compare/v5.0.2...v5.0.3>`__
18+
19+
- [inotify] Improve cleaning up ``Inotify`` threads, and add ``eventlet`` test cases (`#1070 <https://github.com/gorakhargosh/watchdog/pull/1070>`__)
20+
- Thanks to our beloved contributors: @BoboTiG, @ethan-vanderheijden
1321

1422
5.0.2
1523
~~~~~

docs/source/global.rst.inc

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
.. |author_email| replace:: contact@tiger-222.fr
55
.. |copyright| replace:: Copyright 2011-2024 Yesudeep Mangalapilly, Mickaël Schoentgen & contributors.
66
.. |project_name| replace:: ``watchdog``
7-
.. |project_version| replace:: 5.0.3
7+
.. |project_version| replace:: 5.0.4
88

99
.. _issue tracker: https://github.com/gorakhargosh/watchdog/issues
1010
.. _code repository: https://github.com/gorakhargosh/watchdog

src/watchdog/observers/inotify_c.py

+36-6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import ctypes.util
66
import errno
77
import os
8+
import select
89
import struct
910
import threading
1011
from ctypes import c_char_p, c_int, c_uint32
@@ -148,6 +149,9 @@ def __init__(self, path: bytes, *, recursive: bool = False, event_mask: int | No
148149
Inotify._raise_error()
149150
self._inotify_fd = inotify_fd
150151
self._lock = threading.Lock()
152+
self._closed = False
153+
self._waiting_to_read = True
154+
self._kill_r, self._kill_w = os.pipe()
151155

152156
# Stores the watch descriptor for a given path.
153157
self._wd_for_path: dict[bytes, int] = {}
@@ -230,13 +234,19 @@ def remove_watch(self, path: bytes) -> None:
230234
def close(self) -> None:
231235
"""Closes the inotify instance and removes all associated watches."""
232236
with self._lock:
233-
if self._path in self._wd_for_path:
234-
wd = self._wd_for_path[self._path]
235-
inotify_rm_watch(self._inotify_fd, wd)
237+
if not self._closed:
238+
self._closed = True
236239

237-
# descriptor may be invalid because file was deleted
238-
with contextlib.suppress(OSError):
239-
os.close(self._inotify_fd)
240+
if self._path in self._wd_for_path:
241+
wd = self._wd_for_path[self._path]
242+
inotify_rm_watch(self._inotify_fd, wd)
243+
244+
if self._waiting_to_read:
245+
# inotify_rm_watch() should write data to _inotify_fd and wake
246+
# the thread, but writing to the kill channel will gaurentee this
247+
os.write(self._kill_w, b"!")
248+
else:
249+
self._close_resources()
240250

241251
def read_events(self, *, event_buffer_size: int = DEFAULT_EVENT_BUFFER_SIZE) -> list[InotifyEvent]:
242252
"""Reads events from inotify and yields them."""
@@ -276,6 +286,21 @@ def _recursive_simulate(src_path: bytes) -> list[InotifyEvent]:
276286
event_buffer = None
277287
while True:
278288
try:
289+
with self._lock:
290+
if self._closed:
291+
return []
292+
293+
self._waiting_to_read = True
294+
295+
select.select([self._inotify_fd, self._kill_r], [], [])
296+
297+
with self._lock:
298+
self._waiting_to_read = False
299+
300+
if self._closed:
301+
self._close_resources()
302+
return []
303+
279304
event_buffer = os.read(self._inotify_fd, event_buffer_size)
280305
except OSError as e:
281306
if e.errno == errno.EINTR:
@@ -340,6 +365,11 @@ def _recursive_simulate(src_path: bytes) -> list[InotifyEvent]:
340365

341366
return event_list
342367

368+
def _close_resources(self) -> None:
369+
os.close(self._inotify_fd)
370+
os.close(self._kill_r)
371+
os.close(self._kill_w)
372+
343373
# Non-synchronized methods.
344374
def _add_dir_watch(self, path: bytes, mask: int, *, recursive: bool) -> None:
345375
"""Adds a watch (optionally recursively) for the given directory path

src/watchdog/utils/bricks.py

+9-7
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,16 @@ def _init(self, maxsize: int) -> None:
7272
super()._init(maxsize)
7373
self._last_item = None
7474

75-
def _put(self, item: Any) -> None:
75+
def put(self, item: Any, block: bool = True, timeout: float | None = None) -> None: # noqa: FBT001,FBT002
76+
"""This method will be used by `eventlet`, when enabled, so we cannot use force proper keyword-only
77+
arguments nor touch the signature. Also, the `timeout` argument will be ignored in that case.
78+
"""
7679
if self._last_item is None or item != self._last_item:
77-
super()._put(item)
78-
self._last_item = item
79-
else:
80-
# `put` increments `unfinished_tasks` even if we did not put
81-
# anything into the queue here
82-
self.unfinished_tasks -= 1
80+
super().put(item, block, timeout)
81+
82+
def _put(self, item: Any) -> None:
83+
super()._put(item)
84+
self._last_item = item
8385

8486
def _get(self) -> Any:
8587
item = super()._get()

src/watchdog/version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# ``docs/source/global.rst.inc`` file as well.
55
VERSION_MAJOR = 5
66
VERSION_MINOR = 0
7-
VERSION_BUILD = 3
7+
VERSION_BUILD = 4
88
VERSION_INFO = (VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD)
99
VERSION_STRING = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}"
1010

tests/isolated/__init__.py

Whitespace-only changes.
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
if __name__ == "__main__":
2+
import eventlet
3+
4+
eventlet.monkey_patch()
5+
6+
import signal
7+
import sys
8+
import tempfile
9+
10+
from watchdog.events import LoggingEventHandler
11+
from watchdog.observers import Observer
12+
13+
with tempfile.TemporaryDirectory() as temp_dir:
14+
15+
def run_observer():
16+
event_handler = LoggingEventHandler()
17+
observer = Observer()
18+
observer.schedule(event_handler, temp_dir)
19+
observer.start()
20+
eventlet.sleep(1)
21+
observer.stop()
22+
23+
def on_alarm(signum, frame):
24+
print("Observer.stop() never finished!", file=sys.stderr) # noqa: T201
25+
sys.exit(1)
26+
27+
signal.signal(signal.SIGALRM, on_alarm)
28+
signal.alarm(4)
29+
30+
thread = eventlet.spawn(run_observer)
31+
thread.wait()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
if __name__ == "__main__":
2+
import eventlet
3+
4+
eventlet.monkey_patch()
5+
6+
from watchdog.utils.bricks import SkipRepeatsQueue
7+
8+
q = SkipRepeatsQueue(10)
9+
q.put("A")
10+
q.put("A")
11+
q.put("A")
12+
q.put("A")
13+
q.put("B")
14+
q.put("A")
15+
16+
value = q.get()
17+
assert value == "A"
18+
q.task_done()
19+
20+
assert q.unfinished_tasks == 2
21+
22+
value = q.get()
23+
assert value == "B"
24+
q.task_done()
25+
26+
assert q.unfinished_tasks == 1
27+
28+
value = q.get()
29+
assert value == "A"
30+
q.task_done()
31+
32+
assert q.empty()
33+
assert q.unfinished_tasks == 0

tests/markers.py

-7
This file was deleted.

tests/test_inotify_c.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import errno
1212
import logging
1313
import os
14+
import select
1415
import struct
1516
from typing import TYPE_CHECKING
1617
from unittest.mock import patch
@@ -56,6 +57,13 @@ def test_late_double_deletion(helper: Helper, p: P, event_queue: TestEventQueue,
5657
+ struct_inotify(wd=3, mask=const.IN_IGNORED)
5758
)
5859

60+
select_bkp = select.select
61+
62+
def fakeselect(read_list, *args, **kwargs):
63+
if inotify_fd in read_list:
64+
return [inotify_fd], [], []
65+
return select_bkp(read_list, *args, **kwargs)
66+
5967
os_read_bkp = os.read
6068

6169
def fakeread(fd, length):
@@ -92,8 +100,9 @@ def inotify_rm_watch(fd, wd):
92100
mock3 = patch.object(inotify_c, "inotify_init", new=inotify_init)
93101
mock4 = patch.object(inotify_c, "inotify_add_watch", new=inotify_add_watch)
94102
mock5 = patch.object(inotify_c, "inotify_rm_watch", new=inotify_rm_watch)
103+
mock6 = patch.object(select, "select", new=fakeselect)
95104

96-
with mock1, mock2, mock3, mock4, mock5:
105+
with mock1, mock2, mock3, mock4, mock5, mock6:
97106
start_watching(path=p(""))
98107
# Watchdog Events
99108
for evt_cls in [DirCreatedEvent, DirDeletedEvent] * 2:

tests/test_isolated.py

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import importlib
2+
3+
import pytest
4+
5+
from watchdog.utils import platform
6+
7+
from .utils import run_isolated_test
8+
9+
10+
# Kqueue isn't supported by Eventlet, so BSD is out
11+
# Current usage ReadDirectoryChangesW on Windows is blocking, though async may be possible
12+
@pytest.mark.skipif(not platform.is_linux(), reason="Eventlet only supported in Linux")
13+
def test_observer_stops_in_eventlet():
14+
if not importlib.util.find_spec("eventlet"):
15+
pytest.skip("eventlet not installed")
16+
17+
run_isolated_test("eventlet_observer_stops.py")
18+
19+
20+
@pytest.mark.skipif(not platform.is_linux(), reason="Eventlet only supported in Linux")
21+
def test_eventlet_skip_repeat_queue():
22+
if not importlib.util.find_spec("eventlet"):
23+
pytest.skip("eventlet not installed")
24+
25+
run_isolated_test("eventlet_skip_repeat_queue.py")

tests/test_skip_repeats_queue.py

+1-16
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
from __future__ import annotations
22

3-
import pytest
4-
53
from watchdog import events
64
from watchdog.utils.bricks import SkipRepeatsQueue
75

8-
from .markers import cpython_only
9-
106

11-
def basic_actions():
7+
def test_basic_queue():
128
q = SkipRepeatsQueue()
139

1410
e1 = (2, "fred")
@@ -25,10 +21,6 @@ def basic_actions():
2521
assert q.empty()
2622

2723

28-
def test_basic_queue():
29-
basic_actions()
30-
31-
3224
def test_allow_nonconsecutive():
3325
q = SkipRepeatsQueue()
3426

@@ -86,10 +78,3 @@ def test_consecutives_allowed_across_empties():
8678
q.put(e1) # this repeat is allowed because 'last' added is now gone from queue
8779
assert e1 == q.get()
8880
assert q.empty()
89-
90-
91-
@cpython_only
92-
def test_eventlet_monkey_patching():
93-
eventlet = pytest.importorskip("eventlet")
94-
eventlet.monkey_patch()
95-
basic_actions()

tests/utils.py

+28
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import dataclasses
44
import os
5+
import subprocess
6+
import sys
57
from queue import Queue
68
from typing import Protocol
79

@@ -97,3 +99,29 @@ def close(self) -> None:
9799
alive = [emitter.is_alive() for emitter in self.emitters]
98100
self.emitters = []
99101
assert alive == [False] * len(alive)
102+
103+
104+
def run_isolated_test(path):
105+
isolated_test_prefix = os.path.join("tests", "isolated")
106+
path = os.path.abspath(os.path.join(isolated_test_prefix, path))
107+
108+
src_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")
109+
new_env = os.environ.copy()
110+
new_env["PYTHONPATH"] = os.pathsep.join([*sys.path, src_dir])
111+
112+
new_argv = [sys.executable, path]
113+
114+
p = subprocess.Popen(
115+
new_argv,
116+
env=new_env,
117+
)
118+
119+
# in case test goes haywire, don't let it run forever
120+
timeout = 10
121+
try:
122+
p.communicate(timeout=timeout)
123+
except subprocess.TimeoutExpired:
124+
p.kill()
125+
raise
126+
127+
assert p.returncode == 0

tox.ini

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ extras =
3333
watchmedo
3434
commands =
3535
python -m ruff format docs/source/examples src tests
36-
python -m ruff check --fix src docs/source/examples tests
36+
python -m ruff check --fix --unsafe-fixes src docs/source/examples tests
3737

3838
[testenv:types]
3939
usedevelop = true

0 commit comments

Comments
 (0)