From ea2748a209747c26293540c3bf7b3b561afd1bf2 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar Date: Tue, 9 Jun 2026 17:06:42 +0530 Subject: [PATCH] feat(companion): Apply cwd and env in spawned companion child Child runs _apply_environment before the target: os.chdir(cwd) then os.environ.update(env). Co-Authored-By: Claude Opus 4.8 --- docs/design/companion-process-manager.md | 2 +- gunicorn/companion/manager.py | 26 +++++++++++++++++++++--- tests/test_companion_manager.py | 18 ++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/design/companion-process-manager.md b/docs/design/companion-process-manager.md index 539ab627..fb3146e2 100644 --- a/docs/design/companion-process-manager.md +++ b/docs/design/companion-process-manager.md @@ -672,7 +672,7 @@ No per-companion logic in Arbiter. - [x] Add status description helpers. - [x] Add `CompanionManager` skeleton. - [x] Spawn one companion process from the manager. -- [ ] Apply `cwd` and `env` before target. +- [x] Apply `cwd` and `env` before target. - [ ] Redirect `stdout` and `stderr`. - [ ] Reap exited companion processes. - [ ] Implement `STARTING -> RUNNING` using `startsecs`. diff --git a/gunicorn/companion/manager.py b/gunicorn/companion/manager.py index 40990d42..0c5f4561 100644 --- a/gunicorn/companion/manager.py +++ b/gunicorn/companion/manager.py @@ -3,12 +3,18 @@ # See the NOTICE for more information. +from __future__ import annotations + import importlib import os import time +from typing import TYPE_CHECKING, Callable, Iterable, Union from gunicorn.companion.process import CompanionProcess, State +if TYPE_CHECKING: + from gunicorn.companion.process import CompanionConfig + class CompanionManager: """Forks and supervises companion processes. @@ -19,12 +25,12 @@ class CompanionManager: socket, and the run loop arrive in later tasks. """ - def __init__(self, configs, log): + def __init__(self, configs: Iterable[CompanionConfig], log): self.log = log self.pid = os.getpid() self.processes = {c.name: CompanionProcess(c) for c in configs} - def spawn_process(self, proc): + def spawn_process(self, proc: CompanionProcess) -> int: """Fork one companion. Parent records the pid and moves the companion to STARTING. Child @@ -41,6 +47,7 @@ class CompanionManager: return pid try: + self._apply_environment(proc.config) target = self._resolve_target(proc.config.target) target() except SystemExit: @@ -51,7 +58,20 @@ class CompanionManager: os._exit(0) @staticmethod - def _resolve_target(target): + def _apply_environment(config: CompanionConfig) -> None: + """Apply ``cwd`` and ``env`` in the child before running the target. + + cwd is changed first so a relative path in env (or the target itself) + resolves against it. env is merged onto the inherited environment, not + replaced, so the companion keeps the manager's variables. + """ + if config.cwd: + os.chdir(config.cwd) + if config.env: + os.environ.update(config.env) + + @staticmethod + def _resolve_target(target: Union[Callable, str]) -> Callable: """Return the zero-arg callable for a companion target. Accepts an already-callable target or a ``"module:attr"`` import diff --git a/tests/test_companion_manager.py b/tests/test_companion_manager.py index 0213f730..abd4bd67 100644 --- a/tests/test_companion_manager.py +++ b/tests/test_companion_manager.py @@ -36,6 +36,24 @@ def test_resolve_target_rejects_bad_string(): CompanionManager._resolve_target("no_colon") +def test_apply_environment_sets_cwd_and_env(): + config = CompanionConfig(name="rq", target=lambda: None, + cwd="/tmp", env={"COMPANION_X": "1"}) + with mock.patch("os.chdir") as chdir, \ + mock.patch.dict("os.environ", {}, clear=False): + CompanionManager._apply_environment(config) + chdir.assert_called_once_with("/tmp") + import os + assert os.environ["COMPANION_X"] == "1" + + +def test_apply_environment_noop_without_cwd_env(): + config = CompanionConfig(name="rq", target=lambda: None) + with mock.patch("os.chdir") as chdir: + CompanionManager._apply_environment(config) + chdir.assert_not_called() + + def test_spawn_parent_records_pid_and_starting(): mgr = make_manager("rq") proc = mgr.processes["rq"]