Merge pull request #3450 from benoitc/fix/ssl-want-read-error-3448

fix: handle SSLWantReadError in finish_body() (#3448)
This commit is contained in:
Benoit Chesneau 2026-01-23 11:17:46 +01:00 committed by GitHub
commit 7c22955837
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 164 additions and 26 deletions

View File

@ -36,17 +36,18 @@ jobs:
- name: Install Dependencies (non-toxic) - name: Install Dependencies (non-toxic)
if: ${{ ! matrix.toxenv }} if: ${{ ! matrix.toxenv }}
run: | run: |
python -m pip install sphinx python -m pip install --upgrade pip
- name: "Update docs" python -m pip install -e .
- name: "Check generated docs"
if: ${{ ! matrix.toxenv }} if: ${{ ! matrix.toxenv }}
run: | run: |
# this will update docs/source/settings.rst - but will not create html output # Regenerate settings.md and check for uncommitted changes
(cd docs && sphinx-build -b "dummy" -d _build/doctrees source "_build/dummy") python scripts/build_settings_doc.py
git update-index --assume-unchanged docs/source/settings.rst
if unclean=$(git status --untracked-files=no --porcelain) && [ -z "$unclean" ]; then if unclean=$(git status --untracked-files=no --porcelain) && [ -z "$unclean" ]; then
echo "no uncommitted changes in working tree (as it should be)" echo "no uncommitted changes in working tree (as it should be)"
else 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" echo "$unclean"
git diff
exit 2 exit 2
fi fi

View File

@ -285,7 +285,14 @@ Format: https://docs.python.org/3/library/logging.config.html#logging.config.jso
**Command line:** `--log-syslog-to SYSLOG_ADDR` **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. 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: A string referring to one of the following bundled classes:
* ``sync`` * ``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]``) ``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]``) ``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]``) ``pip install gunicorn[tornado]``)
* ``gthread`` - Python 2 requires the futures package to be installed * ``gthread`` - Python 2 requires the futures package to be installed
(or install it via ``pip install gunicorn[gthread]``) (or install it via ``pip install gunicorn[gthread]``)

View File

@ -1568,6 +1568,15 @@ class SyslogTo(Setting):
else: else:
default = "udp://localhost:514" 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 = """\ desc = """\
Address to send syslog messages. Address to send syslog messages.

View File

@ -2,6 +2,8 @@
# This file is part of gunicorn released under the MIT license. # This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information. # See the NOTICE for more information.
import ssl
from gunicorn.http.message import Request from gunicorn.http.message import Request
from gunicorn.http.unreader import SocketUnreader, IterUnreader from gunicorn.http.unreader import SocketUnreader, IterUnreader
@ -33,9 +35,13 @@ class Parser:
leftover body bytes. leftover body bytes.
""" """
if self.mesg: if self.mesg:
data = self.mesg.body.read(8192) try:
while data:
data = self.mesg.body.read(8192) 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): def __next__(self):
# Stop if HTTP dictates a stop. # Stop if HTTP dictates a stop.

View File

