From 5639d467f3d309447f36cd3aae0f975e590a9a04 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar Date: Tue, 9 Jun 2026 16:57:18 +0530 Subject: [PATCH] feat(companion): Add CompanionManager skeleton and single-companion spawn Co-Authored-By: Claude Opus 4.8 --- docs/design/companion-process-manager.md | 4 +- gunicorn/companion/manager.py | 65 ++++++++++++++++++++++++ tests/test_companion_manager.py | 48 +++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 gunicorn/companion/manager.py create mode 100644 tests/test_companion_manager.py diff --git a/docs/design/companion-process-manager.md b/docs/design/companion-process-manager.md index f7cea6c1..539ab627 100644 --- a/docs/design/companion-process-manager.md +++ b/docs/design/companion-process-manager.md @@ -670,8 +670,8 @@ No per-companion logic in Arbiter. - [x] Add public process states. - [x] Add `CompanionProcess` runtime state. - [x] Add status description helpers. -- [ ] Add `CompanionManager` skeleton. -- [ ] Spawn one companion process from the manager. +- [x] Add `CompanionManager` skeleton. +- [x] Spawn one companion process from the manager. - [ ] Apply `cwd` and `env` before target. - [ ] Redirect `stdout` and `stderr`. - [ ] Reap exited companion processes. diff --git a/gunicorn/companion/manager.py b/gunicorn/companion/manager.py new file mode 100644 index 00000000..40990d42 --- /dev/null +++ b/gunicorn/companion/manager.py @@ -0,0 +1,65 @@ +# +# This file is part of gunicorn released under the MIT license. +# See the NOTICE for more information. + + +import importlib +import os +import time + +from gunicorn.companion.process import CompanionProcess, State + + +class CompanionManager: + """Forks and supervises companion processes. + + Created by the arbiter after preload. Holds one ``CompanionProcess`` per + configured companion and owns the fork lifecycle. This skeleton wires + construction and single-companion spawn; reaping, backoff, the control + socket, and the run loop arrive in later tasks. + """ + + def __init__(self, configs, log): + self.log = log + self.pid = os.getpid() + self.processes = {c.name: CompanionProcess(c) for c in configs} + + def spawn_process(self, proc): + """Fork one companion. + + Parent records the pid and moves the companion to STARTING. Child + resolves and runs the target, exiting the worker on any failure so a + crashed companion never leaks back into the manager's control flow. + """ + pid = os.fork() + if pid != 0: + proc.pid = pid + proc.state = State.STARTING + proc.started_at = time.time() + proc.manual_stop = False + self.log.info("companion %s started (pid %s)", proc.name, pid) + return pid + + try: + target = self._resolve_target(proc.config.target) + target() + except SystemExit: + raise + except BaseException: + self.log.exception("companion %s crashed", proc.name) + os._exit(1) + os._exit(0) + + @staticmethod + def _resolve_target(target): + """Return the zero-arg callable for a companion target. + + Accepts an already-callable target or a ``"module:attr"`` import + string, e.g. ``"frappe_companions:start_rq_default"``. + """ + if callable(target): + return target + module, sep, attr = target.partition(":") + if not sep: + raise ValueError("companion target %r must be 'module:callable'" % target) + return getattr(importlib.import_module(module), attr) diff --git a/tests/test_companion_manager.py b/tests/test_companion_manager.py new file mode 100644 index 00000000..0213f730 --- /dev/null +++ b/tests/test_companion_manager.py @@ -0,0 +1,48 @@ +# +# This file is part of gunicorn released under the MIT license. +# See the NOTICE for more information. + +from unittest import mock + +import pytest + +from gunicorn.companion.manager import CompanionManager +from gunicorn.companion.process import CompanionConfig, State + + +def make_manager(*names): + configs = [CompanionConfig(name=n, target=lambda: None) for n in names] + return CompanionManager(configs, log=mock.Mock()) + + +def test_manager_builds_one_process_per_config(): + mgr = make_manager("rq", "scheduler") + assert set(mgr.processes) == {"rq", "scheduler"} + assert mgr.processes["rq"].state == State.STOPPED + + +def test_resolve_target_accepts_callable(): + fn = lambda: None + assert CompanionManager._resolve_target(fn) is fn + + +def test_resolve_target_import_string(): + # os.getpid is a real "module:attr" target. + assert CompanionManager._resolve_target("os:getpid") is __import__("os").getpid + + +def test_resolve_target_rejects_bad_string(): + with pytest.raises(ValueError): + CompanionManager._resolve_target("no_colon") + + +def test_spawn_parent_records_pid_and_starting(): + mgr = make_manager("rq") + proc = mgr.processes["rq"] + with mock.patch("os.fork", return_value=4321): + pid = mgr.spawn_process(proc) + assert pid == 4321 + assert proc.pid == 4321 + assert proc.state == State.STARTING + assert proc.started_at is not None + assert proc.manual_stop is False