-
-
Notifications
You must be signed in to change notification settings - Fork 754
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Test workers #1995
Test workers #1995
Conversation
- Enable the required parallel mode - Set the required `COVERAGE_PROCESS_START` environment variable - Add `coverage_enable_subprocess` for `coverage.process_startup` - Combine coverage reports before reporting coverage - Update `.gitignore` to ignore files named `.coverage*` https://coverage.readthedocs.io/en/latest/subprocess.html
Occasional flakes are seen with some less-common signals. This commit will add some simple conditionals to avoid `AssertionError` exceptions when those signals are not handled as expected.
`WatchFilesReload` flakes when used with coverage.py parallel mode. It may have to do with the threading in the `touch_soon` fixture. Other WatchGod and Watchfiles flake on Windows specifically. Errors: ```text ================================== FAILURES =================================== ___________ TestBaseReload.test_override_defaults[WatchFilesReload] ___________ self = <test_reload.TestBaseReload object at 0x0000014D4AB94160> touch_soon = <function touch_soon.<locals>.start at 0x0000014D4C3192D0> @pytest.mark.parametrize("reloader_class", [WatchFilesReload, WatchGodReload]) def test_override_defaults(self, touch_soon) -> None: dotted_file = self.reload_path / ".dotted" dotted_dir_file = self.reload_path / ".dotted_dir" / "file.txt" python_file = self.reload_path / "main.py" with as_cwd(self.reload_path): config = Config( app="tests.test_config:asgi_app", reload=True, # We need to add *.txt otherwise no regular files will match reload_includes=[".*", "*.txt"], reload_excludes=["*.py"], ) reloader = self._setup_reloader(config) assert self._reload_tester(touch_soon, reloader, dotted_file) assert self._reload_tester(touch_soon, reloader, dotted_dir_file) > assert not self._reload_tester(touch_soon, reloader, python_file) E AssertionError: assert not [WindowsPath('C:/Users/runneradmin/AppData/Local/Temp/pytest-of-runneradmin/pytest-0/reload_directory1/.coverage.fv-az811-309.2664.795750')] E + where [WindowsPath('C:/Users/runneradmin/AppData/Local/Temp/pytest-of-runneradmin/pytest-0/reload_directory1/.coverage.fv-az811-309.2664.795750')] = <bound method TestBaseReload._reload_tester of <test_reload.TestBaseReload object at 0x0000014D4AB94160>>(<function touch_soon.<locals>.start at 0x0000014D4C3192D0>, <uvicorn.supervisors.watchfilesreload.WatchFilesReload object at 0x0000014D4C39DEA0>, WindowsPath('C:/Users/runneradmin/AppData/Local/Temp/pytest-of-runneradmin/pytest-0/reload_directory1/main.py')) E + where <bound method TestBaseReload._reload_tester of <test_reload.TestBaseReload object at 0x0000014D4AB94160>> = <test_reload.TestBaseReload object at 0x0000014D4AB94160>._reload_tester tests\supervisors\test_reload.py:253: AssertionError ---------------------------- Captured stderr call ----------------------------- INFO: Will watch for changes in these directories: ['C:\\Users\\runneradmin\\AppData\\Local\\Temp\\pytest-of-runneradmin\\pytest-0\\reload_directory1'] INFO: Started reloader process [6280] using WatchFiles ------------------------------ Captured log call ------------------------------ INFO uvicorn.error:config.py:345 Will watch for changes in these directories: ['C:\\Users\\runneradmin\\AppData\\Local\\Temp\\pytest-of-runneradmin\\pytest-0\\reload_directory1'] INFO uvicorn.error:basereload.py:77 Started reloader process [6280] using WatchFiles ____________ TestBaseReload.test_override_defaults[WatchGodReload] ____________ self = <test_reload.TestBaseReload object at 0x0000014D4AB959F0> touch_soon = <function touch_soon.<locals>.start at 0x0000014D4C3E77F0> @pytest.mark.parametrize("reloader_class", [WatchFilesReload, WatchGodReload]) def test_override_defaults(self, touch_soon) -> None: dotted_file = self.reload_path / ".dotted" dotted_dir_file = self.reload_path / ".dotted_dir" / "file.txt" python_file = self.reload_path / "main.py" with as_cwd(self.reload_path): config = Config( app="tests.test_config:asgi_app", reload=True, # We need to add *.txt otherwise no regular files will match reload_includes=[".*", "*.txt"], reload_excludes=["*.py"], ) reloader = self._setup_reloader(config) assert self._reload_tester(touch_soon, reloader, dotted_file) > assert self._reload_tester(touch_soon, reloader, dotted_dir_file) tests\supervisors\test_reload.py:252: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = <test_reload.TestBaseReload object at 0x0000014D4AB959F0> touch_soon = <function touch_soon.<locals>.start at 0x0000014D4C3E77F0> reloader = <uvicorn.supervisors.watchgodreload.WatchGodReload object at 0x0000014D4C39DAB0> files = (WindowsPath('C:/Users/runneradmin/AppData/Local/Temp/pytest-of-runneradmin/pytest-0/reload_directory1/.dotted_dir/file.txt'),) @py_assert2 = [WindowsPath('C:/Users/runneradmin/AppData/Local/Temp/pytest-of-runneradmin/pytest-0/reload_directory1/.coverage.fv-az...s/runneradmin/AppData/Local/Temp/pytest-of-runneradmin/pytest-0/reload_directory1/.coverage.fv-az811-309.4856.060689')] @py_assert4 = False @py_format5 = "assert not [WindowsPath('C:/Users/runneradmin/AppData/Local/Temp/pytest-of-runneradmin/pytest-0/reload_directory1/.co...v-az811-309.4856.060689')] = next(<uvicorn.supervisors.watchgodreload.WatchGodReload object at 0x0000014D4C39DAB0>)\n}" def _reload_tester( self, touch_soon, reloader: BaseReload, *files: Path ) -> Optional[List[Path]]: reloader.restart() if WatchFilesReload is not None and isinstance(reloader, WatchFilesReload): touch_soon(*files) else: > assert not next(reloader) E AssertionError: assert not [WindowsPath('C:/Users/runneradmin/AppData/Local/Temp/pytest-of-runneradmin/pytest-0/reload_directory1/.coverage.fv-az...s/runneradmin/AppData/Local/Temp/pytest-of-runneradmin/pytest-0/reload_directory1/.coverage.fv-az811-309.4856.060689')] E + where [WindowsPath('C:/Users/runneradmin/AppData/Local/Temp/pytest-of-runneradmin/pytest-0/reload_directory1/.coverage.fv-az...s/runneradmin/AppData/Local/Temp/pytest-of-runneradmin/pytest-0/reload_directory1/.coverage.fv-az811-309.4856.060689')] = next(<uvicorn.supervisors.watchgodreload.WatchGodReload object at 0x0000014D4C39DAB0>) tests\supervisors\test_reload.py:63: AssertionError ---------------------------- Captured stderr call ----------------------------- INFO: Will watch for changes in these directories: ['C:\\Users\\runneradmin\\AppData\\Local\\Temp\\pytest-of-runneradmin\\pytest-0\\reload_directory1'] INFO: Started reloader process [6280] using WatchGod ------------------------------ Captured log call ------------------------------ INFO uvicorn.error:config.py:345 Will watch for changes in these directories: ['C:\\Users\\runneradmin\\AppData\\Local\\Temp\\pytest-of-runneradmin\\pytest-0\\reload_directory1'] INFO uvicorn.error:basereload.py:77 Started reloader process [6280] using WatchGod ```
The GitHub Actions `jobs.<job_id>.strategy.fail-fast` setting controls how failures are handled. The default of `true` will fail the entire job when any job in the matrix fails. With the introduction of platform-specific worker testing, subprocesses, and operating system signal handling, there is potential for specific workflow jobs to occasionally fail. In these cases, it can help to set `fail-fast: false` to ensure that those specific failed jobs can be identified. https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
With the introduction of platform-specific worker testing, subprocesses, and operating system signal handling, there is potential for specific workflow jobs to occasionally hang. In these cases, it can help to set a timeout on the test step to ensure the workflow jobs don't hang until the default 360 minute (6 hour) timeout. https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
- Explain how to skip subprocess tests by using the custom pytest marker - Explain how to re-run failed GitHub Actions jobs
Help wantedI'm seeing some occasional test flakes with The tests sometimes fail on this line: uvicorn/tests/protocols/test_websocket.py Line 754 in 0624d57
The errors typically look like this (expand).
Could we modify or remove this test? I could use some help figuring this out. |
tests/supervisors/test_reload.py
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Help wanted
Some of the reloader tests in tests/test_reload.py
have been flaking, sometimes only when using watchfiles
, sometimes only on Windows. Not sure why these flakes are occurring, but it could relate to using coverage.py parallel mode with the threading done in the test fixture.
The errors typically look like this (expand).
Run scripts/test
+ [ -z true ]
+ export COVERAGE_PROCESS_START=/home/runner/work/uvicorn/uvicorn/setup.cfg
+ coverage run --debug config -m pytest
-- config ----------------------------------------------------
attempted_config_files: .coveragerc
setup.cfg
branch: False
command_line: None
concurrency: -none-
config_file: /home/runner/work/uvicorn/uvicorn/setup.cfg
config_files_read: /home/runner/work/uvicorn/uvicorn/setup.cfg
context: None
cover_pylib: False
data_file: .coverage
debug: config
debug_file: None
disable_warnings: -none-
dynamic_context: None
exclude_also: -none-
exclude_list: pragma: no cover
pragma: nocover
if TYPE_CHECKING:
if typing.TYPE_CHECKING:
raise NotImplementedError
py-not-win32
py-linux
py-gte-38
extra_css: None
fail_under: 98.8
format: None
html_dir: htmlcov
html_skip_covered: None
html_skip_empty: None
html_title: Coverage report
ignore_errors: False
include_namespace_packages: False
json_output: coverage.json
json_pretty_print: False
json_show_contexts: False
lcov_output: coverage.lcov
parallel: True
partial_always_list: while (True|1|False|0):
if (True|1|False|0):
partial_list: #\s*(pragma|PRAGMA)[:\s]?\s*(no|NO)\s*(branch|BRANCH)
paths: {}
plugin_options: {'coverage_conditional_plugin': {'rules': '\n"sys_platform == \'win32\'": py-win32\n"sys_platform != \'win32\'": py-not-win32\n"sys_platform == \'linux\'": py-linux\n"sys_platform == \'darwin\'": py-darwin\n"sys_version_info >= (3, 8)": py-gte-38\n"sys_version_info < (3, 8)": py-lt-38'}}
plugins: coverage_conditional_plugin
precision: 2
relative_files: False
report_contexts: None
report_include: None
report_omit: None
run_include: -none-
run_omit: venv/*
show_contexts: False
show_missing: True
sigterm: True
skip_covered: True
skip_empty: False
sort: None
source: None
source_pkgs: uvicorn
tests
timid: False
xml_output: coverage.xml
xml_package_depth: 99
-- end -------------------------------------------------------
============================= test session starts ==============================
platform linux -- Python 3.10.11, pytest-7.2.1, pluggy-1.0.0
rootdir: /home/runner/work/uvicorn/uvicorn, configfile: setup.cfg
plugins: anyio-3.6.2, mock-3.10.0
collected 484 items
tests/test_auto_detection.py ... [ 0%]
tests/test_cli.py ............ [ 3%]
tests/test_config.py .................................................. [ 13%]
tests/test_default_headers.py ...... [ 14%]
tests/test_lifespan.py ................ [ 17%]
tests/test_main.py ......... [ 19%]
tests/test_ssl.py .... [ 20%]
tests/test_subprocess.py .. [ 21%]
tests/test_workers.py .................... [ 25%]
tests/importer/test_importer.py ...... [ 26%]
tests/middleware/test_logging.py .............. [ 29%]
tests/middleware/test_message_logger.py .. [ 29%]
tests/middleware/test_proxy_headers.py ........... [ 32%]
tests/middleware/test_wsgi.py ........... [ 34%]
tests/protocols/test_http.py ........................................... [ 43%]
................................................................... [ 57%]
tests/protocols/test_utils.py ...... [ 58%]
tests/protocols/test_websocket.py ...................................... [ 66%]
........................................................................ [ 80%]
........................................................ [ 92%]
tests/supervisors/test_multiprocess.py . [ 92%]
tests/supervisors/test_reload.py .............F......F...F.......... [100%]
=================================== FAILURES ===================================
_ TestBaseReload.test_should_not_reload_when_exclude_pattern_match_file_is_changed[WatchFilesReload] _
self = <test_reload.TestBaseReload object at 0x7f7f17c7a110>
touch_soon = <function touch_soon.<locals>.start at 0x7f7f1324e710>
@pytest.mark.parametrize("reloader_class", [WatchFilesReload, WatchGodReload])
def test_should_not_reload_when_exclude_pattern_match_file_is_changed(
self, touch_soon
) -> None:
python_file = self.reload_path / "app" / "src" / "main.py"
css_file = self.reload_path / "app" / "css" / "main.css"
js_file = self.reload_path / "app" / "js" / "main.js"
with as_cwd(self.reload_path):
config = Config(
app="tests.test_config:asgi_app",
reload=True,
reload_includes=["*"],
reload_excludes=["*.coverage*", "*.js"],
)
reloader = self._setup_reloader(config)
assert self._reload_tester(touch_soon, reloader, python_file)
> assert self._reload_tester(touch_soon, reloader, css_file)
E AssertionError: assert []
E + where [] = <bound method TestBaseReload._reload_tester of <test_reload.TestBaseReload object at 0x7f7f17c7a110>>(<function touch_soon.<locals>.start at 0x7f7f1324e710>, <uvicorn.supervisors.watchfilesreload.WatchFilesReload object at 0x7f7f17d410c0>, PosixPath('/tmp/pytest-of-runner/pytest-0/reload_directory1/app/css/main.css'))
E + where <bound method TestBaseReload._reload_tester of <test_reload.TestBaseReload object at 0x7f7f17c7a110>> = <test_reload.TestBaseReload object at 0x7f7f17c7a110>._reload_tester
tests/supervisors/test_reload.py:168: AssertionError
----------------------------- Captured stderr call -----------------------------
INFO: Will watch for changes in these directories: ['/tmp/pytest-of-runner/pytest-0/reload_directory1/.dotted_dir', '/tmp/pytest-of-runner/pytest-0/reload_directory1/app', '/tmp/pytest-of-runner/pytest-0/reload_directory1/app_first', '/tmp/pytest-of-runner/pytest-0/reload_directory1/app_second', '/tmp/pytest-of-runner/pytest-0/reload_directory1/app_third', '/tmp/pytest-of-runner/pytest-0/reload_directory1/ext']
INFO: Started reloader process [1950] using WatchFiles
------------------------------ Captured log call -------------------------------
INFO uvicorn.error:config.py:343 Will watch for changes in these directories: ['/tmp/pytest-of-runner/pytest-0/reload_directory1/.dotted_dir', '/tmp/pytest-of-runner/pytest-0/reload_directory1/app', '/tmp/pytest-of-runner/pytest-0/reload_directory1/app_first', '/tmp/pytest-of-runner/pytest-0/reload_directory1/app_second', '/tmp/pytest-of-runner/pytest-0/reload_directory1/app_third', '/tmp/pytest-of-runner/pytest-0/reload_directory1/ext']
INFO uvicorn.error:basereload.py:77 Started reloader process [1950] using WatchFiles
_ TestBaseReload.test_should_reload_when_directories_have_same_prefix[WatchFilesReload] _
self = <test_reload.TestBaseReload object at 0x7f7f17c7aa10>
touch_soon = <function touch_soon.<locals>.start at 0x7f7f132c11b0>
@pytest.mark.parametrize(
"reloader_class", [StatReload, WatchGodReload, WatchFilesReload]
)
def test_should_reload_when_directories_have_same_prefix(self, touch_soon) -> None:
app_dir = self.reload_path / "app"
app_file = app_dir / "src" / "main.py"
app_first_dir = self.reload_path / "app_first"
app_first_file = app_first_dir / "src" / "main.py"
with as_cwd(self.reload_path):
config = Config(
app="tests.test_config:asgi_app",
reload=True,
reload_dirs=[str(app_dir), str(app_first_dir)],
)
reloader = self._setup_reloader(config)
assert self._reload_tester(touch_soon, reloader, app_file)
> assert self._reload_tester(touch_soon, reloader, app_first_file)
E AssertionError: assert []
E + where [] = <bound method TestBaseReload._reload_tester of <test_reload.TestBaseReload object at 0x7f7f17c7aa10>>(<function touch_soon.<locals>.start at 0x7f7f132c11b0>, <uvicorn.supervisors.watchfilesreload.WatchFilesReload object at 0x7f7f17ff9390>, PosixPath('/tmp/pytest-of-runner/pytest-0/reload_directory1/app_first/src/main.py'))
E + where <bound method TestBaseReload._reload_tester of <test_reload.TestBaseReload object at 0x7f7f17c7aa10>> = <test_reload.TestBaseReload object at 0x7f7f17c7aa10>._reload_tester
tests/supervisors/test_reload.py:205: AssertionError
----------------------------- Captured stderr call -----------------------------
INFO: Will watch for changes in these directories: ['/tmp/pytest-of-runner/pytest-0/reload_directory1/app', '/tmp/pytest-of-runner/pytest-0/reload_directory1/app_first']
INFO: Started reloader process [1950] using WatchFiles
------------------------------ Captured log call -------------------------------
INFO uvicorn.error:config.py:343 Will watch for changes in these directories: ['/tmp/pytest-of-runner/pytest-0/reload_directory1/app', '/tmp/pytest-of-runner/pytest-0/reload_directory1/app_first']
INFO uvicorn.error:basereload.py:77 Started reloader process [1950] using WatchFiles
___________ TestBaseReload.test_override_defaults[WatchFilesReload] ____________
self = <test_reload.TestBaseReload object at 0x7f7f17c7b880>
touch_soon = <function touch_soon.<locals>.start at 0x7f7f132c16c0>
@pytest.mark.parametrize("reloader_class", [WatchFilesReload, WatchGodReload])
def test_override_defaults(self, touch_soon) -> None:
dotted_file = self.reload_path / ".dotted"
dotted_dir_file = self.reload_path / ".dotted_dir" / "file.txt"
python_file = self.reload_path / "main.py"
with as_cwd(self.reload_path):
config = Config(
app="tests.test_config:asgi_app",
reload=True,
# We need to add *.txt otherwise no regular files will match
reload_includes=[".*", "*.txt"],
reload_excludes=["*.coverage*", "*.py"],
)
reloader = self._setup_reloader(config)
assert self._reload_tester(touch_soon, reloader, dotted_file)
> assert self._reload_tester(touch_soon, reloader, dotted_dir_file)
E AssertionError: assert []
E + where [] = <bound method TestBaseReload._reload_tester of <test_reload.TestBaseReload object at 0x7f7f17c7b880>>(<function touch_soon.<locals>.start at 0x7f7f132c16c0>, <uvicorn.supervisors.watchfilesreload.WatchFilesReload object at 0x7f7f17a64b20>, PosixPath('/tmp/pytest-of-runner/pytest-0/reload_directory1/.dotted_dir/file.txt'))
E + where <bound method TestBaseReload._reload_tester of <test_reload.TestBaseReload object at 0x7f7f17c7b880>> = <test_reload.TestBaseReload object at 0x7f7f17c7b880>._reload_tester
tests/supervisors/test_reload.py:250: AssertionError
----------------------------- Captured stderr call -----------------------------
INFO: Will watch for changes in these directories: ['/tmp/pytest-of-runner/pytest-0/reload_directory1']
INFO: Started reloader process [1950] using WatchFiles
------------------------------ Captured log call -------------------------------
INFO uvicorn.error:config.py:343 Will watch for changes in these directories: ['/tmp/pytest-of-runner/pytest-0/reload_directory1']
INFO uvicorn.error:basereload.py:77 Started reloader process [1950] using WatchFiles
=================== 3 failed, 481 passed in 93.14s (0:01:33) ===================
This PR caused the flaky behavior: #1804 Revert PR welcome, or solution to the problem... 😅 |
Thank you for pointing that out. I will take a look into it. |
To run the test suite without subprocess tests: | ||
|
||
```shell | ||
$ scripts/test -m "not subprocess" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it possible to not test by default, and do the opposite: scripts/test -m "subprocess"
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can. The way to disable subprocess tests by default is to add the -m "not subprocess"
to addopts
in pyproject.toml
.
The downsides to this approach are:
- When we do want to run the subprocess tests, test commands and invocation become more complicated. We would have to add another marker called
unmarked
and add it to all the other tests (Apply a default marker to all unmarked tests pytest-dev/pytest#8573), then do-m "subprocess or unmarked"
to run all the tests. I tried it but pytest is still skipping a lot of tests and I'm having trouble with it. - Coverage reports will fail by default, which may confuse people.
- It doesn't match up as well with the pytest docs on custom markers (the docs recommend the
"not subprocess"
approach for skipping tests with custom markers).
I prefer the "not subprocess"
approach overall.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm inclined about removing the workers.py
module from uvicorn.
Conflicts: pyproject.toml requirements.txt
If possible, please consider covering the ssl_version parameter as well. Trying to update ai-api-1 | [2023-07-27 11:00:32 +0300] [766] [INFO] Booting worker with pid: 766
ai-api-1 | [2023-07-27 11:00:32 +0300] [760] [ERROR] Exception in worker process
ai-api-1 | Traceback (most recent call last):
ai-api-1 | File "/opt/venv/lib/python3.10/site-packages/gunicorn/arbiter.py", line 609, in spawn_worker
ai-api-1 | worker.init_process()
ai-api-1 | File "/opt/venv/lib/python3.10/site-packages/uvicorn/workers.py", line 66, in init_process
ai-api-1 | super(UvicornWorker, self).init_process()
ai-api-1 | File "/opt/venv/lib/python3.10/site-packages/gunicorn/workers/base.py", line 142, in init_process
ai-api-1 | self.run()
ai-api-1 | File "/opt/venv/lib/python3.10/site-packages/uvicorn/workers.py", line 98, in run
ai-api-1 | return asyncio.run(self._serve())
ai-api-1 | File "/usr/local/lib/python3.10/asyncio/runners.py", line 44, in run
ai-api-1 | return loop.run_until_complete(main)
ai-api-1 | File "uvloop/loop.pyx", line 1517, in uvloop.loop.Loop.run_until_complete
ai-api-1 | File "/opt/venv/lib/python3.10/site-packages/uvicorn/workers.py", line 93, in _serve
ai-api-1 | await server.serve(sockets=self.sockets)
ai-api-1 | File "/opt/venv/lib/python3.10/site-packages/uvicorn/server.py", line 68, in serve
ai-api-1 | config.load()
ai-api-1 | File "/opt/venv/lib/python3.10/site-packages/uvicorn/config.py", line 430, in load
ai-api-1 | self.ssl: Optional[ssl.SSLContext] = create_ssl_context(
ai-api-1 | File "/opt/venv/lib/python3.10/site-packages/uvicorn/config.py", line 119, in create_ssl_context
ai-api-1 | ctx = ssl.SSLContext(ssl_version)
ai-api-1 | File "/usr/local/lib/python3.10/ssl.py", line 496, in __new__
ai-api-1 | self = _SSLContext.__new__(cls, protocol)
ai-api-1 | TypeError: 'str' object cannot be interpreted as an integer Relevant code: # prestart.sh
export GUNICORN_CMD_ARGS=" \
--ssl-version=5 \
--cert-reqs=2 \
--ca-certs=/app/cert/[...] \
--keyfile=/app/cert/[...] \
--certfile=/app/cert/[...]" # start.sh
# ... random stuff
# Start Gunicorn
exec python -m gunicorn -k "$WORKER_CLASS" -c "$GUNICORN_CONF" "$APP_MODULE" |
I'm going to push this code to https://pypi.org/project/uvicorn-worker/ when I have a bit more time, and deprecate the uvicorn worker on uvicorn code source. |
@br3ndonland I've invited you as a collaborator. I'll be closing this, and deprecating the worker from uvicorn. |
This project supports the Gunicorn web server. Gunicorn's server design includes a primary "arbiter" process that spawns "worker" processes. Workers are child processes, each with their own running server. Workers are implemented as Python classes. Custom workers can be supplied. This project also supports the Uvicorn web server. In the past, Uvicorn supplied workers for use with Gunicorn. The Uvicorn workers were not tested. The `uvicorn.workers` module was completely omitted from coverage measurement due to use of the coverage.py `include` setting to specify source files. Efforts were made to test the Uvicorn workers (encode/uvicorn#1834, encode/uvicorn#1995), but the workers were arbitrarily deprecated and moved to someone's personal project (encode/uvicorn#2302), instead of an Encode-managed project as would have been expected (encode/uvicorn#517 (comment)) Rather than introducing a production dependency on a separate Uvicorn workers package that is not managed by Encode, this commit will add the Gunicorn workers directly to this project. This commit will add the code from `uvicorn.workers` to a new module `inboard/gunicorn_workers.py`. The code will be preserved as it was prior to deprecation, with a copy of the Uvicorn license and necessary updates for compliance with the code quality settings in this project: - Ruff [UP008](https://docs.astral.sh/ruff/rules/super-call-with-parameters/) - Ruff [UP035](https://docs.astral.sh/ruff/rules/deprecated-import/) - mypy `[attr-defined]` - Module "uvicorn.main" does not explicitly export attribute "Server" - mypy `[import-untyped]` - `gunicorn.arbiter` - mypy `[import-untyped]` - `gunicorn.workers.base` - mypy `[misc]` - Class cannot subclass "Worker" (has type "Any") - mypy `[type-arg]` - Missing type parameters for generic type "dict" (on `config_kwargs`) This commit will also add tests of 100% of the Gunicorn worker code to a new module `tests/test_gunicorn_workers.py`. A test fixture starts a subprocess running Gunicorn with a Uvicorn worker and an ASGI app. The subprocess includes an instance of `httpx.Client` for HTTP requests to the Uvicorn worker's ASGI app, and saves its output to a temporary file for assertions on `stdout`/`stderr`. Tests can send operating system signals to the process. The coverage.py configuration will be updated for subprocess test coverage measurement. Changes to coverage measurement include: - Enable the required parallel mode (note that it is important to ensure the `.gitignore` ignores files named `.coverage.*` because many coverage files are generated when subprocesses are measured in parallel mode) - Set the required `COVERAGE_PROCESS_START` environment variable - Add the `coverage_enable_subprocess` package to invoke `coverage.process_startup` - Combine coverage reports before reporting coverage - Add instructions to `contributing.md` about how to omit subprocess tests Related: https://github.com/encode/uvicorn/blob/4fd507718eb0313e2de66123e6737b054088f722/LICENSE.md https://github.com/encode/uvicorn/blob/4fd507718eb0313e2de66123e6737b054088f722/uvicorn/workers.py encode/uvicorn#517 (comment) encode/uvicorn#1834 encode/uvicorn#1995 encode/uvicorn#2302 https://coverage.readthedocs.io/en/latest/subprocess.html https://docs.gunicorn.org/en/latest/design.html https://docs.gunicorn.org/en/latest/signals.html https://www.uvicorn.org/deployment/#gunicorn
Description
The
uvicorn.workers
module provides worker classes intended for use as Gunicorn workers. The Uvicorn docs say, "For production deployments we recommend using gunicorn with the uvicorn worker class." Other projects suggest similar use cases. For example, Sanic suggests running Gunicorn with the Uvicorn worker (by exposing the Sanic app instance as an ASGI app to Uvicorn).Although we recommend running workers in production, we don't have tests for this approach. It would be helpful to test the workers if we're recommending them for production deployments. PR #631 added some tests for the Gunicorn worker, but these tests were deemed "flaky" (unclear why) and removed in #650 and #658. Gunicorn doesn't really test its own workers either (benoitc/gunicorn#2742), which gives us even more reason to run tests in this project.
Changes
Worker tests
This PR offers tests for 100% of the Gunicorn worker code in a new module
tests/test_workers.py
. Test fixtures are also stored intests/test_workers.py
, rather than intests/conftest.py
, to cleanly separate code depending on Gunicorn from the rest of the tests.A test fixture starts a subprocess running Gunicorn with a Uvicorn worker and an ASGI app. The subprocess includes an instance of
httpx.Client
for HTTP requests to the Uvicorn worker's ASGI app, and saves its output to a temporary file for assertions onstdout
/stderr
. Tests can send signals to the process.Coverage configuration
Previously,
uvicorn.workers
was completely omitted from coverage measurement due to use of theinclude
setting to specify source files. Uvicorn recently switched the coverage.py config from theinclude
setting to thesource_pkgs
setting, which shows the expected coverage (#1990 (comment)).This PR will add tests to cover 100% of the worker code by configuring coverage.py for subprocess test coverage measurement. There is some longstanding awkwardness involved (nedbat/coveragepy#367). Changes in this PR include:
COVERAGE_PROCESS_START
environment variablecoverage_enable_subprocess
package to invokecoverage.process_startup
.gitignore
to ignore files named.coverage*
(many coverage files are generated when subprocesses are measured in parallel mode)Checklist
Related
Uvicorn
tests/test_gunicorn_workers.py
, reverted by Revert "Config coverage increase" #650 and Revert "Add test for uvicorn worker (#631)" #658)SIGQUIT
signal handling. When aSIGINT
is sent to the Gunicorn process, Gunicorn sendsSIGQUIT
to the Uvicorn worker instead ofSIGINT
, and the Uvicorn worker runshandle_exit
instead ofhandle_quit
([Question] - Worker signal send behavior benoitc/gunicorn#2604, [BUG] Received duplicated signal from Gunicorn #1116). ASIGQUIT
handler was added in AddSIGQUIT
handler toUvicornWorker
#1710, but this behavior has not been tested.Other projects