mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-03 19:21:29 +08:00
Merge pull request #3450 from benoitc/fix/ssl-want-read-error-3448
fix: handle SSLWantReadError in finish_body() (#3448)
This commit is contained in:
commit
7c22955837
13
.github/workflows/lint.yml
vendored
13
.github/workflows/lint.yml
vendored
@ -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
|
||||
|
||||
@ -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]``)
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
6
tox.ini
6
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user