From 31e08aac58d5025134f75ef096a8372c6910d65d Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar Date: Tue, 9 Jun 2026 22:50:52 +0530 Subject: [PATCH] feat(companion): Close Gunicorn-only fds in the manager child The forked companion manager inherits the arbiter's HTTP listening sockets, its wakeup pipe, and the worker heartbeat files, none of which the manager uses. Close them in the child before running so the manager and the companions it forks do not pin the arbiter's fds. The manager creates its own signal pipe and control socket after the fork. Co-Authored-By: Claude Opus 4.8 --- docs/design/companion-process-manager.md | 2 +- gunicorn/arbiter.py | 19 +++++++++++++++++++ tests/test_arbiter.py | 14 ++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/design/companion-process-manager.md b/docs/design/companion-process-manager.md index 067c833b..b354a8fa 100644 --- a/docs/design/companion-process-manager.md +++ b/docs/design/companion-process-manager.md @@ -691,7 +691,7 @@ No per-companion logic in Arbiter. - [x] Add manager spawn/reap logic in Arbiter. - [x] Add manager shutdown handling in Arbiter. - [x] Wire Gunicorn reload to manager `reread` or restart. -- [ ] Close Gunicorn-only fds in manager child. +- [x] Close Gunicorn-only fds in manager child. - [ ] Close manager-only fds in companion child. - [ ] Add parent-death cleanup. - [ ] Add lifecycle logs. diff --git a/gunicorn/arbiter.py b/gunicorn/arbiter.py index afcda160..05fb3983 100644 --- a/gunicorn/arbiter.py +++ b/gunicorn/arbiter.py @@ -699,6 +699,7 @@ class Arbiter: # Process Child try: + self._close_gunicorn_fds() util._setproctitle("companion manager [%s]" % self.proc_name) manager.run() sys.exit(0) @@ -708,6 +709,24 @@ class Arbiter: self.log.exception("Exception in companion manager process") sys.exit(-1) + def _close_gunicorn_fds(self): + """Close fds the manager inherited from the arbiter but never uses. + + The companion manager serves no HTTP traffic and does not run the + arbiter's signal loop, so it drops the listening sockets, the arbiter's + wakeup pipe, and the worker heartbeat files. Closing them keeps the + manager (and the companions it forks) from pinning the arbiter's fds. + """ + for listener in self.LISTENERS: + listener.close() + for pipe_fd in self.PIPE: + try: + os.close(pipe_fd) + except OSError: + pass + for worker in self.WORKERS.values(): + worker.tmp.close() + def reload_companion_manager(self): """Restart the companion manager so it picks up the new configuration. diff --git a/tests/test_arbiter.py b/tests/test_arbiter.py index 9d215c4a..523bbec5 100644 --- a/tests/test_arbiter.py +++ b/tests/test_arbiter.py @@ -205,6 +205,20 @@ def test_stop_companion_manager_clears_pid_when_already_gone(): assert arbiter.companion_manager_pid == 0 +def test_close_gunicorn_fds_in_manager_child(): + arbiter = gunicorn.arbiter.Arbiter(DummyApplication()) + listener = mock.Mock() + worker = mock.Mock() + arbiter.LISTENERS = [listener] + arbiter.WORKERS = {1: worker} + arbiter.PIPE = [7, 8] + with mock.patch("os.close") as os_close: + arbiter._close_gunicorn_fds() + listener.close.assert_called_once_with() + worker.tmp.close.assert_called_once_with() + assert os_close.call_count == 2 + + def test_reload_companion_manager_restarts_running(): arbiter = gunicorn.arbiter.Arbiter(DummyApplication()) arbiter.cfg.set("companion_workers", [{"name": "rq", "target": "pkg:run"}])