gunicorn/tests/test_signal_integration.py
Benoit Chesneau 3568af1388 Skip SIGINT shutdown test on PyPy
SIGINT handling differs on PyPy and can cause flaky test failures.
The SIGTERM test covers the same graceful shutdown behavior reliably.
2026-03-23 14:10:05 +01:00

228 lines
6.8 KiB
Python

#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""
Integration tests for arbiter signal handling.
These tests start a real gunicorn process and verify signal handling
works correctly with actual requests and signals.
"""
import os
import signal
import socket
import subprocess
import sys
import time
import pytest
# Timeout for CI environments (VMs can be slow, PyPy needs more time)
CI_TIMEOUT = 90
# Simple WSGI app inline
SIMPLE_APP = '''
def application(environ, start_response):
"""Basic hello world response."""
status = '200 OK'
body = b'Hello, World!'
headers = [
('Content-Type', 'text/plain'),
('Content-Length', str(len(body))),
]
start_response(status, headers)
return [body]
'''
def find_free_port():
"""Find a free port to bind to."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('127.0.0.1', 0))
return s.getsockname()[1]
def wait_for_server(host, port, timeout=CI_TIMEOUT):
"""Wait until server is accepting connections."""
start = time.monotonic()
while time.monotonic() - start < timeout:
try:
with socket.create_connection((host, port), timeout=1):
return True
except (ConnectionRefusedError, socket.timeout, OSError):
time.sleep(0.1)
return False
def make_request(host, port, path='/'):
"""Make a simple HTTP request and return the response body."""
with socket.create_connection((host, port), timeout=5) as sock:
request = f'GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n'
sock.sendall(request.encode())
response = b''
while True:
chunk = sock.recv(4096)
if not chunk:
break
response += chunk
return response
@pytest.fixture
def app_module(tmp_path):
"""Create a temporary app module."""
app_file = tmp_path / "app.py"
app_file.write_text(SIMPLE_APP)
return str(app_file.parent), "app:application"
@pytest.fixture
def gunicorn_server(app_module):
"""Start and stop a gunicorn server."""
app_dir, app_name = app_module
port = find_free_port()
# Start gunicorn
cmd = [
sys.executable, '-m', 'gunicorn',
'--bind', f'127.0.0.1:{port}',
'--workers', '2',
'--worker-class', 'sync',
'--access-logfile', '-',
'--error-logfile', '-',
'--log-level', 'info',
'--timeout', '30',
'--graceful-timeout', '30',
app_name
]
# Use setsid to create new process group for proper signal handling
proc = subprocess.Popen(
cmd,
cwd=app_dir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={**os.environ, 'PYTHONPATH': app_dir},
preexec_fn=os.setsid
)
# Wait for server to start
if not wait_for_server('127.0.0.1', port):
proc.terminate()
proc.wait()
stdout, stderr = proc.communicate()
pytest.fail(f"Gunicorn failed to start:\nstdout: {stdout.decode()}\nstderr: {stderr.decode()}")
yield proc, port
# Cleanup - use process group kill for better cleanup
if proc.poll() is None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, OSError):
pass
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, OSError):
pass
proc.wait()
class TestSignalHandlingIntegration:
"""Integration tests for signal handling."""
def test_basic_request(self, gunicorn_server):
"""Verify the server responds to basic requests."""
proc, port = gunicorn_server
response = make_request('127.0.0.1', port)
assert b'Hello, World!' in response
def test_graceful_shutdown_sigterm(self, gunicorn_server):
"""Verify SIGTERM causes graceful shutdown."""
proc, port = gunicorn_server
# Verify server is working
response = make_request('127.0.0.1', port)
assert b'Hello, World!' in response
# Send SIGTERM to the process group for reliable signal delivery
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, OSError):
proc.send_signal(signal.SIGTERM)
# Wait for process to exit
try:
exit_code = proc.wait(timeout=CI_TIMEOUT)
assert exit_code == 0, f"Expected exit code 0, got {exit_code}"
except subprocess.TimeoutExpired:
proc.kill()
pytest.fail("Gunicorn did not exit within timeout after SIGTERM")
@pytest.mark.skipif(
hasattr(sys, 'pypy_version_info'),
reason="SIGINT handling differs on PyPy, use SIGTERM test instead"
)
def test_graceful_shutdown_sigint(self, gunicorn_server):
"""Verify SIGINT causes graceful shutdown."""
proc, port = gunicorn_server
# Verify server is working
response = make_request('127.0.0.1', port)
assert b'Hello, World!' in response
# Send SIGINT to the process group for reliable signal delivery
try:
os.killpg(os.getpgid(proc.pid), signal.SIGINT)
except (ProcessLookupError, OSError):
proc.send_signal(signal.SIGINT)
# Wait for process to exit
try:
exit_code = proc.wait(timeout=CI_TIMEOUT)
assert exit_code == 0, f"Expected exit code 0, got {exit_code}"
except subprocess.TimeoutExpired:
proc.kill()
pytest.fail("Gunicorn did not exit within timeout after SIGINT")
def test_sighup_reload(self, gunicorn_server):
"""Verify SIGHUP triggers reload."""
proc, port = gunicorn_server
# Verify server is working
response = make_request('127.0.0.1', port)
assert b'Hello, World!' in response
# Send SIGHUP to the master process (not process group - only master handles reload)
proc.send_signal(signal.SIGHUP)
# Wait a moment for reload
time.sleep(2)
# Verify server still works after reload
assert proc.poll() is None, "Server died after SIGHUP"
response = make_request('127.0.0.1', port)
assert b'Hello, World!' in response
def test_multiple_requests_under_load(self, gunicorn_server):
"""Verify server handles multiple concurrent requests."""
proc, port = gunicorn_server
# Make several requests in sequence
for _ in range(10):
response = make_request('127.0.0.1', port)
assert b'Hello, World!' in response
# Verify server is still running
assert proc.poll() is None
if __name__ == '__main__':
pytest.main([__file__, '-v'])