mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-07 21:21:30 +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)
|
- 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
|
||||||
|
|||||||
@ -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]``)
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
6
tox.ini
6
tox.ini
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user