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)
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

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`
**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]``)

View File

@ -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.

View File

@ -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.

View File

@ -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)

View File

@ -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