diff --git a/docs/design/companion-process-manager.md b/docs/design/companion-process-manager.md index 908ed8d0..41429d87 100644 --- a/docs/design/companion-process-manager.md +++ b/docs/design/companion-process-manager.md @@ -677,7 +677,7 @@ No per-companion logic in Arbiter. - [x] Reap exited companion processes. - [x] Implement `STARTING -> RUNNING` using `startsecs`. - [x] Implement `BACKOFF` with fixed `companion_restart_delay`. -- [ ] Implement `start_process`. +- [x] Implement `start_process`. - [ ] Implement `stop_process`. - [ ] Implement `restart_process`. - [ ] Preserve and clear `manual_stop` correctly. diff --git a/gunicorn/companion/manager.py b/gunicorn/companion/manager.py index 36422a67..c1cf04e7 100644 --- a/gunicorn/companion/manager.py +++ b/gunicorn/companion/manager.py @@ -58,6 +58,27 @@ class CompanionManager: os._exit(1) os._exit(0) + def start_process(self, name: str): + """Start a companion by name (the control ``start`` command). + + Follows the supervisor-style rules: a STOPPED or BACKOFF companion + clears its ``manual_stop`` flag, drops any pending retry, and is spawned + right away. RUNNING and STARTING are already-up, so they report success + without doing anything. STOPPING is rejected so the caller polls status + and retries once the old child is gone. Returns ``(ok, message)``. + """ + proc = self.processes.get(name) + if proc is None: + return False, "unknown companion %s" % name + if proc.state in (State.RUNNING, State.STARTING): + return True, "%s already %s" % (name, proc.state.lower()) + if proc.state == State.STOPPING: + return False, "%s is stopping; retry" % name + proc.manual_stop = False + proc.next_retry_at = None + self.spawn_process(proc) + return True, "%s started" % name + def reap_processes(self) -> list: """Reap any companions that have exited and record their exit info. diff --git a/tests/test_companion_manager.py b/tests/test_companion_manager.py index c4cc6c43..9d8c662f 100644 --- a/tests/test_companion_manager.py +++ b/tests/test_companion_manager.py @@ -125,6 +125,49 @@ def test_reap_no_children(): assert mgr.reap_processes() == [] +def test_start_process_stopped_spawns(): + mgr = make_manager("rq") + proc = mgr.processes["rq"] + with mock.patch("os.fork", return_value=70) as fork: + ok, _ = mgr.start_process("rq") + fork.assert_called_once() + assert ok and proc.state == State.STARTING and proc.manual_stop is False + + +def test_start_process_backoff_cancels_retry(): + mgr = make_manager("rq") + proc = mgr.processes["rq"] + proc.state = State.BACKOFF + proc.next_retry_at = 999.0 + proc.manual_stop = True + with mock.patch("os.fork", return_value=71): + ok, _ = mgr.start_process("rq") + assert ok and proc.state == State.STARTING + assert proc.next_retry_at is None and proc.manual_stop is False + + +def test_start_process_running_is_noop(): + mgr = make_manager("rq") + mgr.processes["rq"].state = State.RUNNING + with mock.patch("os.fork") as fork: + ok, _ = mgr.start_process("rq") + assert ok + fork.assert_not_called() + + +def test_start_process_stopping_rejected(): + mgr = make_manager("rq") + mgr.processes["rq"].state = State.STOPPING + ok, msg = mgr.start_process("rq") + assert not ok and "stopping" in msg + + +def test_start_process_unknown(): + mgr = make_manager("rq") + ok, _ = mgr.start_process("nope") + assert not ok + + def test_handle_exit_unexpected_backoff(): mgr = make_manager("rq") proc = mgr.processes["rq"]