Skip to content

Commit 3e6d6b9

Browse files
committed
feat(ctl): query dirty arbiter for worker info in 'show all'
Add MSG_TYPE_STATUS to dirty protocol to allow querying the dirty arbiter for its workers. The control socket now connects to the dirty arbiter socket to retrieve worker information.
1 parent 9f7000f commit 3e6d6b9

File tree

5 files changed

+105
-30
lines changed

5 files changed

+105
-30
lines changed

gunicorn/ctl/cli.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -206,17 +206,21 @@ def format_all(data: dict) -> str:
206206
dirty_workers = data.get("dirty_workers", [])
207207
lines.append(f"DIRTY WORKERS ({data.get('dirty_worker_count', 0)})")
208208
if dirty_workers:
209-
lines.append(f" {'PID':<10} {'AGE':<6} {'APPS':<30} {'LAST_BEAT'}")
210-
lines.append(f" {'-' * 58}")
209+
lines.append(f" {'PID':<10} {'AGE':<6} {'APPS'}")
210+
lines.append(f" {'-' * 50}")
211211
for w in dirty_workers:
212212
pid = w.get("pid", "?")
213213
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}")
214+
apps = w.get("apps", [])
215+
# Show each app on its own line if multiple
216+
if apps:
217+
first_app = apps[0].split(":")[-1] # Just the class name
218+
lines.append(f" {pid:<10} {age:<6} {first_app}")
219+
for app in apps[1:]:
220+
app_name = app.split(":")[-1]
221+
lines.append(f" {'':<10} {'':<6} {app_name}")
222+
else:
223+
lines.append(f" {pid:<10} {age:<6} (no apps)")
220224
else:
221225
lines.append(" (none)")
222226
else:

gunicorn/ctl/handlers.py

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ def show_all(self) -> dict:
441441
# Sort by age
442442
web_workers.sort(key=lambda w: w["age"])
443443

444-
# Dirty arbiter and workers
444+
# Dirty arbiter info (runs in separate process)
445445
dirty_arbiter_info = None
446446
dirty_workers = []
447447

@@ -452,26 +452,8 @@ def show_all(self) -> dict:
452452
"role": "dirty master",
453453
}
454454

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"])
455+
# Query dirty arbiter for worker info via its socket
456+
dirty_workers = self._query_dirty_workers()
475457

476458
return {
477459
"arbiter": arbiter_info,
@@ -482,6 +464,47 @@ def show_all(self) -> dict:
482464
"dirty_worker_count": len(dirty_workers),
483465
}
484466

