diff --git a/gunicorn/ctl/cli.py b/gunicorn/ctl/cli.py index d110bd0e..923b2a29 100644 --- a/gunicorn/ctl/cli.py +++ b/gunicorn/ctl/cli.py @@ -168,6 +168,64 @@ def format_help(data: dict) -> str: return "\n".join(lines) +def format_all(data: dict) -> str: + """Format show all output for display.""" + lines = [] + + # Arbiter + arbiter = data.get("arbiter", {}) + lines.append("ARBITER (master)") + lines.append(f" PID: {arbiter.get('pid', '?')}") + lines.append("") + + # Web workers + web_workers = data.get("web_workers", []) + lines.append(f"WEB WORKERS ({data.get('web_worker_count', 0)})") + if web_workers: + lines.append(f" {'PID':<10} {'AGE':<6} {'BOOTED':<8} {'LAST_BEAT'}") + lines.append(f" {'-' * 38}") + for w in web_workers: + pid = w.get("pid", "?") + age = w.get("age", "?") + booted = "yes" if w.get("booted") else "no" + hb = w.get("last_heartbeat") + hb_str = f"{hb}s ago" if hb is not None else "n/a" + lines.append(f" {pid:<10} {age:<6} {booted:<8} {hb_str}") + else: + lines.append(" (none)") + lines.append("") + + # Dirty arbiter + dirty_arbiter = data.get("dirty_arbiter") + if dirty_arbiter: + lines.append("DIRTY ARBITER") + lines.append(f" PID: {dirty_arbiter.get('pid', '?')}") + lines.append("") + + # Dirty workers + dirty_workers = data.get("dirty_workers", []) + lines.append(f"DIRTY WORKERS ({data.get('dirty_worker_count', 0)})") + if dirty_workers: + lines.append(f" {'PID':<10} {'AGE':<6} {'APPS':<30} {'LAST_BEAT'}") + lines.append(f" {'-' * 58}") + for w in dirty_workers: + pid = w.get("pid", "?") + age = w.get("age", "?") + apps = ", ".join(w.get("apps", [])) + if len(apps) > 28: + apps = apps[:25] + "..." + hb = w.get("last_heartbeat") + hb_str = f"{hb}s ago" if hb is not None else "n/a" + lines.append(f" {pid:<10} {age:<6} {apps:<30} {hb_str}") + else: + lines.append(" (none)") + else: + lines.append("DIRTY ARBITER") + lines.append(" (not running)") + + return "\n".join(lines) + + def format_response(command: str, data: dict) -> str: """ Format response data based on command. @@ -182,7 +240,9 @@ def format_response(command: str, data: dict) -> str: cmd_lower = command.lower().strip() # Route to specific formatters - if cmd_lower == "show workers": + if cmd_lower == "show all": + return format_all(data) + elif cmd_lower == "show workers": return format_workers(data) elif cmd_lower == "show dirty": return format_dirty(data) diff --git a/gunicorn/ctl/handlers.py b/gunicorn/ctl/handlers.py index 7005b5bd..e1810462 100644 --- a/gunicorn/ctl/handlers.py +++ b/gunicorn/ctl/handlers.py @@ -405,6 +405,83 @@ class CommandHandlers: return {"status": "shutting_down", "mode": mode} + def show_all(self) -> dict: + """ + Return overview of all processes (arbiter, web workers, dirty arbiter, dirty workers). + + Returns: + Dictionary with complete process hierarchy + """ + now = time.monotonic() + + # Arbiter info + arbiter_info = { + "pid": self.arbiter.pid, + "type": "arbiter", + "role": "master", + } + + # Web workers (HTTP workers) + web_workers = [] + for pid, worker in self.arbiter.WORKERS.items(): + try: + last_update = worker.tmp.last_update() + last_heartbeat = round(now - last_update, 2) + except (OSError, ValueError): + last_heartbeat = None + + web_workers.append({ + "pid": pid, + "type": "web", + "age": worker.age, + "booted": worker.booted, + "last_heartbeat": last_heartbeat, + }) + + # Sort by age + web_workers.sort(key=lambda w: w["age"]) + + # Dirty arbiter and workers + dirty_arbiter_info = None + dirty_workers = [] + + if self.arbiter.dirty_arbiter_pid: + dirty_arbiter_info = { + "pid": self.arbiter.dirty_arbiter_pid, + "type": "dirty_arbiter", + "role": "dirty master", + } + + # Get dirty workers if we have access + dirty_arbiter = getattr(self.arbiter, 'dirty_arbiter', None) + if dirty_arbiter and hasattr(dirty_arbiter, 'workers'): + for pid, worker in dirty_arbiter.workers.items(): + try: + last_update = worker.tmp.last_update() + last_heartbeat = round(now - last_update, 2) + except (OSError, ValueError, AttributeError): + last_heartbeat = None + + dirty_workers.append({ + "pid": pid, + "type": "dirty", + "age": worker.age, + "apps": getattr(worker, 'app_paths', []), + "booted": getattr(worker, 'booted', False), + "last_heartbeat": last_heartbeat, + }) + + dirty_workers.sort(key=lambda w: w["age"]) + + return { + "arbiter": arbiter_info, + "web_workers": web_workers, + "web_worker_count": len(web_workers), + "dirty_arbiter": dirty_arbiter_info, + "dirty_workers": dirty_workers, + "dirty_worker_count": len(dirty_workers), + } + def help(self) -> dict: """ Return list of available commands. @@ -413,6 +490,7 @@ class CommandHandlers: Dictionary with commands and descriptions """ commands = { + "show all": "Show all processes (arbiter, web workers, dirty workers)", "show workers": "List HTTP workers with their status", "show dirty": "List dirty workers and apps", "show config": "Show current effective configuration", diff --git a/gunicorn/ctl/server.py b/gunicorn/ctl/server.py index 3558d6e4..59259f69 100644 --- a/gunicorn/ctl/server.py +++ b/gunicorn/ctl/server.py @@ -242,11 +242,13 @@ class ControlSocketServer: def _handle_show(self, args: list) -> dict: """Handle 'show' commands.""" if not args: - raise ValueError("Missing show target (workers|dirty|config|stats|listeners)") + raise ValueError("Missing show target (all|workers|dirty|config|stats|listeners)") target = args[0].lower() - if target == "workers": + if target == "all": + return self.handlers.show_all() + elif target == "workers": return self.handlers.show_workers() elif target == "dirty": return self.handlers.show_dirty() diff --git a/tests/ctl/test_handlers.py b/tests/ctl/test_handlers.py index f18f75ce..414aaa23 100644 --- a/tests/ctl/test_handlers.py +++ b/tests/ctl/test_handlers.py @@ -356,6 +356,47 @@ class TestShutdown: mock_kill.assert_called_once_with(12345, signal.SIGINT) +class TestShowAll: + """Tests for show all command.""" + + def test_show_all_basic(self): + """Test show all command.""" + arbiter = MockArbiter() + arbiter.WORKERS = { + 1001: MockWorker(1001, 1), + 1002: MockWorker(1002, 2), + } + handlers = CommandHandlers(arbiter) + + result = handlers.show_all() + + assert "arbiter" in result + assert result["arbiter"]["pid"] == 12345 + assert result["arbiter"]["type"] == "arbiter" + + assert "web_workers" in result + assert result["web_worker_count"] == 2 + assert len(result["web_workers"]) == 2 + + assert "dirty_arbiter" in result + assert result["dirty_arbiter"] is None + + assert "dirty_workers" in result + assert result["dirty_worker_count"] == 0 + + def test_show_all_with_dirty(self): + """Test show all with dirty arbiter running.""" + arbiter = MockArbiter() + arbiter.dirty_arbiter_pid = 2000 + handlers = CommandHandlers(arbiter) + + result = handlers.show_all() + + assert result["dirty_arbiter"] is not None + assert result["dirty_arbiter"]["pid"] == 2000 + assert result["dirty_arbiter"]["type"] == "dirty_arbiter" + + class TestHelp: """Tests for help command.""" @@ -368,6 +409,7 @@ class TestHelp: assert "commands" in result commands = result["commands"] + assert "show all" in commands assert "show workers" in commands assert "worker add [N]" in commands assert "reload" in commands