gunicorn/tests/test_arbiter.py
Tanmoy Sarkar 457bc5a69a feat(companion): Spawn and reap the manager from the arbiter
Run the companion manager as a single arbiter child with its own
supervision loop, and host the config model with its loader.

config.py holds CompanionConfig (moved from process.py) and
build_companion_configs(cfg), which expands each companion_workers entry into
a CompanionConfig, filling omitted fields from the global companion_* settings.
It is also the reread config_loader. process.py keeps State and CompanionProcess.

CompanionManager.run() is the forked-child body: installs SIGCHLD/SIGTERM/SIGINT
via a self-pipe, brings up the control socket, starts every companion, then
select-waits on the socket and the pipe. Each tick reaps exits, retries backoff,
promotes past startsecs, and SIGKILLs companions past their stop deadline.
SIGTERM/SIGINT stop all companions and return.

Arbiter gains companion_manager_pid, manage_companion_manager (respawns the
manager when it is gone and companions are configured), spawn_companion_manager
(fork; child runs the loop), and reap detection that clears the pid on exit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:24:53 +05:30

221 lines
7.3 KiB
Python

#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
import os
from unittest import mock
import gunicorn.app.base
import gunicorn.arbiter
from gunicorn.config import ReusePort
class DummyApplication(gunicorn.app.base.BaseApplication):
"""
Dummy application that has a default configuration.
"""
def init(self, parser, opts, args):
"""No-op"""
def load(self):
"""No-op"""
def load_config(self):
"""No-op"""
@mock.patch('gunicorn.sock.close_sockets')
def test_arbiter_stop_closes_listeners(close_sockets):
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
listener1 = mock.Mock()
listener2 = mock.Mock()
listeners = [listener1, listener2]
arbiter.LISTENERS = listeners
arbiter.stop()
close_sockets.assert_called_with(listeners, True)
@mock.patch('gunicorn.sock.close_sockets')
def test_arbiter_stop_child_does_not_unlink_listeners(close_sockets):
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
arbiter.reexec_pid = os.getpid()
arbiter.stop()
close_sockets.assert_called_with([], False)
@mock.patch('gunicorn.sock.close_sockets')
def test_arbiter_stop_parent_does_not_unlink_listeners(close_sockets):
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
arbiter.master_pid = os.getppid()
arbiter.stop()
close_sockets.assert_called_with([], False)
@mock.patch('gunicorn.sock.close_sockets')
def test_arbiter_stop_does_not_unlink_systemd_listeners(close_sockets):
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
arbiter.systemd = True
arbiter.stop()
close_sockets.assert_called_with([], False)
@mock.patch('gunicorn.sock.close_sockets')
def test_arbiter_stop_does_not_unlink_when_using_reuse_port(close_sockets):
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
arbiter.cfg.settings['reuse_port'] = ReusePort()
arbiter.cfg.settings['reuse_port'].set(True)
arbiter.stop()
close_sockets.assert_called_with([], False)
@mock.patch('os.getpid')
@mock.patch('os.fork')
@mock.patch('os.execvpe')
def test_arbiter_reexec_passing_systemd_sockets(execvpe, fork, getpid):
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
arbiter.LISTENERS = [mock.Mock(), mock.Mock()]
arbiter.systemd = True
fork.return_value = 0
getpid.side_effect = [2, 3]
arbiter.reexec()
environ = execvpe.call_args[0][2]
assert environ['GUNICORN_PID'] == '2'
assert environ['LISTEN_FDS'] == '2'
assert environ['LISTEN_PID'] == '3'
@mock.patch('os.getpid')
@mock.patch('os.fork')
@mock.patch('os.execvpe')
def test_arbiter_reexec_passing_gunicorn_sockets(execvpe, fork, getpid):
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
listener1 = mock.Mock()
listener2 = mock.Mock()
listener1.fileno.return_value = 4
listener2.fileno.return_value = 5
arbiter.LISTENERS = [listener1, listener2]
fork.return_value = 0
getpid.side_effect = [2, 3]
arbiter.reexec()
environ = execvpe.call_args[0][2]
assert environ['GUNICORN_FD'] == '4,5'
assert environ['GUNICORN_PID'] == '2'
@mock.patch('os.fork')
def test_arbiter_reexec_limit_parent(fork):
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
arbiter.reexec_pid = ~os.getpid()
arbiter.reexec()
assert fork.called is False, "should not fork when there is already a child"
@mock.patch('os.fork')
def test_arbiter_reexec_limit_child(fork):
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
arbiter.master_pid = ~os.getpid()
arbiter.reexec()
assert fork.called is False, "should not fork when arbiter is a child"
@mock.patch('os.fork')
def test_arbiter_calls_worker_exit(mock_os_fork):
mock_os_fork.return_value = 0
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
arbiter.cfg.settings['worker_exit'] = mock.Mock()
arbiter.pid = None
mock_worker = mock.Mock()
arbiter.worker_class = mock.Mock(return_value=mock_worker)
try:
arbiter.spawn_worker()
except SystemExit:
pass
arbiter.cfg.worker_exit.assert_called_with(arbiter, mock_worker)
@mock.patch('os.waitpid')
def test_arbiter_reap_workers(mock_os_waitpid):
mock_os_waitpid.side_effect = [(42, 0), (0, 0)]
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
arbiter.cfg.settings['child_exit'] = mock.Mock()
mock_worker = mock.Mock()
arbiter.WORKERS = {42: mock_worker}
arbiter.reap_workers()
mock_worker.tmp.close.assert_called_with()
arbiter.cfg.child_exit.assert_called_with(arbiter, mock_worker)
def test_arbiter_manage_companion_manager_spawns_when_configured():
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
arbiter.cfg.set("companion_workers", [{"name": "rq", "target": "pkg:run"}])
arbiter.spawn_companion_manager = mock.Mock()
arbiter.manage_companion_manager()
arbiter.spawn_companion_manager.assert_called_once_with()
def test_arbiter_manage_companion_manager_noop_without_companions():
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
arbiter.spawn_companion_manager = mock.Mock()
arbiter.manage_companion_manager()
arbiter.spawn_companion_manager.assert_not_called()
def test_arbiter_manage_companion_manager_noop_when_running():
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
arbiter.cfg.set("companion_workers", [{"name": "rq", "target": "pkg:run"}])
arbiter.companion_manager_pid = 4242
arbiter.spawn_companion_manager = mock.Mock()
arbiter.manage_companion_manager()
arbiter.spawn_companion_manager.assert_not_called()
@mock.patch('os.waitpid')
def test_arbiter_reap_clears_companion_manager_pid(mock_os_waitpid):
mock_os_waitpid.side_effect = [(4242, 0), (0, 0)]
arbiter = gunicorn.arbiter.Arbiter(DummyApplication())
arbiter.companion_manager_pid = 4242
arbiter.reap_workers()
assert arbiter.companion_manager_pid == 0
class PreloadedAppWithEnvSettings(DummyApplication):
"""
Simple application that makes use of the 'preload' feature to
start the application before spawning worker processes and sets
environmental variable configuration settings.
"""
def load_config(self):
"""Set the 'preload_app' and 'raw_env' settings in order to verify their
interaction below.
"""
self.cfg.set('raw_env', [
'SOME_PATH=/tmp/something', 'OTHER_PATH=/tmp/something/else'])
self.cfg.set('preload_app', True)
def wsgi(self):
"""Assert that the expected environmental variables are set when
the main entry point of this application is called as part of a
'preloaded' application.
"""
verify_env_vars()
return super().wsgi()
def verify_env_vars():
assert os.getenv('SOME_PATH') == '/tmp/something'
assert os.getenv('OTHER_PATH') == '/tmp/something/else'
def test_env_vars_available_during_preload():
"""Ensure that configured environmental variables are set during the
initial set up of the application (called from the .setup() method of
the Arbiter) such that they are available during the initial loading
of the WSGI application.
"""
# Note that we aren't making any assertions here, they are made in the
# dummy application object being loaded here instead.
gunicorn.arbiter.Arbiter(PreloadedAppWithEnvSettings())