From 84d69c46fdfe720f4bb4f2ac3e259f2fa02874c9 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar Date: Tue, 9 Jun 2026 17:52:25 +0530 Subject: [PATCH] feat(companion): Promote companions from STARTING to RUNNING after startsecs Add promote_running to CompanionManager: scans STARTING companions and moves any that have stayed alive at least their startsecs window to RUNNING, logging the pid and returning the promoted ones. Companions that die inside the window are left to reaping. Add tests for promotion after the window, too-early no-op, and non-STARTING. Co-Authored-By: Claude Opus 4.8 --- docs/design/companion-process-manager.md | 2 +- gunicorn/companion/manager.py | 18 ++++++++++++++ tests/test_companion_manager.py | 30 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/design/companion-process-manager.md b/docs/design/companion-process-manager.md index 7b11a176..e2bbfe70 100644 --- a/docs/design/companion-process-manager.md +++ b/docs/design/companion-process-manager.md @@ -675,7 +675,7 @@ No per-companion logic in Arbiter. - [x] Apply `cwd` and `env` before target. - [x] Redirect `stdout` and `stderr`. - [x] Reap exited companion processes. -- [ ] Implement `STARTING -> RUNNING` using `startsecs`. +- [x] Implement `STARTING -> RUNNING` using `startsecs`. - [ ] Implement `BACKOFF` with fixed `companion_restart_delay`. - [ ] Implement `start_process`. - [ ] Implement `stop_process`. diff --git a/gunicorn/companion/manager.py b/gunicorn/companion/manager.py index 413c46a2..36ef60c5 100644 --- a/gunicorn/companion/manager.py +++ b/gunicorn/companion/manager.py @@ -84,6 +84,24 @@ class CompanionManager: reaped.append(proc) return reaped + def promote_running(self, now: float = None) -> list: + """Move companions that survived ``startsecs`` from STARTING to RUNNING. + + A freshly spawned companion starts in STARTING. If it stays alive for + its ``startsecs`` window it is considered up and becomes RUNNING; if it + dies first, reaping handles it instead. Returns the promoted ones. + """ + now = now or time.time() + promoted = [] + for proc in self.processes.values(): + if proc.state != State.STARTING or proc.started_at is None: + continue + if now - proc.started_at >= proc.config.startsecs: + proc.state = State.RUNNING + self.log.info("companion %s running (pid %s)", proc.name, proc.pid) + promoted.append(proc) + return promoted + def _process_by_pid(self, pid: int): for proc in self.processes.values(): if proc.pid == pid: diff --git a/tests/test_companion_manager.py b/tests/test_companion_manager.py index 0169b8cf..c1c38826 100644 --- a/tests/test_companion_manager.py +++ b/tests/test_companion_manager.py @@ -125,6 +125,36 @@ def test_reap_no_children(): assert mgr.reap_processes() == [] +def test_promote_running_after_startsecs(): + mgr = make_manager("rq") + proc = mgr.processes["rq"] + proc.config.startsecs = 1 + proc.state = State.STARTING + proc.started_at = 100.0 + promoted = mgr.promote_running(now=101.5) + assert promoted == [proc] + assert proc.state == State.RUNNING + + +def test_promote_running_too_early(): + mgr = make_manager("rq") + proc = mgr.processes["rq"] + proc.config.startsecs = 5 + proc.state = State.STARTING + proc.started_at = 100.0 + assert mgr.promote_running(now=102.0) == [] + assert proc.state == State.STARTING + + +def test_promote_running_ignores_non_starting(): + mgr = make_manager("rq") + proc = mgr.processes["rq"] + proc.state = State.BACKOFF + proc.started_at = 100.0 + assert mgr.promote_running(now=999.0) == [] + assert proc.state == State.BACKOFF + + def test_spawn_parent_records_pid_and_starting(): mgr = make_manager("rq") proc = mgr.processes["rq"]