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

View File

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

View File

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

View File

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