# # This file is part of gunicorn released under the MIT license. # See the NOTICE for more information. """ Integration tests for control socket fork safety. These tests verify that the control socket server properly handles fork() with different worker types (sync, gthread, gevent) without causing deadlocks. """ import os import signal import socket import subprocess import sys import time import tempfile import pytest # Timeout for CI environments CI_TIMEOUT = 30 # Simple WSGI app 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 wait_for_socket(socket_path, timeout=CI_TIMEOUT): """Wait until Unix socket exists.""" start = time.monotonic() while time.monotonic() - start < timeout: if os.path.exists(socket_path): return True 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" def start_gunicorn(app_dir, app_name, worker_class, port, control_socket_path): """Start a gunicorn server with specified worker class and control socket.""" cmd = [ sys.executable, '-m', 'gunicorn', '--bind', f'127.0.0.1:{port}', '--workers', '2', '--worker-class', worker_class, '--access-logfile', '-', '--error-logfile', '-', '--log-level', 'debug', '--timeout', '30', '--control-socket', control_socket_path, app_name ] # Add threads for gthread worker if worker_class == 'gthread': cmd.extend(['--threads', '2']) proc = subprocess.Popen( cmd, cwd=app_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env={**os.environ, 'PYTHONPATH': app_dir}, preexec_fn=os.setsid ) return proc def cleanup_gunicorn(proc): """Clean up a gunicorn process.""" 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() def get_short_socket_path(prefix): """Get a short socket path that won't exceed Unix socket path limits. macOS limits Unix socket paths to ~104 characters, so we use /tmp directly. """ import uuid return f"/tmp/gunicorn-{prefix}-{uuid.uuid4().hex[:8]}.ctl" class TestControlSocketForkSafetySyncWorker: """Test control socket fork safety with sync worker.""" def test_sync_worker_boots_with_control_socket(self, app_module, tmp_path): """Verify sync worker boots without deadlock when control socket is enabled.""" app_dir, app_name = app_module port = find_free_port() # Use short path to avoid Unix socket path length limits (104 chars on macOS) control_socket = get_short_socket_path("sync") proc = start_gunicorn(app_dir, app_name, 'sync', port, control_socket) try: # Wait for server to start - should not deadlock if not wait_for_server('127.0.0.1', port, timeout=15): stdout, stderr = proc.communicate(timeout=1) pytest.fail( f"Sync worker deadlocked during startup:\n" f"stdout: {stdout.decode()}\n" f"stderr: {stderr.decode()}" ) # Verify server responds response = make_request('127.0.0.1', port) assert b'Hello, World!' in response # Wait for control socket to be created (started after workers spawn) assert wait_for_socket(control_socket, timeout=5), \ f"Control socket was not created at {control_socket}" finally: cleanup_gunicorn(proc) # Clean up socket file if os.path.exists(control_socket): os.unlink(control_socket) class TestControlSocketForkSafetyGthreadWorker: """Test control socket fork safety with gthread worker.""" def test_gthread_worker_boots_with_control_socket(self, app_module, tmp_path): """Verify gthread worker boots without deadlock when control socket is enabled.""" app_dir, app_name = app_module port = find_free_port() control_socket = get_short_socket_path("gthread") proc = start_gunicorn(app_dir, app_name, 'gthread', port, control_socket) try: # Wait for server to start - should not deadlock if not wait_for_server('127.0.0.1', port, timeout=15): stdout, stderr = proc.communicate(timeout=1) pytest.fail( f"Gthread worker deadlocked during startup:\n" f"stdout: {stdout.decode()}\n" f"stderr: {stderr.decode()}" ) # Verify server responds response = make_request('127.0.0.1', port) assert b'Hello, World!' in response # Wait for control socket to be created (started after workers spawn) assert wait_for_socket(control_socket, timeout=5), \ f"Control socket was not created at {control_socket}" finally: cleanup_gunicorn(proc) if os.path.exists(control_socket): os.unlink(control_socket) def is_gevent_available(): """Check if gevent is available.""" try: import gevent # noqa: F401 return True except ImportError: return False @pytest.mark.skipif(not is_gevent_available(), reason="gevent not installed") class TestControlSocketForkSafetyGeventWorker: """Test control socket fork safety with gevent worker.""" def test_gevent_worker_boots_with_control_socket(self, app_module, tmp_path): """Verify gevent worker boots without deadlock when control socket is enabled. This test is critical for issue #3509 - the gevent worker uses monkey patching which can interact badly with asyncio in the control socket thread. """ app_dir, app_name = app_module port = find_free_port() control_socket = get_short_socket_path("gevent") proc = start_gunicorn(app_dir, app_name, 'gevent', port, control_socket) try: # Wait for server to start - should not deadlock # Gevent workers may take slightly longer to boot if not wait_for_server('127.0.0.1', port, timeout=20): stdout, stderr = proc.communicate(timeout=1) pytest.fail( f"Gevent worker deadlocked during startup:\n" f"stdout: {stdout.decode()}\n" f"stderr: {stderr.decode()}" ) # Verify server responds response = make_request('127.0.0.1', port) assert b'Hello, World!' in response # Wait for control socket to be created (started after workers spawn) assert wait_for_socket(control_socket, timeout=5), \ f"Control socket was not created at {control_socket}" finally: cleanup_gunicorn(proc) if os.path.exists(control_socket): os.unlink(control_socket) def test_gevent_worker_handles_multiple_requests(self, app_module, tmp_path): """Verify gevent worker handles multiple requests with control socket enabled.""" app_dir, app_name = app_module port = find_free_port() control_socket = get_short_socket_path("gevent2") proc = start_gunicorn(app_dir, app_name, 'gevent', port, control_socket) try: if not wait_for_server('127.0.0.1', port, timeout=20): stdout, stderr = proc.communicate(timeout=1) pytest.fail( f"Gevent worker deadlocked during startup:\n" f"stdout: {stdout.decode()}\n" f"stderr: {stderr.decode()}" ) # Make multiple requests 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, "Server died unexpectedly" finally: cleanup_gunicorn(proc) if os.path.exists(control_socket): os.unlink(control_socket) class TestControlSocketDisabled: """Test that disabling control socket works.""" def test_no_control_socket_flag(self, app_module, tmp_path): """Verify --no-control-socket flag disables control socket.""" app_dir, app_name = app_module port = find_free_port() control_socket = str(tmp_path / "gunicorn.ctl") cmd = [ sys.executable, '-m', 'gunicorn', '--bind', f'127.0.0.1:{port}', '--workers', '1', '--worker-class', 'sync', '--access-logfile', '-', '--error-logfile', '-', '--log-level', 'debug', '--no-control-socket', app_name ] proc = subprocess.Popen( cmd, cwd=app_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env={**os.environ, 'PYTHONPATH': app_dir}, preexec_fn=os.setsid ) try: if not wait_for_server('127.0.0.1', port, timeout=15): stdout, stderr = proc.communicate(timeout=1) pytest.fail( f"Server failed to start:\n" f"stdout: {stdout.decode()}\n" f"stderr: {stderr.decode()}" ) # Verify server responds response = make_request('127.0.0.1', port) assert b'Hello, World!' in response # Verify control socket does NOT exist assert not os.path.exists(control_socket), "Control socket should not exist" finally: cleanup_gunicorn(proc) class TestControlSocketAfterReload: """Test control socket survives reload.""" def test_control_socket_after_sighup(self, app_module, tmp_path): """Verify control socket still works after SIGHUP reload.""" app_dir, app_name = app_module port = find_free_port() control_socket = get_short_socket_path("reload") proc = start_gunicorn(app_dir, app_name, 'sync', port, control_socket) try: if not wait_for_server('127.0.0.1', port, timeout=15): stdout, stderr = proc.communicate(timeout=1) pytest.fail( f"Server failed to start:\n" f"stdout: {stdout.decode()}\n" f"stderr: {stderr.decode()}" ) # Verify server and control socket work response = make_request('127.0.0.1', port) assert b'Hello, World!' in response assert wait_for_socket(control_socket, timeout=5), \ f"Control socket was not created at {control_socket}" # Send SIGHUP to trigger reload proc.send_signal(signal.SIGHUP) # Wait for reload to complete 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 # Verify control socket is re-created by the new master. # The arbiter recreates the socket asynchronously after SIGHUP, so # poll for it instead of asserting on a fixed sleep. assert wait_for_socket(control_socket, timeout=10), \ "Control socket was not re-created after reload" finally: cleanup_gunicorn(proc) if os.path.exists(control_socket): os.unlink(control_socket) if __name__ == '__main__': pytest.main([__file__, '-v'])