@ -5,19 +5,17 @@
"""Tests for the gthread worker.""" """Tests for the gthread worker."""
import errno import errno
import fcntl
import os import os
import queue
import selectors import selectors
import threading import threading
import time import time
from collections import deque from collections import deque
from concurrent import futures from concurrent import futures
from functools import partial
from unittest import mock from unittest import mock
import pytest import pytest
from gunicorn import http
from gunicorn.config import Config from gunicorn.config import Config
from gunicorn.workers import gthread from gunicorn.workers import gthread
@ -85,7 +83,7 @@ class TestTConn:
sock = FakeSocket() sock = FakeSocket()
sock.setblocking(True) 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__ # TConn sets socket to non-blocking in __init__
assert sock.blocking is False assert sock.blocking is False
@ -147,7 +145,7 @@ class TestPollableMethodQueue:
q.init() q.init()
results = [] results = []
q.defer(lambda x: results.append(x), 42) q.defer(results.append, 42)
# Simulate the selector reading from the pipe # Simulate the selector reading from the pipe
q.run_callbacks(None) q.run_callbacks(None)
@ -162,7 +160,7 @@ class TestPollableMethodQueue:
results = [] results = []
for i in range(5): for i in range(5):
q.defer(lambda x: results.append(x), i) q.defer(results.append, i)
q.run_callbacks(None) q.run_callbacks(None)
@ -220,9 +218,6 @@ class TestPollableMethodQueue:
def test_queue_nonblocking_pipe(self): def test_queue_nonblocking_pipe(self):
"""Test that pipe is non-blocking (BSD compatibility).""" """Test that pipe is non-blocking (BSD compatibility)."""
import os
import fcntl
q = gthread.PollableMethodQueue() q = gthread.PollableMethodQueue()
q.init() q.init()
@ -889,18 +884,22 @@ class TestWorkerLiveness:
# Track notify calls # Track notify calls
notify_calls = [] notify_calls = []
original_notify = worker.notify original_notify = worker.notify
def tracking_notify(): def tracking_notify():
notify_calls.append(time.monotonic()) notify_calls.append(time.monotonic())
original_notify() original_notify()
worker.notify = tracking_notify worker.notify = tracking_notify
# Mock poller.select to exit after first iteration # Mock poller.select to exit after first iteration
call_count = [0] call_count = [0]
def mock_select(timeout): def mock_select(timeout):
call_count[0] += 1 call_count[0] += 1
if call_count[0] > 1: if call_count[0] > 1:
worker.alive = False worker.alive = False
return [] return []
worker.poller.select.side_effect = mock_select worker.poller.select.side_effect = mock_select
# Mock is_parent_alive to return True # Mock is_parent_alive to return True
@ -1010,6 +1009,7 @@ class TestSignalHandling:
# Track iterations # Track iterations
iterations = [0] iterations = [0]
def mock_select(timeout): def mock_select(timeout):
iterations[0] += 1 iterations[0] += 1
if iterations[0] == 1: if iterations[0] == 1:
@ -1022,6 +1022,7 @@ class TestSignalHandling:
# Connection finishes # Connection finishes
worker.nr_conns = 0 worker.nr_conns = 0
return [] return []
worker.poller.select.side_effect = mock_select worker.poller.select.side_effect = mock_select
worker.is_parent_alive = mock.Mock(return_value=True) worker.is_parent_alive = mock.Mock(return_value=True)
@ -1096,9 +1097,11 @@ class TestWorkerArbiterIntegration:
worker.ppid = 99999999 # Invalid ppid worker.ppid = 99999999 # Invalid ppid
iterations = [0] iterations = [0]
def mock_select(timeout): def mock_select(timeout):
iterations[0] += 1 iterations[0] += 1
return [] return []
worker.poller.select.side_effect = mock_select worker.poller.select.side_effect = mock_select
worker.run() worker.run()
@ -1282,3 +1285,119 @@ class TestSignalInteraction:
assert worker.alive is False # But shutting down assert worker.alive is False # But shutting down
worker.method_queue.close() 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)

View File

@ -33,6 +33,7 @@ commands =
gunicorn \ gunicorn \
tests/test_arbiter.py \ tests/test_arbiter.py \
tests/test_config.py \ tests/test_config.py \
tests/test_gthread.py \
tests/test_http.py \ tests/test_http.py \
tests/test_invalid_requests.py \ tests/test_invalid_requests.py \
tests/test_logger.py \ tests/test_logger.py \
@ -48,16 +49,11 @@ deps =
[testenv:docs-lint] [testenv:docs-lint]
no_package = true no_package = true
allowlist_externals =
rst-lint
bash
grep
deps = deps =
restructuredtext_lint restructuredtext_lint
pygments pygments
commands = commands =
rst-lint README.rst docs/README.rst 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] [testenv:pycodestyle]
no_package = true no_package = true