gunicorn/tests/test_dirty_integration.py
Benoit Chesneau b67ff0b31d test: fix warnings and flaky tests in dirty arbiter tests
- Close coroutines in mocked asyncio.run to prevent "never awaited" warning
- Fix flaky integration tests with proper async cleanup and try/finally
- Add uvloop to testing dependencies so uvloop test runs
- Add pytest warning filter for eventlet/asyncio incompatibility
2026-01-25 10:23:25 +01:00

377 lines
13 KiB
Python

#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""Integration tests for dirty arbiter with main arbiter."""
import os
import struct
import pytest
from gunicorn.arbiter import Arbiter
from gunicorn.config import Config
from gunicorn.app.base import BaseApplication
from gunicorn.dirty.protocol import DirtyProtocol
class MockStreamWriter:
"""Mock StreamWriter that captures written messages."""
def __init__(self):
self.messages = []
self._buffer = b""
self.closed = False
def write(self, data):
self._buffer += data
async def drain(self):
while len(self._buffer) >= DirtyProtocol.HEADER_SIZE:
length = struct.unpack(
DirtyProtocol.HEADER_FORMAT,
self._buffer[:DirtyProtocol.HEADER_SIZE]
)[0]
total_size = DirtyProtocol.HEADER_SIZE + length
if len(self._buffer) >= total_size:
msg_data = self._buffer[DirtyProtocol.HEADER_SIZE:total_size]
self._buffer = self._buffer[total_size:]
self.messages.append(DirtyProtocol.decode(msg_data))
else:
break
def close(self):
self.closed = True
async def wait_closed(self):
pass
def get_extra_info(self, name):
return None
class SimpleDirtyTestApp(BaseApplication):
"""Simple test application for integration tests."""
def __init__(self, options=None):
self.options = options or {}
self.cfg = None
super().__init__()
def load_config(self):
for key, value in self.options.items():
if key in self.cfg.settings:
self.cfg.set(key.lower(), value)
def load(self):
def app(environ, start_response):
status = '200 OK'
output = b'Hello World!'
response_headers = [('Content-type', 'text/plain'),
('Content-Length', str(len(output)))]
start_response(status, response_headers)
return [output]
return app
class TestArbiterDirtyIntegration:
"""Tests for arbiter integration with dirty arbiter."""
def test_arbiter_init_with_dirty_config(self):
"""Test arbiter initializes with dirty configuration."""
app = SimpleDirtyTestApp(options={
'dirty_workers': 2,
'dirty_apps': ['tests.support_dirty_app:TestDirtyApp'],
'bind': '127.0.0.1:0',
})
arbiter = Arbiter(app)
assert arbiter.dirty_arbiter_pid == 0
assert arbiter.dirty_arbiter is None
assert arbiter.cfg.dirty_workers == 2
assert arbiter.cfg.dirty_apps == ['tests.support_dirty_app:TestDirtyApp']
def test_arbiter_init_without_dirty_config(self):
"""Test arbiter initializes without dirty configuration."""
app = SimpleDirtyTestApp(options={
'bind': '127.0.0.1:0',
})
arbiter = Arbiter(app)
assert arbiter.dirty_arbiter_pid == 0
assert arbiter.cfg.dirty_workers == 0
assert arbiter.cfg.dirty_apps == []
class TestDirtyIntegrationEnvironment:
"""Tests for environment setup."""
def test_dirty_socket_env_var_set(self):
"""Test that GUNICORN_DIRTY_SOCKET env var is set when dirty arbiter spawns."""
# This test would require actually spawning the dirty arbiter
# which involves forking. We'll skip this for unit tests.
pass
class TestDirtyExecutionTimeout:
"""Tests for execution timeout handling."""
@pytest.mark.asyncio
async def test_worker_to_worker_communication(self):
"""Test protocol communication between worker and arbiter."""
import asyncio
import tempfile
from gunicorn.dirty.worker import DirtyWorker
from gunicorn.dirty.protocol import DirtyProtocol, make_request
class MockLog:
def debug(self, *a, **kw): pass
def info(self, *a, **kw): pass
def warning(self, *a, **kw): pass
def error(self, *a, **kw): pass
def close_on_exec(self): pass
def reopen_files(self): pass
cfg = Config()
cfg.set("dirty_timeout", 300)
log = MockLog()
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, "worker.sock")
worker = DirtyWorker(
age=1,
ppid=os.getpid(),
app_paths=["tests.support_dirty_app:TestDirtyApp"],
cfg=cfg,
log=log,
socket_path=socket_path
)
worker.pid = os.getpid()
worker.load_apps()
# Start worker server
server = await asyncio.start_unix_server(
worker.handle_connection,
path=socket_path
)
# Connect as client
reader, writer = await asyncio.open_unix_connection(socket_path)
# Send a request
request = make_request(
request_id="timeout-test-1",
app_path="tests.support_dirty_app:TestDirtyApp",
action="compute",
args=(10, 5),
kwargs={"operation": "add"}
)
await DirtyProtocol.write_message_async(writer, request)
# Receive response
response = await DirtyProtocol.read_message_async(reader)
assert response["type"] == DirtyProtocol.MSG_TYPE_RESPONSE
assert response["result"] == 15
# Cleanup
writer.close()
await writer.wait_closed()
server.close()
await server.wait_closed()
worker._cleanup()
@pytest.mark.asyncio
async def test_arbiter_timeout_response(self):
"""Test that arbiter returns timeout error when worker doesn't respond."""
import asyncio
import tempfile
from gunicorn.dirty.arbiter import DirtyArbiter
from gunicorn.dirty.protocol import DirtyProtocol, make_request
class MockLog:
def debug(self, *a, **kw): pass
def info(self, *a, **kw): pass
def warning(self, *a, **kw): pass
def error(self, *a, **kw): pass
def critical(self, *a, **kw): pass
def exception(self, *a, **kw): pass
def close_on_exec(self): pass
def reopen_files(self): pass
cfg = Config()
cfg.set("dirty_workers", 0)
cfg.set("dirty_timeout", 1) # 1 second timeout
log = MockLog()
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, "arbiter.sock")
worker_socket_path = os.path.join(tmpdir, "worker.sock")
arbiter = DirtyArbiter(cfg=cfg, log=log, socket_path=socket_path)
arbiter.pid = os.getpid()
arbiter.alive = True
slow_server = None
try:
# Register a fake worker that will never respond
fake_pid = 99999
arbiter.workers[fake_pid] = "fake_worker"
arbiter.worker_sockets[fake_pid] = worker_socket_path
# Create a "slow" worker server that accepts but never responds
async def slow_client_handler(reader, writer):
# Read the request but don't respond (simulating timeout)
try:
await asyncio.sleep(10) # Longer than timeout
except asyncio.CancelledError:
pass
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
slow_server = await asyncio.start_unix_server(
slow_client_handler,
path=worker_socket_path
)
request = make_request(
request_id="timeout-test",
app_path="test:App",
action="slow_action"
)
# Use MockStreamWriter to capture the response
mock_writer = MockStreamWriter()
await arbiter.route_request(request, mock_writer)
assert len(mock_writer.messages) == 1
response = mock_writer.messages[0]
assert response["type"] == DirtyProtocol.MSG_TYPE_ERROR
assert "timeout" in response["error"]["error_type"].lower()
finally:
# Cancel any pending consumer tasks
arbiter.alive = False
for task in arbiter.worker_consumers.values():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# Close worker connections
arbiter._close_worker_connection(fake_pid)
# Cleanup server
if slow_server:
slow_server.close()
await slow_server.wait_closed()
arbiter._cleanup_sync()
@pytest.mark.asyncio
async def test_full_request_response_flow(self):
"""Test full request-response flow between arbiter and worker."""
import asyncio
import tempfile
from gunicorn.dirty.arbiter import DirtyArbiter
from gunicorn.dirty.worker import DirtyWorker
from gunicorn.dirty.protocol import DirtyProtocol, make_request
class MockLog:
def debug(self, *a, **kw): pass
def info(self, *a, **kw): pass
def warning(self, *a, **kw): pass
def error(self, *a, **kw): pass
def critical(self, *a, **kw): pass
def exception(self, *a, **kw): pass
def close_on_exec(self): pass
def reopen_files(self): pass
cfg = Config()
cfg.set("dirty_workers", 0)
cfg.set("dirty_timeout", 10)
log = MockLog()
with tempfile.TemporaryDirectory() as tmpdir:
arbiter_socket_path = os.path.join(tmpdir, "arbiter.sock")
worker_socket_path = os.path.join(tmpdir, "worker.sock")
worker = None
arbiter = None
worker_server = None
fake_pid = 12345
try:
# Create worker
worker = DirtyWorker(
age=1,
ppid=os.getpid(),
app_paths=["tests.support_dirty_app:TestDirtyApp"],
cfg=cfg,
log=log,
socket_path=worker_socket_path
)
worker.pid = os.getpid()
worker.load_apps()
# Start worker server
worker_server = await asyncio.start_unix_server(
worker.handle_connection,
path=worker_socket_path
)
# Create arbiter
arbiter = DirtyArbiter(cfg=cfg, log=log, socket_path=arbiter_socket_path)
arbiter.pid = os.getpid()
arbiter.alive = True
# Register worker
arbiter.workers[fake_pid] = worker
arbiter.worker_sockets[fake_pid] = worker_socket_path
# Route a request using MockStreamWriter
request = make_request(
request_id="full-flow-test",
app_path="tests.support_dirty_app:TestDirtyApp",
action="compute",
args=(7, 3),
kwargs={"operation": "multiply"}
)
mock_writer = MockStreamWriter()
await arbiter.route_request(request, mock_writer)
assert len(mock_writer.messages) == 1
response = mock_writer.messages[0]
assert response["type"] == DirtyProtocol.MSG_TYPE_RESPONSE
assert response["result"] == 21
finally:
# Cancel any pending consumer tasks
if arbiter:
arbiter.alive = False
for task in arbiter.worker_consumers.values():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# Close arbiter's connection first
arbiter._close_worker_connection(fake_pid)
arbiter._cleanup_sync()
# Close worker server
if worker_server:
worker_server.close()
await worker_server.wait_closed()
if worker:
worker._cleanup()