Skip to content

Commit 93d134f

Browse files
Add throttling to file system polling and reduce CPU usage on default setting, remove watchman references (#83)
* reduce poll cpu usage * Add poll throttle * Fix references to watchman * Increment version
1 parent e31ea2e commit 93d134f

File tree

8 files changed

+169
-35
lines changed

8 files changed

+169
-35
lines changed

.ptyme_track/JamesHutchison

Lines changed: 92 additions & 0 deletions
Large diffs are not rendered by default.

.vscode/launch.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"program": "${workspaceFolder}/metatests/metatest_runner.py",
4040
"justMyCode": false,
4141
"args": [
42-
"--use-watchman"
42+
"--use-os-events"
4343
]
4444
// "args": [
4545
// "--do-not-reset-daemon"

README.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,9 @@ This can easily be done in VS Code with the following launch profile:
104104
},
105105
```
106106

107-
The daemon can be configured to use either watchman or polling to detect file changes.
108-
In my experience, watchman, running on docker on your system, frequently stops detecting changes without warning.
109-
The default behavior is to use polling, which is slower and has higher CPU usage, but is more likely to work.
110-
You can improve the behavior by limiting what files are watched via the watch globs. When using watchman,
111-
a file is opened for every file watched, so you will exhaust your open file limit if you watch too many.
107+
The daemon can be configured to use either file system polling or OS-based polling.
108+
The polling behavior is used by default and has higher compatibility. For example, if you're using
109+
Docker for Windows with WSL2, you're going to have a bad time with inotify.
112110

113111
If the daemon is already running and you run pytest with `--daemon`, then the old one will be stopped
114112
and a new one will be started. Note that `pytest --daemon` is NOT how you run tests. It is only used to start
@@ -134,10 +132,15 @@ Then enable automatically starting the daemon in your settings:
134132
```
135133

