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"}])