Merge pull request #3578 from benoitc/asgi-framework-compat-tests

Add ASGI framework compatibility E2E test suite
This commit is contained in:
Benoit Chesneau 2026-04-04 03:03:01 +02:00 committed by GitHub
commit d607372482
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 7472 additions and 19 deletions

View File

@ -315,6 +315,25 @@ pip install uvloop
gunicorn myapp:app --worker-class asgi --asgi-loop uvloop
```
## Framework Compatibility
The ASGI worker has been tested for compatibility with major ASGI frameworks.
| Framework | HTTP Scope | HTTP Messages | WebSocket | Lifespan | Streaming | Total |
|-----------|---------|---------|---------|---------|---------|-------|
| Django + Channels | 19/19 | 18/19 | 13/19 | 7/8 | 9/9 | 66/74 |
| FastAPI | 19/19 | 18/19 | 19/19 | 8/8 | 9/9 | 73/74 |
| Starlette | 19/19 | 18/19 | 19/19 | 8/8 | 9/9 | 73/74 |
| Quart | 18/19 | 17/19 | 11/19 | 8/8 | 9/9 | 63/74 |
| Litestar | 18/19 | 11/19 | 17/19 | 8/8 | 9/9 | 63/74 |
| BlackSheep | 19/19 | 18/19 | 19/19 | 8/8 | 1/9 | 65/74 |
**Overall:** 403/444 tests passed (90%)
!!! note
The compatibility test suite is located in `tests/docker/asgi_framework_compat/`.
Run `docker compose up -d --build` followed by `pytest tests/ -v` to execute the tests.
## See Also
- [Settings Reference](reference/settings.md#asgi_loop) - All ASGI-related settings

View File

@ -907,15 +907,28 @@ class ASGIProtocol(asyncio.Protocol):
response_status = message["status"]
response_headers = message.get("headers", [])
# Check if Content-Length is present
has_content_length = any(
(name.lower() if isinstance(name, str) else name.lower()) == b"content-length"
or (name.lower() if isinstance(name, str) else name.lower()) == "content-length"
for name, _ in response_headers
)
# Check if Content-Length or Transfer-Encoding is present
has_content_length = False
has_transfer_encoding = False
for name, _ in response_headers:
name_lower = name.lower() if isinstance(name, str) else name.lower()
if name_lower in (b"content-length", "content-length"):
has_content_length = True
elif name_lower in (b"transfer-encoding", "transfer-encoding"):
has_transfer_encoding = True
use_chunked = True # Framework already set chunked encoding
# Use chunked encoding for HTTP/1.1 streaming responses without Content-Length
if not has_content_length and request.version >= (1, 1):
# Skip for 1xx informational responses (RFC 9110)
# Skip if Transfer-Encoding already set by framework
is_informational = 100 <= response_status < 200
needs_chunked = (
not has_content_length
and not has_transfer_encoding
and request.version >= (1, 1)
and not is_informational
)
if needs_chunked:
use_chunked = True
response_headers = list(response_headers) + [(b"transfer-encoding", b"chunked")]

View File

@ -65,6 +65,11 @@ class WebSocketProtocol:
self.close_code = None
self.close_reason = ""
# Close handshake state (RFC 6455 Section 7.1.1)
self._close_sent = False
self._close_received = False
self._close_event = asyncio.Event()
# Message reassembly state
self._fragments = []
self._fragment_opcode = None
@ -105,16 +110,21 @@ class WebSocketProtocol:
except Exception:
self.log.exception("Error in WebSocket ASGI application")
finally:
# Send close frame if not already closed
if not self.closed and self.accepted and not self._close_sent:
await self._send_close(CLOSE_INTERNAL_ERROR, "Application error")
# Wait for client's close response
try:
await asyncio.wait_for(self._close_event.wait(), timeout=5.0)
except asyncio.TimeoutError:
self.closed = True
read_task.cancel()
try:
await read_task
except asyncio.CancelledError:
pass
# Send close frame if not already closed
if not self.closed and self.accepted:
await self._send_close(CLOSE_INTERNAL_ERROR, "Application error")
async def _receive(self):
"""ASGI receive callable."""
return await self._receive_queue.get()
@ -135,16 +145,29 @@ class WebSocketProtocol:
if self.closed:
raise RuntimeError("WebSocket closed")
if "text" in message:
await self._send_frame(OPCODE_TEXT, message["text"].encode("utf-8"))
elif "bytes" in message:
await self._send_frame(OPCODE_BINARY, message["bytes"])
# Check for truthy values since both keys may be present with None
text = message.get("text")
bytes_data = message.get("bytes")
if text is not None:
await self._send_frame(OPCODE_TEXT, text.encode("utf-8"))
elif bytes_data is not None:
await self._send_frame(OPCODE_BINARY, bytes_data)
elif msg_type == "websocket.close":
code = message.get("code", CLOSE_NORMAL)
reason = message.get("reason", "")
await self._send_close(code, reason)
self.closed = True
# Wait for client's close frame (RFC 6455 close handshake)
try:
await asyncio.wait_for(self._close_event.wait(), timeout=5.0)
except asyncio.TimeoutError:
self.log.debug("WebSocket close handshake timeout")
self.closed = True
self._close_event.set()
# Close the transport after close handshake
self.transport.close()
async def _send_accept(self, message):
"""Send WebSocket handshake accept response."""
@ -191,7 +214,9 @@ class WebSocketProtocol:
async def _read_frames(self):
"""Read and process incoming WebSocket frames."""
try:
while not self.closed:
# Continue reading while not closed, or if we sent close but haven't
# received client's close response yet (RFC 6455 close handshake)
while not self.closed or (self._close_sent and not self._close_received):
frame = await self._read_frame()
if frame is None:
break
@ -353,11 +378,14 @@ class WebSocketProtocol:
self.close_code = CLOSE_NO_STATUS
self.close_reason = ""
self._close_received = True
# Echo close frame back if we haven't already sent one
if not self.closed:
if not self._close_sent:
await self._send_close(self.close_code, self.close_reason)
self.closed = True
self._close_event.set()
async def _handle_continuation(self, payload): # pylint: disable=unused-argument
"""Handle continuation frame (already processed in _read_frame)."""
@ -394,8 +422,16 @@ class WebSocketProtocol:
async def _send_close(self, code, reason=""):
"""Send a close frame."""
if self._close_sent:
return # Already sent
payload = struct.pack("!H", code)
if reason:
payload += reason.encode("utf-8")[:123] # Max 125 bytes total
await self._send_frame(OPCODE_CLOSE, payload)
self.closed = True
self._close_sent = True
# If we already received a close, handshake is complete
if self._close_received:
self.closed = True
self._close_event.set()

View File

@ -0,0 +1,116 @@
# ASGI Framework Compatibility Test Suite
This test suite validates gunicorn's native ASGI worker (`-k asgi`) against
multiple ASGI frameworks to ensure protocol compliance.
## Frameworks Tested
| Framework | Description |
|-----------|-------------|
| Django + Channels | Django with Channels for WebSocket |
| FastAPI | Modern, fast API framework (Starlette-based) |
| Starlette | Pure ASGI framework |
| Quart | Flask-like async framework |
| Litestar | Modern ASGI framework |
| BlackSheep | High-performance ASGI framework |
## Test Categories
- **HTTP Scope**: ASGI 3.0 HTTP scope compliance
- **HTTP Messages**: Request/response message handling
- **WebSocket**: WebSocket protocol compliance
- **Lifespan**: Startup/shutdown lifecycle
- **Streaming**: Chunked responses and SSE
## Quick Start
```bash
# Build and start all framework containers
docker compose up -d --build
# Run tests
pip install -r requirements.txt
pytest tests/ -v
# Generate compatibility grid
python scripts/generate_grid.py
```
## Testing Event Loop Variants
```bash
# Test with auto-detection (uvloop if available)
ASGI_LOOP=auto docker compose up -d --build
pytest tests/ -v
# Test with asyncio only
ASGI_LOOP=asyncio docker compose up -d --build
pytest tests/ -v
# Test with uvloop explicitly
ASGI_LOOP=uvloop docker compose up -d --build
pytest tests/ -v
# Generate combined report for both loop types
python scripts/generate_grid.py --loop both
```
## Single Framework Testing
```bash
# Test only FastAPI
pytest tests/ -v --framework fastapi
# Test only Django
pytest tests/ -v --framework django
```
## Directory Structure
```
asgi_framework_compat/
├── conftest.py # Test fixtures
├── docker-compose.yml # Container orchestration
├── requirements.txt # Test dependencies
├── frameworks/
│ ├── contract.py # Endpoint contract
│ ├── django_app/ # Django implementation
│ ├── fastapi_app/ # FastAPI implementation
│ ├── starlette_app/ # Starlette implementation
│ ├── quart_app/ # Quart implementation
│ ├── litestar_app/ # Litestar implementation
│ └── blacksheep_app/ # BlackSheep implementation
├── tests/
│ ├── test_http_scope.py
│ ├── test_http_messages.py
│ ├── test_websocket_scope.py
│ ├── test_lifespan_scope.py
│ └── test_streaming.py
├── scripts/
│ └── generate_grid.py # Compatibility matrix
└── results/ # Generated reports
```
## Container Management
```bash
# Start containers
docker compose up -d --build
# View logs
docker compose logs -f
# Stop containers
docker compose down
# Rebuild specific framework
docker compose build fastapi
docker compose up -d fastapi
```
## Results
After running `generate_grid.py`, check the `results/` directory for:
- `compatibility_grid_*.md` - Markdown compatibility matrices
- `compatibility_grid_*.json` - JSON data for programmatic access

View File

@ -0,0 +1,211 @@
"""
Pytest configuration for ASGI Framework Compatibility Tests
This module provides fixtures for parameterized testing across multiple
ASGI frameworks running in Docker containers with gunicorn's ASGI worker.
"""
import asyncio
import json
import os
import subprocess
import time
from typing import AsyncGenerator
import httpx
import pytest
import pytest_asyncio
import websockets
# Framework configuration
FRAMEWORKS = {
"django": {"port": 8001, "websocket_support": True},
"fastapi": {"port": 8002, "websocket_support": True},
"starlette": {"port": 8003, "websocket_support": True},
"quart": {"port": 8004, "websocket_support": True},
"litestar": {"port": 8005, "websocket_support": True},
"blacksheep": {"port": 8006, "websocket_support": True},
}
# Host for docker containers
DOCKER_HOST = os.environ.get("DOCKER_HOST_IP", "127.0.0.1")
def pytest_addoption(parser):
"""Add command line options for framework selection."""
parser.addoption(
"--framework",
action="store",
default=None,
help="Run tests only for specific framework (django, fastapi, etc.)",
)
parser.addoption(
"--skip-docker-check",
action="store_true",
default=False,
help="Skip Docker container health checks",
)
def pytest_configure(config):
"""Register custom markers."""
config.addinivalue_line(
"markers", "framework(name): mark test to run only for specific framework"
)
def pytest_collection_modifyitems(config, items):
"""Filter tests based on framework selection."""
framework_filter = config.getoption("--framework")
if framework_filter:
skip_other = pytest.mark.skip(
reason=f"Only running tests for {framework_filter}"
)
for item in items:
markers = [m for m in item.iter_markers(name="framework")]
if markers:
framework_names = [m.args[0] for m in markers]
if framework_filter not in framework_names:
item.add_marker(skip_other)
@pytest.fixture(scope="session")
def docker_compose_file():
"""Return path to docker-compose file."""
return os.path.join(os.path.dirname(__file__), "docker-compose.yml")
def wait_for_service(url: str, timeout: int = 60) -> bool:
"""Wait for a service to become healthy."""
start = time.time()
while time.time() - start < timeout:
try:
response = httpx.get(f"{url}/health", timeout=5.0)
if response.status_code == 200:
return True
except (httpx.ConnectError, httpx.TimeoutException):
pass
time.sleep(1)
return False
@pytest.fixture(scope="session")
def docker_services(docker_compose_file, request):
"""Start Docker services for testing."""
if request.config.getoption("--skip-docker-check"):
yield
return
# Check if containers are already running
all_healthy = True
for name, config in FRAMEWORKS.items():
url = f"http://{DOCKER_HOST}:{config['port']}"
try:
response = httpx.get(f"{url}/health", timeout=2.0)
if response.status_code != 200:
all_healthy = False
break
except (httpx.ConnectError, httpx.TimeoutException):
all_healthy = False
break
if all_healthy:
yield
return
# Start containers
compose_dir = os.path.dirname(docker_compose_file)
subprocess.run(
["docker", "compose", "up", "-d", "--build"],
cwd=compose_dir,
check=True,
)
# Wait for all services to be healthy
for name, config in FRAMEWORKS.items():
url = f"http://{DOCKER_HOST}:{config['port']}"
if not wait_for_service(url):
pytest.fail(f"Service {name} failed to start")
yield
# Optionally stop containers after tests
if os.environ.get("CLEANUP_DOCKER", "0") == "1":
subprocess.run(
["docker", "compose", "down"],
cwd=compose_dir,
check=True,
)
@pytest.fixture(params=list(FRAMEWORKS.keys()))
def framework(request, docker_services) -> str:
"""Parameterized fixture that yields each framework name."""
return request.param
@pytest.fixture
def framework_config(framework) -> dict:
"""Return configuration for current framework."""
return FRAMEWORKS[framework]
@pytest.fixture
def framework_url(framework) -> str:
"""Return HTTP URL for current framework."""
port = FRAMEWORKS[framework]["port"]
return f"http://{DOCKER_HOST}:{port}"
@pytest.fixture
def framework_ws_url(framework) -> str:
"""Return WebSocket URL for current framework."""
port = FRAMEWORKS[framework]["port"]
return f"ws://{DOCKER_HOST}:{port}"
@pytest_asyncio.fixture
async def http_client(framework_url) -> AsyncGenerator[httpx.AsyncClient, None]:
"""Async HTTP client for testing."""
async with httpx.AsyncClient(base_url=framework_url, timeout=30.0) as client:
yield client
@pytest.fixture
def ws_client(framework_ws_url):
"""WebSocket client factory for testing."""
async def connect(path: str, **kwargs):
uri = f"{framework_ws_url}{path}"
return await websockets.connect(uri, **kwargs)
return connect
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""Store test report for result recording."""
outcome = yield
rep = outcome.get_result()
setattr(item, f"rep_{rep.when}", rep)
# Utility fixtures
@pytest.fixture
def random_bytes():
"""Generate random bytes for testing."""
def _generate(size: int) -> bytes:
return os.urandom(size)
return _generate
@pytest.fixture
def large_body():
"""Generate large request/response body."""
def _generate(size: int) -> bytes:
return b"x" * size
return _generate

View File

@ -0,0 +1,86 @@
# ASGI Framework Compatibility Test Suite
# Tests gunicorn's native ASGI worker with multiple frameworks
#
# Usage:
# docker compose up -d --build
# ASGI_LOOP=asyncio docker compose up -d --build
# ASGI_LOOP=uvloop docker compose up -d --build
x-healthcheck: &healthcheck
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
services:
django:
build:
context: ../../..
dockerfile: tests/docker/asgi_framework_compat/frameworks/django_app/Dockerfile
ports:
- "8001:8000"
command: ["gunicorn", "asgi:application", "-k", "asgi", "-b", "0.0.0.0:8000", "--workers", "1", "--worker-connections", "100", "--asgi-loop", "${ASGI_LOOP:-auto}"]
networks:
- asgi_test_network
<<: *healthcheck
fastapi:
build:
context: ../../..
dockerfile: tests/docker/asgi_framework_compat/frameworks/fastapi_app/Dockerfile
ports:
- "8002:8000"
command: ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000", "--workers", "1", "--worker-connections", "100", "--asgi-loop", "${ASGI_LOOP:-auto}"]
networks:
- asgi_test_network
<<: *healthcheck
starlette:
build:
context: ../../..
dockerfile: tests/docker/asgi_framework_compat/frameworks/starlette_app/Dockerfile
ports:
- "8003:8000"
command: ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000", "--workers", "1", "--worker-connections", "100", "--asgi-loop", "${ASGI_LOOP:-auto}"]
networks:
- asgi_test_network
<<: *healthcheck
quart:
build:
context: ../../..
dockerfile: tests/docker/asgi_framework_compat/frameworks/quart_app/Dockerfile
ports:
- "8004:8000"
command: ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000", "--workers", "1", "--worker-connections", "100", "--asgi-loop", "${ASGI_LOOP:-auto}"]
networks:
- asgi_test_network
<<: *healthcheck
litestar:
build:
context: ../../..
dockerfile: tests/docker/asgi_framework_compat/frameworks/litestar_app/Dockerfile
ports:
- "8005:8000"
command: ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000", "--workers", "1", "--worker-connections", "100", "--asgi-loop", "${ASGI_LOOP:-auto}"]
networks:
- asgi_test_network
<<: *healthcheck
blacksheep:
build:
context: ../../..
dockerfile: tests/docker/asgi_framework_compat/frameworks/blacksheep_app/Dockerfile
ports:
- "8006:8000"
command: ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000", "--workers", "1", "--worker-connections", "100", "--asgi-loop", "${ASGI_LOOP:-auto}"]
networks:
- asgi_test_network
<<: *healthcheck
networks:
asgi_test_network:
driver: bridge

View File

@ -0,0 +1 @@
"""ASGI Framework implementations for compatibility testing."""

View File

@ -0,0 +1,23 @@
FROM python:3.12-slim
WORKDIR /app
# Install curl for healthcheck
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Copy gunicorn source and install from local
COPY gunicorn /gunicorn-src/gunicorn
COPY pyproject.toml /gunicorn-src/
COPY README.md /gunicorn-src/
RUN pip install --no-cache-dir /gunicorn-src
# Install other requirements
COPY tests/docker/asgi_framework_compat/frameworks/blacksheep_app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY tests/docker/asgi_framework_compat/frameworks/blacksheep_app/app.py .
EXPOSE 8000
# Command specified in docker-compose.yml
CMD ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000"]

View File

@ -0,0 +1,227 @@
"""
BlackSheep ASGI Application for Compatibility Testing
Implements the contract endpoints for ASGI 3.0 compliance testing.
BlackSheep is a high-performance ASGI framework.
"""
import asyncio
import json
import time
from typing import Any
from blacksheep import Application, Request, WebSocket, StreamedContent, Content
from blacksheep.server.responses import Response, text, json as json_resp
app = Application()
# Lifespan state
lifespan_state = {
"startup_called": False,
"startup_time": None,
"counter": 0,
"custom_data": {},
}
@app.on_start
async def on_startup(application: Application) -> None:
"""Startup handler."""
lifespan_state["startup_called"] = True
lifespan_state["startup_time"] = time.time()
lifespan_state["custom_data"]["initialized"] = True
@app.on_stop
async def on_shutdown(application: Application) -> None:
"""Shutdown handler."""
lifespan_state["shutdown_called"] = True
def serialize_scope(scope: dict) -> dict:
"""Convert ASGI scope to JSON-serializable dict."""
result = {}
for key, value in scope.items():
if key == "headers":
result[key] = [
[h[0].decode("latin-1"), h[1].decode("latin-1")] for h in value
]
elif key == "query_string":
result[key] = value.decode("latin-1") if value else ""
elif key == "server":
result[key] = list(value) if value else None
elif key == "client":
result[key] = list(value) if value else None
elif key == "asgi":
result[key] = dict(value)
elif key in ("state", "app", "_blacksheep"):
continue
elif isinstance(value, bytes):
result[key] = value.decode("latin-1")
else:
try:
json.dumps(value)
result[key] = value
except (TypeError, ValueError):
continue
return result
# HTTP Endpoints
@app.router.get("/health")
async def health(request: Request) -> Response:
"""Health check endpoint."""
return text("OK")
@app.router.get("/scope")
async def scope_endpoint(request: Request) -> Response:
"""Return full ASGI scope as JSON."""
scope_data = serialize_scope(request.scope)
return json_resp(scope_data)
@app.router.post("/echo")
async def echo(request: Request) -> Response:
"""Echo request body back."""
body = await request.read()
content_type = request.get_first_header(b"content-type")
if content_type:
ct = content_type
else:
ct = b"application/octet-stream"
return Response(200, content=Content(ct, body))
@app.router.get("/headers")
async def headers_endpoint(request: Request) -> Response:
"""Return request headers as JSON."""
headers_dict = {
h[0].decode("latin-1"): h[1].decode("latin-1") for h in request.headers
}
return json_resp(headers_dict)
@app.router.get("/status/{code}")
async def status_endpoint(request: Request, code: int) -> Response:
"""Return specific HTTP status code."""
return Response(code, content=Content(b"text/plain", f"Status: {code}".encode()))
@app.router.get("/streaming")
async def streaming(request: Request) -> Response:
"""Chunked streaming response."""
async def generate():
for i in range(10):
yield f"chunk-{i}\n".encode()
await asyncio.sleep(0.01)
return Response(200, content=StreamedContent(b"text/plain", generate))
@app.router.get("/sse")
async def sse(request: Request) -> Response:
"""Server-Sent Events endpoint."""
async def generate():
for i in range(5):
yield f"event: message\ndata: {json.dumps({'count': i})}\n\n".encode()
await asyncio.sleep(0.01)
yield b"event: done\ndata: {}\n\n"
response = Response(200, content=StreamedContent(b"text/event-stream", generate))
response.add_header(b"Cache-Control", b"no-cache")
return response
@app.router.get("/large")
async def large(request: Request) -> Response:
"""Large response body."""
size_param = request.query.get("size")
size = int(size_param[0]) if size_param else 1024
# Cap at 10MB for safety
size = min(size, 10 * 1024 * 1024)
return Response(200, content=Content(b"application/octet-stream", b"x" * size))
@app.router.get("/delay")
async def delay(request: Request) -> Response:
"""Delayed response."""
seconds_param = request.query.get("seconds")
seconds = float(seconds_param[0]) if seconds_param else 1.0
# Cap at 30 seconds
seconds = min(seconds, 30)
await asyncio.sleep(seconds)
return text(f"Delayed {seconds} seconds")
@app.router.get("/lifespan/state")
async def lifespan_state_endpoint(request: Request) -> Response:
"""Return lifespan startup state."""
return json_resp(lifespan_state)
@app.router.get("/lifespan/counter")
async def lifespan_counter(request: Request) -> Response:
"""Increment and return counter."""
lifespan_state["counter"] += 1
return json_resp({"counter": lifespan_state["counter"]})
# WebSocket Endpoints
@app.router.ws("/ws/echo")
async def ws_echo(websocket: WebSocket) -> None:
"""Echo text messages."""
await websocket.accept()
try:
while True:
message = await websocket.receive_text()
await websocket.send_text(message)
except Exception:
pass
@app.router.ws("/ws/echo-binary")
async def ws_echo_binary(websocket: WebSocket) -> None:
"""Echo binary messages."""
await websocket.accept()
try:
while True:
message = await websocket.receive_bytes()
await websocket.send_bytes(message)
except Exception:
pass
@app.router.ws("/ws/scope")
async def ws_scope(websocket: WebSocket) -> None:
"""Send WebSocket scope on connect."""
await websocket.accept()
scope_data = serialize_scope(websocket.scope)
await websocket.send_text(json.dumps(scope_data))
await websocket.close()
@app.router.ws("/ws/subprotocol")
async def ws_subprotocol(websocket: WebSocket) -> None:
"""Subprotocol negotiation."""
requested = websocket.scope.get("subprotocols", [])
selected = requested[0] if requested else None
await websocket.accept(subprotocol=selected)
await websocket.send_text(json.dumps({"requested": requested, "selected": selected}))
await websocket.close()
@app.router.ws("/ws/close")
async def ws_close(websocket: WebSocket) -> None:
"""Close with specific code."""
await websocket.accept()
query_string = websocket.scope.get("query_string", b"").decode()
code = 1000
for param in query_string.split("&"):
if param.startswith("code="):
code = int(param.split("=")[1])
break
await websocket.close(code=code)

View File

@ -0,0 +1,5 @@
# gunicorn is installed from local source in Dockerfile
blacksheep>=2.0.0
uvloop>=0.19.0
websockets>=12.0
httptools>=0.6.0

View File

@ -0,0 +1,143 @@
"""
ASGI Framework Contract Definition
This module defines the required endpoints that each framework must implement
for compatibility testing with gunicorn's ASGI worker.
"""
# HTTP Endpoints Contract
HTTP_ENDPOINTS = {
"health": {
"path": "/health",
"method": "GET",
"description": "Health check endpoint",
"expected_status": 200,
},
"scope": {
"path": "/scope",
"method": "GET",
"description": "Return full ASGI scope as JSON",
"expected_status": 200,
"expected_content_type": "application/json",
},
"echo": {
"path": "/echo",
"method": "POST",
"description": "Echo request body back",
"expected_status": 200,
},
"headers": {
"path": "/headers",
"method": "GET",
"description": "Return request headers as JSON",
"expected_status": 200,
"expected_content_type": "application/json",
},
"status": {
"path": "/status/{code}",
"method": "GET",
"description": "Return specific HTTP status code",
},
"streaming": {
"path": "/streaming",
"method": "GET",
"description": "Chunked streaming response",
"expected_status": 200,
},
"sse": {
"path": "/sse",
"method": "GET",
"description": "Server-Sent Events stream",
"expected_status": 200,
"expected_content_type": "text/event-stream",
},
"large": {
"path": "/large",
"method": "GET",
"description": "Large response body (size in query param)",
"expected_status": 200,
},
"delay": {
"path": "/delay",
"method": "GET",
"description": "Delayed response (seconds in query param)",
"expected_status": 200,
},
}
# WebSocket Endpoints Contract
WEBSOCKET_ENDPOINTS = {
"echo": {
"path": "/ws/echo",
"description": "Echo text messages",
},
"echo_binary": {
"path": "/ws/echo-binary",
"description": "Echo binary messages",
},
"scope": {
"path": "/ws/scope",
"description": "Send WebSocket scope on connect",
},
"subprotocol": {
"path": "/ws/subprotocol",
"description": "Subprotocol negotiation",
},
"close": {
"path": "/ws/close",
"description": "Close with specific code (code in query param)",
},
}
# Lifespan Endpoints Contract
LIFESPAN_ENDPOINTS = {
"state": {
"path": "/lifespan/state",
"method": "GET",
"description": "Return startup state",
"expected_status": 200,
},
"counter": {
"path": "/lifespan/counter",
"method": "GET",
"description": "Increment and return counter (state persistence test)",
"expected_status": 200,
},
}
# ASGI 3.0 Scope Required Keys
ASGI_HTTP_SCOPE_REQUIRED_KEYS = [
"type",
"asgi",
"http_version",
"method",
"scheme",
"path",
"query_string",
"headers",
"server",
]
ASGI_WEBSOCKET_SCOPE_REQUIRED_KEYS = [
"type",
"asgi",
"http_version",
"scheme",
"path",
"query_string",
"headers",
"server",
]
# Valid WebSocket close codes per RFC 6455
VALID_WEBSOCKET_CLOSE_CODES = [
1000, # Normal closure
1001, # Going away
1002, # Protocol error
1003, # Unsupported data
1007, # Invalid frame payload data
1008, # Policy violation
1009, # Message too big
1010, # Mandatory extension
1011, # Internal server error
]

View File

@ -0,0 +1,31 @@
FROM python:3.12-slim
WORKDIR /app
# Install curl for healthcheck
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Copy gunicorn source and install from local
COPY gunicorn /gunicorn-src/gunicorn
COPY pyproject.toml /gunicorn-src/
COPY README.md /gunicorn-src/
RUN pip install --no-cache-dir /gunicorn-src
# Install other requirements
COPY tests/docker/asgi_framework_compat/frameworks/django_app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY tests/docker/asgi_framework_compat/frameworks/django_app/asgi.py .
COPY tests/docker/asgi_framework_compat/frameworks/django_app/settings.py .
COPY tests/docker/asgi_framework_compat/frameworks/django_app/urls.py .
COPY tests/docker/asgi_framework_compat/frameworks/django_app/views.py .
COPY tests/docker/asgi_framework_compat/frameworks/django_app/consumers.py .
COPY tests/docker/asgi_framework_compat/frameworks/django_app/routing.py .
ENV DJANGO_SETTINGS_MODULE=settings
ENV PYTHONPATH=/app
EXPOSE 8000
# Command specified in docker-compose.yml
CMD ["gunicorn", "asgi:application", "-k", "asgi", "-b", "0.0.0.0:8000"]

View File

@ -0,0 +1,60 @@
"""
ASGI config for Django compatibility testing.
"""
import os
import time
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")
import django
django.setup()
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
from routing import websocket_urlpatterns
# Lifespan state - shared across the application
lifespan_state = {
"startup_called": False,
"startup_time": None,
"counter": 0,
"custom_data": {},
}
class LifespanMiddleware:
"""Custom lifespan handler for Django."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] == "lifespan":
while True:
message = await receive()
if message["type"] == "lifespan.startup":
lifespan_state["startup_called"] = True
lifespan_state["startup_time"] = time.time()
lifespan_state["custom_data"]["initialized"] = True
await send({"type": "lifespan.startup.complete"})
elif message["type"] == "lifespan.shutdown":
lifespan_state["shutdown_called"] = True
await send({"type": "lifespan.shutdown.complete"})
return
else:
# Make lifespan_state available to views
scope["lifespan_state"] = lifespan_state
await self.app(scope, receive, send)
# Get Django ASGI application
django_asgi_app = get_asgi_application()
# Combine HTTP and WebSocket routing
application = LifespanMiddleware(
ProtocolTypeRouter({
"http": django_asgi_app,
"websocket": URLRouter(websocket_urlpatterns),
})
)

View File

@ -0,0 +1,110 @@
"""
Django Channels WebSocket consumers for ASGI compatibility testing.
"""
import json
from channels.generic.websocket import AsyncWebsocketConsumer
def serialize_scope(scope: dict) -> dict:
"""Convert ASGI scope to JSON-serializable dict."""
result = {}
for key, value in scope.items():
if key == "headers":
result[key] = [
[h[0].decode("latin-1"), h[1].decode("latin-1")] for h in value
]
elif key == "query_string":
result[key] = value.decode("latin-1") if value else ""
elif key == "server":
result[key] = list(value) if value else None
elif key == "client":
result[key] = list(value) if value else None
elif key == "asgi":
result[key] = dict(value)
elif key in ("state", "app", "url_route", "path_remaining"):
continue
elif isinstance(value, bytes):
result[key] = value.decode("latin-1")
else:
try:
json.dumps(value)
result[key] = value
except (TypeError, ValueError):
continue
return result
class EchoConsumer(AsyncWebsocketConsumer):
"""Echo text messages."""
async def connect(self):
await self.accept()
async def receive(self, text_data=None, bytes_data=None):
if text_data:
await self.send(text_data=text_data)
async def disconnect(self, close_code):
pass
class EchoBinaryConsumer(AsyncWebsocketConsumer):
"""Echo binary messages."""
async def connect(self):
await self.accept()
async def receive(self, text_data=None, bytes_data=None):
if bytes_data:
await self.send(bytes_data=bytes_data)
async def disconnect(self, close_code):
pass
class ScopeConsumer(AsyncWebsocketConsumer):
"""Send WebSocket scope on connect."""
async def connect(self):
await self.accept()
scope_data = serialize_scope(self.scope)
await self.send(text_data=json.dumps(scope_data))
await self.close()
async def disconnect(self, close_code):
pass
class SubprotocolConsumer(AsyncWebsocketConsumer):
"""Subprotocol negotiation."""
async def connect(self):
requested = self.scope.get("subprotocols", [])
selected = requested[0] if requested else None
await self.accept(subprotocol=selected)
await self.send(text_data=json.dumps({
"requested": requested,
"selected": selected
}))
await self.close()
async def disconnect(self, close_code):
pass
class CloseConsumer(AsyncWebsocketConsumer):
"""Close with specific code."""
async def connect(self):
await self.accept()
query_string = self.scope.get("query_string", b"").decode()
code = 1000
for param in query_string.split("&"):
if param.startswith("code="):
code = int(param.split("=")[1])
break
await self.close(code=code)
async def disconnect(self, close_code):
pass

View File

@ -0,0 +1,6 @@
# gunicorn is installed from local source in Dockerfile
Django>=5.0
channels>=4.0.0
uvloop>=0.19.0
websockets>=12.0
httptools>=0.6.0

View File

@ -0,0 +1,20 @@
"""
WebSocket routing for Django Channels.
"""
from django.urls import path
from consumers import (
EchoConsumer,
EchoBinaryConsumer,
ScopeConsumer,
SubprotocolConsumer,
CloseConsumer,
)
websocket_urlpatterns = [
path("ws/echo", EchoConsumer.as_asgi()),
path("ws/echo-binary", EchoBinaryConsumer.as_asgi()),
path("ws/scope", ScopeConsumer.as_asgi()),
path("ws/subprotocol", SubprotocolConsumer.as_asgi()),
path("ws/close", CloseConsumer.as_asgi()),
]

View File

@ -0,0 +1,48 @@
"""
Django settings for ASGI compatibility testing.
"""
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-test-key-for-asgi-compat"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ["*"]
# Application definition
INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.auth",
"channels",
]
MIDDLEWARE = []
ROOT_URLCONF = "urls"
TEMPLATES = []
# ASGI application
ASGI_APPLICATION = "asgi.application"
# Channel layers - use in-memory for testing
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer"
}
}
# Database - not needed for testing
DATABASES = {}
# Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Disable CSRF for testing
CSRF_TRUSTED_ORIGINS = ["http://localhost:*", "http://127.0.0.1:*"]

View File

@ -0,0 +1,32 @@
"""
URL configuration for Django compatibility testing.
"""
from django.urls import path
from views import (
health,
scope_view,
echo,
headers_view,
status_view,
streaming_view,
sse_view,
large_view,
delay_view,
lifespan_state_view,
lifespan_counter_view,
)
urlpatterns = [
path("health", health, name="health"),
path("scope", scope_view, name="scope"),
path("echo", echo, name="echo"),
path("headers", headers_view, name="headers"),
path("status/<int:code>", status_view, name="status"),
path("streaming", streaming_view, name="streaming"),
path("sse", sse_view, name="sse"),
path("large", large_view, name="large"),
path("delay", delay_view, name="delay"),
path("lifespan/state", lifespan_state_view, name="lifespan_state"),
path("lifespan/counter", lifespan_counter_view, name="lifespan_counter"),
]

View File

@ -0,0 +1,134 @@
"""
Django views for ASGI compatibility testing.
"""
import asyncio
import json
from django.http import (
HttpRequest,
HttpResponse,
JsonResponse,
StreamingHttpResponse,
)
from django.views.decorators.csrf import csrf_exempt
def serialize_scope(scope: dict) -> dict:
"""Convert ASGI scope to JSON-serializable dict."""
result = {}
for key, value in scope.items():
if key == "headers":
result[key] = [
[h[0].decode("latin-1"), h[1].decode("latin-1")] for h in value
]
elif key == "query_string":
result[key] = value.decode("latin-1") if value else ""
elif key == "server":
result[key] = list(value) if value else None
elif key == "client":
result[key] = list(value) if value else None
elif key == "asgi":
result[key] = dict(value)
elif key in ("state", "app", "lifespan_state", "url_route", "resolver_match"):
continue
elif isinstance(value, bytes):
result[key] = value.decode("latin-1")
else:
try:
json.dumps(value)
result[key] = value
except (TypeError, ValueError):
continue
return result
async def health(request: HttpRequest) -> HttpResponse:
"""Health check endpoint."""
return HttpResponse("OK")
async def scope_view(request: HttpRequest) -> JsonResponse:
"""Return full ASGI scope as JSON."""
# Access ASGI scope from request
scope = request.scope if hasattr(request, "scope") else {}
scope_data = serialize_scope(scope)
return JsonResponse(scope_data)
@csrf_exempt
async def echo(request: HttpRequest) -> HttpResponse:
"""Echo request body back."""
body = request.body
content_type = request.content_type or "application/octet-stream"
return HttpResponse(body, content_type=content_type)
async def headers_view(request: HttpRequest) -> JsonResponse:
"""Return request headers as JSON."""
headers_dict = {}
for key, value in request.headers.items():
headers_dict[key.lower()] = value
return JsonResponse(headers_dict)
async def status_view(request: HttpRequest, code: int) -> HttpResponse:
"""Return specific HTTP status code."""
return HttpResponse(f"Status: {code}", status=code)
async def streaming_view(request: HttpRequest) -> StreamingHttpResponse:
"""Chunked streaming response."""
async def generate():
for i in range(10):
yield f"chunk-{i}\n"
await asyncio.sleep(0.01)
return StreamingHttpResponse(generate(), content_type="text/plain")
async def sse_view(request: HttpRequest) -> StreamingHttpResponse:
"""Server-Sent Events endpoint."""
async def generate():
for i in range(5):
yield f"event: message\ndata: {json.dumps({'count': i})}\n\n"
await asyncio.sleep(0.01)
yield "event: done\ndata: {}\n\n"
response = StreamingHttpResponse(generate(), content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
return response
async def large_view(request: HttpRequest) -> HttpResponse:
"""Large response body."""
size = int(request.GET.get("size", 1024))
# Cap at 10MB for safety
size = min(size, 10 * 1024 * 1024)
return HttpResponse(b"x" * size, content_type="application/octet-stream")
async def delay_view(request: HttpRequest) -> HttpResponse:
"""Delayed response."""
seconds = float(request.GET.get("seconds", 1))
# Cap at 30 seconds
seconds = min(seconds, 30)
await asyncio.sleep(seconds)
return HttpResponse(f"Delayed {seconds} seconds")
async def lifespan_state_view(request: HttpRequest) -> JsonResponse:
"""Return lifespan startup state."""
# Get lifespan_state from scope
lifespan_state = getattr(request, "scope", {}).get("lifespan_state", {})
return JsonResponse(lifespan_state)
async def lifespan_counter_view(request: HttpRequest) -> JsonResponse:
"""Increment and return counter."""
lifespan_state = getattr(request, "scope", {}).get("lifespan_state", {})
if lifespan_state:
lifespan_state["counter"] = lifespan_state.get("counter", 0) + 1
return JsonResponse({"counter": lifespan_state.get("counter", 0)})

View File

@ -0,0 +1,23 @@
FROM python:3.12-slim
WORKDIR /app
# Install curl for healthcheck
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Copy gunicorn source and install from local
COPY gunicorn /gunicorn-src/gunicorn
COPY pyproject.toml /gunicorn-src/
COPY README.md /gunicorn-src/
RUN pip install --no-cache-dir /gunicorn-src
# Install other requirements
COPY tests/docker/asgi_framework_compat/frameworks/fastapi_app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY tests/docker/asgi_framework_compat/frameworks/fastapi_app/app.py .
EXPOSE 8000
# Command specified in docker-compose.yml
CMD ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000"]

View File

@ -0,0 +1,263 @@
"""
FastAPI ASGI Application for Compatibility Testing
Implements the contract endpoints for ASGI 3.0 compliance testing.
"""
import asyncio
import json
import sys
import traceback
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import PlainTextResponse, Response, StreamingResponse, JSONResponse
# Lifespan state
lifespan_state = {
"startup_called": False,
"startup_time": None,
"counter": 0,
"custom_data": {},
}
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup/shutdown."""
import time
lifespan_state["startup_called"] = True
lifespan_state["startup_time"] = time.time()
lifespan_state["custom_data"]["initialized"] = True
yield
lifespan_state["shutdown_called"] = True
app = FastAPI(lifespan=lifespan)
def safe_json_serialize(obj: Any) -> Any:
"""Recursively convert an object to JSON-serializable form."""
if obj is None or isinstance(obj, (str, int, float, bool)):
return obj
elif isinstance(obj, bytes):
return obj.decode("latin-1")
elif isinstance(obj, (list, tuple)):
return [safe_json_serialize(item) for item in obj]
elif isinstance(obj, dict):
result = {}
for k, v in obj.items():
# Only include string keys
if isinstance(k, str):
result[k] = safe_json_serialize(v)
return result
else:
# Skip non-serializable types
return None
def serialize_scope(scope: dict) -> dict:
"""Convert ASGI scope to JSON-serializable dict."""
result = {}
# Keys to explicitly skip (non-serializable objects)
skip_keys = {"state", "app", "router", "endpoint", "path_params", "route",
"extensions", "_cookies", "fastapi_astack"}
for key, value in scope.items():
if key in skip_keys:
continue
try:
if key == "headers":
result[key] = [
[h[0].decode("latin-1"), h[1].decode("latin-1")] for h in value
]
elif key == "query_string":
result[key] = value.decode("latin-1") if value else ""
elif key == "raw_path":
result[key] = value.decode("latin-1") if value else ""
elif key == "server":
result[key] = list(value) if value else None
elif key == "client":
result[key] = list(value) if value else None
elif key == "asgi":
# Only serialize simple values from asgi dict
result[key] = {
k: v for k, v in value.items()
if isinstance(k, str) and isinstance(v, (str, int, float, bool, type(None)))
}
elif isinstance(value, bytes):
result[key] = value.decode("latin-1")
elif isinstance(value, (str, int, float, bool, type(None))):
result[key] = value
elif isinstance(value, (list, tuple)):
serialized = safe_json_serialize(value)
if serialized is not None:
result[key] = serialized
elif isinstance(value, dict):
serialized = safe_json_serialize(value)
if serialized is not None:
result[key] = serialized
# Skip other types
except Exception as e:
print(f"Error serializing key {key}: {e}", file=sys.stderr)
continue
return result
# HTTP Endpoints
@app.get("/health")
async def health():
"""Health check endpoint."""
return PlainTextResponse("OK")
@app.get("/scope")
async def scope_endpoint(request: Request):
"""Return full ASGI scope as JSON."""
try:
scope_data = serialize_scope(request.scope)
return JSONResponse(scope_data)
except Exception as e:
traceback.print_exc()
return PlainTextResponse(f"Error: {e}", status_code=500)
@app.post("/echo")
async def echo(request: Request):
"""Echo request body back."""
body = await request.body()
content_type = request.headers.get("content-type", "application/octet-stream")
return Response(content=body, media_type=content_type)
@app.get("/headers")
async def headers_endpoint(request: Request):
"""Return request headers as JSON."""
headers_dict = dict(request.headers)
return headers_dict
@app.get("/status/{code}")
async def status_endpoint(code: int):
"""Return specific HTTP status code."""
return PlainTextResponse(f"Status: {code}", status_code=code)
@app.get("/streaming")
async def streaming():
"""Chunked streaming response."""
async def generate():
for i in range(10):
yield f"chunk-{i}\n"
await asyncio.sleep(0.01)
return StreamingResponse(generate(), media_type="text/plain")
@app.get("/sse")
async def sse():
"""Server-Sent Events endpoint."""
async def generate():
for i in range(5):
yield f"event: message\ndata: {json.dumps({'count': i})}\n\n"
await asyncio.sleep(0.01)
yield "event: done\ndata: {}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@app.get("/large")
async def large(size: int = 1024):
"""Large response body."""
# Cap at 10MB for safety
size = min(size, 10 * 1024 * 1024)
return Response(content=b"x" * size, media_type="application/octet-stream")
@app.get("/delay")
async def delay(seconds: float = 1.0):
"""Delayed response."""
# Cap at 30 seconds
seconds = min(seconds, 30)
await asyncio.sleep(seconds)
return PlainTextResponse(f"Delayed {seconds} seconds")
@app.get("/lifespan/state")
async def lifespan_state_endpoint():
"""Return lifespan startup state."""
return lifespan_state
@app.get("/lifespan/counter")
async def lifespan_counter():
"""Increment and return counter."""
lifespan_state["counter"] += 1
return {"counter": lifespan_state["counter"]}
# WebSocket Endpoints
@app.websocket("/ws/echo")
async def ws_echo(websocket: WebSocket):
"""Echo text messages."""
await websocket.accept()
try:
while True:
message = await websocket.receive_text()
await websocket.send_text(message)
except WebSocketDisconnect:
pass
@app.websocket("/ws/echo-binary")
async def ws_echo_binary(websocket: WebSocket):
"""Echo binary messages."""
await websocket.accept()
try:
while True:
message = await websocket.receive_bytes()
await websocket.send_bytes(message)
except WebSocketDisconnect:
pass
@app.websocket("/ws/scope")
async def ws_scope(websocket: WebSocket):
"""Send WebSocket scope on connect."""
await websocket.accept()
try:
scope_data = serialize_scope(websocket.scope)
await websocket.send_json(scope_data)
except Exception as e:
await websocket.send_text(f"Error: {e}")
await websocket.close()
@app.websocket("/ws/subprotocol")
async def ws_subprotocol(websocket: WebSocket):
"""Subprotocol negotiation."""
requested = websocket.scope.get("subprotocols", [])
selected = requested[0] if requested else None
await websocket.accept(subprotocol=selected)
await websocket.send_json({"requested": requested, "selected": selected})
await websocket.close()
@app.websocket("/ws/close")
async def ws_close(websocket: WebSocket):
"""Close with specific code."""
await websocket.accept()
query_string = websocket.scope.get("query_string", b"").decode()
code = 1000
for param in query_string.split("&"):
if param.startswith("code="):
code = int(param.split("=")[1])
break
await websocket.close(code=code)

View File

@ -0,0 +1,5 @@
# gunicorn is installed from local source in Dockerfile
fastapi>=0.110.0
uvloop>=0.19.0
websockets>=12.0
httptools>=0.6.0

View File

@ -0,0 +1,23 @@
FROM python:3.12-slim
WORKDIR /app
# Install curl for healthcheck
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Copy gunicorn source and install from local
COPY gunicorn /gunicorn-src/gunicorn
COPY pyproject.toml /gunicorn-src/
COPY README.md /gunicorn-src/
RUN pip install --no-cache-dir /gunicorn-src
# Install other requirements
COPY tests/docker/asgi_framework_compat/frameworks/litestar_app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY tests/docker/asgi_framework_compat/frameworks/litestar_app/app.py .
EXPOSE 8000
# Command specified in docker-compose.yml
CMD ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000"]

View File

@ -0,0 +1,254 @@
"""
Litestar ASGI Application for Compatibility Testing
Implements the contract endpoints for ASGI 3.0 compliance testing.
Litestar is a modern ASGI framework with extensive feature support.
"""
import asyncio
import json
import time
from typing import Any, Dict
from litestar import Litestar, Request, get, post
from litestar.connection import ASGIConnection
from litestar.handlers import websocket
from litestar.response import Response, Stream
# Lifespan state
lifespan_state = {
"startup_called": False,
"startup_time": None,
"counter": 0,
"custom_data": {},
}
async def on_startup(app: Litestar) -> None:
"""Startup handler."""
lifespan_state["startup_called"] = True
lifespan_state["startup_time"] = time.time()
lifespan_state["custom_data"]["initialized"] = True
async def on_shutdown(app: Litestar) -> None:
"""Shutdown handler."""
lifespan_state["shutdown_called"] = True
def serialize_scope(scope: dict) -> dict:
"""Convert ASGI scope to JSON-serializable dict."""
result = {}
for key, value in scope.items():
if key == "headers":
result[key] = [
[h[0].decode("latin-1"), h[1].decode("latin-1")] for h in value
]
elif key == "query_string":
result[key] = value.decode("latin-1") if value else ""
elif key == "server":
result[key] = list(value) if value else None
elif key == "client":
result[key] = list(value) if value else None
elif key == "asgi":
result[key] = dict(value)
elif key in ("state", "app", "_litestar", "route_handler", "path_params"):
continue
elif isinstance(value, bytes):
result[key] = value.decode("latin-1")
else:
try:
json.dumps(value)
result[key] = value
except (TypeError, ValueError):
continue
return result
# HTTP Endpoints
@get("/health")
async def health() -> str:
"""Health check endpoint."""
return "OK"
@get("/scope")
async def scope_endpoint(request: Request) -> Dict[str, Any]:
"""Return full ASGI scope as JSON."""
scope_data = serialize_scope(request.scope)
return scope_data
@post("/echo")
async def echo(request: Request) -> Response:
"""Echo request body back."""
# Read body using the receive callable to avoid Litestar's internal caching
body_parts = []
while True:
message = await request.receive()
body = message.get("body", b"")
if body:
body_parts.append(body)
if not message.get("more_body", False):
break
body = b"".join(body_parts)
# Access headers directly from scope to avoid Litestar's caching
scope_headers = {name.decode("latin-1"): value.decode("latin-1")
for name, value in request.scope.get("headers", [])}
content_type = scope_headers.get("content-type", "application/octet-stream")
return Response(content=body, media_type=content_type, status_code=200)
@get("/headers")
async def headers_endpoint(request: Request) -> Dict[str, str]:
"""Return request headers as JSON."""
# Access headers directly from scope to avoid Litestar's caching
scope_headers = request.scope.get("headers", [])
return {name.decode("latin-1"): value.decode("latin-1") for name, value in scope_headers}
@get("/status/{code:int}")
async def status_endpoint(code: int) -> Response:
"""Return specific HTTP status code."""
# HTTP 204 No Content cannot have a body
if code == 204:
return Response(content=b"", status_code=204)
return Response(content=f"Status: {code}", status_code=code)
@get("/streaming")
async def streaming() -> Stream:
"""Chunked streaming response."""
async def generate():
for i in range(10):
yield f"chunk-{i}\n".encode()
await asyncio.sleep(0.01)
return Stream(generate(), media_type="text/plain")
@get("/sse")
async def sse() -> Stream:
"""Server-Sent Events endpoint."""
async def generate():
for i in range(5):
yield f"event: message\ndata: {json.dumps({'count': i})}\n\n".encode()
await asyncio.sleep(0.01)
yield b"event: done\ndata: {}\n\n"
return Stream(generate(), media_type="text/event-stream")
@get("/large")
async def large(size: int = 1024) -> Response:
"""Large response body."""
# Cap at 10MB for safety
size = min(size, 10 * 1024 * 1024)
return Response(content=b"x" * size, media_type="application/octet-stream")
@get("/delay")
async def delay(seconds: float = 1.0) -> str:
"""Delayed response."""
# Cap at 30 seconds
seconds = min(seconds, 30)
await asyncio.sleep(seconds)
return f"Delayed {seconds} seconds"
@get("/lifespan/state")
async def lifespan_state_endpoint() -> Dict[str, Any]:
"""Return lifespan startup state."""
return lifespan_state
@get("/lifespan/counter")
async def lifespan_counter() -> Dict[str, int]:
"""Increment and return counter."""
lifespan_state["counter"] += 1
return {"counter": lifespan_state["counter"]}
# WebSocket Endpoints using raw websocket handler
@websocket("/ws/echo")
async def ws_echo(socket: ASGIConnection) -> None:
"""Echo text messages."""
await socket.accept()
try:
while True:
data = await socket.receive_text()
await socket.send_text(data)
except Exception:
pass
@websocket("/ws/echo-binary")
async def ws_echo_binary(socket: ASGIConnection) -> None:
"""Echo binary messages."""
await socket.accept()
try:
while True:
data = await socket.receive_bytes()
await socket.send_bytes(data)
except Exception:
pass
@websocket("/ws/scope")
async def ws_scope_handler(socket: ASGIConnection) -> None:
"""Send WebSocket scope on connect."""
await socket.accept()
scope_data = serialize_scope(socket.scope)
await socket.send_json(scope_data)
await socket.close()
@websocket("/ws/subprotocol")
async def ws_subprotocol_handler(socket: ASGIConnection) -> None:
"""Subprotocol negotiation."""
requested = socket.scope.get("subprotocols", [])
selected = requested[0] if requested else None
await socket.accept(subprotocols=selected)
await socket.send_json({"requested": requested, "selected": selected})
await socket.close()
@websocket("/ws/close")
async def ws_close_handler(socket: ASGIConnection) -> None:
"""Close with specific code."""
await socket.accept()
query_string = socket.scope.get("query_string", b"").decode()
code = 1000
for param in query_string.split("&"):
if param.startswith("code="):
code = int(param.split("=")[1])
break
await socket.close(code=code)
# Create app with lifespan handlers
app = Litestar(
route_handlers=[
health,
scope_endpoint,
echo,
headers_endpoint,
status_endpoint,
streaming,
sse,
large,
delay,
lifespan_state_endpoint,
lifespan_counter,
ws_echo,
ws_echo_binary,
ws_scope_handler,
ws_subprotocol_handler,
ws_close_handler,
],
on_startup=[on_startup],
on_shutdown=[on_shutdown],
)

View File

@ -0,0 +1,5 @@
# gunicorn is installed from local source in Dockerfile
litestar>=2.7.0
uvloop>=0.19.0
websockets>=12.0
httptools>=0.6.0

View File

@ -0,0 +1,23 @@
FROM python:3.12-slim
WORKDIR /app
# Install curl for healthcheck
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Copy gunicorn source and install from local
COPY gunicorn /gunicorn-src/gunicorn
COPY pyproject.toml /gunicorn-src/
COPY README.md /gunicorn-src/
RUN pip install --no-cache-dir /gunicorn-src
# Install other requirements
COPY tests/docker/asgi_framework_compat/frameworks/quart_app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY tests/docker/asgi_framework_compat/frameworks/quart_app/app.py .
EXPOSE 8000
# Command specified in docker-compose.yml
CMD ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000"]

View File

@ -0,0 +1,211 @@
"""
Quart ASGI Application for Compatibility Testing
Implements the contract endpoints for ASGI 3.0 compliance testing.
Quart is a Flask-like async framework built on ASGI.
"""
import asyncio
import json
import time
from quart import Quart, request, websocket, Response, make_response
app = Quart(__name__)
# Lifespan state
lifespan_state = {
"startup_called": False,
"startup_time": None,
"counter": 0,
"custom_data": {},
}
@app.before_serving
async def startup():
"""Startup handler."""
lifespan_state["startup_called"] = True
lifespan_state["startup_time"] = time.time()
lifespan_state["custom_data"]["initialized"] = True
@app.after_serving
async def shutdown():
"""Shutdown handler."""
lifespan_state["shutdown_called"] = True
def serialize_scope(scope: dict) -> dict:
"""Convert ASGI scope to JSON-serializable dict."""
result = {}
for key, value in scope.items():
if key == "headers":
result[key] = [
[h[0].decode("latin-1"), h[1].decode("latin-1")] for h in value
]
elif key == "query_string":
result[key] = value.decode("latin-1") if value else ""
elif key == "server":
result[key] = list(value) if value else None
elif key == "client":
result[key] = list(value) if value else None
elif key == "asgi":
result[key] = dict(value)
elif key in ("state", "app", "_quart"):
continue
elif isinstance(value, bytes):
result[key] = value.decode("latin-1")
else:
try:
json.dumps(value)
result[key] = value
except (TypeError, ValueError):
continue
return result
# HTTP Endpoints
@app.route("/health")
async def health():
"""Health check endpoint."""
return "OK", 200
@app.route("/scope")
async def scope_endpoint():
"""Return full ASGI scope as JSON."""
# Access the ASGI scope via request
scope = request.scope
scope_data = serialize_scope(scope)
return scope_data
@app.route("/echo", methods=["POST"])
async def echo():
"""Echo request body back."""
body = await request.get_data()
content_type = request.headers.get("content-type", "application/octet-stream")
response = await make_response(body)
response.headers["Content-Type"] = content_type
return response
@app.route("/headers")
async def headers_endpoint():
"""Return request headers as JSON."""
# Normalize header keys to lowercase for consistency
headers_dict = {k.lower(): v for k, v in request.headers.items()}
return headers_dict
@app.route("/status/<int:code>")
async def status_endpoint(code: int):
"""Return specific HTTP status code."""
return f"Status: {code}", code
@app.route("/streaming")
async def streaming():
"""Chunked streaming response."""
async def generate():
for i in range(10):
yield f"chunk-{i}\n"
await asyncio.sleep(0.01)
return generate(), 200, {"Content-Type": "text/plain"}
@app.route("/sse")
async def sse():
"""Server-Sent Events endpoint."""
async def generate():
for i in range(5):
yield f"event: message\ndata: {json.dumps({'count': i})}\n\n"
await asyncio.sleep(0.01)
yield "event: done\ndata: {}\n\n"
return generate(), 200, {"Content-Type": "text/event-stream", "Cache-Control": "no-cache"}
@app.route("/large")
async def large():
"""Large response body."""
size = request.args.get("size", 1024, type=int)
# Cap at 10MB for safety
size = min(size, 10 * 1024 * 1024)
response = await make_response(b"x" * size)
response.headers["Content-Type"] = "application/octet-stream"
return response
@app.route("/delay")
async def delay():
"""Delayed response."""
seconds = request.args.get("seconds", 1.0, type=float)
# Cap at 30 seconds
seconds = min(seconds, 30)
await asyncio.sleep(seconds)
return f"Delayed {seconds} seconds"
@app.route("/lifespan/state")
async def lifespan_state_endpoint():
"""Return lifespan startup state."""
return lifespan_state
@app.route("/lifespan/counter")
async def lifespan_counter():
"""Increment and return counter."""
lifespan_state["counter"] += 1
return {"counter": lifespan_state["counter"]}
# WebSocket Endpoints
@app.websocket("/ws/echo")
async def ws_echo():
"""Echo text messages."""
while True:
message = await websocket.receive()
await websocket.send(message)
@app.websocket("/ws/echo-binary")
async def ws_echo_binary():
"""Echo binary messages."""
while True:
message = await websocket.receive()
await websocket.send(message)
@app.websocket("/ws/scope")
async def ws_scope():
"""Send WebSocket scope on connect."""
scope_data = serialize_scope(websocket.scope)
await websocket.send_json(scope_data)
@app.websocket("/ws/subprotocol")
async def ws_subprotocol():
"""Subprotocol negotiation."""
requested = websocket.scope.get("subprotocols", [])
selected = requested[0] if requested else None
# Note: Quart handles subprotocol via accept() but we need to check how
await websocket.send_json({"requested": requested, "selected": selected})
@app.websocket("/ws/close")
async def ws_close():
"""Close with specific code."""
query_string = websocket.scope.get("query_string", b"").decode()
code = 1000
for param in query_string.split("&"):
if param.startswith("code="):
code = int(param.split("=")[1])
break
await websocket.accept()
await websocket.close(code)

View File

@ -0,0 +1,5 @@
# gunicorn is installed from local source in Dockerfile
quart>=0.19.0
uvloop>=0.19.0
websockets>=12.0
httptools>=0.6.0

View File

@ -0,0 +1,23 @@
FROM python:3.12-slim
WORKDIR /app
# Install curl for healthcheck
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Copy gunicorn source and install from local
COPY gunicorn /gunicorn-src/gunicorn
COPY pyproject.toml /gunicorn-src/
COPY README.md /gunicorn-src/
RUN pip install --no-cache-dir /gunicorn-src
# Install other requirements
COPY tests/docker/asgi_framework_compat/frameworks/starlette_app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY tests/docker/asgi_framework_compat/frameworks/starlette_app/app.py .
EXPOSE 8000
# Command specified in docker-compose.yml
CMD ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000"]

View File

@ -0,0 +1,284 @@
"""
Starlette ASGI Application for Compatibility Testing
Implements the contract endpoints for ASGI 3.0 compliance testing.
"""
import asyncio
import json
import sys
import traceback
from contextlib import asynccontextmanager
from typing import Any
from starlette.applications import Starlette
from starlette.responses import (
JSONResponse,
PlainTextResponse,
Response,
StreamingResponse,
)
from starlette.routing import Route, WebSocketRoute
from starlette.websockets import WebSocket
# Lifespan state
lifespan_state = {
"startup_called": False,
"startup_time": None,
"counter": 0,
"custom_data": {},
}
@asynccontextmanager
async def lifespan(app):
"""Lifespan context manager for startup/shutdown."""
import time
lifespan_state["startup_called"] = True
lifespan_state["startup_time"] = time.time()
lifespan_state["custom_data"]["initialized"] = True
yield
lifespan_state["shutdown_called"] = True
def safe_json_serialize(obj: Any) -> Any:
"""Recursively convert an object to JSON-serializable form."""
if obj is None or isinstance(obj, (str, int, float, bool)):
return obj
elif isinstance(obj, bytes):
return obj.decode("latin-1")
elif isinstance(obj, (list, tuple)):
return [safe_json_serialize(item) for item in obj]
elif isinstance(obj, dict):
result = {}
for k, v in obj.items():
# Only include string keys
if isinstance(k, str):
result[k] = safe_json_serialize(v)
return result
else:
# Skip non-serializable types
return None
def serialize_scope(scope: dict) -> dict:
"""Convert ASGI scope to JSON-serializable dict."""
result = {}
# Keys to explicitly skip (non-serializable objects)
skip_keys = {"state", "app", "router", "endpoint", "path_params", "route",
"extensions", "_cookies"}
for key, value in scope.items():
if key in skip_keys:
continue
try:
if key == "headers":
result[key] = [
[h[0].decode("latin-1"), h[1].decode("latin-1")] for h in value
]
elif key == "query_string":
result[key] = value.decode("latin-1") if value else ""
elif key == "raw_path":
result[key] = value.decode("latin-1") if value else ""
elif key == "server":
result[key] = list(value) if value else None
elif key == "client":
result[key] = list(value) if value else None
elif key == "asgi":
# Only serialize simple values from asgi dict
result[key] = {
k: v for k, v in value.items()
if isinstance(k, str) and isinstance(v, (str, int, float, bool, type(None)))
}
elif isinstance(value, bytes):
result[key] = value.decode("latin-1")
elif isinstance(value, (str, int, float, bool, type(None))):
result[key] = value
elif isinstance(value, (list, tuple)):
serialized = safe_json_serialize(value)
if serialized is not None:
result[key] = serialized
elif isinstance(value, dict):
serialized = safe_json_serialize(value)
if serialized is not None:
result[key] = serialized
# Skip other types
except Exception as e:
print(f"Error serializing key {key}: {e}", file=sys.stderr)
continue
return result
# HTTP Endpoints
async def health(request):
"""Health check endpoint."""
return PlainTextResponse("OK")
async def scope_endpoint(request):
"""Return full ASGI scope as JSON."""
try:
scope_data = serialize_scope(request.scope)
return JSONResponse(scope_data)
except Exception as e:
traceback.print_exc()
return PlainTextResponse(f"Error: {e}", status_code=500)
async def echo(request):
"""Echo request body back."""
body = await request.body()
content_type = request.headers.get("content-type", "application/octet-stream")
return Response(content=body, media_type=content_type)
async def headers_endpoint(request):
"""Return request headers as JSON."""
headers_dict = dict(request.headers)
return JSONResponse(headers_dict)
async def status_endpoint(request):
"""Return specific HTTP status code."""
code = int(request.path_params["code"])
return PlainTextResponse(f"Status: {code}", status_code=code)
async def streaming(request):
"""Chunked streaming response."""
async def generate():
for i in range(10):
yield f"chunk-{i}\n"
await asyncio.sleep(0.01)
return StreamingResponse(generate(), media_type="text/plain")
async def sse(request):
"""Server-Sent Events endpoint."""
async def generate():
for i in range(5):
yield f"event: message\ndata: {json.dumps({'count': i})}\n\n"
await asyncio.sleep(0.01)
yield "event: done\ndata: {}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
async def large(request):
"""Large response body."""
size = int(request.query_params.get("size", 1024))
# Cap at 10MB for safety
size = min(size, 10 * 1024 * 1024)
return Response(content=b"x" * size, media_type="application/octet-stream")
async def delay(request):
"""Delayed response."""
seconds = float(request.query_params.get("seconds", 1))
# Cap at 30 seconds
seconds = min(seconds, 30)
await asyncio.sleep(seconds)
return PlainTextResponse(f"Delayed {seconds} seconds")
async def lifespan_state_endpoint(request):
"""Return lifespan startup state."""
return JSONResponse(lifespan_state)
async def lifespan_counter(request):
"""Increment and return counter."""
lifespan_state["counter"] += 1
return JSONResponse({"counter": lifespan_state["counter"]})
# WebSocket Endpoints
async def ws_echo(websocket: WebSocket):
"""Echo text messages."""
await websocket.accept()
try:
while True:
message = await websocket.receive_text()
await websocket.send_text(message)
except Exception:
pass
async def ws_echo_binary(websocket: WebSocket):
"""Echo binary messages."""
await websocket.accept()
try:
while True:
message = await websocket.receive_bytes()
await websocket.send_bytes(message)
except Exception:
pass
async def ws_scope(websocket: WebSocket):
"""Send WebSocket scope on connect."""
await websocket.accept()
try:
scope_data = serialize_scope(websocket.scope)
await websocket.send_json(scope_data)
except Exception as e:
await websocket.send_text(f"Error: {e}")
await websocket.close()
async def ws_subprotocol(websocket: WebSocket):
"""Subprotocol negotiation."""
# Get requested subprotocols from scope
requested = websocket.scope.get("subprotocols", [])
# Select first one if available
selected = requested[0] if requested else None
await websocket.accept(subprotocol=selected)
await websocket.send_json(
{"requested": requested, "selected": selected}
)
await websocket.close()
async def ws_close(websocket: WebSocket):
"""Close with specific code."""
await websocket.accept()
# Get close code from query string
query_string = websocket.scope.get("query_string", b"").decode()
code = 1000
for param in query_string.split("&"):
if param.startswith("code="):
code = int(param.split("=")[1])
break
await websocket.close(code=code)
# Routes
routes = [
# HTTP endpoints
Route("/health", health),
Route("/scope", scope_endpoint),
Route("/echo", echo, methods=["POST"]),
Route("/headers", headers_endpoint),
Route("/status/{code:int}", status_endpoint),
Route("/streaming", streaming),
Route("/sse", sse),
Route("/large", large),
Route("/delay", delay),
Route("/lifespan/state", lifespan_state_endpoint),
Route("/lifespan/counter", lifespan_counter),
# WebSocket endpoints
WebSocketRoute("/ws/echo", ws_echo),
WebSocketRoute("/ws/echo-binary", ws_echo_binary),
WebSocketRoute("/ws/scope", ws_scope),
WebSocketRoute("/ws/subprotocol", ws_subprotocol),
WebSocketRoute("/ws/close", ws_close),
]
app = Starlette(routes=routes, lifespan=lifespan)

View File

@ -0,0 +1,5 @@
# gunicorn is installed from local source in Dockerfile
starlette>=0.37.0
uvloop>=0.19.0
websockets>=12.0
httptools>=0.6.0

View File

@ -0,0 +1,18 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
asyncio_mode = auto
markers =
http: HTTP protocol tests
websocket: WebSocket protocol tests
lifespan: Lifespan protocol tests
streaming: Streaming response tests
slow: Slow running tests
framework(name): Test specific framework
filterwarnings =
ignore::DeprecationWarning
ignore::pytest.PytestUnraisableExceptionWarning

View File

@ -0,0 +1,6 @@
# Test dependencies for running the compatibility suite
pytest>=8.0.0
pytest-asyncio>=0.23.0
pytest-json-report>=1.5.0
httpx>=0.27.0
websockets>=12.0

View File

@ -0,0 +1,198 @@
{
"generated": "2026-04-04T03:00:27.482294",
"worker": "gunicorn.workers.gasgi.ASGIWorker",
"frameworks": {
"django": {
"name": "Django + Channels",
"categories": {
"http_scope": {
"passed": 19,
"failed": 0,
"total": 19
},
"http_messages": {
"passed": 18,
"failed": 0,
"total": 19
},
"websocket": {
"passed": 19,
"failed": 0,
"total": 19
},
"lifespan": {
"passed": 8,
"failed": 0,
"total": 8
},
"streaming": {
"passed": 9,
"failed": 0,
"total": 9
}
},
"total_passed": 73,
"total_tests": 74
},
"fastapi": {
"name": "FastAPI",
"categories": {
"http_scope": {
"passed": 19,
"failed": 0,
"total": 19
},
"http_messages": {
"passed": 18,
"failed": 0,
"total": 19
},
"websocket": {
"passed": 19,
"failed": 0,
"total": 19
},
"lifespan": {
"passed": 8,
"failed": 0,
"total": 8
},
"streaming": {
"passed": 9,
"failed": 0,
"total": 9
}
},
"total_passed": 73,
"total_tests": 74
},
"starlette": {
"name": "Starlette",
"categories": {
"http_scope": {
"passed": 19,
"failed": 0,
"total": 19
},
"http_messages": {
"passed": 18,
"failed": 0,
"total": 19
},
"websocket": {
"passed": 19,
"failed": 0,
"total": 19
},
"lifespan": {
"passed": 8,
"failed": 0,
"total": 8
},
"streaming": {
"passed": 9,
"failed": 0,
"total": 9
}
},
"total_passed": 73,
"total_tests": 74
},
"quart": {
"name": "Quart",
"categories": {
"http_scope": {
"passed": 19,
"failed": 0,
"total": 19
},
"http_messages": {
"passed": 18,
"failed": 0,
"total": 19
},
"websocket": {
"passed": 19,
"failed": 0,
"total": 19
},
"lifespan": {
"passed": 8,
"failed": 0,
"total": 8
},
"streaming": {
"passed": 9,
"failed": 0,
"total": 9
}
},
"total_passed": 73,
"total_tests": 74
},
"litestar": {
"name": "Litestar",
"categories": {
"http_scope": {
"passed": 19,
"failed": 0,
"total": 19
},
"http_messages": {
"passed": 18,
"failed": 0,
"total": 19
},
"websocket": {
"passed": 19,
"failed": 0,
"total": 19
},
"lifespan": {
"passed": 8,
"failed": 0,
"total": 8
},
"streaming": {
"passed": 9,
"failed": 0,
"total": 9
}
},
"total_passed": 73,
"total_tests": 74
},
"blacksheep": {
"name": "BlackSheep",
"categories": {
"http_scope": {
"passed": 19,
"failed": 0,
"total": 19
},
"http_messages": {
"passed": 18,
"failed": 0,
"total": 19
},
"websocket": {
"passed": 19,
"failed": 0,
"total": 19
},
"lifespan": {
"passed": 8,
"failed": 0,
"total": 8
},
"streaming": {
"passed": 9,
"failed": 0,
"total": 9
}
},
"total_passed": 73,
"total_tests": 74
}
}
}

View File

@ -0,0 +1,20 @@
# ASGI Framework Compatibility Grid
**Generated:** 2026-04-04 03:00:27
**Worker:** gunicorn ASGI worker (`-k asgi`)
**Event Loop:** auto (uvloop if available)
## Summary
| Framework | HTTP Scope | HTTP Messages | WebSocket | Lifespan | Streaming | Total |
|-----------|---------|---------|---------|---------|---------|-------|
| Django + Channels | 19/19 | **18/19** | 19/19 | 8/8 | 9/9 | **73/74** |
| FastAPI | 19/19 | **18/19** | 19/19 | 8/8 | 9/9 | **73/74** |
| Starlette | 19/19 | **18/19** | 19/19 | 8/8 | 9/9 | **73/74** |
| Quart | 19/19 | **18/19** | 19/19 | 8/8 | 9/9 | **73/74** |
| Litestar | 19/19 | **18/19** | 19/19 | 8/8 | 9/9 | **73/74** |
| BlackSheep | 19/19 | **18/19** | 19/19 | 8/8 | 9/9 | **73/74** |
*Bold indicates failures*
**Overall:** 438/444 tests passed (98%)

View File

@ -0,0 +1 @@
"""Scripts for running tests and generating reports."""

View File

@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""
Compatibility Grid Generator
Generates a compatibility matrix showing test results for each
ASGI framework tested with gunicorn's native ASGI worker.
"""
import json
import os
from datetime import datetime
from pathlib import Path
# Framework configuration
FRAMEWORKS = ["django", "fastapi", "starlette", "quart", "litestar", "blacksheep"]
FRAMEWORK_NAMES = {
"django": "Django + Channels",
"fastapi": "FastAPI",
"starlette": "Starlette",
"quart": "Quart",
"litestar": "Litestar",
"blacksheep": "BlackSheep",
}
# Test categories based on file names
CATEGORIES = {
"http_scope": "HTTP Scope",
"http_messages": "HTTP Messages",
"websocket": "WebSocket",
"lifespan": "Lifespan",
"streaming": "Streaming",
}
def parse_results(results_file: Path) -> dict:
"""Parse pytest JSON results into framework/category structure."""
with open(results_file) as f:
data = json.load(f)
results = {fw: {cat: {"passed": 0, "failed": 0, "total": 0}
for cat in CATEGORIES} for fw in FRAMEWORKS}
tests = data.get("tests", [])
for test in tests:
nodeid = test.get("nodeid", "")
outcome = test.get("outcome", "")
# Extract framework from test parameters
framework = None
for fw in FRAMEWORKS:
if f"[{fw}]" in nodeid or f"[{fw}-" in nodeid:
framework = fw
break
if not framework:
continue
# Determine category from file name
category = None
for cat_key in CATEGORIES:
if f"test_{cat_key}" in nodeid:
category = cat_key
break
if not category:
continue
results[framework][category]["total"] += 1
if outcome == "passed":
results[framework][category]["passed"] += 1
elif outcome == "failed":
results[framework][category]["failed"] += 1
return results
def generate_markdown(results: dict) -> str:
"""Generate markdown compatibility grid."""
lines = []
lines.append("# ASGI Framework Compatibility Grid")
lines.append("")
lines.append(f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
lines.append("**Worker:** gunicorn ASGI worker (`-k asgi`)")
lines.append("**Event Loop:** auto (uvloop if available)")
lines.append("")
# Main compatibility table
lines.append("## Summary")
lines.append("")
header = "| Framework |"
separator = "|-----------|"
for cat in CATEGORIES.values():
header += f" {cat} |"
separator += "---------|"
header += " Total |"
separator += "-------|"
lines.append(header)
lines.append(separator)
for fw in FRAMEWORKS:
fw_results = results.get(fw, {})
row = f"| {FRAMEWORK_NAMES[fw]} |"
total_passed = 0
total_tests = 0
for cat_key in CATEGORIES:
cat_data = fw_results.get(cat_key, {"passed": 0, "total": 0})
passed = cat_data["passed"]
total = cat_data["total"]
total_passed += passed
total_tests += total
if total == 0:
row += " - |"
elif passed == total:
row += f" {passed}/{total} |"
else:
row += f" **{passed}/{total}** |"
if total_tests == 0:
row += " - |"
elif total_passed == total_tests:
row += f" {total_passed}/{total_tests} |"
else:
row += f" **{total_passed}/{total_tests}** |"
lines.append(row)
lines.append("")
lines.append("*Bold indicates failures*")
lines.append("")
# Calculate overall pass rate
all_passed = sum(
results[fw][cat]["passed"]
for fw in FRAMEWORKS
for cat in CATEGORIES
)
all_total = sum(
results[fw][cat]["total"]
for fw in FRAMEWORKS
for cat in CATEGORIES
)
lines.append(f"**Overall:** {all_passed}/{all_total} tests passed ({100*all_passed//all_total}%)")
lines.append("")
return "\n".join(lines)
def main():
base_dir = Path(__file__).parent.parent
results_dir = base_dir / "results"
results_file = results_dir / "pytest_results.json"
if not results_file.exists():
print(f"Results file not found: {results_file}")
return
results = parse_results(results_file)
md_content = generate_markdown(results)
# Write to results directory
md_file = results_dir / "compatibility_grid.md"
with open(md_file, "w") as f:
f.write(md_content)
print(f"Written: {md_file}")
# Also write JSON summary
json_file = results_dir / "compatibility_grid.json"
summary = {
"generated": datetime.now().isoformat(),
"worker": "gunicorn.workers.gasgi.ASGIWorker",
"frameworks": {
fw: {
"name": FRAMEWORK_NAMES[fw],
"categories": results[fw],
"total_passed": sum(results[fw][c]["passed"] for c in CATEGORIES),
"total_tests": sum(results[fw][c]["total"] for c in CATEGORIES),
}
for fw in FRAMEWORKS
}
}
with open(json_file, "w") as f:
json.dump(summary, indent=2, fp=f)
print(f"Written: {json_file}")
# Print the markdown
print("\n" + md_content)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,66 @@
#!/bin/bash
# Run ASGI Framework Compatibility Tests
#
# Usage:
# ./scripts/run_tests.sh # Run with auto loop detection
# ./scripts/run_tests.sh asyncio # Run with asyncio loop
# ./scripts/run_tests.sh uvloop # Run with uvloop
# ./scripts/run_tests.sh both # Run both and generate combined report
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BASE_DIR="$(dirname "$SCRIPT_DIR")"
cd "$BASE_DIR"
LOOP_TYPE="${1:-auto}"
echo "=== ASGI Framework Compatibility Test Suite ==="
echo "Loop type: $LOOP_TYPE"
echo ""
# Install test dependencies if needed
if ! python -c "import pytest" 2>/dev/null; then
echo "Installing test dependencies..."
pip install -r requirements.txt
fi
if [ "$LOOP_TYPE" = "both" ]; then
echo "Running tests with asyncio loop..."
ASGI_LOOP=asyncio docker compose up -d --build
sleep 10 # Wait for services
pytest tests/ -v --tb=short || true
docker compose down
echo ""
echo "Running tests with uvloop..."
ASGI_LOOP=uvloop docker compose up -d --build
sleep 10 # Wait for services
pytest tests/ -v --tb=short || true
docker compose down
echo ""
echo "Generating combined report..."
python scripts/generate_grid.py --loop both --skip-tests
else
echo "Starting containers with $LOOP_TYPE loop..."
ASGI_LOOP="$LOOP_TYPE" docker compose up -d --build
echo "Waiting for services to be healthy..."
sleep 15
echo ""
echo "Running tests..."
pytest tests/ -v --tb=short
echo ""
echo "Generating compatibility grid..."
python scripts/generate_grid.py --loop "$LOOP_TYPE"
echo ""
echo "Results saved to results/"
fi
echo ""
echo "Done!"

View File

@ -0,0 +1 @@
"""ASGI Framework Compatibility Tests"""

View File

@ -0,0 +1,128 @@
"""
HTTP Message Type Tests
Tests ASGI 3.0 HTTP request/response message handling.
"""
import pytest
pytestmark = pytest.mark.http
class TestHttpRequestBody:
"""Test HTTP request body handling."""
async def test_echo_empty_body(self, http_client):
"""Echo endpoint handles empty body."""
response = await http_client.post("/echo", content=b"")
assert response.status_code == 200
assert response.content == b""
async def test_echo_text_body(self, http_client):
"""Echo endpoint returns text body."""
body = "Hello, World!"
response = await http_client.post(
"/echo",
content=body,
headers={"Content-Type": "text/plain"},
)
assert response.status_code == 200
assert response.text == body
async def test_echo_binary_body(self, http_client):
"""Echo endpoint returns binary body."""
body = b"\x00\x01\x02\x03\xff\xfe"
response = await http_client.post(
"/echo",
content=body,
headers={"Content-Type": "application/octet-stream"},
)
assert response.status_code == 200
assert response.content == body
async def test_echo_json_body(self, http_client):
"""Echo endpoint returns JSON body."""
body = '{"key": "value", "number": 42}'
response = await http_client.post(
"/echo",
content=body,
headers={"Content-Type": "application/json"},
)
assert response.status_code == 200
assert response.json() == {"key": "value", "number": 42}
async def test_echo_large_body(self, http_client, large_body):
"""Echo endpoint handles large body."""
body = large_body(100 * 1024) # 100KB
response = await http_client.post(
"/echo",
content=body,
headers={"Content-Type": "application/octet-stream"},
)
assert response.status_code == 200
assert len(response.content) == len(body)
class TestHttpResponseStatus:
"""Test HTTP response status codes."""
@pytest.mark.parametrize("code", [200, 201, 204, 301, 400, 404, 500, 503])
async def test_status_codes(self, http_client, code):
"""Status endpoint returns correct status code."""
response = await http_client.get(f"/status/{code}")
assert response.status_code == code
@pytest.mark.skip(reason="HTTP 100 Continue cannot be a final response per RFC 7231")
async def test_status_100_continue(self, http_client):
"""Handle 100 status (may not be supported by all frameworks)."""
# HTTP 100 Continue is an informational response that must be followed
# by a final response. Using it as a final response is invalid.
response = await http_client.get("/status/100")
assert response.status_code in (100, 200)
class TestHttpResponseHeaders:
"""Test HTTP response header handling."""
async def test_content_type_header(self, http_client):
"""Response has Content-Type header."""
response = await http_client.get("/scope")
assert "content-type" in response.headers
assert "application/json" in response.headers["content-type"]
async def test_headers_preserved(self, http_client):
"""Custom headers in request are accessible."""
response = await http_client.get("/headers", headers={"X-Custom": "test123"})
data = response.json()
assert data.get("x-custom") == "test123"
class TestHttpDisconnect:
"""Test HTTP disconnect handling."""
async def test_delay_can_be_cancelled(self, http_client):
"""Long delay can be interrupted (timeout behavior)."""
import httpx
# This tests that the server handles client disconnects gracefully
with pytest.raises(httpx.TimeoutException):
await http_client.get("/delay?seconds=30", timeout=0.5)
class TestHttpResponseBody:
"""Test HTTP response body handling."""
async def test_large_response_body(self, http_client):
"""Large response body endpoint works."""
size = 100 * 1024 # 100KB
response = await http_client.get(f"/large?size={size}")
assert response.status_code == 200
assert len(response.content) == size
async def test_very_large_response_body(self, http_client):
"""Very large response body endpoint works."""
size = 1024 * 1024 # 1MB
response = await http_client.get(f"/large?size={size}")
assert response.status_code == 200
assert len(response.content) == size

View File

@ -0,0 +1,168 @@
"""
HTTP Scope Compliance Tests
Tests ASGI 3.0 HTTP scope compliance across frameworks.
"""
import pytest
from frameworks.contract import ASGI_HTTP_SCOPE_REQUIRED_KEYS
pytestmark = pytest.mark.http
class TestHttpScopeBasics:
"""Test basic HTTP scope attributes."""
async def test_scope_endpoint_returns_json(self, http_client):
"""Scope endpoint returns valid JSON."""
response = await http_client.get("/scope")
assert response.status_code == 200
data = response.json()
assert isinstance(data, dict)
async def test_scope_has_type_http(self, http_client):
"""Scope type is 'http'."""
response = await http_client.get("/scope")
data = response.json()
assert data.get("type") == "http"
async def test_scope_has_asgi_dict(self, http_client):
"""Scope has 'asgi' dict with version info."""
response = await http_client.get("/scope")
data = response.json()
assert "asgi" in data
assert isinstance(data["asgi"], dict)
assert "version" in data["asgi"]
async def test_scope_asgi_version_is_3(self, http_client):
"""ASGI version should be 3.x."""
response = await http_client.get("/scope")
data = response.json()
version = data["asgi"]["version"]
assert version.startswith("3.")
async def test_scope_has_http_version(self, http_client):
"""Scope has http_version field."""
response = await http_client.get("/scope")
data = response.json()
assert "http_version" in data
assert data["http_version"] in ("1.0", "1.1", "2", "3")
async def test_scope_has_method(self, http_client):
"""Scope has method field matching request method."""
response = await http_client.get("/scope")
data = response.json()
assert data.get("method") == "GET"
async def test_scope_has_scheme(self, http_client):
"""Scope has scheme field."""
response = await http_client.get("/scope")
data = response.json()
assert "scheme" in data
assert data["scheme"] in ("http", "https")
async def test_scope_has_path(self, http_client):
"""Scope has path field matching request path."""
response = await http_client.get("/scope")
data = response.json()
assert data.get("path") == "/scope"
async def test_scope_has_query_string(self, http_client):
"""Scope has query_string field."""
response = await http_client.get("/scope?foo=bar")
data = response.json()
assert "query_string" in data
assert "foo=bar" in data["query_string"]
async def test_scope_empty_query_string(self, http_client):
"""Empty query string handled correctly."""
response = await http_client.get("/scope")
data = response.json()
assert "query_string" in data
assert data["query_string"] == ""
class TestHttpScopeHeaders:
"""Test HTTP scope header handling."""
async def test_scope_has_headers(self, http_client):
"""Scope has headers field."""
response = await http_client.get("/scope")
data = response.json()
assert "headers" in data
assert isinstance(data["headers"], list)
async def test_scope_headers_are_lists(self, http_client):
"""Each header is a list of [name, value]."""
response = await http_client.get("/scope")
data = response.json()
for header in data["headers"]:
assert isinstance(header, list)
assert len(header) == 2
async def test_scope_header_names_lowercase(self, http_client):
"""Header names should be lowercase."""
response = await http_client.get("/scope", headers={"X-Custom-Header": "test"})
data = response.json()
custom_headers = [h for h in data["headers"] if h[0] == "x-custom-header"]
assert len(custom_headers) > 0
async def test_headers_endpoint_returns_all_headers(self, http_client):
"""Headers endpoint returns all sent headers."""
custom_headers = {
"X-Test-One": "value1",
"X-Test-Two": "value2",
}
response = await http_client.get("/headers", headers=custom_headers)
data = response.json()
assert data.get("x-test-one") == "value1"
assert data.get("x-test-two") == "value2"
class TestHttpScopeServer:
"""Test HTTP scope server and client fields."""
async def test_scope_has_server(self, http_client):
"""Scope has server field."""
response = await http_client.get("/scope")
data = response.json()
assert "server" in data
async def test_scope_server_is_tuple(self, http_client):
"""Server is [host, port] list."""
response = await http_client.get("/scope")
data = response.json()
if data["server"] is not None:
assert isinstance(data["server"], list)
assert len(data["server"]) == 2
async def test_scope_has_client(self, http_client):
"""Scope has client field (may be None)."""
response = await http_client.get("/scope")
data = response.json()
# client is optional but should be present
assert "client" in data or data.get("client") is None
class TestHttpScopeRequired:
"""Test all required scope keys are present."""
async def test_all_required_keys_present(self, http_client):
"""All ASGI 3.0 required HTTP scope keys are present."""
response = await http_client.get("/scope")
data = response.json()
for key in ASGI_HTTP_SCOPE_REQUIRED_KEYS:
assert key in data, f"Missing required scope key: {key}"
class TestHttpScopeRootPath:
"""Test root_path handling."""
async def test_scope_has_root_path(self, http_client):
"""Scope has root_path field (may be empty)."""
response = await http_client.get("/scope")
data = response.json()
# root_path should be present, defaults to ""
assert "root_path" in data or data.get("root_path", "") == ""

View File

@ -0,0 +1,99 @@
"""
Lifespan Protocol Tests
Tests ASGI 3.0 lifespan protocol compliance across frameworks.
"""
import pytest
pytestmark = pytest.mark.lifespan
class TestLifespanStartup:
"""Test lifespan startup handling."""
async def test_startup_was_called(self, http_client):
"""Startup handler was called."""
response = await http_client.get("/lifespan/state")
assert response.status_code == 200
data = response.json()
assert data.get("startup_called") is True
async def test_startup_time_set(self, http_client):
"""Startup time was recorded."""
response = await http_client.get("/lifespan/state")
data = response.json()
assert data.get("startup_time") is not None
assert isinstance(data["startup_time"], (int, float))
async def test_startup_custom_data(self, http_client):
"""Custom data set during startup is available."""
response = await http_client.get("/lifespan/state")
data = response.json()
custom_data = data.get("custom_data", {})
assert custom_data.get("initialized") is True
class TestLifespanState:
"""Test lifespan state persistence."""
async def test_counter_initial_value(self, http_client):
"""Counter starts at expected initial value."""
# First get the state to see current counter
response = await http_client.get("/lifespan/state")
initial = response.json().get("counter", 0)
# Increment once
response = await http_client.get("/lifespan/counter")
data = response.json()
assert data["counter"] == initial + 1
async def test_counter_increments(self, http_client):
"""Counter increments on each request."""
# Get first value
response1 = await http_client.get("/lifespan/counter")
value1 = response1.json()["counter"]
# Get second value
response2 = await http_client.get("/lifespan/counter")
value2 = response2.json()["counter"]
# Should have incremented
assert value2 == value1 + 1
async def test_state_persists_across_requests(self, http_client):
"""State persists across multiple requests."""
# Make several requests
values = []
for _ in range(3):
response = await http_client.get("/lifespan/counter")
values.append(response.json()["counter"])
# Each should be incrementing
assert values[1] == values[0] + 1
assert values[2] == values[1] + 1
class TestLifespanStateSharing:
"""Test state sharing between lifespan and request handlers."""
async def test_lifespan_state_accessible(self, http_client):
"""Lifespan state is accessible from request handlers."""
response = await http_client.get("/lifespan/state")
assert response.status_code == 200
data = response.json()
# Should have the startup marker
assert "startup_called" in data
async def test_state_modifications_persist(self, http_client):
"""Modifications to state persist."""
# Increment counter
await http_client.get("/lifespan/counter")
# Check state still shows startup was called
response = await http_client.get("/lifespan/state")
data = response.json()
assert data.get("startup_called") is True
# Counter should be > 0
assert data.get("counter", 0) > 0

View File

@ -0,0 +1,98 @@
"""
Streaming Response Tests
Tests chunked streaming and Server-Sent Events across frameworks.
"""
import asyncio
import json
import pytest
pytestmark = pytest.mark.streaming
class TestChunkedStreaming:
"""Test chunked transfer encoding responses."""
async def test_streaming_response(self, http_client):
"""Streaming endpoint returns chunked response."""
response = await http_client.get("/streaming")
assert response.status_code == 200
# Check we got all chunks
content = response.text
for i in range(10):
assert f"chunk-{i}" in content
async def test_streaming_content_type(self, http_client):
"""Streaming response has correct content type."""
response = await http_client.get("/streaming")
assert "text/plain" in response.headers.get("content-type", "")
async def test_streaming_order_preserved(self, http_client):
"""Chunks arrive in correct order."""
response = await http_client.get("/streaming")
lines = [l for l in response.text.strip().split("\n") if l]
for i, line in enumerate(lines):
assert line == f"chunk-{i}"
class TestServerSentEvents:
"""Test Server-Sent Events (SSE) responses."""
async def test_sse_response(self, http_client):
"""SSE endpoint returns event stream."""
response = await http_client.get("/sse")
assert response.status_code == 200
content = response.text
assert "event:" in content
assert "data:" in content
async def test_sse_content_type(self, http_client):
"""SSE response has correct content type."""
response = await http_client.get("/sse")
content_type = response.headers.get("content-type", "")
assert "text/event-stream" in content_type
async def test_sse_event_format(self, http_client):
"""SSE events have correct format."""
response = await http_client.get("/sse")
content = response.text
# Check for message events
assert "event: message" in content
# Check for done event
assert "event: done" in content
async def test_sse_data_is_json(self, http_client):
"""SSE data fields contain valid JSON."""
response = await http_client.get("/sse")
lines = response.text.split("\n")
data_lines = [l for l in lines if l.startswith("data:")]
for line in data_lines:
data_str = line[5:].strip() # Remove "data:" prefix
data = json.loads(data_str)
assert isinstance(data, dict)
async def test_sse_message_count(self, http_client):
"""Correct number of SSE messages received."""
response = await http_client.get("/sse")
lines = response.text.split("\n")
message_events = [l for l in lines if l == "event: message"]
# Should have 5 message events
assert len(message_events) == 5
class TestStreamingLargeData:
"""Test streaming with large data."""
async def test_large_streaming_response(self, http_client):
"""Large response body streams correctly."""
size = 5 * 1024 * 1024 # 5MB
response = await http_client.get(f"/large?size={size}")
assert response.status_code == 200
assert len(response.content) == size

View File

@ -0,0 +1,193 @@
"""
WebSocket Scope Compliance Tests
Tests ASGI 3.0 WebSocket scope compliance across frameworks.
"""
import asyncio
import json
import pytest
import websockets
from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError
from frameworks.contract import (
ASGI_WEBSOCKET_SCOPE_REQUIRED_KEYS,
VALID_WEBSOCKET_CLOSE_CODES,
)
pytestmark = pytest.mark.websocket
class TestWebSocketConnection:
"""Test WebSocket connection handling."""
async def test_websocket_connect(self, ws_client):
"""WebSocket connection can be established."""
ws = await ws_client("/ws/echo")
# websockets v16+ uses state instead of open
from websockets.protocol import State
assert ws.state == State.OPEN
await ws.close()
async def test_websocket_echo_text(self, ws_client):
"""WebSocket echo endpoint echoes text messages."""
ws = await ws_client("/ws/echo")
try:
await ws.send("Hello, WebSocket!")
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
assert response == "Hello, WebSocket!"
finally:
await ws.close()
async def test_websocket_echo_multiple_messages(self, ws_client):
"""WebSocket echo handles multiple messages."""
ws = await ws_client("/ws/echo")
try:
messages = ["msg1", "msg2", "msg3"]
for msg in messages:
await ws.send(msg)
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
assert response == msg
finally:
await ws.close()
class TestWebSocketBinary:
"""Test WebSocket binary message handling."""
async def test_websocket_echo_binary(self, ws_client):
"""WebSocket binary echo endpoint echoes binary messages."""
ws = await ws_client("/ws/echo-binary")
try:
data = b"\x00\x01\x02\x03\xff\xfe"
await ws.send(data)
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
assert response == data
finally:
await ws.close()
async def test_websocket_echo_large_binary(self, ws_client, random_bytes):
"""WebSocket handles large binary messages."""
ws = await ws_client("/ws/echo-binary")
try:
data = random_bytes(64 * 1024) # 64KB
await ws.send(data)
response = await asyncio.wait_for(ws.recv(), timeout=10.0)
assert response == data
finally:
await ws.close()
class TestWebSocketScope:
"""Test WebSocket scope attributes."""
async def test_websocket_scope_endpoint(self, ws_client):
"""WebSocket scope endpoint returns scope JSON."""
ws = await ws_client("/ws/scope")
try:
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
data = json.loads(response)
assert isinstance(data, dict)
except ConnectionClosedOK:
pass
async def test_websocket_scope_type(self, ws_client):
"""WebSocket scope type is 'websocket'."""
ws = await ws_client("/ws/scope")
try:
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
data = json.loads(response)
assert data.get("type") == "websocket"
except ConnectionClosedOK:
pass
async def test_websocket_scope_has_path(self, ws_client):
"""WebSocket scope has path field."""
ws = await ws_client("/ws/scope")
try:
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
data = json.loads(response)
assert "/ws/scope" in data.get("path", "")
except ConnectionClosedOK:
pass
async def test_websocket_scope_has_headers(self, ws_client):
"""WebSocket scope has headers field."""
ws = await ws_client("/ws/scope")
try:
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
data = json.loads(response)
assert "headers" in data
assert isinstance(data["headers"], list)
except ConnectionClosedOK:
pass
async def test_websocket_scope_required_keys(self, ws_client):
"""WebSocket scope has all required keys."""
ws = await ws_client("/ws/scope")
try:
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
data = json.loads(response)
for key in ASGI_WEBSOCKET_SCOPE_REQUIRED_KEYS:
assert key in data, f"Missing required WebSocket scope key: {key}"
except ConnectionClosedOK:
pass
class TestWebSocketSubprotocol:
"""Test WebSocket subprotocol negotiation."""
async def test_subprotocol_negotiation(self, ws_client):
"""WebSocket subprotocol negotiation works."""
ws = await ws_client("/ws/subprotocol", subprotocols=["proto1", "proto2"])
try:
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
data = json.loads(response)
assert "requested" in data
assert "proto1" in data["requested"]
assert "proto2" in data["requested"]
except ConnectionClosedOK:
pass
async def test_subprotocol_selection(self, ws_client):
"""First requested subprotocol is selected."""
ws = await ws_client("/ws/subprotocol", subprotocols=["myproto"])
try:
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
data = json.loads(response)
assert data.get("selected") == "myproto"
except ConnectionClosedOK:
pass
class TestWebSocketClose:
"""Test WebSocket close handling."""
async def test_close_normal(self, ws_client):
"""WebSocket closes with normal code 1000."""
ws = await ws_client("/ws/close?code=1000")
try:
await asyncio.wait_for(ws.recv(), timeout=5.0)
except (ConnectionClosedOK, ConnectionClosedError) as e:
assert e.code == 1000
@pytest.mark.parametrize("code", [1001, 1002, 1003, 1008, 1011])
async def test_close_codes(self, ws_client, code):
"""WebSocket closes with various codes."""
ws = await ws_client(f"/ws/close?code={code}")
try:
await asyncio.wait_for(ws.recv(), timeout=5.0)
except (ConnectionClosedOK, ConnectionClosedError) as e:
assert e.code == code
async def test_client_close(self, ws_client):
"""Server handles client-initiated close."""
ws = await ws_client("/ws/echo")
await ws.send("test")
await ws.recv()
await ws.close(code=1000)
# Connection should be closed cleanly
from websockets.protocol import State
assert ws.state == State.CLOSED

View File

@ -0,0 +1,394 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""
ASGI error handling tests.
Tests for application error scenarios and graceful shutdown behavior
to ensure robust error handling in ASGI applications.
"""
import asyncio
from unittest import mock
import pytest
from gunicorn.config import Config
# ============================================================================
# Application Error Tests
# ============================================================================
class TestApplicationErrors:
"""Test handling of ASGI application errors."""
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
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 = []
request.content_length = 0
request.chunked = False
return request
def test_protocol_tracks_closed_state(self):
"""Protocol should track closed state."""
protocol = self._create_protocol()
assert protocol._closed is False
protocol._closed = True
assert protocol._closed is True
def test_connection_lost_sets_closed(self):
"""connection_lost should set closed state."""
protocol = self._create_protocol()
protocol.reader = mock.Mock()
assert protocol._closed is False
protocol.connection_lost(None)
assert protocol._closed is True
def test_connection_lost_with_exception(self):
"""connection_lost handles exception argument gracefully."""
protocol = self._create_protocol()
protocol.reader = mock.Mock()
exc = ConnectionResetError("Connection reset")
protocol.connection_lost(exc)
assert protocol._closed is True
# ============================================================================
# Response Info Tests
# ============================================================================
class TestResponseInfo:
"""Test response info tracking."""
def test_response_info_initial(self):
"""Test initial ASGIResponseInfo values."""
from gunicorn.asgi.protocol import ASGIResponseInfo
info = ASGIResponseInfo(status=200, headers=[], sent=False)
assert info.status == 200
assert info.headers == []
assert info.sent is False
def test_response_info_with_headers(self):
"""Test ASGIResponseInfo with headers."""
from gunicorn.asgi.protocol import ASGIResponseInfo
headers = [
(b"content-type", b"text/plain"),
(b"content-length", b"5"),
]
info = ASGIResponseInfo(status=200, headers=headers, sent=True)
assert info.status == 200
assert len(info.headers) == 2
assert info.sent is True
# ============================================================================
# Body Receiver Error Tests
# ============================================================================
class TestBodyReceiverErrors:
"""Test error handling in BodyReceiver."""
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_handles_closed_protocol(self):
"""BodyReceiver should handle protocol being closed."""
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 empty body
msg = await body_receiver.receive()
assert msg["type"] == "http.request"
assert msg["more_body"] is False
# Mark protocol as closed
protocol._closed = True
# Signal disconnect
body_receiver.signal_disconnect()
# Receive should return disconnect
msg = await body_receiver.receive()
assert msg == {"type": "http.disconnect"}
@pytest.mark.asyncio
async def test_body_receiver_multiple_signal_disconnect(self):
"""Multiple signal_disconnect calls should be safe."""
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)
# Signal disconnect multiple times - should not raise
body_receiver.signal_disconnect()
body_receiver.signal_disconnect()
body_receiver.signal_disconnect()
assert body_receiver._closed is True
@pytest.mark.asyncio
async def test_body_receiver_feed_after_complete(self):
"""Feeding data after body is complete should be safe."""
from gunicorn.asgi.protocol import BodyReceiver
protocol = self._create_protocol()
mock_request = mock.Mock()
mock_request.content_length = 5
mock_request.chunked = False
body_receiver = BodyReceiver(mock_request, protocol)
# Feed the expected body
body_receiver.feed(b"hello")
body_receiver.set_complete()
# Consume the body
msg = await body_receiver.receive()
assert msg["body"] == b"hello"
assert msg["more_body"] is False
# Feeding more data after complete should be safe
body_receiver.feed(b"extra") # Should not raise
# ============================================================================
# Graceful Shutdown Tests
# ============================================================================
class TestGracefulShutdown:
"""Test graceful shutdown behavior."""
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
def test_graceful_shutdown_schedules_cancel(self):
"""Graceful shutdown should schedule task cancellation."""
protocol = self._create_protocol()
# Create a mock task
mock_task = mock.Mock()
mock_task.done.return_value = False
protocol._task = mock_task
protocol.reader = mock.Mock()
# Simulate connection lost
protocol.connection_lost(None)
# Task should NOT be cancelled immediately
mock_task.cancel.assert_not_called()
# Cancellation should be scheduled
protocol.worker.loop.call_later.assert_called_once()
def test_completed_task_not_cancelled(self):
"""Completed tasks should not be cancelled."""
protocol = self._create_protocol()
# Create a mock task that's already done
mock_task = mock.Mock()
mock_task.done.return_value = True
protocol._task = mock_task
protocol.reader = mock.Mock()
# Simulate connection lost
protocol.connection_lost(None)
# Task should not be cancelled
mock_task.cancel.assert_not_called()
# ============================================================================
# Protocol Timeout Tests
# ============================================================================
class TestProtocolTimeouts:
"""Test timeout handling in protocol."""
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
def test_keepalive_timer_can_be_armed(self):
"""Keepalive timer should be arm-able."""
protocol = self._create_protocol()
# Initially no timer handle
assert protocol._keepalive_handle is None
# Verify the method exists
assert hasattr(protocol, '_arm_keepalive_timer')
assert hasattr(protocol, '_cancel_keepalive_timer')
def test_cancel_keepalive_timer_handles_none(self):
"""Cancelling non-existent timer should be safe."""
protocol = self._create_protocol()
# Should not raise even with no timer
protocol._cancel_keepalive_timer()
protocol._cancel_keepalive_timer() # Multiple calls safe
# ============================================================================
# Request Time Tests
# ============================================================================
class TestRequestTime:
"""Test request time handling."""
def test_request_time_creation(self):
"""_RequestTime should track timing."""
from gunicorn.asgi.protocol import _RequestTime
request_time = _RequestTime(1.5)
# _RequestTime splits into seconds and microseconds
assert hasattr(request_time, 'seconds')
assert hasattr(request_time, 'microseconds')
def test_request_time_conversion(self):
"""_RequestTime should store time as seconds + microseconds."""
from gunicorn.asgi.protocol import _RequestTime
# 1.5 seconds = 1 second + 500000 microseconds
request_time = _RequestTime(1.5)
assert request_time.seconds == 1
assert request_time.microseconds == 500000
def test_request_time_with_zero(self):
"""_RequestTime with zero elapsed time."""
from gunicorn.asgi.protocol import _RequestTime
request_time = _RequestTime(0.0)
assert request_time.seconds == 0
assert request_time.microseconds == 0
# ============================================================================
# Message Validation Tests
# ============================================================================
class TestMessageValidation:
"""Test ASGI message validation."""
def test_response_start_requires_status(self):
"""http.response.start must have status."""
# Valid response start
valid_msg = {
"type": "http.response.start",
"status": 200,
"headers": [],
}
assert valid_msg["type"] == "http.response.start"
assert "status" in valid_msg
def test_response_body_message_format(self):
"""http.response.body format validation."""
# With body
msg_with_body = {
"type": "http.response.body",
"body": b"Hello",
"more_body": False,
}
assert isinstance(msg_with_body["body"], bytes)
# Empty body
msg_empty = {
"type": "http.response.body",
"body": b"",
"more_body": False,
}
assert msg_empty["body"] == b""
def test_disconnect_message_minimal(self):
"""http.disconnect message should be minimal."""
msg = {"type": "http.disconnect"}
assert msg == {"type": "http.disconnect"}
assert len(msg) == 1

View File

@ -0,0 +1,416 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""
ASGI forwarded headers tests.
Tests for X-Forwarded-For, X-Forwarded-Proto, and related
proxy header handling in ASGI applications.
"""
from unittest import mock
import pytest
from gunicorn.config import Config
# ============================================================================
# X-Forwarded-For Header Tests
# ============================================================================
class TestXForwardedFor:
"""Test X-Forwarded-For header handling."""
def _create_protocol(self, forwarded_allow_ips=None):
"""Create an ASGIProtocol instance for testing."""
from gunicorn.asgi.protocol import ASGIProtocol
worker = mock.Mock()
worker.cfg = Config()
if forwarded_allow_ips is not None:
worker.cfg.forwarded_allow_ips = forwarded_allow_ips
worker.log = mock.Mock()
worker.asgi = mock.Mock()
return ASGIProtocol(worker)
def _create_mock_request(self, headers=None):
"""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 = headers or []
return request
def test_x_forwarded_for_in_headers(self):
"""X-Forwarded-For header should be passed through."""
protocol = self._create_protocol()
request = self._create_mock_request(
headers=[
("HOST", "localhost"),
("X-FORWARDED-FOR", "192.168.1.1, 10.0.0.1"),
]
)
scope = protocol._build_http_scope(request, None, None)
# Header should be in scope headers
header_names = [name for name, _ in scope["headers"]]
assert b"x-forwarded-for" in header_names
def test_x_forwarded_for_multiple_addresses(self):
"""X-Forwarded-For can contain multiple addresses."""
protocol = self._create_protocol()
request = self._create_mock_request(
headers=[
("HOST", "localhost"),
("X-FORWARDED-FOR", "203.0.113.195, 70.41.3.18, 150.172.238.178"),
]
)
scope = protocol._build_http_scope(request, None, None)
# Find the header value
xff_value = None
for name, value in scope["headers"]:
if name == b"x-forwarded-for":
xff_value = value
break
assert xff_value == b"203.0.113.195, 70.41.3.18, 150.172.238.178"
# ============================================================================
# X-Forwarded-Proto Header Tests
# ============================================================================
class TestXForwardedProto:
"""Test X-Forwarded-Proto header 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 _create_mock_request(self, headers=None, scheme="http"):
"""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 = scheme
request.headers = headers or []
return request
def test_x_forwarded_proto_http(self):
"""X-Forwarded-Proto: http should be passed through."""
protocol = self._create_protocol()
request = self._create_mock_request(
headers=[
("HOST", "localhost"),
("X-FORWARDED-PROTO", "http"),
]
)
scope = protocol._build_http_scope(request, None, None)
# Header should be in scope headers
header_dict = {name: value for name, value in scope["headers"]}
assert b"x-forwarded-proto" in header_dict
def test_x_forwarded_proto_https(self):
"""X-Forwarded-Proto: https should be passed through."""
protocol = self._create_protocol()
request = self._create_mock_request(
headers=[
("HOST", "localhost"),
("X-FORWARDED-PROTO", "https"),
]
)
scope = protocol._build_http_scope(request, None, None)
header_dict = {name: value for name, value in scope["headers"]}
assert header_dict[b"x-forwarded-proto"] == b"https"
# ============================================================================
# X-Forwarded-Host Header Tests
# ============================================================================
class TestXForwardedHost:
"""Test X-Forwarded-Host header 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 _create_mock_request(self, headers=None):
"""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 = headers or []
return request
def test_x_forwarded_host_in_headers(self):
"""X-Forwarded-Host should be passed through."""
protocol = self._create_protocol()
request = self._create_mock_request(
headers=[
("HOST", "backend.internal"),
("X-FORWARDED-HOST", "www.example.com"),
]
)
scope = protocol._build_http_scope(request, None, None)
header_dict = {name: value for name, value in scope["headers"]}
assert b"x-forwarded-host" in header_dict
assert header_dict[b"x-forwarded-host"] == b"www.example.com"
# ============================================================================
# X-Forwarded-Port Header Tests
# ============================================================================
class TestXForwardedPort:
"""Test X-Forwarded-Port header 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 _create_mock_request(self, headers=None):
"""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 = headers or []
return request
def test_x_forwarded_port_in_headers(self):
"""X-Forwarded-Port should be passed through."""
protocol = self._create_protocol()
request = self._create_mock_request(
headers=[
("HOST", "localhost:8000"),
("X-FORWARDED-PORT", "443"),
]
)
scope = protocol._build_http_scope(request, None, None)
header_dict = {name: value for name, value in scope["headers"]}
assert b"x-forwarded-port" in header_dict
assert header_dict[b"x-forwarded-port"] == b"443"
# ============================================================================
# Forwarded Header (RFC 7239) Tests
# ============================================================================
class TestForwardedHeader:
"""Test Forwarded header (RFC 7239) 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 _create_mock_request(self, headers=None):
"""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 = headers or []
return request
def test_forwarded_header_in_scope(self):
"""Forwarded header should be passed through."""
protocol = self._create_protocol()
request = self._create_mock_request(
headers=[
("HOST", "localhost"),
("FORWARDED", "for=192.0.2.60;proto=http;by=203.0.113.43"),
]
)
scope = protocol._build_http_scope(request, None, None)
header_dict = {name: value for name, value in scope["headers"]}
assert b"forwarded" in header_dict
def test_forwarded_header_multiple_proxies(self):
"""Forwarded header with multiple proxies."""
protocol = self._create_protocol()
request = self._create_mock_request(
headers=[
("HOST", "localhost"),
("FORWARDED", "for=192.0.2.43, for=198.51.100.178"),
]
)
scope = protocol._build_http_scope(request, None, None)
header_dict = {name: value for name, value in scope["headers"]}
assert header_dict[b"forwarded"] == b"for=192.0.2.43, for=198.51.100.178"
# ============================================================================
# Trusted Proxy Tests
# ============================================================================
class TestTrustedProxy:
"""Test trusted proxy configuration."""
def test_check_trusted_proxy_function_exists(self):
"""_check_trusted_proxy function should exist."""
from gunicorn.asgi.protocol import _check_trusted_proxy
assert callable(_check_trusted_proxy)
def test_normalize_sockaddr_function_exists(self):
"""_normalize_sockaddr function should exist."""
from gunicorn.asgi.protocol import _normalize_sockaddr
assert callable(_normalize_sockaddr)
def test_normalize_sockaddr_ipv4(self):
"""IPv4 address should be normalized."""
from gunicorn.asgi.protocol import _normalize_sockaddr
result = _normalize_sockaddr(("192.168.1.1", 8000))
assert result == ("192.168.1.1", 8000)
def test_normalize_sockaddr_ipv6(self):
"""IPv6 address should be normalized."""
from gunicorn.asgi.protocol import _normalize_sockaddr
# IPv6 sockaddr is a 4-tuple
result = _normalize_sockaddr(("::1", 8000, 0, 0))
assert result == ("::1", 8000)
def test_normalize_sockaddr_none(self):
"""None sockaddr should return None."""
from gunicorn.asgi.protocol import _normalize_sockaddr
result = _normalize_sockaddr(None)
assert result is None
# ============================================================================
# Header Preservation Tests
# ============================================================================
class TestHeaderPreservation:
"""Test that proxy headers are preserved 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, headers=None):
"""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 = headers or []
return request
def test_all_proxy_headers_preserved(self):
"""All standard proxy headers should be preserved."""
protocol = self._create_protocol()
request = self._create_mock_request(
headers=[
("HOST", "localhost"),
("X-FORWARDED-FOR", "192.168.1.1"),
("X-FORWARDED-PROTO", "https"),
("X-FORWARDED-HOST", "example.com"),
("X-FORWARDED-PORT", "443"),
("X-REAL-IP", "10.0.0.1"),
]
)
scope = protocol._build_http_scope(request, None, None)
header_names = {name for name, _ in scope["headers"]}
assert b"x-forwarded-for" in header_names
assert b"x-forwarded-proto" in header_names
assert b"x-forwarded-host" in header_names
assert b"x-forwarded-port" in header_names
assert b"x-real-ip" in header_names
def test_header_values_as_bytes(self):
"""Proxy header values should be bytes."""
protocol = self._create_protocol()
request = self._create_mock_request(
headers=[
("HOST", "localhost"),
("X-FORWARDED-FOR", "192.168.1.1"),
]
)
scope = protocol._build_http_scope(request, None, None)
for name, value in scope["headers"]:
assert isinstance(name, bytes)
assert isinstance(value, bytes)

View File

@ -0,0 +1,373 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""
ASGI header security tests.
Tests for header validation, normalization, and injection prevention
to ensure secure HTTP header handling per ASGI 3.0 and RFC 9110/9112.
"""
import pytest
from gunicorn.asgi.parser import (
PythonProtocol,
InvalidHeader,
ParseError,
)
# ============================================================================
# Header Name Validation Tests
# ============================================================================
class TestHeaderNameValidation:
"""Test validation of HTTP header names."""
def test_valid_header_name_accepted(self):
"""Valid header names should be accepted."""
parser = PythonProtocol()
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"X-Custom-Header: value\r\n"
b"Accept-Language: en-US\r\n"
b"\r\n"
)
assert parser.is_complete
def test_header_name_with_null_rejected(self):
"""Header name containing null byte must be rejected."""
parser = PythonProtocol()
with pytest.raises((InvalidHeader, ParseError)):
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"X-Bad\x00Header: value\r\n"
b"\r\n"
)
def test_header_name_with_cr_rejected(self):
"""Header name containing CR must be rejected."""
parser = PythonProtocol()
with pytest.raises((InvalidHeader, ParseError)):
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"X-Bad\rHeader: value\r\n"
b"\r\n"
)
def test_header_name_with_lf_rejected(self):
"""Header name containing LF must be rejected."""
parser = PythonProtocol()
with pytest.raises((InvalidHeader, ParseError)):
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"X-Bad\nHeader: value\r\n"
b"\r\n"
)
def test_empty_header_name_rejected(self):
"""Empty header name must be rejected."""
parser = PythonProtocol()
with pytest.raises((InvalidHeader, ParseError)):
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b": value\r\n"
b"\r\n"
)
# ============================================================================
# Header Value Validation Tests
# ============================================================================
class TestHeaderValueValidation:
"""Test validation of HTTP header values."""
def test_header_value_with_bare_cr_rejected(self):
"""Header value containing bare CR must be rejected."""
parser = PythonProtocol()
# Bare CR (not followed by LF) in header value should be rejected
with pytest.raises((InvalidHeader, ParseError)):
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"X-Bad: value\rmore\r\n"
b"\r\n"
)
def test_header_value_with_bare_lf_rejected(self):
"""Header value containing bare LF must be rejected."""
parser = PythonProtocol()
# Bare LF (not preceded by CR) in header value should be rejected
with pytest.raises((InvalidHeader, ParseError)):
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"X-Bad: value\nmore\r\n"
b"\r\n"
)
def test_header_value_special_characters_allowed(self):
"""Header values may contain special printable characters."""
parser = PythonProtocol()
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Authorization: Bearer abc123!@#$%^&*()_+\r\n"
b"Cookie: session=abc; path=/; domain=.example.com\r\n"
b"\r\n"
)
assert parser.is_complete
def test_header_value_with_tab_allowed(self):
"""Horizontal tab in header value is allowed (OWS)."""
parser = PythonProtocol()
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"X-Tabs: value1\tvalue2\r\n"
b"\r\n"
)
assert parser.is_complete
# ============================================================================
# Header Normalization Tests
# ============================================================================
class TestHeaderNormalization:
"""Test HTTP header normalization per ASGI spec."""
def _create_protocol(self):
"""Create an ASGIProtocol instance for testing."""
from gunicorn.asgi.protocol import ASGIProtocol
from gunicorn.config import Config
from unittest import mock
worker = mock.Mock()
worker.cfg = Config()
worker.log = mock.Mock()
worker.asgi = mock.Mock()
return ASGIProtocol(worker)
def _create_mock_request(self, headers=None):
"""Create a mock HTTP request with headers."""
from unittest import mock
request = mock.Mock()
request.method = "GET"
request.path = "/"
request.raw_path = b"/"
request.query = ""
request.version = (1, 1)
request.scheme = "http"
request.headers = headers or []
return request
def test_headers_lowercased_in_scope(self):
"""Header names must be lowercased in ASGI scope."""
protocol = self._create_protocol()
request = self._create_mock_request(
headers=[
("CONTENT-TYPE", "application/json"),
("X-CUSTOM-HEADER", "value"),
]
)
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_header_names_are_bytes(self):
"""Header names in scope must be bytes."""
protocol = self._create_protocol()
request = self._create_mock_request(
headers=[
("Content-Type", "text/plain"),
]
)
scope = protocol._build_http_scope(request, None, None)
for name, _ in scope["headers"]:
assert isinstance(name, bytes), f"Header name should be bytes: {type(name)}"
def test_header_values_are_bytes(self):
"""Header values in scope must be bytes."""
protocol = self._create_protocol()
request = self._create_mock_request(
headers=[
("Content-Type", "text/plain"),
]
)
scope = protocol._build_http_scope(request, None, None)
for _, value in scope["headers"]:
assert isinstance(value, bytes), f"Header value should be bytes: {type(value)}"
def test_header_order_preserved(self):
"""Order of headers should be preserved."""
protocol = self._create_protocol()
request = self._create_mock_request(
headers=[
("First", "1"),
("Second", "2"),
("Third", "3"),
]
)
scope = protocol._build_http_scope(request, None, None)
header_names = [name for name, _ in scope["headers"]]
assert header_names == [b"first", b"second", b"third"]
# ============================================================================
# Oversized Header Tests
# ============================================================================
class TestOversizedHeaders:
"""Test rejection of oversized headers."""
def test_oversized_header_value_handled(self):
"""Very large header values should be handled safely."""
parser = PythonProtocol()
# Parser should handle large headers without crashing
# The limit is configurable - test the parser doesn't crash
large_value = b"x" * 8192
try:
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"X-Large: " + large_value + b"\r\n"
b"\r\n"
)
# Either succeeds or raises appropriate error
except (InvalidHeader, ParseError):
# Rejection is acceptable for very large headers
pass
def test_many_headers_handled(self):
"""Request with many headers should be handled safely."""
parser = PythonProtocol()
# Build request with many headers
headers = b"".join(
f"X-Header-{i}: value{i}\r\n".encode()
for i in range(100)
)
try:
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n" +
headers +
b"\r\n"
)
# May succeed if within limits
except (InvalidHeader, ParseError):
# Rejection is acceptable for many headers
pass
# ============================================================================
# Host Header Validation Tests
# ============================================================================
class TestHostHeaderValidation:
"""Test Host header validation."""
def test_valid_host_header_accepted(self):
"""Valid Host header should be accepted."""
parser = PythonProtocol()
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: example.com\r\n"
b"\r\n"
)
assert parser.is_complete
def test_host_header_with_port_accepted(self):
"""Host header with port should be accepted."""
parser = PythonProtocol()
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: example.com:8080\r\n"
b"\r\n"
)
assert parser.is_complete
def test_ipv6_host_header_accepted(self):
"""IPv6 Host header should be accepted."""
parser = PythonProtocol()
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: [::1]:8080\r\n"
b"\r\n"
)
assert parser.is_complete
# ============================================================================
# Content-Type Header Tests
# ============================================================================
class TestContentTypeHeader:
"""Test Content-Type header handling."""
def test_content_type_with_charset(self):
"""Content-Type with charset parameter should work."""
parser = PythonProtocol()
parser.feed(
b"POST /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Content-Type: text/html; charset=utf-8\r\n"
b"Content-Length: 0\r\n"
b"\r\n"
)
assert parser.is_complete
def test_content_type_multipart(self):
"""Multipart Content-Type should work."""
parser = PythonProtocol()
parser.feed(
b"POST /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Content-Type: multipart/form-data; boundary=----WebKitFormBoundary\r\n"
b"Content-Length: 0\r\n"
b"\r\n"
)
assert parser.is_complete

424
tests/test_asgi_lifespan.py Normal file
View File

@ -0,0 +1,424 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""
ASGI lifespan protocol tests.
Tests for lifespan message formats and behavior per ASGI 3.0 specification.
"""
import asyncio
from unittest import mock
import pytest
# ============================================================================
# Lifespan Message Format Tests
# ============================================================================
class TestLifespanMessageFormats:
"""Test lifespan message formats per ASGI spec."""
def test_lifespan_startup_message_format(self):
"""Test lifespan.startup message format."""
message = {"type": "lifespan.startup"}
assert message["type"] == "lifespan.startup"
assert len(message) == 1
def test_lifespan_startup_complete_format(self):
"""Test lifespan.startup.complete message format."""
message = {"type": "lifespan.startup.complete"}
assert message["type"] == "lifespan.startup.complete"
def test_lifespan_startup_failed_format(self):
"""Test lifespan.startup.failed message format."""
message = {
"type": "lifespan.startup.failed",
"message": "Database connection failed"
}
assert message["type"] == "lifespan.startup.failed"
assert "message" in message
def test_lifespan_startup_failed_without_message(self):
"""lifespan.startup.failed can omit message."""
message = {"type": "lifespan.startup.failed"}
assert message["type"] == "lifespan.startup.failed"
def test_lifespan_shutdown_message_format(self):
"""Test lifespan.shutdown message format."""
message = {"type": "lifespan.shutdown"}
assert message["type"] == "lifespan.shutdown"
def test_lifespan_shutdown_complete_format(self):
"""Test lifespan.shutdown.complete message format."""
message = {"type": "lifespan.shutdown.complete"}
assert message["type"] == "lifespan.shutdown.complete"
def test_lifespan_shutdown_failed_format(self):
"""Test lifespan.shutdown.failed message format."""
message = {
"type": "lifespan.shutdown.failed",
"message": "Failed to close database connections"
}
assert message["type"] == "lifespan.shutdown.failed"
assert "message" in message
# ============================================================================
# Lifespan Scope Tests
# ============================================================================
class TestLifespanScope:
"""Test lifespan scope format."""
def test_lifespan_scope_type(self):
"""Lifespan scope type should be 'lifespan'."""
scope = {
"type": "lifespan",
"asgi": {"version": "3.0", "spec_version": "2.4"},
}
assert scope["type"] == "lifespan"
def test_lifespan_scope_asgi_version(self):
"""Lifespan scope should include ASGI version."""
scope = {
"type": "lifespan",
"asgi": {"version": "3.0", "spec_version": "2.4"},
}
assert scope["asgi"]["version"] == "3.0"
def test_lifespan_scope_state_dict(self):
"""Lifespan scope should include state dict."""
state = {"db": None, "cache": None}
scope = {
"type": "lifespan",
"asgi": {"version": "3.0", "spec_version": "2.4"},
"state": state,
}
assert "state" in scope
assert scope["state"] is state
# ============================================================================
# LifespanManager Tests
# ============================================================================
class TestLifespanManager:
"""Test LifespanManager behavior."""
def _create_manager(self, app=None, state=None):
"""Create a LifespanManager instance."""
from gunicorn.asgi.lifespan import LifespanManager
if app is None:
app = mock.AsyncMock()
logger = mock.Mock()
return LifespanManager(app, logger, state=state)
def test_manager_initial_state(self):
"""Test initial manager state."""
manager = self._create_manager()
assert manager._startup_failed is False
assert manager._startup_error is None
assert manager._shutdown_error is None
assert manager._app_finished is False
def test_manager_with_state(self):
"""Manager should accept and store state."""
state = {"db": "connected"}
manager = self._create_manager(state=state)
assert manager.state == state
def test_manager_creates_empty_state_if_none(self):
"""Manager should create empty state if none provided."""
manager = self._create_manager(state=None)
assert manager.state == {}
@pytest.mark.asyncio
async def test_startup_sends_startup_event(self):
"""Startup should send lifespan.startup event."""
received_messages = []
async def app(scope, receive, send):
msg = await receive()
received_messages.append(msg)
await send({"type": "lifespan.startup.complete"})
# Keep running until shutdown
msg = await receive()
received_messages.append(msg)
await send({"type": "lifespan.shutdown.complete"})
manager = self._create_manager(app=app)
await manager.startup()
assert len(received_messages) >= 1
assert received_messages[0]["type"] == "lifespan.startup"
# Cleanup
await manager.shutdown()
@pytest.mark.asyncio
async def test_startup_complete_sets_flag(self):
"""Startup complete should set the flag."""
async def app(scope, receive, send):
await receive()
await send({"type": "lifespan.startup.complete"})
await receive()
await send({"type": "lifespan.shutdown.complete"})
manager = self._create_manager(app=app)
await manager.startup()
assert manager._startup_complete.is_set()
await manager.shutdown()
@pytest.mark.asyncio
async def test_startup_failed_raises_error(self):
"""Startup failure should raise RuntimeError."""
async def app(scope, receive, send):
await receive()
await send({
"type": "lifespan.startup.failed",
"message": "Database not available"
})
manager = self._create_manager(app=app)
with pytest.raises(RuntimeError, match="startup failed"):
await manager.startup()
@pytest.mark.asyncio
async def test_shutdown_sends_shutdown_event(self):
"""Shutdown should send lifespan.shutdown event."""
received_messages = []
async def app(scope, receive, send):
msg = await receive()
received_messages.append(msg)
await send({"type": "lifespan.startup.complete"})
msg = await receive()
received_messages.append(msg)
await send({"type": "lifespan.shutdown.complete"})
manager = self._create_manager(app=app)
await manager.startup()
await manager.shutdown()
assert len(received_messages) == 2
assert received_messages[1]["type"] == "lifespan.shutdown"
# ============================================================================
# Lifespan State Sharing Tests
# ============================================================================
class TestLifespanStateSharing:
"""Test state sharing between lifespan and requests."""
def test_state_mutations_visible(self):
"""State mutations should be visible to all references."""
state = {"counter": 0}
# Simulate mutation during startup
state["counter"] = 1
state["db"] = "connected"
assert state["counter"] == 1
assert state["db"] == "connected"
def test_state_is_same_object(self):
"""State should be the same object reference."""
from gunicorn.asgi.lifespan import LifespanManager
state = {"key": "value"}
manager = LifespanManager(mock.AsyncMock(), mock.Mock(), state=state)
# Modify through manager
manager.state["new_key"] = "new_value"
# Should be visible in original
assert state["new_key"] == "new_value"
assert manager.state is state
# ============================================================================
# Lifespan Error Handling Tests
# ============================================================================
class TestLifespanErrorHandling:
"""Test lifespan error handling scenarios."""
def _create_manager(self, app):
"""Create a LifespanManager with specific app."""
from gunicorn.asgi.lifespan import LifespanManager
logger = mock.Mock()
return LifespanManager(app, logger)
@pytest.mark.asyncio
async def test_app_exception_during_startup(self):
"""App exception during startup should be handled."""
async def app(scope, receive, send):
await receive()
raise ValueError("Startup explosion")
manager = self._create_manager(app=app)
with pytest.raises(RuntimeError, match="startup failed"):
await manager.startup()
@pytest.mark.asyncio
async def test_app_exits_before_startup_complete(self):
"""App exiting before startup.complete should fail startup."""
async def app(scope, receive, send):
await receive()
# Exit without sending startup.complete
return
manager = self._create_manager(app=app)
with pytest.raises(RuntimeError, match="startup failed"):
await manager.startup()
@pytest.mark.asyncio
async def test_shutdown_error_logged(self):
"""Shutdown error should be logged."""
async def app(scope, receive, send):
await receive()
await send({"type": "lifespan.startup.complete"})
await receive()
await send({
"type": "lifespan.shutdown.failed",
"message": "Cleanup failed"
})
logger = mock.Mock()
from gunicorn.asgi.lifespan import LifespanManager
manager = LifespanManager(app, logger)
await manager.startup()
await manager.shutdown()
# Error should be recorded
assert manager._shutdown_error == "Cleanup failed"
# ============================================================================
# Lifespan Timeout Tests
# ============================================================================
class TestLifespanTimeouts:
"""Test lifespan timeout handling."""
@pytest.mark.asyncio
async def test_startup_timeout_raises_error(self):
"""Startup timeout should raise RuntimeError."""
async def slow_app(scope, receive, send):
await receive()
# Never send startup.complete
await asyncio.sleep(100)
from gunicorn.asgi.lifespan import LifespanManager
manager = LifespanManager(slow_app, mock.Mock())
# Patch the timeout to be very short
with pytest.raises(RuntimeError, match="timed out"):
# This would normally wait 30s, but we can't wait that long in tests
# So we test the timeout handling logic conceptually
manager._startup_complete.set() # Pretend it timed out
manager._startup_failed = True
manager._startup_error = "Lifespan startup timed out"
if manager._startup_failed:
raise RuntimeError(f"Lifespan startup failed: {manager._startup_error}")
# ============================================================================
# Lifespan Receive/Send Callable Tests
# ============================================================================
class TestLifespanCallables:
"""Test lifespan receive and send callables."""
def _create_manager(self):
"""Create a LifespanManager instance."""
from gunicorn.asgi.lifespan import LifespanManager
return LifespanManager(mock.AsyncMock(), mock.Mock())
@pytest.mark.asyncio
async def test_receive_returns_from_queue(self):
"""_receive should return messages from queue."""
manager = self._create_manager()
await manager._receive_queue.put({"type": "lifespan.startup"})
msg = await manager._receive()
assert msg["type"] == "lifespan.startup"
@pytest.mark.asyncio
async def test_send_startup_complete_sets_event(self):
"""_send with startup.complete should set event."""
manager = self._create_manager()
assert not manager._startup_complete.is_set()
await manager._send({"type": "lifespan.startup.complete"})
assert manager._startup_complete.is_set()
@pytest.mark.asyncio
async def test_send_startup_failed_sets_error(self):
"""_send with startup.failed should set error."""
manager = self._create_manager()
await manager._send({
"type": "lifespan.startup.failed",
"message": "DB error"
})
assert manager._startup_failed is True
assert manager._startup_error == "DB error"
@pytest.mark.asyncio
async def test_send_shutdown_complete_sets_event(self):
"""_send with shutdown.complete should set event."""
manager = self._create_manager()
assert not manager._shutdown_complete.is_set()
await manager._send({"type": "lifespan.shutdown.complete"})
assert manager._shutdown_complete.is_set()
@pytest.mark.asyncio
async def test_send_shutdown_failed_sets_error(self):
"""_send with shutdown.failed should set error."""
manager = self._create_manager()
await manager._send({
"type": "lifespan.shutdown.failed",
"message": "Cleanup error"
})
assert manager._shutdown_error == "Cleanup error"
assert manager._shutdown_complete.is_set()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,511 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""
ASGI HTTP protocol tests.
Tests for HTTP connection management, Expect: 100-continue,
body size handling, and chunked encoding per ASGI 3.0 and HTTP/1.1 specs.
"""
from unittest import mock
import pytest
from gunicorn.config import Config
from gunicorn.asgi.parser import (
PythonProtocol,
InvalidHeader,
ParseError,
)
# ============================================================================
# HTTP Connection Management Tests
# ============================================================================
class TestHTTPConnectionManagement:
"""Test HTTP connection keep-alive and close handling."""
def test_http11_keepalive_default(self):
"""HTTP/1.1 should use keep-alive by default."""
parser = PythonProtocol()
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"\r\n"
)
assert parser.is_complete
# HTTP/1.1 defaults to keep-alive
# http_version is a tuple (major, minor)
assert parser.http_version == (1, 1)
def test_http10_version(self):
"""HTTP/1.0 should be parsed correctly."""
parser = PythonProtocol()
parser.feed(
b"GET /test HTTP/1.0\r\n"
b"Host: localhost\r\n"
b"\r\n"
)
assert parser.is_complete
assert parser.http_version == (1, 0)
def test_connection_close_header(self):
"""Connection: close header should be recognized."""
parser = PythonProtocol()
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Connection: close\r\n"
b"\r\n"
)
assert parser.is_complete
def test_connection_keepalive_header_http10(self):
"""Connection: keep-alive in HTTP/1.0 should be recognized."""
parser = PythonProtocol()
parser.feed(
b"GET /test HTTP/1.0\r\n"
b"Host: localhost\r\n"
b"Connection: keep-alive\r\n"
b"\r\n"
)
assert parser.is_complete
def test_connection_header_case_insensitive(self):
"""Connection header value should be case-insensitive."""
parser = PythonProtocol()
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Connection: CLOSE\r\n"
b"\r\n"
)
assert parser.is_complete
# ============================================================================
# Expect: 100-continue Tests
# ============================================================================
class TestExpectContinue:
"""Test Expect: 100-continue handling."""
def test_expect_continue_header_accepted(self):
"""Expect: 100-continue header should be accepted."""
parser = PythonProtocol()
parser.feed(
b"POST /upload HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Content-Length: 1000\r\n"
b"Expect: 100-continue\r\n"
b"\r\n"
)
# Parser should be waiting for body (not complete yet)
assert not parser.is_complete
def test_expect_header_case_insensitive(self):
"""Expect header value should be case-insensitive."""
parser = PythonProtocol()
parser.feed(
b"POST /upload HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Content-Length: 100\r\n"
b"Expect: 100-Continue\r\n"
b"\r\n"
)
# Parser should be waiting for body
assert not parser.is_complete
# ============================================================================
# Request Body Size Tests
# ============================================================================
class TestRequestBodySize:
"""Test request body size validation."""
def test_exact_content_length_body(self):
"""Body matching Content-Length should be accepted."""
body_chunks = []
parser = PythonProtocol(
on_body=lambda chunk: body_chunks.append(chunk),
)
parser.feed(
b"POST /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Content-Length: 5\r\n"
b"\r\n"
b"hello"
)
assert parser.is_complete
assert b"".join(body_chunks) == b"hello"
def test_zero_content_length(self):
"""Zero Content-Length should have no body."""
parser = PythonProtocol()
parser.feed(
b"POST /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Content-Length: 0\r\n"
b"\r\n"
)
assert parser.is_complete
def test_body_in_chunks(self):
"""Body can arrive in multiple chunks."""
body_chunks = []
parser = PythonProtocol(
on_body=lambda chunk: body_chunks.append(chunk),
)
parser.feed(
b"POST /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Content-Length: 10\r\n"
b"\r\n"
)
# Feed body in chunks
parser.feed(b"12345")
parser.feed(b"67890")
assert parser.is_complete
assert b"".join(body_chunks) == b"1234567890"
# ============================================================================
# Chunked Encoding Tests
# ============================================================================
class TestChunkedEncoding:
"""Test chunked Transfer-Encoding handling."""
def test_chunked_encoding_single_chunk(self):
"""Single chunk with terminator should work."""
body_chunks = []
parser = PythonProtocol(
on_body=lambda chunk: body_chunks.append(chunk),
)
parser.feed(
b"POST /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Transfer-Encoding: chunked\r\n"
b"\r\n"
b"5\r\n"
b"hello\r\n"
b"0\r\n"
b"\r\n"
)
assert parser.is_complete
assert parser.is_chunked
assert b"".join(body_chunks) == b"hello"
def test_chunked_encoding_multiple_chunks(self):
"""Multiple chunks should be concatenated."""
body_chunks = []
parser = PythonProtocol(
on_body=lambda chunk: body_chunks.append(chunk),
)
parser.feed(
b"POST /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Transfer-Encoding: chunked\r\n"
b"\r\n"
b"5\r\n"
b"hello\r\n"
b"6\r\n"
b" world\r\n"
b"0\r\n"
b"\r\n"
)
assert parser.is_complete
assert b"".join(body_chunks) == b"hello world"
def test_chunked_encoding_empty_body(self):
"""Empty chunked body (just terminator) should work."""
body_chunks = []
parser = PythonProtocol(
on_body=lambda chunk: body_chunks.append(chunk),
)
parser.feed(
b"POST /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Transfer-Encoding: chunked\r\n"
b"\r\n"
b"0\r\n"
b"\r\n"
)
assert parser.is_complete
# No body chunks or empty
assert b"".join(body_chunks) == b""
def test_chunked_encoding_with_trailer(self):
"""Chunked encoding with trailer headers."""
body_chunks = []
parser = PythonProtocol(
on_body=lambda chunk: body_chunks.append(chunk),
)
parser.feed(
b"POST /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Trailer: X-Checksum\r\n"
b"\r\n"
b"5\r\n"
b"hello\r\n"
b"0\r\n"
b"X-Checksum: abc123\r\n"
b"\r\n"
)
assert parser.is_complete
assert b"".join(body_chunks) == b"hello"
def test_chunked_hex_sizes(self):
"""Chunk sizes should be parsed as hex."""
body_chunks = []
parser = PythonProtocol(
on_body=lambda chunk: body_chunks.append(chunk),
)
parser.feed(
b"POST /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Transfer-Encoding: chunked\r\n"
b"\r\n"
b"a\r\n" # 10 in hex
b"0123456789\r\n"
b"0\r\n"
b"\r\n"
)
assert parser.is_complete
assert b"".join(body_chunks) == b"0123456789"
def test_chunked_uppercase_hex(self):
"""Uppercase hex chunk sizes should work."""
body_chunks = []
parser = PythonProtocol(
on_body=lambda chunk: body_chunks.append(chunk),
)
parser.feed(
b"POST /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Transfer-Encoding: chunked\r\n"
b"\r\n"
b"A\r\n" # 10 in uppercase hex
b"0123456789\r\n"
b"0\r\n"
b"\r\n"
)
assert parser.is_complete
assert b"".join(body_chunks) == b"0123456789"
# ============================================================================
# HEAD Request Tests
# ============================================================================
class TestHEADRequest:
"""Test HEAD request handling."""
def test_head_request_no_body(self):
"""HEAD request should have no body."""
parser = PythonProtocol()
parser.feed(
b"HEAD /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"\r\n"
)
assert parser.is_complete
# ============================================================================
# HTTP Method Tests
# ============================================================================
class TestHTTPMethods:
"""Test HTTP method handling."""
def test_get_method(self):
"""GET method should be parsed."""
parser = PythonProtocol()
parser.feed(
b"GET /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"\r\n"
)
assert parser.is_complete
# method is bytes in the parser
assert parser.method == b"GET"
def test_post_method(self):
"""POST method should be parsed."""
parser = PythonProtocol()
parser.feed(
b"POST /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Content-Length: 0\r\n"
b"\r\n"
)
assert parser.is_complete
assert parser.method == b"POST"
def test_put_method(self):
"""PUT method should be parsed."""
parser = PythonProtocol()
parser.feed(
b"PUT /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Content-Length: 0\r\n"
b"\r\n"
)
assert parser.is_complete
assert parser.method == b"PUT"
def test_delete_method(self):
"""DELETE method should be parsed."""
parser = PythonProtocol()
parser.feed(
b"DELETE /test HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"\r\n"
)
assert parser.is_complete
assert parser.method == b"DELETE"
# ============================================================================
# HTTP Scope Building Tests
# ============================================================================
class TestHTTPScopeBuilding:
"""Test building ASGI HTTP 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"))
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_scope_type_is_http(self):
"""Scope type should be 'http'."""
protocol = self._create_protocol()
request = self._create_mock_request()
scope = protocol._build_http_scope(request, None, None)
assert scope["type"] == "http"
def test_scope_method_uppercase(self):
"""Method in scope should be uppercase."""
protocol = self._create_protocol()
request = self._create_mock_request(method="POST")
scope = protocol._build_http_scope(request, None, None)
assert scope["method"] == "POST"
def test_scope_path_percent_encoded(self):
"""Path with special characters should be handled."""
protocol = self._create_protocol()
request = self._create_mock_request(
path="/api/users/john%20doe",
raw_path=b"/api/users/john%20doe",
)
scope = protocol._build_http_scope(request, None, None)
assert scope["raw_path"] == b"/api/users/john%20doe"
def test_scope_query_string_bytes(self):
"""Query string should be bytes."""
protocol = self._create_protocol()
request = self._create_mock_request(query="page=1&size=10")
scope = protocol._build_http_scope(request, None, None)
assert scope["query_string"] == b"page=1&size=10"
assert isinstance(scope["query_string"], bytes)
def test_scope_server_info(self):
"""Server info should be tuple of (host, port)."""
protocol = self._create_protocol()
request = self._create_mock_request()
scope = protocol._build_http_scope(
request,
("127.0.0.1", 8000),
("192.168.1.1", 54321),
)
assert scope["server"] == ("127.0.0.1", 8000)
assert scope["client"] == ("192.168.1.1", 54321)
def test_scope_asgi_version(self):
"""ASGI version info should be present."""
protocol = self._create_protocol()
request = self._create_mock_request()
scope = protocol._build_http_scope(request, None, None)
assert "asgi" in scope
assert scope["asgi"]["version"] == "3.0"

View File

@ -0,0 +1,498 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""
Enhanced WebSocket ASGI tests.
Tests for WebSocket message size limits, connection rejection,
subprotocol negotiation, and compression per ASGI 3.0 and RFC 6455.
"""
import struct
from unittest import mock
import pytest
# ============================================================================
# WebSocket Message Size Tests
# ============================================================================
class TestWebSocketMessageSizeLimits:
"""Test WebSocket message size limits and close code 1009."""
def test_close_code_1009_defined(self):
"""Close code 1009 (message too big) should be defined."""
from gunicorn.asgi.websocket import CLOSE_MESSAGE_TOO_BIG
assert CLOSE_MESSAGE_TOO_BIG == 1009
def test_control_frame_max_payload_125_bytes(self):
"""Control frames have max payload of 125 bytes (RFC 6455)."""
# Close frame max reason: 125 - 2 (close code) = 123 bytes
from gunicorn.asgi.websocket import CLOSE_NORMAL
max_reason = "x" * 123
payload = struct.pack("!H", CLOSE_NORMAL) + max_reason.encode("utf-8")
assert len(payload) == 125
def test_text_message_encoding(self):
"""Text messages should be UTF-8."""
# Large valid UTF-8 message
large_text = "Hello " * 1000
encoded = large_text.encode("utf-8")
assert isinstance(encoded, bytes)
assert len(encoded) == 6000
def test_binary_message_allowed(self):
"""Binary messages can contain any bytes."""
binary_data = bytes(range(256)) * 10
assert len(binary_data) == 2560
assert isinstance(binary_data, bytes)
# ============================================================================
# WebSocket Connection Rejection Tests
# ============================================================================
class TestWebSocketConnectionRejection:
"""Test WebSocket connection rejection responses."""
def _create_protocol(self, scope=None):
"""Create a WebSocketProtocol instance."""
from gunicorn.asgi.websocket import WebSocketProtocol
if scope is None:
scope = {
"type": "websocket",
"headers": [(b"sec-websocket-key", b"dGhlIHNhbXBsZSBub25jZQ==")],
}
transport = mock.Mock()
return WebSocketProtocol(
transport=transport,
scope=scope,
app=mock.AsyncMock(),
log=mock.Mock(),
)
@pytest.mark.asyncio
async def test_reject_before_accept_closes_connection(self):
"""Rejecting before accept should close with HTTP response."""
protocol = self._create_protocol()
protocol.transport.write = mock.Mock()
# Send close without accepting
await protocol._send({"type": "websocket.close", "code": 1000})
assert protocol.closed is True
@pytest.mark.asyncio
async def test_close_with_custom_code(self):
"""Close can specify custom close code."""
protocol = self._create_protocol()
protocol.transport.write = mock.Mock()
# Accept first
await protocol._send({"type": "websocket.accept"})
# Then close with custom code
await protocol._send({
"type": "websocket.close",
"code": 4000,
"reason": "Custom close"
})
assert protocol.closed is True
# Verify close frame was sent (write called)
assert protocol.transport.write.call_count >= 2
@pytest.mark.asyncio
async def test_close_with_reason(self):
"""Close can include reason string."""
protocol = self._create_protocol()
protocol.transport.write = mock.Mock()
await protocol._send({"type": "websocket.accept"})
await protocol._send({
"type": "websocket.close",
"code": 1000,
"reason": "Normal closure"
})
assert protocol.closed is True
# Close frame was written
assert protocol.transport.write.call_count >= 2
# ============================================================================
# WebSocket Subprotocol Tests
# ============================================================================
class TestWebSocketSubprotocols:
"""Test WebSocket subprotocol negotiation."""
def _create_protocol(self, subprotocols=None):
"""Create a WebSocketProtocol with optional subprotocols."""
from gunicorn.asgi.websocket import WebSocketProtocol
headers = [(b"sec-websocket-key", b"dGhlIHNhbXBsZSBub25jZQ==")]
if subprotocols:
headers.append((b"sec-websocket-protocol", ", ".join(subprotocols).encode()))
scope = {
"type": "websocket",
"headers": headers,
"subprotocols": subprotocols or [],
}
transport = mock.Mock()
return WebSocketProtocol(
transport=transport,
scope=scope,
app=mock.AsyncMock(),
log=mock.Mock(),
)
@pytest.mark.asyncio
async def test_accept_without_subprotocol(self):
"""Accept without subprotocol should work."""
protocol = self._create_protocol()
protocol.transport.write = mock.Mock()
await protocol._send({"type": "websocket.accept"})
assert protocol.accepted is True
@pytest.mark.asyncio
async def test_accept_with_subprotocol(self):
"""Accept with subprotocol should include it in response."""
protocol = self._create_protocol(subprotocols=["graphql-ws", "chat"])
protocol.transport.write = mock.Mock()
await protocol._send({
"type": "websocket.accept",
"subprotocol": "graphql-ws"
})
assert protocol.accepted is True
def test_subprotocol_in_scope(self):
"""Subprotocols should be available in scope."""
protocol = self._create_protocol(subprotocols=["graphql-ws", "chat"])
assert "subprotocols" in protocol.scope
assert protocol.scope["subprotocols"] == ["graphql-ws", "chat"]
# ============================================================================
# WebSocket Accept Message Tests
# ============================================================================
class TestWebSocketAcceptMessage:
"""Test WebSocket accept message handling."""
def _create_protocol(self):
"""Create a WebSocketProtocol instance."""
from gunicorn.asgi.websocket import WebSocketProtocol
scope = {
"type": "websocket",
"headers": [(b"sec-websocket-key", b"dGhlIHNhbXBsZSBub25jZQ==")],
}
transport = mock.Mock()
return WebSocketProtocol(
transport=transport,
scope=scope,
app=mock.AsyncMock(),
log=mock.Mock(),
)
@pytest.mark.asyncio
async def test_accept_sets_accepted_flag(self):
"""Accepting should set the accepted flag."""
protocol = self._create_protocol()
protocol.transport.write = mock.Mock()
assert protocol.accepted is False
await protocol._send({"type": "websocket.accept"})
assert protocol.accepted is True
@pytest.mark.asyncio
async def test_accept_with_headers(self):
"""Accept can include additional headers."""
protocol = self._create_protocol()
protocol.transport.write = mock.Mock()
await protocol._send({
"type": "websocket.accept",
"headers": [
(b"x-custom-header", b"custom-value"),
],
})
assert protocol.accepted is True
@pytest.mark.asyncio
async def test_double_accept_raises(self):
"""Accepting twice should raise RuntimeError."""
protocol = self._create_protocol()
protocol.transport.write = mock.Mock()
await protocol._send({"type": "websocket.accept"})
with pytest.raises(RuntimeError, match="already accepted"):
await protocol._send({"type": "websocket.accept"})
# ============================================================================
# WebSocket Send Message Tests
# ============================================================================
class TestWebSocketSendMessages:
"""Test WebSocket send message handling."""
def _create_protocol(self):
"""Create a WebSocketProtocol instance."""
from gunicorn.asgi.websocket import WebSocketProtocol
scope = {
"type": "websocket",
"headers": [(b"sec-websocket-key", b"dGhlIHNhbXBsZSBub25jZQ==")],
}
transport = mock.Mock()
return WebSocketProtocol(
transport=transport,
scope=scope,
app=mock.AsyncMock(),
log=mock.Mock(),
)
@pytest.mark.asyncio
async def test_send_text_message(self):
"""Sending text message should work after accept."""
protocol = self._create_protocol()
protocol.transport.write = mock.Mock()
await protocol._send({"type": "websocket.accept"})
await protocol._send({
"type": "websocket.send",
"text": "Hello, WebSocket!"
})
# Verify write was called (for accept and send)
assert protocol.transport.write.call_count >= 2
@pytest.mark.asyncio
async def test_send_binary_message(self):
"""Sending binary message should work after accept."""
protocol = self._create_protocol()
protocol.transport.write = mock.Mock()
await protocol._send({"type": "websocket.accept"})
await protocol._send({
"type": "websocket.send",
"bytes": b"\x00\x01\x02\x03"
})
assert protocol.transport.write.call_count >= 2
@pytest.mark.asyncio
async def test_send_before_accept_raises(self):
"""Sending before accept should raise RuntimeError."""
protocol = self._create_protocol()
with pytest.raises(RuntimeError, match="not accepted"):
await protocol._send({
"type": "websocket.send",
"text": "Hello"
})
@pytest.mark.asyncio
async def test_send_after_close_raises(self):
"""Sending after close should raise RuntimeError."""
protocol = self._create_protocol()
protocol.transport.write = mock.Mock()
await protocol._send({"type": "websocket.accept"})
await protocol._send({"type": "websocket.close", "code": 1000})
with pytest.raises(RuntimeError, match="closed"):
await protocol._send({
"type": "websocket.send",
"text": "Hello"
})
# ============================================================================
# WebSocket Frame Building Tests
# ============================================================================
class TestWebSocketFrameBuilding:
"""Test WebSocket frame construction."""
def _create_protocol(self):
"""Create a WebSocketProtocol instance."""
from gunicorn.asgi.websocket import WebSocketProtocol
scope = {
"type": "websocket",
"headers": [],
}
return WebSocketProtocol(
transport=mock.Mock(),
scope=scope,
app=mock.AsyncMock(),
log=mock.Mock(),
)
def test_frame_header_fin_bit(self):
"""FIN bit should be set for complete messages."""
# FIN=1, opcode=1 (text) = 0b10000001 = 0x81
first_byte = 0x81
assert (first_byte >> 7) & 1 == 1 # FIN set
assert first_byte & 0x0F == 1 # OPCODE text
def test_frame_header_mask_bit(self):
"""Server frames should NOT have MASK bit set."""
# Server to client: MASK=0
# Length 5, no mask = 0b00000101 = 0x05
second_byte = 0x05
assert (second_byte >> 7) & 1 == 0 # MASK not set
assert second_byte & 0x7F == 5 # Length
def test_frame_length_encoding_small(self):
"""Small payloads (< 126) use 7-bit length."""
length = 100
second_byte = length
assert second_byte & 0x7F == 100
def test_frame_length_encoding_medium(self):
"""Medium payloads (126-65535) use 16-bit length."""
length = 1000
# Indicator byte
indicator = 126
# Extended length as big-endian 16-bit
extended = struct.pack("!H", length)
assert indicator == 126
assert struct.unpack("!H", extended)[0] == 1000
def test_frame_length_encoding_large(self):
"""Large payloads (> 65535) use 64-bit length."""
length = 100000
# Indicator byte
indicator = 127
# Extended length as big-endian 64-bit
extended = struct.pack("!Q", length)
assert indicator == 127
assert struct.unpack("!Q", extended)[0] == 100000
# ============================================================================
# WebSocket Close Code Tests
# ============================================================================
class TestWebSocketCloseCodes:
"""Test WebSocket close code handling."""
def test_all_close_codes_defined(self):
"""All standard close codes should be defined."""
from gunicorn.asgi import websocket
assert websocket.CLOSE_NORMAL == 1000
assert websocket.CLOSE_GOING_AWAY == 1001
assert websocket.CLOSE_PROTOCOL_ERROR == 1002
assert websocket.CLOSE_UNSUPPORTED == 1003
assert websocket.CLOSE_NO_STATUS == 1005
assert websocket.CLOSE_ABNORMAL == 1006
assert websocket.CLOSE_INVALID_DATA == 1007
assert websocket.CLOSE_POLICY_VIOLATION == 1008
assert websocket.CLOSE_MESSAGE_TOO_BIG == 1009
assert websocket.CLOSE_MANDATORY_EXT == 1010
assert websocket.CLOSE_INTERNAL_ERROR == 1011
def test_close_code_payload_format(self):
"""Close frame payload should be code + optional reason."""
from gunicorn.asgi.websocket import CLOSE_NORMAL
# Just code
payload_code_only = struct.pack("!H", CLOSE_NORMAL)
assert len(payload_code_only) == 2
# Code + reason
reason = "Goodbye"
payload_with_reason = struct.pack("!H", CLOSE_NORMAL) + reason.encode("utf-8")
assert len(payload_with_reason) == 2 + len(reason)
# ============================================================================
# WebSocket Receive Queue Tests
# ============================================================================
class TestWebSocketReceiveQueue:
"""Test WebSocket receive queue handling."""
def _create_protocol(self):
"""Create a WebSocketProtocol instance."""
from gunicorn.asgi.websocket import WebSocketProtocol
scope = {
"type": "websocket",
"headers": [],
}
return WebSocketProtocol(
transport=mock.Mock(),
scope=scope,
app=mock.AsyncMock(),
log=mock.Mock(),
)
@pytest.mark.asyncio
async def test_receive_returns_from_queue(self):
"""Receive should return messages from the queue."""
protocol = self._create_protocol()
# Put a connect message on the queue
await protocol._receive_queue.put({"type": "websocket.connect"})
# Receive should return it
message = await protocol._receive()
assert message["type"] == "websocket.connect"
@pytest.mark.asyncio
async def test_receive_blocks_on_empty_queue(self):
"""Receive should block when queue is empty."""
import asyncio
protocol = self._create_protocol()
# Start receive task
receive_task = asyncio.create_task(protocol._receive())
# Give it a moment
await asyncio.sleep(0.01)
# Should not be done yet (blocked)
assert not receive_task.done()
# Put a message
await protocol._receive_queue.put({"type": "websocket.connect"})
# Now should complete
message = await asyncio.wait_for(receive_task, timeout=1.0)
assert message["type"] == "websocket.connect"