mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-01 18:21:30 +08:00
SIGINT handling differs on PyPy and can cause flaky test failures. The SIGTERM test covers the same graceful shutdown behavior reliably.
228 lines
6.8 KiB
Python
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'])
|