136134
## Arguments and Env Variables
137-
- `PYTEST_DAEMON_USE_WATCHMAN`
138-
- Use watchman to check for file changes (recommended if your system supports it)
135+
- `PYTEST_DAEMON_USE_OS_EVENTS`
136+
- Instead of polling the file system, use OS events such as inotify to check for file changes (recommended if your system supports it)
139137
- Default: `False`
140-
- Command line: `--daemon-use-watchman`
138+
- Command line: `--daemon-use-os-events`
139+
- `PYTEST_DAEMON_POLL_THROTTLE`
140+
- A multipler for how aggressive the daemon does file system polling. This is not used if OS events are used.
141+
- 2.0 = twice as slow, less CPU usage
142+
- Default: `1.0`
143+
- Command line: `--daemon-poll-throttle`
141144
- `PYTEST_DAEMON_PORT`
142145
- The port the daemon listens on.
143146
- Default: `4852`.
@@ -205,7 +208,7 @@ the given module will not be executed.
205208
addopts = "-p pytest_asyncio.plugin -p megamock.plugins.pytest -p pytest_hot_reloading.plugin"
206209
```
207210
- Run out of a Github Codespace or similar dedicated external environment
208-
- Prefer watchman, if your system works well with it. It uses less CPU and can pick up changes faster. Enable it with the environment variable `PYTEST_DAEMON_USE_WATCHMAN=1`. It is only disabled by default for maximum compatibility.
211+
- Prefer using OS events, if your system works well with it. It uses less CPU and can pick up changes faster. Enable it with the environment variable `PYTEST_DAEMON_USE_OS_EVENTS=1`. It is only disabled by default for maximum compatibility.
209212

210213
## Known Issues
211214
- This is alpha, although it's getting closer to where it can be called beta

metatests/metatest_runner.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ class MetaTestRunner:
1414
def __init__(
1515
self,
1616
do_not_reset_daemon: bool,
17-
use_watchman: bool,
17+
use_os_events: bool,
1818
change_delay: float,
1919
retries: int,
2020
temp_dir: Path,
2121
) -> None:
2222
self.do_not_reset_daemon = do_not_reset_daemon
23-
self.use_watchman = use_watchman
23+
self.use_os_events = use_os_events
2424
self.change_delay = change_delay
2525
self.retries = retries
2626
self.temp_dir = temp_dir
@@ -40,15 +40,15 @@ def run_test(
4040
test_name: str,
4141
*file_mod_funcs: Callable,
4242
expect_fail: bool = False,
43-
use_watchman: bool = False,
43+
use_os_events: bool = False,
4444
retries: int = 0,
4545
test_file: str = "test_fixture_changes.py",
4646
):
4747
for retry_num in range(retries + 1):
4848
self.make_fresh_copy()
4949
os.chdir(self.temp_dir)
5050
if system(
51-
f"pytest -p pytest_hot_reloading.plugin --daemon-start-if-needed {'--daemon-use-watchman' if use_watchman else ''} "
51+
f"pytest -p pytest_hot_reloading.plugin --daemon-start-if-needed {'--daemon-use-os-events' if use_os_events else ''} "
5252
f"--daemon-watch-globs '{self.temp_dir}/*.py' "
5353
f"{self.temp_dir}/test_fixture_changes.py::test_always_ran"
5454
):
@@ -266,8 +266,8 @@ def modify_staticmethod_return_value(self) -> None:
266266
def main(self) -> None:
267267
if not self.do_not_reset_daemon:
268268
system("pytest --stop-daemon")
269-
if self.use_watchman:
270-
self.run_test("test_always_ran", use_watchman=True)
269+
if self.use_os_events:
270+
self.run_test("test_always_ran", use_os_events=True)
271271
self.run_test(
272272
"test_adding_fixture",
273273
self.add_fixture,
@@ -354,7 +354,7 @@ def main(self) -> None:
354354
if __name__ == "__main__":
355355
argparser = argparse.ArgumentParser()
356356
argparser.add_argument("--do-not-reset-daemon", action="store_true")
357-
argparser.add_argument("--use-watchman", action="store_true")
357+
argparser.add_argument("--use-os-events", action="store_true")
358358
argparser.add_argument("--change-delay", default=0.01, type=float)
359359
argparser.add_argument("--retry", default=0, type=int)
360360
argparser.add_argument("--temp-dir", default="/tmp/_metatests")
@@ -365,7 +365,7 @@ def main(self) -> None:
365365
temp_dir.mkdir()
366366
runner = MetaTestRunner(
367367
args.do_not_reset_daemon,
368-
args.use_watchman,
368+
args.use_os_events,
369369
args.change_delay,
370370
args.retry,
371371
Path(temp_dir),

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ line-length = 98
99

1010
[tool.poetry]
1111
name = "pytest-hot-reloading"
12-
version = "0.1.0-alpha.15"
12+
version = "0.1.0-alpha.16"
1313
description = ""
1414
authors = ["James Hutchison <jamesghutchison@proton.me>"]
1515
readme = "README.md"

pytest_hot_reloading/client.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class PytestClient:
1515
_pytest_name: str
1616
_will_start_daemon_if_needed: bool
1717
_do_not_autowatch_fixtures: bool
18-
_use_watchman: bool
18+
_use_os_events: bool
1919

2020
def __init__(
2121
self,
@@ -24,7 +24,8 @@ def __init__(
2424
pytest_name: str = "pytest",
2525
start_daemon_if_needed: bool = False,
2626
do_not_autowatch_fixtures: bool = False,
27-
use_watchman: bool = False,
27+
use_os_events: bool = False,
28+
poll_throttle: float = 1.0,
2829
additional_args: Sequence[str] = [],
2930
) -> None:
3031
self._socket = None
@@ -33,8 +34,9 @@ def __init__(
3334
self._pytest_name = pytest_name
3435
self._will_start_daemon_if_needed = start_daemon_if_needed
3536
self._do_not_autowatch_fixtures = do_not_autowatch_fixtures
36-
self._use_watchman = use_watchman
37+
self._use_os_events = use_os_events
3738
self._additional_args = additional_args
39+
self._poll_throttle = poll_throttle
3840

3941
def _get_server(self) -> xmlrpc.client.ServerProxy:
4042
server_url = f"http://{self._daemon_host}:{self._daemon_port}"
@@ -113,6 +115,7 @@ def _start_daemon(self) -> None:
113115
port=self._daemon_port,
114116
pytest_name=self._pytest_name,
115117
do_not_autowatch_fixtures=self._do_not_autowatch_fixtures,
116-
use_watchman=self._use_watchman,
118+
use_os_events=self._use_os_events,
117119
additional_args=self._additional_args,
120+
poll_throttle=self._poll_throttle,
118121
)

pytest_hot_reloading/daemon.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ def start(
4747
watch_globs: str | None = None,
4848
ignore_watch_globs: str | None = None,
4949
do_not_autowatch_fixtures: bool | None = None,
50-
use_watchman: bool | None = None,
50+
use_os_events: bool | None = None,
51+
poll_throttle: float | None = None,
5152
additional_args: Sequence[str] | None = None,
5253
) -> None:
5354
# start the daemon such that it will not close when the parent process closes
@@ -66,8 +67,10 @@ def start(
6667
args += ["--daemon-ignore-watch-globs", ignore_watch_globs]
6768
if do_not_autowatch_fixtures:
6869
args += ["--daemon-do-not-autowatch-fixtures"]
69-
if use_watchman:
70-
args += ["--daemon-use-watchman"]
70+
if use_os_events:
71+
args += ["--daemon-use-os-events"]
72+
if poll_throttle:
73+
args += ["--daemon-poll-throttle", str(poll_throttle)]
7174
subprocess.Popen(
7275
args + list(additional_args or []),
7376
env=os.environ,

pytest_hot_reloading/plugin.py

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import inspect
77
import os
88
import sys
9+
import time
910
from enum import Enum
1011
from pathlib import Path
1112
from typing import TYPE_CHECKING, Callable, Optional
@@ -32,7 +33,8 @@ class EnvVariables(str, Enum):
3233
PYTEST_DAEMON_START_IF_NEEDED = "PYTEST_DAEMON_START_IF_NEEDED"
3334
PYTEST_DAEMON_DISABLE = "PYTEST_DAEMON_DISABLE"
3435
PYTEST_DAEMON_DO_NOT_AUTOWATCH_FIXTURES = "PYTEST_DAEMON_DO_NOT_AUTOWATCH_FIXTURES"
35-
PYTEST_DAEMON_USE_WATCHMAN = "PYTEST_DAEMON_USE_WATCHMAN"
36+
PYTEST_DAEMON_USE_OS_EVENTS = "PYTEST_DAEMON_USE_OS_EVENTS"
37+
PYTEST_DAEMON_POLL_THROTTLE = "PYTEST_DAEMON_POLL_THROTTLE"
3638

3739

3840
def pytest_addoption(parser) -> None:
@@ -110,18 +112,26 @@ def pytest_addoption(parser) -> None:
110112
),
111113
)
112114
group.addoption(
113-
"--daemon-use-watchman",
115+
"--daemon-use-os-events",
114116
action="store_true",
115117
default=(
116-
os.getenv(EnvVariables.PYTEST_DAEMON_USE_WATCHMAN, "False").lower() in ("true", "1")
118+
os.getenv(EnvVariables.PYTEST_DAEMON_USE_OS_EVENTS, "False").lower() in ("true", "1")
117119
),
118120
help=(
119-
"Use watchman instead of polling. "
121+
"Use OS events such as inotify instead of polling. "
120122
"This reduces CPU usage, takes up open file handles, and improves responsiveness. "
121123
"Some systems cannot reliably use this."
122124
),
123125
)
124126

127+
group.addoption(
128+
"--daemon-poll-throttle",
129+
default=(os.getenv(EnvVariables.PYTEST_DAEMON_POLL_THROTTLE, "1")),
130+
help=(
131+
"The throttle for polling, as a float multiplier. Higher numbers are slower but tax the CPU less."
132+
),
133+
)
134+
125135

126136
# list of pytest hooks
127137
# https://docs.pytest.org/en/stable/reference.html#_pytest.hookspec.pytest_addhooks
@@ -301,15 +311,37 @@ def setup_jurigged(config: Config):
301311
monkey_patch_jurigged_function_definition()
302312
monkeypatch_group_definition()
303313
if not config.option.daemon_do_not_autowatch_fixtures:
304-
monkeypatch_fixture_marker(config.option.daemon_use_watchman)
314+
monkeypatch_fixture_marker(config.option.daemon_use_os_events)
305315
else:
306316
print("Not autowatching fixtures")
307317

308318
pattern = _get_pattern_filters(config)
309-
# TODO: intelligently use poll versus watchman (https://github.com/JamesHutchison/pytest-hot-reloading/issues/16)
319+
# TODO: intelligently use poll (https://github.com/JamesHutchison/pytest-hot-reloading/issues/16)
320+
321+
poll_throttle = float(config.option.daemon_poll_throttle)
322+
323+
from watchdog.observers.polling import PollingObserverVFS
324+
325+
class NewPollingObserverVFS(PollingObserverVFS):
326+
def __init__(self, stat, listdir, polling_interval=2) -> None:
327+
def lagged_listdir(*args, **kwargs):
328+
time.sleep(0.02 * poll_throttle) # give CPU a break!
329+
return listdir(*args, **kwargs)
330+
331+
super().__init__(stat, lagged_listdir, polling_interval * poll_throttle)
332+
333+
jurigged.live.PollingObserverVFS = NewPollingObserverVFS
334+
335+
poll: bool | float
336+
337+
if config.option.daemon_use_os_events:
338+
poll = False
339+
else:
340+
poll = 2 # seconds
341+
310342
jurigged.watch(
311343
pattern=pattern,
312-
poll=(not config.option.daemon_use_watchman),
344+
poll=poll,
313345
)
314346

315347

@@ -323,7 +355,7 @@ def watch_file(path: Path | str) -> None:
323355
seen_files: set[str] = set()
324356

325357

326-
def monkeypatch_fixture_marker(use_watchman: bool):
358+
def monkeypatch_fixture_marker(use_os_events: bool):
327359
import pytest
328360
from _pytest import fixtures
329361

@@ -389,7 +421,8 @@ def _plugin_logic(config: Config) -> int:
389421
pytest_name=pytest_name,
390422
start_daemon_if_needed=config.option.daemon_start_if_needed, # --daemon-start-if-needed
391423
do_not_autowatch_fixtures=config.option.daemon_do_not_autowatch_fixtures, # --daemon-do-not-autowatch-fixtures
392-
use_watchman=config.option.daemon_use_watchman, # --daemon-use-watchman
424+
use_os_events=config.option.daemon_use_os_events, # --daemon-use-os-events
425+
poll_throttle=config.option.daemon_poll_throttle, # --daemon-poll-throttle
393426
additional_args=config.invocation_params.args,
394427
)
395428

0 commit comments

Comments
 (0)