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 <noreply@anthropic.com>
This commit is contained in:
Tanmoy Sarkar 2026-06-09 17:06:42 +05:30
parent 5639d467f3
commit ea2748a209
3 changed files with 42 additions and 4 deletions

View File

@ -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`.

View File

@ -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

View File

@ -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"]