From 2bf7e1b1fb0665ad5ca83a2c241b05361072fe24 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar Date: Tue, 9 Jun 2026 17:30:18 +0530 Subject: [PATCH] feat(companion): Redirect companion stdout and stderr Child calls _redirect_output after env setup: each configured log path is opened append-mode and dup2'd onto fd 1/2. None/inherit keeps the inherited fd; stderr stdout shares stdout's fd. Rotation stays external. Add tests for inherit, append flags, file dup2, and stderr-to-stdout. Co-Authored-By: Claude Opus 4.8 --- docs/design/companion-process-manager.md | 2 +- gunicorn/companion/manager.py | 28 ++++++++++++++++ tests/test_companion_manager.py | 41 ++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/design/companion-process-manager.md b/docs/design/companion-process-manager.md index fb3146e2..89efe5ca 100644 --- a/docs/design/companion-process-manager.md +++ b/docs/design/companion-process-manager.md @@ -673,7 +673,7 @@ No per-companion logic in Arbiter. - [x] Add `CompanionManager` skeleton. - [x] Spawn one companion process from the manager. - [x] Apply `cwd` and `env` before target. -- [ ] Redirect `stdout` and `stderr`. +- [x] Redirect `stdout` and `stderr`. - [ ] Reap exited companion processes. - [ ] Implement `STARTING -> RUNNING` using `startsecs`. - [ ] Implement `BACKOFF` with fixed `companion_restart_delay`. diff --git a/gunicorn/companion/manager.py b/gunicorn/companion/manager.py index 0c5f4561..2c25b33f 100644 --- a/gunicorn/companion/manager.py +++ b/gunicorn/companion/manager.py @@ -48,6 +48,7 @@ class CompanionManager: try: self._apply_environment(proc.config) + self._redirect_output(proc.config) target = self._resolve_target(proc.config.target) target() except SystemExit: @@ -70,6 +71,33 @@ class CompanionManager: if config.env: os.environ.update(config.env) + @staticmethod + def _redirect_output(config: CompanionConfig) -> None: + """Send the companion's stdout and stderr to its configured log files. + + By default a companion just inherits the manager's stdout/stderr, so + leaving these unset (or ``"inherit"``) keeps that. Give a file path and + we append the output there instead. For stderr you can also pass + ``"stdout"`` to fold the two streams into one file. + """ + out = CompanionManager._open_output(config.stdout) + if out is not None: + os.dup2(out, 1) + if config.stderr == "stdout": + os.dup2(1, 2) + else: + err = CompanionManager._open_output(config.stderr) + if err is not None: + os.dup2(err, 2) + + @staticmethod + def _open_output(value): + """Open one log file for writing, or return None to leave the stream + as-is when the companion should keep inheriting it.""" + if value in (None, "inherit"): + return None + return os.open(value, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644) + @staticmethod def _resolve_target(target: Union[Callable, str]) -> Callable: """Return the zero-arg callable for a companion target. diff --git a/tests/test_companion_manager.py b/tests/test_companion_manager.py index abd4bd67..3a6aa04b 100644 --- a/tests/test_companion_manager.py +++ b/tests/test_companion_manager.py @@ -2,6 +2,7 @@ # This file is part of gunicorn released under the MIT license. # See the NOTICE for more information. +import os from unittest import mock import pytest @@ -54,6 +55,46 @@ def test_apply_environment_noop_without_cwd_env(): chdir.assert_not_called() +def test_open_output_inherit_returns_none(): + assert CompanionManager._open_output(None) is None + assert CompanionManager._open_output("inherit") is None + + +def test_open_output_path_opens_append(): + with mock.patch("os.open", return_value=9) as op: + fd = CompanionManager._open_output("/var/log/rq.log") + assert fd == 9 + flags = op.call_args.args[1] + assert flags & os.O_APPEND and flags & os.O_CREAT + + +def test_redirect_output_files(): + config = CompanionConfig(name="rq", target=lambda: None, + stdout="/o.log", stderr="/e.log") + with mock.patch("os.open", side_effect=[10, 11]), \ + mock.patch("os.dup2") as dup2: + CompanionManager._redirect_output(config) + dup2.assert_any_call(10, 1) + dup2.assert_any_call(11, 2) + + +def test_redirect_output_stderr_to_stdout(): + config = CompanionConfig(name="rq", target=lambda: None, + stdout="/o.log", stderr="stdout") + with mock.patch("os.open", return_value=10), \ + mock.patch("os.dup2") as dup2: + CompanionManager._redirect_output(config) + dup2.assert_any_call(10, 1) + dup2.assert_any_call(1, 2) + + +def test_redirect_output_inherit_noop(): + config = CompanionConfig(name="rq", target=lambda: None) + with mock.patch("os.dup2") as dup2: + CompanionManager._redirect_output(config) + dup2.assert_not_called() + + def test_spawn_parent_records_pid_and_starting(): mgr = make_manager("rq") proc = mgr.processes["rq"]