467+
def _query_dirty_workers(self) -> list:
468+
"""
469+
Query the dirty arbiter for worker information.
470+
471+
Connects to the dirty arbiter socket and sends a status request.
472+
473+
Returns:
474+
List of dirty worker info dicts, or empty list on error
475+
"""
476+
import socket
477+
dirty_socket_path = os.environ.get('GUNICORN_DIRTY_SOCKET')
478+
if not dirty_socket_path:
479+
return []
480+
481+
try:
482+
from gunicorn.dirty.protocol import DirtyProtocol
483+
484+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
485+
sock.settimeout(2.0)
486+
sock.connect(dirty_socket_path)
487+
488+
# Send status request
489+
request = {
490+
"type": DirtyProtocol.MSG_TYPE_STATUS,
491+
"id": "ctl-status-1",
492+
}
493+
DirtyProtocol.write_message(sock, request)
494+
495+
# Read response
496+
response = DirtyProtocol.read_message(sock)
497+
sock.close()
498+
499+
if response.get("type") == DirtyProtocol.MSG_TYPE_RESPONSE:
500+
data = response.get("data", {})
501+
return data.get("workers", [])
502+
503+
except Exception:
504+
pass
505+
506+
return []
507+
485508
def help(self) -> dict:
486509
"""
487510
Return list of available commands.

gunicorn/dirty/arbiter.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,9 @@ async def handle_client(self, reader, writer):
423423
# Handle stash operations
424424
if msg_type == DirtyProtocol.MSG_TYPE_STASH:
425425
await self.handle_stash_request(message, writer)
426+
# Handle status queries
427+
elif msg_type == DirtyProtocol.MSG_TYPE_STATUS:
428+
await self.handle_status_request(message, writer)
426429
else:
427430
# Route request to a dirty worker - pass writer for streaming
428431
await self.route_request(message, writer)
@@ -646,6 +649,47 @@ def _close_worker_connection(self, worker_pid):
646649
# Stash (shared state) operations - handled directly in arbiter
647650
# -------------------------------------------------------------------------
648651

652+
async def handle_status_request(self, message, client_writer):
653+
"""
654+
Handle a status query request.
655+
656+
Returns information about the dirty arbiter and its workers.
657+
658+
Args:
659+
message: Status request message
660+
client_writer: StreamWriter to send response to client
661+
"""
662+
request_id = message.get("id", "unknown")
663+
now = time.monotonic()
664+
665+
workers_info = []
666+
for pid, worker in self.workers.items():
667+
try:
668+
last_update = worker.tmp.last_update()
669+
last_heartbeat = round(now - last_update, 2)
670+
except (OSError, ValueError, AttributeError):
671+
last_heartbeat = None
672+
673+
workers_info.append({
674+
"pid": pid,
675+
"age": worker.age,
676+
"apps": getattr(worker, 'app_paths', []),
677+
"booted": getattr(worker, 'booted', False),
678+
"last_heartbeat": last_heartbeat,
679+
})
680+
681+
workers_info.sort(key=lambda w: w["age"])
682+
683+
result = {
684+
"arbiter_pid": self.pid,
685+
"workers": workers_info,
686+
"worker_count": len(workers_info),
687+
"apps": list(self.app_specs.keys()) if self.app_specs else [],
688+
}
689+
690+
response = make_response(request_id, result)
691+
await DirtyProtocol.write_message_async(client_writer, response)
692+
649693
async def handle_stash_request(self, message, client_writer):
650694
"""
651695
Handle a stash operation directly in the arbiter.

gunicorn/dirty/protocol.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
MSG_TYPE_CHUNK = 0x04
4444
MSG_TYPE_END = 0x05
4545
MSG_TYPE_STASH = 0x10 # Stash operations (shared state between workers)
46+
MSG_TYPE_STATUS = 0x11 # Status query for arbiter/workers
4647

4748
# Message type names (for backwards compatibility with old API)
4849
MSG_TYPE_REQUEST_STR = "request"
@@ -51,6 +52,7 @@
5152
MSG_TYPE_CHUNK_STR = "chunk"
5253
MSG_TYPE_END_STR = "end"
5354
MSG_TYPE_STASH_STR = "stash"
55+
MSG_TYPE_STATUS_STR = "status"
5456

5557
# Map int types to string names
5658
MSG_TYPE_TO_STR = {
@@ -60,6 +62,7 @@
6062
MSG_TYPE_CHUNK: MSG_TYPE_CHUNK_STR,
6163
MSG_TYPE_END: MSG_TYPE_END_STR,
6264
MSG_TYPE_STASH: MSG_TYPE_STASH_STR,
65+
MSG_TYPE_STATUS: MSG_TYPE_STATUS_STR,
6366
}
6467

6568
# Map string names to int types
@@ -98,6 +101,7 @@ class BinaryProtocol:
98101
MSG_TYPE_CHUNK = MSG_TYPE_CHUNK_STR
99102
MSG_TYPE_END = MSG_TYPE_END_STR
100103
MSG_TYPE_STASH = MSG_TYPE_STASH_STR
104+
MSG_TYPE_STATUS = MSG_TYPE_STATUS_STR
101105

102106
@staticmethod
103107
def encode_header(msg_type: int, request_id: int, payload_length: int) -> bytes:

tests/ctl/test_handlers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ def test_show_all_basic(self):
381381
assert "dirty_arbiter" in result
382382
assert result["dirty_arbiter"] is None
383383

384-
assert "dirty_workers" in result
384+
# No dirty workers when no dirty arbiter
385385
assert result["dirty_worker_count"] == 0
386386

387387
def test_show_all_with_dirty(self):

0 commit comments

Comments
 (0)