From 47b9a1861993748920a97495b9faf7f7374d8777 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Fri, 23 Jan 2026 09:38:41 +0100 Subject: [PATCH 1/5] fix: handle SSLWantReadError in finish_body() (#3448) The finish_body() function can raise ssl.SSLWantReadError when discarding unread request body data on SSL connections. This causes TLS requests to fail intermittently with "Invalid request" errors. Handle SSLWantReadError by treating it as "no more data to read". This is safe because finish_body() only discards leftover data before keepalive - if SSL says "need to wait for more data", there's nothing left to discard. Fixes #3448 --- gunicorn/http/parser.py | 10 +++- tests/test_gthread.py | 115 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) 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..71101a8d 100644 --- a/tests/test_gthread.py +++ b/tests/test_gthread.py @@ -1282,3 +1282,118 @@ 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) From 0e175a2d34779f936212be5cf940e957238691b3 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Fri, 23 Jan 2026 09:56:32 +0100 Subject: [PATCH 2/5] fix: resolve lint issues and remove obsolete Sphinx references - Fix lint issues in test_gthread.py: - Remove unused imports (queue, partial, http) - Move fcntl import to top level - Remove unused variable assignment - Replace unnecessary lambdas with method references - Add blank lines before nested function definitions (E306) - Update .github/workflows/lint.yml: - Replace Sphinx docs check with MkDocs settings generator - docs/source directory no longer exists after MkDocs migration - Update tox.ini: - Remove docs/source/*.rst lint (directory doesn't exist) - Add tests/test_gthread.py to lint targets --- .github/workflows/lint.yml | 12 ++++++------ tests/test_gthread.py | 22 +++++++++++++--------- tox.ini | 6 +----- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 11f27c83..a51f41fb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,17 +36,17 @@ 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" exit 2 fi diff --git a/tests/test_gthread.py b/tests/test_gthread.py index 71101a8d..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() @@ -1349,6 +1352,7 @@ class TestFinishBodySSL: # 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: 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 From 38e23175e788fe4c35d7d6340e3a56dafb4407d9 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Fri, 23 Jan 2026 09:57:33 +0100 Subject: [PATCH 3/5] docs: regenerate settings.md with updated worker versions --- docs/content/reference/settings.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/content/reference/settings.md b/docs/content/reference/settings.md index e24227bf..018a0793 100644 --- a/docs/content/reference/settings.md +++ b/docs/content/reference/settings.md @@ -1442,11 +1442,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]``) From f68ad2e095b7a69cc851fe51576c72b7a721a225 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Fri, 23 Jan 2026 10:04:57 +0100 Subject: [PATCH 4/5] ci: add git diff output to diagnose settings.md issue --- .github/workflows/lint.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a51f41fb..8e1e6a2d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -48,5 +48,6 @@ jobs: else echo "did you forget to run 'python scripts/build_settings_doc.py'?" echo "$unclean" + git diff exit 2 fi From 46e772683866854641a547d8b9dfd1d19aab0f3b Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Fri, 23 Jan 2026 10:08:01 +0100 Subject: [PATCH 5/5] fix: make syslog_addr default platform-neutral in docs The syslog_addr setting has different defaults depending on the platform (macOS, FreeBSD, OpenBSD, Linux). Added default_doc to show all platform-specific defaults in the documentation, ensuring consistent output regardless of which platform generates the docs. Also kept the diagnostic git diff in CI for future debugging. --- docs/content/reference/settings.md | 9 ++++++++- gunicorn/config.py | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/content/reference/settings.md b/docs/content/reference/settings.md index 018a0793..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. diff --git a/gunicorn/config.py b/gunicorn/config.py index 2dcf64d0..eb93857b 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.