diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 11f27c83..8e1e6a2d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,17 +36,18 @@ jobs: - name: Install Dependencies (non-toxic) if: ${{ ! matrix.toxenv }} run: | - python -m pip install sphinx - - name: "Update docs" + python -m pip install --upgrade pip + python -m pip install -e . + - name: "Check generated docs" if: ${{ ! matrix.toxenv }} run: | - # this will update docs/source/settings.rst - but will not create html output - (cd docs && sphinx-build -b "dummy" -d _build/doctrees source "_build/dummy") - git update-index --assume-unchanged docs/source/settings.rst + # Regenerate settings.md and check for uncommitted changes + python scripts/build_settings_doc.py if unclean=$(git status --untracked-files=no --porcelain) && [ -z "$unclean" ]; then echo "no uncommitted changes in working tree (as it should be)" else - echo "did you forget to run `make -C docs html`?" + echo "did you forget to run 'python scripts/build_settings_doc.py'?" echo "$unclean" + git diff exit 2 fi diff --git a/docs/content/reference/settings.md b/docs/content/reference/settings.md index e24227bf..52cd3152 100644 --- a/docs/content/reference/settings.md +++ b/docs/content/reference/settings.md @@ -285,7 +285,14 @@ Format: https://docs.python.org/3/library/logging.config.html#logging.config.jso **Command line:** `--log-syslog-to SYSLOG_ADDR` -**Default:** `'unix:///var/run/syslog'` +**Default:** + +Platform-specific: + +* macOS: ``'unix:///var/run/syslog'`` +* FreeBSD/DragonFly: ``'unix:///var/run/log'`` +* OpenBSD: ``'unix:///dev/log'`` +* Linux/other: ``'udp://localhost:514'`` Address to send syslog messages. @@ -1442,11 +1449,11 @@ libraries may be installed using setuptools' ``extras_require`` feature. A string referring to one of the following bundled classes: * ``sync`` -* ``eventlet`` - Requires eventlet >= 0.24.1 (or install it via +* ``eventlet`` - Requires eventlet >= 0.40.3 (or install it via ``pip install gunicorn[eventlet]``) -* ``gevent`` - Requires gevent >= 1.4 (or install it via +* ``gevent`` - Requires gevent >= 24.10.1 (or install it via ``pip install gunicorn[gevent]``) -* ``tornado`` - Requires tornado >= 0.2 (or install it via +* ``tornado`` - Requires tornado >= 6.5.0 (or install it via ``pip install gunicorn[tornado]``) * ``gthread`` - Python 2 requires the futures package to be installed (or install it via ``pip install gunicorn[gthread]``) diff --git a/gunicorn/config.py b/gunicorn/config.py index d4f75068..01f8a837 100644 --- a/gunicorn/config.py +++ b/gunicorn/config.py @@ -1568,6 +1568,15 @@ class SyslogTo(Setting): else: default = "udp://localhost:514" + default_doc = """\ + Platform-specific: + + * macOS: ``'unix:///var/run/syslog'`` + * FreeBSD/DragonFly: ``'unix:///var/run/log'`` + * OpenBSD: ``'unix:///dev/log'`` + * Linux/other: ``'udp://localhost:514'`` + """ + desc = """\ Address to send syslog messages. diff --git a/gunicorn/http/parser.py b/gunicorn/http/parser.py index 05ee6ca6..5b2da115 100644 --- a/gunicorn/http/parser.py +++ b/gunicorn/http/parser.py @@ -2,6 +2,8 @@ # This file is part of gunicorn released under the MIT license. # See the NOTICE for more information. +import ssl + from gunicorn.http.message import Request from gunicorn.http.unreader import SocketUnreader, IterUnreader @@ -33,9 +35,13 @@ class Parser: leftover body bytes. """ if self.mesg: - data = self.mesg.body.read(8192) - while data: + try: data = self.mesg.body.read(8192) + while data: + data = self.mesg.body.read(8192) + except ssl.SSLWantReadError: + # SSL socket has no more application data available + pass def __next__(self): # Stop if HTTP dictates a stop. diff --git a/tests/test_gthread.py b/tests/test_gthread.py index b8dbea14..6e215977 100644 --- a/tests/test_gthread.py +++ b/tests/test_gthread.py @@ -5,19 +5,17 @@ """Tests for the gthread worker.""" import errno +import fcntl import os -import queue import selectors import threading import time from collections import deque from concurrent import futures -from functools import partial from unittest import mock import pytest -from gunicorn import http from gunicorn.config import Config from gunicorn.workers import gthread @@ -85,7 +83,7 @@ class TestTConn: sock = FakeSocket() sock.setblocking(True) - conn = gthread.TConn(cfg, sock, ('127.0.0.1', 12345), ('127.0.0.1', 8000)) + gthread.TConn(cfg, sock, ('127.0.0.1', 12345), ('127.0.0.1', 8000)) # TConn sets socket to non-blocking in __init__ assert sock.blocking is False @@ -147,7 +145,7 @@ class TestPollableMethodQueue: q.init() results = [] - q.defer(lambda x: results.append(x), 42) + q.defer(results.append, 42) # Simulate the selector reading from the pipe q.run_callbacks(None) @@ -162,7 +160,7 @@ class TestPollableMethodQueue: results = [] for i in range(5): - q.defer(lambda x: results.append(x), i) + q.defer(results.append, i) q.run_callbacks(None) @@ -220,9 +218,6 @@ class TestPollableMethodQueue: def test_queue_nonblocking_pipe(self): """Test that pipe is non-blocking (BSD compatibility).""" - import os - import fcntl - q = gthread.PollableMethodQueue() q.init() @@ -889,18 +884,22 @@ class TestWorkerLiveness: # Track notify calls notify_calls = [] original_notify = worker.notify + def tracking_notify(): notify_calls.append(time.monotonic()) original_notify() + worker.notify = tracking_notify # Mock poller.select to exit after first iteration call_count = [0] + def mock_select(timeout): call_count[0] += 1 if call_count[0] > 1: worker.alive = False return [] + worker.poller.select.side_effect = mock_select # Mock is_parent_alive to return True @@ -1010,6 +1009,7 @@ class TestSignalHandling: # Track iterations iterations = [0] + def mock_select(timeout): iterations[0] += 1 if iterations[0] == 1: @@ -1022,6 +1022,7 @@ class TestSignalHandling: # Connection finishes worker.nr_conns = 0 return [] + worker.poller.select.side_effect = mock_select worker.is_parent_alive = mock.Mock(return_value=True) @@ -1096,9 +1097,11 @@ class TestWorkerArbiterIntegration: worker.ppid = 99999999 # Invalid ppid iterations = [0] + def mock_select(timeout): iterations[0] += 1 return [] + worker.poller.select.side_effect = mock_select worker.run() @@ -1282,3 +1285,119 @@ class TestSignalInteraction: assert worker.alive is False # But shutting down worker.method_queue.close() + + +class TestFinishBodySSL: + """Tests for SSL error handling in finish_body().""" + + def test_finish_body_handles_ssl_want_read_error(self): + """Test that finish_body() handles SSLWantReadError gracefully. + + When discarding unread body data on SSL connections, the socket + may raise SSLWantReadError if there's no application data available. + This should be treated as "no more data" rather than an error. + """ + import ssl + from gunicorn.http.parser import RequestParser + + # Create a mock SSL socket that raises SSLWantReadError on recv + class MockSSLSocket: + def __init__(self): + self._fileno = 123 + + def fileno(self): + return self._fileno + + def recv(self, size): + raise ssl.SSLWantReadError("The operation did not complete") + + def setblocking(self, blocking): + pass + + cfg = Config() + sock = MockSSLSocket() + parser = RequestParser(cfg, sock, ('127.0.0.1', 12345)) + + # Create a mock message with a body that will trigger socket read + mock_body = mock.Mock() + mock_body.read.side_effect = ssl.SSLWantReadError("The operation did not complete") + + mock_mesg = mock.Mock() + mock_mesg.body = mock_body + parser.mesg = mock_mesg + + # finish_body() should handle SSLWantReadError without raising + parser.finish_body() # Should not raise + + # Verify body.read was called + mock_body.read.assert_called_once_with(8192) + + def test_finish_body_reads_all_data_before_ssl_error(self): + """Test that finish_body() reads all available data before SSLWantReadError.""" + import ssl + from gunicorn.http.parser import RequestParser + + cfg = Config() + + # Create a mock socket + class MockSocket: + def recv(self, size): + return b'' + + def setblocking(self, blocking): + pass + + sock = MockSocket() + parser = RequestParser(cfg, sock, ('127.0.0.1', 12345)) + + # Create a mock message body that returns data then raises SSLWantReadError + call_count = [0] + + def mock_read(size): + call_count[0] += 1 + if call_count[0] <= 2: + return b'x' * size # Return data first two times + raise ssl.SSLWantReadError("The operation did not complete") + + mock_body = mock.Mock() + mock_body.read.side_effect = mock_read + + mock_mesg = mock.Mock() + mock_mesg.body = mock_body + parser.mesg = mock_mesg + + # finish_body() should read all data and handle SSLWantReadError + parser.finish_body() # Should not raise + + # Verify body.read was called multiple times (2 data reads + 1 error) + assert call_count[0] == 3 + + def test_finish_body_normal_operation(self): + """Test that finish_body() works normally when no SSL error occurs.""" + from gunicorn.http.parser import RequestParser + + cfg = Config() + + class MockSocket: + def recv(self, size): + return b'' + + def setblocking(self, blocking): + pass + + sock = MockSocket() + parser = RequestParser(cfg, sock, ('127.0.0.1', 12345)) + + # Create a mock message body that returns empty (end of data) + mock_body = mock.Mock() + mock_body.read.return_value = b'' + + mock_mesg = mock.Mock() + mock_mesg.body = mock_body + parser.mesg = mock_mesg + + # finish_body() should work normally + parser.finish_body() + + # Verify body.read was called once and returned empty + mock_body.read.assert_called_once_with(8192) diff --git a/tox.ini b/tox.ini index 359cb90b..b328e7e2 100644 --- a/tox.ini +++ b/tox.ini @@ -33,6 +33,7 @@ commands = gunicorn \ tests/test_arbiter.py \ tests/test_config.py \ + tests/test_gthread.py \ tests/test_http.py \ tests/test_invalid_requests.py \ tests/test_logger.py \ @@ -48,16 +49,11 @@ deps = [testenv:docs-lint] no_package = true -allowlist_externals = - rst-lint - bash - grep deps = restructuredtext_lint pygments commands = rst-lint README.rst docs/README.rst - bash -c "(set -o pipefail; rst-lint --encoding utf-8 docs/source/*.rst | grep -v 'Unknown interpreted text role\|Unknown directive type'); test $? == 1" [testenv:pycodestyle] no_package = true