Skip to content

Commit 9f7000f

Browse files
committed
feat(ctl): add 'show all' command for process overview
Displays complete hierarchy: arbiter PID, web workers with their PIDs/status, dirty arbiter PID, and dirty workers with their apps.
1 parent 3963e85 commit 9f7000f

File tree

4 files changed

+185
-3
lines changed

4 files changed

+185
-3
lines changed

gunicorn/ctl/cli.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,64 @@ def format_help(data: dict) -> str:
168168
return "\n".join(lines)
169169

170170

171+
def format_all(data: dict) -> str:
172+
"""Format show all output for display."""
173+
lines = []
174+
175+
# Arbiter
176+
arbiter = data.get("arbiter", {})
177+
lines.append("ARBITER (master)")
178+
lines.append(f" PID: {arbiter.get('pid', '?')}")
179+
lines.append("")
180+
181+
# Web workers
182+
web_workers = data.get("web_workers", [])
183+
lines.append(f"WEB WORKERS ({data.get('web_worker_count', 0)})")
184+
if web_workers:
185+
lines.append(f" {'PID':<10} {'AGE':<6} {'BOOTED':<8} {'LAST_BEAT'}")
186+
lines.append(f" {'-' * 38}")
187+
for w in web_workers:
188+
pid = w.get("pid", "?")
189+
age = w.get("age", "?")
190+
booted = "yes" if w.get("booted") else "no"
191+
hb = w.get("last_heartbeat")
192+
hb_str = f"{hb}s ago" if hb is not None else "n/a"
193+
lines.append(f" {pid:<10} {age:<6} {booted:<8} {hb_str}")
194+
else:
195+
lines.append(" (none)")
196+
lines.append("")
197+
198+
# Dirty arbiter
199+
dirty_arbiter = data.get("dirty_arbiter")
200+
if dirty_arbiter:
201+
lines.append("DIRTY ARBITER")
202+
lines.append(f" PID: {dirty_arbiter.get('pid', '?')}")
203+
lines.append("")
204+
205+
# Dirty workers
206+
dirty_workers = data.get("dirty_workers", [])
207+
lines.append(f"DIRTY WORKERS ({data.get('dirty_worker_count', 0)})")
208+
if dirty_workers:
209+
lines.append(f" {'PID':<10} {'AGE':<6} {'APPS':<30} {'LAST_BEAT'}")
210+
lines.append(f" {'-' * 58}")
211+
for w in dirty_workers:
212+
pid = w.get("pid", "?")
213+
age = w.get("age", "?")
214+
apps = ", ".join(w.get("apps", []))
215+
if len(apps) > 28:
216+
apps = apps[:25] + "..."
217+
hb = w.get("last_heartbeat")
218+
hb_str = f"{hb}s ago" if hb is not None else "n/a"
219+
lines.append(f" {pid:<10} {age:<6} {apps:<30} {hb_str}")
220+
else:
221+
lines.append(" (none)")
222+
else:
223+
lines.append("DIRTY ARBITER")
224+
lines.append(" (not running)")
225+
226+
return "\n".join(lines)
227+
228+
171229
def format_response(command: str, data: dict) -> str:
172230
"""
173231
Format response data based on command.
@@ -182,7 +240,9 @@ def format_response(command: str, data: dict) -> str:
182240
cmd_lower = command.lower().strip()
183241

184242
# Route to specific formatters
185-
if cmd_lower == "show workers":
243+
if cmd_lower == "show all":
244+
return format_all(data)
245+
elif cmd_lower == "show workers":
186246
return format_workers(data)
187247
elif cmd_lower == "show dirty":
188248
return format_dirty(data)

gunicorn/ctl/handlers.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,83 @@ def shutdown(self, mode: str = "graceful") -> dict:
405405

406406
return {"status": "shutting_down", "mode": mode}
407407

408+
def show_all(self) -> dict:
409+
"""
410+
Return overview of all processes (arbiter, web workers, dirty arbiter, dirty workers).
411+
412+
Returns:
413+
Dictionary with complete process hierarchy
414+
"""
415+
now = time.monotonic()
416+
417+
# Arbiter info
418+
arbiter_info = {
419+
"pid": self.arbiter.pid,
420+
"type": "arbiter",
421+
"role": "master",
422+
}
423+
424+
# Web workers (HTTP workers)
425+
web_workers = []
426+
for pid, worker in self.arbiter.WORKERS.items():
427+
try:
428+
last_update = worker.tmp.last_update()
429+
last_heartbeat = round(now - last_update, 2)
430+
except (OSError, ValueError):
431+
last_heartbeat = None
432+
433+
web_workers.append({
434+
"pid": pid,
435+
"type": "web",
436+
"age": worker.age,
437+
"booted": worker.booted,
438+
"last_heartbeat": last_heartbeat,
439+
})
440+
441+
# Sort by age
442+
web_workers.sort(key=lambda w: w["age"])
443+
444+
# Dirty arbiter and workers
445+
dirty_arbiter_info = None
446+
dirty_workers = []
447+
448+
if self.arbiter.dirty_arbiter_pid:
449+
dirty_arbiter_info = {
450+
"pid": self.arbiter.dirty_arbiter_pid,
451+
"type": "dirty_arbiter",
452+
"role": "dirty master",
453+
}
454+
455+
# Get dirty workers if we have access
456+
dirty_arbiter = getattr(self.arbiter, 'dirty_arbiter', None)
457+
if dirty_arbiter and hasattr(dirty_arbiter, 'workers'):
458+
for pid, worker in dirty_arbiter.workers.items():
459+
try:
460+
last_update = worker.tmp.last_update()
461+
last_heartbeat = round(now - last_update, 2)
462+
except (OSError, ValueError, AttributeError):
463+
last_heartbeat = None
464+
465+
dirty_workers.append({
466+
"pid": pid,
467+
"type": "dirty",
468+
"age": worker.age,
469+
"apps": getattr(worker, 'app_paths', []),
470+
"booted": getattr(worker, 'booted', False),
471+
"last_heartbeat": last_heartbeat,
472+
})
473+
474+
dirty_workers.sort(key=lambda w: w["age"])
475+
476+
return {
477+
"arbiter": arbiter_info,
478+
"web_workers": web_workers,
479+
"web_worker_count": len(web_workers),
480+
"dirty_arbiter": dirty_arbiter_info,
481+
"dirty_workers": dirty_workers,
482+
"dirty_worker_count": len(dirty_workers),
483+
}
484+
408485
def help(self) -> dict:
409486
"""
410487
Return list of available commands.
@@ -413,6 +490,7 @@ def help(self) -> dict:
413490
Dictionary with commands and descriptions
414491
"""
415492
commands = {
493+
"show all": "Show all processes (arbiter, web workers, dirty workers)",
416494
"show workers": "List HTTP workers with their status",
417495
"show dirty": "List dirty workers and apps",
418496
"show config": "Show current effective configuration",

gunicorn/ctl/server.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,13 @@ def _execute_command(self, parts: list) -> dict:
242242
def _handle_show(self, args: list) -> dict:
243243
"""Handle 'show' commands."""
244244
if not args:
245-
raise ValueError("Missing show target (workers|dirty|config|stats|listeners)")
245+
raise ValueError("Missing show target (all|workers|dirty|config|stats|listeners)")
246246

247247
target = args[0].lower()
248248

249-
if target == "workers":
249+
if target == "all":
250+
return self.handlers.show_all()
251+
elif target == "workers":
250252
return self.handlers.show_workers()
251253
elif target == "dirty":
252254
return self.handlers.show_dirty()

tests/ctl/test_handlers.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,47 @@ def test_shutdown_quick(self):
356356
mock_kill.assert_called_once_with(12345, signal.SIGINT)
357357

358358

359+
class TestShowAll:
360+
"""Tests for show all command."""
361+
362+
def test_show_all_basic(self):
363+
"""Test show all command."""
364+
arbiter = MockArbiter()
365+
arbiter.WORKERS = {
366+
1001: MockWorker(1001, 1),
367+
1002: MockWorker(1002, 2),
368+
}
369+
handlers = CommandHandlers(arbiter)
370+
371+
result = handlers.show_all()
372+
373+
assert "arbiter" in result
374+
assert result["arbiter"]["pid"] == 12345
375+
assert result["arbiter"]["type"] == "arbiter"
376+
377+
assert "web_workers" in result
378+
assert result["web_worker_count"] == 2
379+
assert len(result["web_workers"]) == 2
380+
381+
assert "dirty_arbiter" in result
382+
assert result["dirty_arbiter"] is None
383+
384+
assert "dirty_workers" in result
385+
assert result["dirty_worker_count"] == 0
386+
387+
def test_show_all_with_dirty(self):
388+
"""Test show all with dirty arbiter running."""
389+
arbiter = MockArbiter()
390+
arbiter.dirty_arbiter_pid = 2000
391+
handlers = CommandHandlers(arbiter)
392+
393+
result = handlers.show_all()
394+
395+
assert result["dirty_arbiter"] is not None
396+
assert result["dirty_arbiter"]["pid"] == 2000
397+
assert result["dirty_arbiter"]["type"] == "dirty_arbiter"
398+
399+
359400
class TestHelp:
360401
"""Tests for help command."""
361402

@@ -368,6 +409,7 @@ def test_help(self):
368409

369410
assert "commands" in result
370411
commands = result["commands"]
412+
assert "show all" in commands
371413
assert "show workers" in commands
372414
assert "worker add [N]" in commands
373415
assert "reload" in commands

0 commit comments

Comments
 (0)