mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-01 18:21:30 +08:00
BodyReceiver.receive() now blocks after body is finished until actual disconnect, instead of returning http.disconnect immediately. This fixes Django's listen_for_disconnect task thinking client disconnected early. Adds regression tests for the fix. Fixes #3484
1108 lines
38 KiB
Python
1108 lines
38 KiB
Python
#
|
|
# This file is part of gunicorn released under the MIT license.
|
|
# See the NOTICE for more information.
|
|
|
|
"""
|
|
ASGI 3.0 specification compliance tests.
|
|
|
|
Tests that gunicorn's ASGI implementation conforms to the ASGI 3.0 spec:
|
|
https://asgi.readthedocs.io/en/latest/specs/main.html
|
|
"""
|
|
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from gunicorn.config import Config
|
|
|
|
|
|
# ============================================================================
|
|
# ASGI Version Tests
|
|
# ============================================================================
|
|
|
|
class TestASGIVersion:
|
|
"""Test ASGI version information in scope."""
|
|
|
|
def _create_protocol(self):
|
|
"""Create an ASGIProtocol instance for testing."""
|
|
from gunicorn.asgi.protocol import ASGIProtocol
|
|
|
|
worker = mock.Mock()
|
|
worker.cfg = Config()
|
|
worker.log = mock.Mock()
|
|
worker.asgi = mock.Mock()
|
|
|
|
return ASGIProtocol(worker)
|
|
|
|
def _create_mock_request(self, **kwargs):
|
|
"""Create a mock HTTP request."""
|
|
request = mock.Mock()
|
|
request.method = kwargs.get("method", "GET")
|
|
path = kwargs.get("path", "/")
|
|
request.path = path
|
|
request.raw_path = kwargs.get("raw_path", path.encode("latin-1") if path else b"")
|
|
request.query = kwargs.get("query", "")
|
|
request.version = kwargs.get("version", (1, 1))
|
|
request.scheme = kwargs.get("scheme", "http")
|
|
request.headers = kwargs.get("headers", [])
|
|
return request
|
|
|
|
def test_asgi_version_present(self):
|
|
"""Test that 'asgi' key is present in HTTP scope."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(
|
|
request,
|
|
("127.0.0.1", 8000),
|
|
("127.0.0.1", 12345),
|
|
)
|
|
|
|
assert "asgi" in scope
|
|
|
|
def test_asgi_version_is_dict(self):
|
|
"""Test that 'asgi' value is a dictionary."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(
|
|
request,
|
|
("127.0.0.1", 8000),
|
|
("127.0.0.1", 12345),
|
|
)
|
|
|
|
assert isinstance(scope["asgi"], dict)
|
|
|
|
def test_asgi_version_value(self):
|
|
"""Test that ASGI version is '3.0'."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(
|
|
request,
|
|
("127.0.0.1", 8000),
|
|
("127.0.0.1", 12345),
|
|
)
|
|
|
|
assert scope["asgi"]["version"] == "3.0"
|
|
|
|
def test_asgi_spec_version_present(self):
|
|
"""Test that spec_version is present in ASGI dict."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(
|
|
request,
|
|
("127.0.0.1", 8000),
|
|
("127.0.0.1", 12345),
|
|
)
|
|
|
|
assert "spec_version" in scope["asgi"]
|
|
|
|
def test_asgi_spec_version_value(self):
|
|
"""Test that spec_version follows semantic versioning."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(
|
|
request,
|
|
("127.0.0.1", 8000),
|
|
("127.0.0.1", 12345),
|
|
)
|
|
|
|
spec_version = scope["asgi"]["spec_version"]
|
|
# Should be in format "X.Y" (major.minor)
|
|
parts = spec_version.split(".")
|
|
assert len(parts) == 2
|
|
assert all(part.isdigit() for part in parts)
|
|
|
|
|
|
# ============================================================================
|
|
# HTTP Scope Keys Tests (ASGI HTTP Connection Scope)
|
|
# ============================================================================
|
|
|
|
class TestHTTPScopeKeys:
|
|
"""Test required keys in HTTP connection scope per ASGI spec."""
|
|
|
|
def _create_protocol(self):
|
|
"""Create an ASGIProtocol instance for testing."""
|
|
from gunicorn.asgi.protocol import ASGIProtocol
|
|
|
|
worker = mock.Mock()
|
|
worker.cfg = Config()
|
|
worker.log = mock.Mock()
|
|
worker.asgi = mock.Mock()
|
|
|
|
return ASGIProtocol(worker)
|
|
|
|
def _create_mock_request(self, **kwargs):
|
|
"""Create a mock HTTP request."""
|
|
request = mock.Mock()
|
|
request.method = kwargs.get("method", "GET")
|
|
path = kwargs.get("path", "/")
|
|
request.path = path
|
|
request.raw_path = kwargs.get("raw_path", path.encode("latin-1") if path else b"")
|
|
request.query = kwargs.get("query", "")
|
|
request.version = kwargs.get("version", (1, 1))
|
|
request.scheme = kwargs.get("scheme", "http")
|
|
request.headers = kwargs.get("headers", [])
|
|
return request
|
|
|
|
def test_type_key_present(self):
|
|
"""Test 'type' key is present and equals 'http'."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(
|
|
request,
|
|
("127.0.0.1", 8000),
|
|
("127.0.0.1", 12345),
|
|
)
|
|
|
|
assert scope["type"] == "http"
|
|
|
|
def test_http_version_key_present(self):
|
|
"""Test 'http_version' key is present."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(
|
|
request,
|
|
("127.0.0.1", 8000),
|
|
("127.0.0.1", 12345),
|
|
)
|
|
|
|
assert "http_version" in scope
|
|
assert scope["http_version"] == "1.1"
|
|
|
|
def test_http_version_formats(self):
|
|
"""Test various HTTP version formats."""
|
|
protocol = self._create_protocol()
|
|
|
|
# HTTP/1.0
|
|
request_10 = self._create_mock_request(version=(1, 0))
|
|
scope_10 = protocol._build_http_scope(request_10, None, None)
|
|
assert scope_10["http_version"] == "1.0"
|
|
|
|
# HTTP/1.1
|
|
request_11 = self._create_mock_request(version=(1, 1))
|
|
scope_11 = protocol._build_http_scope(request_11, None, None)
|
|
assert scope_11["http_version"] == "1.1"
|
|
|
|
def test_method_key_present(self):
|
|
"""Test 'method' key is present and is uppercase string."""
|
|
protocol = self._create_protocol()
|
|
|
|
for method in ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]:
|
|
request = self._create_mock_request(method=method)
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
assert scope["method"] == method
|
|
assert scope["method"].isupper()
|
|
|
|
def test_scheme_key_present(self):
|
|
"""Test 'scheme' key is present."""
|
|
protocol = self._create_protocol()
|
|
|
|
# HTTP
|
|
request_http = self._create_mock_request(scheme="http")
|
|
scope_http = protocol._build_http_scope(request_http, None, None)
|
|
assert scope_http["scheme"] == "http"
|
|
|
|
# HTTPS
|
|
request_https = self._create_mock_request(scheme="https")
|
|
scope_https = protocol._build_http_scope(request_https, None, None)
|
|
assert scope_https["scheme"] == "https"
|
|
|
|
def test_path_key_present(self):
|
|
"""Test 'path' key is present and starts with /."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request(path="/api/users")
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert "path" in scope
|
|
assert scope["path"] == "/api/users"
|
|
assert scope["path"].startswith("/")
|
|
|
|
def test_raw_path_key_present(self):
|
|
"""Test 'raw_path' key is present and is bytes."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request(path="/api/users")
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert "raw_path" in scope
|
|
assert isinstance(scope["raw_path"], bytes)
|
|
assert scope["raw_path"] == b"/api/users"
|
|
|
|
def test_query_string_key_present(self):
|
|
"""Test 'query_string' key is present and is bytes."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request(query="page=1&limit=10")
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert "query_string" in scope
|
|
assert isinstance(scope["query_string"], bytes)
|
|
assert scope["query_string"] == b"page=1&limit=10"
|
|
|
|
def test_query_string_empty(self):
|
|
"""Test 'query_string' is empty bytes when no query."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request(query="")
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert scope["query_string"] == b""
|
|
|
|
def test_root_path_key_present(self):
|
|
"""Test 'root_path' key is present."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert "root_path" in scope
|
|
assert isinstance(scope["root_path"], str)
|
|
|
|
def test_headers_key_present(self):
|
|
"""Test 'headers' key is present and is list of 2-tuples."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request(
|
|
headers=[("HOST", "localhost"), ("ACCEPT", "text/html")]
|
|
)
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert "headers" in scope
|
|
assert isinstance(scope["headers"], list)
|
|
|
|
for header in scope["headers"]:
|
|
assert isinstance(header, tuple)
|
|
assert len(header) == 2
|
|
|
|
def test_headers_are_bytes(self):
|
|
"""Test that header names and values are bytes."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request(
|
|
headers=[("HOST", "localhost"), ("CONTENT-TYPE", "application/json")]
|
|
)
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
for name, value in scope["headers"]:
|
|
assert isinstance(name, bytes), f"Header name should be bytes: {name}"
|
|
assert isinstance(value, bytes), f"Header value should be bytes: {value}"
|
|
|
|
def test_headers_names_lowercase(self):
|
|
"""Test that header names are lowercase."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request(
|
|
headers=[("HOST", "localhost"), ("Content-Type", "application/json")]
|
|
)
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
for name, _ in scope["headers"]:
|
|
assert name == name.lower(), f"Header name should be lowercase: {name}"
|
|
|
|
def test_server_key_present(self):
|
|
"""Test 'server' key is present when sockname provided."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(
|
|
request,
|
|
("127.0.0.1", 8000),
|
|
("127.0.0.1", 12345),
|
|
)
|
|
|
|
assert "server" in scope
|
|
assert scope["server"] == ("127.0.0.1", 8000)
|
|
|
|
def test_server_key_none(self):
|
|
"""Test 'server' key is None when sockname not provided."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert scope["server"] is None
|
|
|
|
def test_client_key_present(self):
|
|
"""Test 'client' key is present when peername provided."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(
|
|
request,
|
|
("127.0.0.1", 8000),
|
|
("192.168.1.100", 54321),
|
|
)
|
|
|
|
assert "client" in scope
|
|
assert scope["client"] == ("192.168.1.100", 54321)
|
|
|
|
def test_client_key_none(self):
|
|
"""Test 'client' key is None when peername not provided."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert scope["client"] is None
|
|
|
|
|
|
# ============================================================================
|
|
# HTTP Message Format Tests
|
|
# ============================================================================
|
|
|
|
class TestHTTPMessageFormats:
|
|
"""Test HTTP message formats per ASGI spec."""
|
|
|
|
def test_http_request_message_format(self):
|
|
"""Test http.request message format."""
|
|
message = {
|
|
"type": "http.request",
|
|
"body": b"request body",
|
|
"more_body": False,
|
|
}
|
|
|
|
assert message["type"] == "http.request"
|
|
assert isinstance(message["body"], bytes)
|
|
assert isinstance(message["more_body"], bool)
|
|
|
|
def test_http_request_message_empty_body(self):
|
|
"""Test http.request message with empty body."""
|
|
message = {
|
|
"type": "http.request",
|
|
"body": b"",
|
|
"more_body": False,
|
|
}
|
|
|
|
assert message["body"] == b""
|
|
assert message["more_body"] is False
|
|
|
|
def test_http_response_start_format(self):
|
|
"""Test http.response.start message format."""
|
|
message = {
|
|
"type": "http.response.start",
|
|
"status": 200,
|
|
"headers": [
|
|
(b"content-type", b"text/plain"),
|
|
(b"content-length", b"13"),
|
|
],
|
|
}
|
|
|
|
assert message["type"] == "http.response.start"
|
|
assert isinstance(message["status"], int)
|
|
assert 100 <= message["status"] < 600
|
|
assert isinstance(message["headers"], list)
|
|
|
|
def test_http_response_body_format(self):
|
|
"""Test http.response.body message format."""
|
|
message = {
|
|
"type": "http.response.body",
|
|
"body": b"Hello, World!",
|
|
"more_body": False,
|
|
}
|
|
|
|
assert message["type"] == "http.response.body"
|
|
assert isinstance(message["body"], bytes)
|
|
assert isinstance(message["more_body"], bool)
|
|
|
|
def test_http_response_body_streaming(self):
|
|
"""Test http.response.body message for streaming."""
|
|
# First chunk
|
|
chunk1 = {
|
|
"type": "http.response.body",
|
|
"body": b"First chunk",
|
|
"more_body": True,
|
|
}
|
|
|
|
# Last chunk
|
|
chunk2 = {
|
|
"type": "http.response.body",
|
|
"body": b"Last chunk",
|
|
"more_body": False,
|
|
}
|
|
|
|
assert chunk1["more_body"] is True
|
|
assert chunk2["more_body"] is False
|
|
|
|
def test_http_disconnect_format(self):
|
|
"""Test http.disconnect message format."""
|
|
message = {"type": "http.disconnect"}
|
|
|
|
assert message["type"] == "http.disconnect"
|
|
|
|
|
|
# ============================================================================
|
|
# HTTP Response Status Codes Tests
|
|
# ============================================================================
|
|
|
|
class TestHTTPStatusCodes:
|
|
"""Test HTTP status code handling."""
|
|
|
|
def _create_protocol(self):
|
|
"""Create an ASGIProtocol instance for testing."""
|
|
from gunicorn.asgi.protocol import ASGIProtocol
|
|
|
|
worker = mock.Mock()
|
|
worker.cfg = Config()
|
|
worker.log = mock.Mock()
|
|
worker.asgi = mock.Mock()
|
|
|
|
return ASGIProtocol(worker)
|
|
|
|
def test_reason_phrase_informational(self):
|
|
"""Test reason phrases for 1xx status codes."""
|
|
protocol = self._create_protocol()
|
|
|
|
assert protocol._get_reason_phrase(100) == "Continue"
|
|
assert protocol._get_reason_phrase(101) == "Switching Protocols"
|
|
assert protocol._get_reason_phrase(103) == "Early Hints"
|
|
|
|
def test_reason_phrase_success(self):
|
|
"""Test reason phrases for 2xx status codes."""
|
|
protocol = self._create_protocol()
|
|
|
|
assert protocol._get_reason_phrase(200) == "OK"
|
|
assert protocol._get_reason_phrase(201) == "Created"
|
|
assert protocol._get_reason_phrase(202) == "Accepted"
|
|
assert protocol._get_reason_phrase(204) == "No Content"
|
|
assert protocol._get_reason_phrase(206) == "Partial Content"
|
|
|
|
def test_reason_phrase_redirect(self):
|
|
"""Test reason phrases for 3xx status codes."""
|
|
protocol = self._create_protocol()
|
|
|
|
assert protocol._get_reason_phrase(301) == "Moved Permanently"
|
|
assert protocol._get_reason_phrase(302) == "Found"
|
|
assert protocol._get_reason_phrase(303) == "See Other"
|
|
assert protocol._get_reason_phrase(304) == "Not Modified"
|
|
assert protocol._get_reason_phrase(307) == "Temporary Redirect"
|
|
assert protocol._get_reason_phrase(308) == "Permanent Redirect"
|
|
|
|
def test_reason_phrase_client_error(self):
|
|
"""Test reason phrases for 4xx status codes."""
|
|
protocol = self._create_protocol()
|
|
|
|
assert protocol._get_reason_phrase(400) == "Bad Request"
|
|
assert protocol._get_reason_phrase(401) == "Unauthorized"
|
|
assert protocol._get_reason_phrase(403) == "Forbidden"
|
|
assert protocol._get_reason_phrase(404) == "Not Found"
|
|
assert protocol._get_reason_phrase(405) == "Method Not Allowed"
|
|
assert protocol._get_reason_phrase(408) == "Request Timeout"
|
|
assert protocol._get_reason_phrase(409) == "Conflict"
|
|
assert protocol._get_reason_phrase(410) == "Gone"
|
|
assert protocol._get_reason_phrase(422) == "Unprocessable Entity"
|
|
assert protocol._get_reason_phrase(429) == "Too Many Requests"
|
|
|
|
def test_reason_phrase_server_error(self):
|
|
"""Test reason phrases for 5xx status codes."""
|
|
protocol = self._create_protocol()
|
|
|
|
assert protocol._get_reason_phrase(500) == "Internal Server Error"
|
|
assert protocol._get_reason_phrase(501) == "Not Implemented"
|
|
assert protocol._get_reason_phrase(502) == "Bad Gateway"
|
|
assert protocol._get_reason_phrase(503) == "Service Unavailable"
|
|
assert protocol._get_reason_phrase(504) == "Gateway Timeout"
|
|
|
|
def test_reason_phrase_unknown(self):
|
|
"""Test reason phrase for unknown status codes."""
|
|
protocol = self._create_protocol()
|
|
|
|
assert protocol._get_reason_phrase(999) == "Unknown"
|
|
assert protocol._get_reason_phrase(418) == "Unknown" # I'm a teapot not defined
|
|
|
|
|
|
# ============================================================================
|
|
# Informational Response Tests (103 Early Hints, etc.)
|
|
# ============================================================================
|
|
|
|
class TestInformationalResponses:
|
|
"""Test support for HTTP 1xx informational responses."""
|
|
|
|
def test_http_response_informational_format(self):
|
|
"""Test http.response.informational message format."""
|
|
message = {
|
|
"type": "http.response.informational",
|
|
"status": 103,
|
|
"headers": [
|
|
(b"link", b"</style.css>; rel=preload; as=style"),
|
|
],
|
|
}
|
|
|
|
assert message["type"] == "http.response.informational"
|
|
assert 100 <= message["status"] < 200
|
|
assert isinstance(message["headers"], list)
|
|
|
|
def test_early_hints_103(self):
|
|
"""Test 103 Early Hints message format."""
|
|
message = {
|
|
"type": "http.response.informational",
|
|
"status": 103,
|
|
"headers": [
|
|
(b"link", b"</style.css>; rel=preload; as=style"),
|
|
(b"link", b"</script.js>; rel=preload; as=script"),
|
|
],
|
|
}
|
|
|
|
assert message["status"] == 103
|
|
|
|
|
|
# ============================================================================
|
|
# ASGI Extensions Tests
|
|
# ============================================================================
|
|
|
|
class TestASGIExtensions:
|
|
"""Test ASGI extensions support."""
|
|
|
|
def _create_protocol(self):
|
|
"""Create an ASGIProtocol instance for testing."""
|
|
from gunicorn.asgi.protocol import ASGIProtocol
|
|
|
|
worker = mock.Mock()
|
|
worker.cfg = Config()
|
|
worker.log = mock.Mock()
|
|
worker.asgi = mock.Mock()
|
|
|
|
return ASGIProtocol(worker)
|
|
|
|
def _create_mock_http2_request(self, **kwargs):
|
|
"""Create a mock HTTP/2 request with priority."""
|
|
request = mock.Mock()
|
|
request.method = kwargs.get("method", "GET")
|
|
request.path = kwargs.get("path", "/")
|
|
request.query = kwargs.get("query", "")
|
|
request.uri = kwargs.get("uri", "/")
|
|
request.scheme = kwargs.get("scheme", "https")
|
|
request.headers = kwargs.get("headers", [])
|
|
request.priority_weight = kwargs.get("priority_weight", 16)
|
|
request.priority_depends_on = kwargs.get("priority_depends_on", 0)
|
|
return request
|
|
|
|
def test_http2_scope_has_extensions(self):
|
|
"""Test that HTTP/2 scope includes extensions dict."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_http2_request()
|
|
|
|
scope = protocol._build_http2_scope(request, None, None)
|
|
|
|
assert "extensions" in scope
|
|
assert isinstance(scope["extensions"], dict)
|
|
|
|
def test_http2_priority_extension(self):
|
|
"""Test http.response.priority extension in HTTP/2 scope."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_http2_request(
|
|
priority_weight=128,
|
|
priority_depends_on=5,
|
|
)
|
|
|
|
scope = protocol._build_http2_scope(request, None, None)
|
|
|
|
assert "http.response.priority" in scope["extensions"]
|
|
priority = scope["extensions"]["http.response.priority"]
|
|
assert "weight" in priority
|
|
assert "depends_on" in priority
|
|
assert priority["weight"] == 128
|
|
assert priority["depends_on"] == 5
|
|
|
|
def test_http2_trailers_extension(self):
|
|
"""Test http.response.trailers extension in HTTP/2 scope."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_http2_request()
|
|
|
|
scope = protocol._build_http2_scope(request, None, None)
|
|
|
|
assert "http.response.trailers" in scope["extensions"]
|
|
|
|
def test_http_response_trailers_message_format(self):
|
|
"""Test http.response.trailers message format."""
|
|
message = {
|
|
"type": "http.response.trailers",
|
|
"headers": [
|
|
(b"grpc-status", b"0"),
|
|
(b"grpc-message", b""),
|
|
],
|
|
"more_trailers": False,
|
|
}
|
|
|
|
assert message["type"] == "http.response.trailers"
|
|
assert isinstance(message["headers"], list)
|
|
|
|
|
|
# ============================================================================
|
|
# State Sharing Tests
|
|
# ============================================================================
|
|
|
|
class TestStateSharing:
|
|
"""Test state sharing between lifespan and request scopes."""
|
|
|
|
def _create_protocol_with_state(self, state):
|
|
"""Create an ASGIProtocol with worker state."""
|
|
from gunicorn.asgi.protocol import ASGIProtocol
|
|
|
|
worker = mock.Mock()
|
|
worker.cfg = Config()
|
|
worker.log = mock.Mock()
|
|
worker.asgi = mock.Mock()
|
|
worker.state = state
|
|
|
|
return ASGIProtocol(worker)
|
|
|
|
def _create_mock_request(self):
|
|
"""Create a mock HTTP request."""
|
|
request = mock.Mock()
|
|
request.method = "GET"
|
|
request.path = "/"
|
|
request.raw_path = b"/"
|
|
request.query = ""
|
|
request.version = (1, 1)
|
|
request.scheme = "http"
|
|
request.headers = []
|
|
return request
|
|
|
|
def test_state_in_http_scope(self):
|
|
"""Test that state dict is included in HTTP scope."""
|
|
state = {"db": "connected", "cache": "ready"}
|
|
protocol = self._create_protocol_with_state(state)
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert "state" in scope
|
|
assert scope["state"] == state
|
|
|
|
def test_state_is_same_object(self):
|
|
"""Test that state is the same object (not a copy)."""
|
|
state = {"counter": 0}
|
|
protocol = self._create_protocol_with_state(state)
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
# Modifying scope["state"] should modify the original
|
|
scope["state"]["counter"] = 1
|
|
assert state["counter"] == 1
|
|
|
|
def test_state_not_present_without_worker_state(self):
|
|
"""Test that state is not in scope if worker has no state."""
|
|
from gunicorn.asgi.protocol import ASGIProtocol
|
|
|
|
worker = mock.Mock(spec=["cfg", "log", "asgi"])
|
|
worker.cfg = Config()
|
|
worker.log = mock.Mock()
|
|
worker.asgi = mock.Mock()
|
|
|
|
protocol = ASGIProtocol(worker)
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert "state" not in scope
|
|
|
|
|
|
# ============================================================================
|
|
# HTTP Disconnect Event Tests (ASGI Spec Compliance)
|
|
# https://asgi.readthedocs.io/en/latest/specs/www.html#disconnect-receive-event
|
|
# ============================================================================
|
|
|
|
class TestHTTPDisconnectEvent:
|
|
"""Test http.disconnect event compliance with ASGI spec.
|
|
|
|
Per the ASGI HTTP Connection Scope spec:
|
|
- Disconnect event is sent when client closes connection
|
|
- Event type MUST be "http.disconnect"
|
|
- Apps should receive this event and clean up gracefully
|
|
"""
|
|
|
|
def _create_protocol(self):
|
|
"""Create an ASGIProtocol instance for testing."""
|
|
from gunicorn.asgi.protocol import ASGIProtocol
|
|
|
|
worker = mock.Mock()
|
|
worker.cfg = Config()
|
|
worker.log = mock.Mock()
|
|
worker.asgi = mock.Mock()
|
|
worker.nr_conns = 1
|
|
worker.loop = mock.Mock()
|
|
|
|
protocol = ASGIProtocol(worker)
|
|
protocol.reader = mock.Mock()
|
|
|
|
return protocol
|
|
|
|
def test_disconnect_event_type(self):
|
|
"""Test that disconnect event signals body receiver per ASGI spec."""
|
|
from gunicorn.asgi.protocol import BodyReceiver
|
|
|
|
protocol = self._create_protocol()
|
|
|
|
# Create a mock request for the body receiver
|
|
mock_request = mock.Mock()
|
|
mock_request.content_length = 100
|
|
mock_request.chunked = False
|
|
|
|
body_receiver = BodyReceiver(mock_request, protocol)
|
|
protocol._body_receiver = body_receiver
|
|
|
|
# Simulate client disconnect
|
|
protocol.connection_lost(None)
|
|
|
|
# Per ASGI spec: disconnect should be signaled
|
|
assert body_receiver._closed
|
|
|
|
def test_disconnect_event_sent_on_connection_lost(self):
|
|
"""Test that disconnect is signaled when connection is lost."""
|
|
from gunicorn.asgi.protocol import BodyReceiver
|
|
|
|
protocol = self._create_protocol()
|
|
|
|
# Create a mock request for the body receiver
|
|
mock_request = mock.Mock()
|
|
mock_request.content_length = 100
|
|
mock_request.chunked = False
|
|
|
|
body_receiver = BodyReceiver(mock_request, protocol)
|
|
protocol._body_receiver = body_receiver
|
|
|
|
assert not body_receiver._closed
|
|
|
|
# Simulate client disconnect
|
|
protocol.connection_lost(None)
|
|
|
|
# Disconnect should have been signaled
|
|
assert body_receiver._closed
|
|
|
|
def test_disconnect_sets_closed_flag(self):
|
|
"""Test that connection_lost sets the closed flag."""
|
|
protocol = self._create_protocol()
|
|
|
|
assert protocol._closed is False
|
|
|
|
protocol.connection_lost(None)
|
|
|
|
assert protocol._closed is True
|
|
|
|
def test_disconnect_allows_graceful_cleanup(self):
|
|
"""Test that disconnect doesn't immediately cancel task.
|
|
|
|
Per ASGI spec, apps should have opportunity to clean up
|
|
when they receive http.disconnect.
|
|
"""
|
|
protocol = self._create_protocol()
|
|
|
|
# Create a mock task
|
|
mock_task = mock.Mock()
|
|
mock_task.done.return_value = False
|
|
protocol._task = mock_task
|
|
|
|
# Simulate disconnect
|
|
protocol.connection_lost(None)
|
|
|
|
# Task should NOT be cancelled immediately
|
|
mock_task.cancel.assert_not_called()
|
|
|
|
# Cancellation should be scheduled after grace period
|
|
protocol.worker.loop.call_later.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_message_format(self):
|
|
"""Test http.disconnect message format per ASGI spec.
|
|
|
|
When body is complete and disconnect is signaled, receive()
|
|
should return {"type": "http.disconnect"}.
|
|
"""
|
|
import asyncio
|
|
from gunicorn.asgi.protocol import BodyReceiver
|
|
|
|
protocol = self._create_protocol()
|
|
|
|
# Create a mock request with no body
|
|
mock_request = mock.Mock()
|
|
mock_request.content_length = 0
|
|
mock_request.chunked = False
|
|
|
|
body_receiver = BodyReceiver(mock_request, protocol)
|
|
protocol._body_receiver = body_receiver
|
|
|
|
# Get initial body message (empty body)
|
|
msg1 = await body_receiver.receive()
|
|
assert msg1["type"] == "http.request"
|
|
assert msg1["more_body"] is False
|
|
|
|
# Signal disconnect (simulating connection_lost)
|
|
# After the fix, receive() waits for actual disconnect signal
|
|
body_receiver.signal_disconnect()
|
|
|
|
# Now receive should return disconnect
|
|
msg2 = await body_receiver.receive()
|
|
|
|
# Per ASGI spec, disconnect message only has 'type'
|
|
assert msg2 == {"type": "http.disconnect"}
|
|
assert len(msg2) == 1
|
|
|
|
|
|
# ============================================================================
|
|
# BodyReceiver Disconnect Regression Tests
|
|
# https://github.com/benoitc/gunicorn/issues/3484
|
|
# ============================================================================
|
|
|
|
class TestBodyReceiverDisconnect:
|
|
"""Regression tests for BodyReceiver._wait_for_disconnect() behavior.
|
|
|
|
The original bug: BodyReceiver.receive() immediately returned
|
|
`http.disconnect` when `_body_finished` was True, but Django (and other
|
|
ASGI frameworks) call `receive()` to listen for client disconnect AFTER
|
|
the response is sent. This caused Django's `listen_for_disconnect` task
|
|
to think the client disconnected before the response could be sent.
|
|
|
|
The fix: After body is finished, receive() now calls _wait_for_disconnect()
|
|
which blocks until signal_disconnect() is called or the waiter is cancelled.
|
|
"""
|
|
|
|
def _create_protocol(self):
|
|
"""Create an ASGIProtocol instance for testing."""
|
|
from gunicorn.asgi.protocol import ASGIProtocol
|
|
|
|
worker = mock.Mock()
|
|
worker.cfg = Config()
|
|
worker.log = mock.Mock()
|
|
worker.asgi = mock.Mock()
|
|
worker.nr_conns = 1
|
|
worker.loop = mock.Mock()
|
|
|
|
protocol = ASGIProtocol(worker)
|
|
protocol._closed = False
|
|
return protocol
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_body_receiver_waits_for_disconnect_after_body_finished(self):
|
|
"""Test that receive() blocks after body is finished until disconnect is signaled.
|
|
|
|
This tests the core regression fix: after body is complete, calling receive()
|
|
should NOT immediately return http.disconnect. It should block until the
|
|
connection actually closes.
|
|
"""
|
|
from gunicorn.asgi.protocol import BodyReceiver
|
|
|
|
protocol = self._create_protocol()
|
|
|
|
# Create a request with no body (body finishes immediately)
|
|
mock_request = mock.Mock()
|
|
mock_request.content_length = 0
|
|
mock_request.chunked = False
|
|
|
|
body_receiver = BodyReceiver(mock_request, protocol)
|
|
|
|
# Get the initial body message (empty body, more_body=False)
|
|
msg1 = await body_receiver.receive()
|
|
assert msg1["type"] == "http.request"
|
|
assert msg1["body"] == b""
|
|
assert msg1["more_body"] is False
|
|
|
|
# At this point, _body_finished is True
|
|
assert body_receiver._body_finished is True
|
|
assert body_receiver._closed is False
|
|
|
|
# Now calling receive() should block, not return immediately
|
|
# We test this by starting receive() as a task and verifying it doesn't complete
|
|
import asyncio
|
|
|
|
receive_task = asyncio.create_task(body_receiver.receive())
|
|
|
|
# Give the task a moment to start
|
|
await asyncio.sleep(0.01)
|
|
|
|
# Task should NOT be done yet (it's waiting for disconnect)
|
|
assert not receive_task.done()
|
|
|
|
# Now signal disconnect
|
|
body_receiver.signal_disconnect()
|
|
|
|
# Task should complete now
|
|
msg2 = await asyncio.wait_for(receive_task, timeout=1.0)
|
|
assert msg2 == {"type": "http.disconnect"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_body_receiver_immediate_disconnect_if_already_closed(self):
|
|
"""Test that receive() immediately returns http.disconnect if already closed.
|
|
|
|
If signal_disconnect() has already been called before receive(),
|
|
it should return http.disconnect immediately without blocking.
|
|
"""
|
|
from gunicorn.asgi.protocol import BodyReceiver
|
|
|
|
protocol = self._create_protocol()
|
|
|
|
# Create a request with body
|
|
mock_request = mock.Mock()
|
|
mock_request.content_length = 100
|
|
mock_request.chunked = False
|
|
|
|
body_receiver = BodyReceiver(mock_request, protocol)
|
|
|
|
# Signal disconnect BEFORE calling receive
|
|
body_receiver.signal_disconnect()
|
|
assert body_receiver._closed is True
|
|
|
|
# receive() should return disconnect immediately
|
|
msg = await body_receiver.receive()
|
|
assert msg == {"type": "http.disconnect"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_body_receiver_respects_protocol_closed_state(self):
|
|
"""Test that receive() checks protocol._closed state.
|
|
|
|
If the protocol is closed but signal_disconnect wasn't called,
|
|
receive() should still detect the disconnect.
|
|
"""
|
|
from gunicorn.asgi.protocol import BodyReceiver
|
|
|
|
protocol = self._create_protocol()
|
|
|
|
mock_request = mock.Mock()
|
|
mock_request.content_length = 0
|
|
mock_request.chunked = False
|
|
|
|
body_receiver = BodyReceiver(mock_request, protocol)
|
|
|
|
# Consume the body first
|
|
msg1 = await body_receiver.receive()
|
|
assert msg1["type"] == "http.request"
|
|
assert msg1["more_body"] is False
|
|
|
|
# Mark protocol as closed
|
|
protocol._closed = True
|
|
|
|
# Start receive task - should detect protocol closure
|
|
import asyncio
|
|
|
|
receive_task = asyncio.create_task(body_receiver.receive())
|
|
|
|
# Give it a moment
|
|
await asyncio.sleep(0.01)
|
|
|
|
# Wake up the waiter by signaling disconnect
|
|
body_receiver.signal_disconnect()
|
|
|
|
msg2 = await asyncio.wait_for(receive_task, timeout=1.0)
|
|
assert msg2 == {"type": "http.disconnect"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_asgi_app_with_disconnect_listener(self):
|
|
"""Test Django-style ASGI app pattern that listens for disconnect.
|
|
|
|
This simulates a real-world scenario where an ASGI app:
|
|
1. Reads the request body
|
|
2. Sends a response
|
|
3. Calls receive() to wait for client disconnect (background task)
|
|
|
|
The bug caused step 3 to return immediately with http.disconnect,
|
|
making Django think the client disconnected mid-response.
|
|
"""
|
|
from gunicorn.asgi.protocol import BodyReceiver
|
|
|
|
protocol = self._create_protocol()
|
|
|
|
# Simulate a POST request with body
|
|
mock_request = mock.Mock()
|
|
mock_request.content_length = 13
|
|
mock_request.chunked = False
|
|
|
|
body_receiver = BodyReceiver(mock_request, protocol)
|
|
|
|
# Simulate sending body data via callback
|
|
body_receiver.feed(b"Hello, World!")
|
|
body_receiver.set_complete()
|
|
|
|
# Step 1: App reads the body
|
|
msg1 = await body_receiver.receive()
|
|
assert msg1["type"] == "http.request"
|
|
assert msg1["body"] == b"Hello, World!"
|
|
assert msg1["more_body"] is False
|
|
|
|
# At this point body is finished
|
|
assert body_receiver._body_finished is True
|
|
|
|
# Step 2: App would send response here (simulated)
|
|
response_sent = True
|
|
|
|
# Step 3: App starts listening for disconnect (like Django does)
|
|
import asyncio
|
|
|
|
disconnect_received = asyncio.Event()
|
|
|
|
async def listen_for_disconnect():
|
|
"""Simulates Django's disconnect listener task."""
|
|
msg = await body_receiver.receive()
|
|
if msg["type"] == "http.disconnect":
|
|
disconnect_received.set()
|
|
return msg
|
|
|
|
listener_task = asyncio.create_task(listen_for_disconnect())
|
|
|
|
# Give listener task time to start waiting
|
|
await asyncio.sleep(0.01)
|
|
|
|
# Listener should be blocked waiting, not done
|
|
assert not listener_task.done()
|
|
assert response_sent # Response was sent before disconnect detected
|
|
|
|
# Simulate client closing connection after receiving response
|
|
body_receiver.signal_disconnect()
|
|
|
|
# Now listener should complete
|
|
msg = await asyncio.wait_for(listener_task, timeout=1.0)
|
|
assert msg == {"type": "http.disconnect"}
|
|
assert disconnect_received.is_set()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_body_receiver_cancellation_during_wait(self):
|
|
"""Test that receive() handles cancellation while waiting for disconnect.
|
|
|
|
When the ASGI task is cancelled (e.g., timeout), the waiting receive()
|
|
catches the CancelledError, marks itself as closed, and the cancellation
|
|
propagates up from the await. The body receiver is marked as closed
|
|
to ensure subsequent calls return disconnect immediately.
|
|
"""
|
|
from gunicorn.asgi.protocol import BodyReceiver
|
|
|
|
protocol = self._create_protocol()
|
|
|
|
mock_request = mock.Mock()
|
|
mock_request.content_length = 0
|
|
mock_request.chunked = False
|
|
|
|
body_receiver = BodyReceiver(mock_request, protocol)
|
|
|
|
# Consume body
|
|
await body_receiver.receive()
|
|
|
|
import asyncio
|
|
|
|
receive_task = asyncio.create_task(body_receiver.receive())
|
|
|
|
# Let it start waiting
|
|
await asyncio.sleep(0.01)
|
|
assert not receive_task.done()
|
|
|
|
# Cancel the task
|
|
receive_task.cancel()
|
|
|
|
# Wait for the task to finish - it may raise CancelledError
|
|
# or return disconnect depending on timing
|
|
try:
|
|
msg = await receive_task
|
|
# If it returns, it should be a disconnect message
|
|
assert msg == {"type": "http.disconnect"}
|
|
except asyncio.CancelledError:
|
|
# Cancellation propagated - this is also valid
|
|
pass
|
|
|
|
# Body receiver should be marked as closed after cancellation
|
|
assert body_receiver._closed is True
|