actualizado 3-sept
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
#
|
||||
# This file is part of gunicorn released under the MIT license.
|
||||
# See the NOTICE for more information.
|
||||
|
||||
"""
|
||||
Gunicorn Control Interface
|
||||
|
||||
Provides a control socket server for runtime management and
|
||||
a CLI client (gunicornc) for interacting with running Gunicorn instances.
|
||||
"""
|
||||
|
||||
from gunicorn.ctl.server import ControlSocketServer
|
||||
from gunicorn.ctl.client import ControlClient
|
||||
from gunicorn.ctl.protocol import ControlProtocol
|
||||
|
||||
__all__ = ['ControlSocketServer', 'ControlClient', 'ControlProtocol']
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+450
@@ -0,0 +1,450 @@
|
||||
#
|
||||
# This file is part of gunicorn released under the MIT license.
|
||||
# See the NOTICE for more information.
|
||||
|
||||
"""
|
||||
gunicornc - Gunicorn control interface CLI
|
||||
|
||||
Interactive and single-command modes for controlling Gunicorn instances.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from gunicorn.config import _get_default_control_socket
|
||||
from gunicorn.ctl.client import ControlClient, ControlClientError, parse_command
|
||||
|
||||
|
||||
def format_workers(data: dict) -> str:
|
||||
"""Format workers output for display."""
|
||||
workers = data.get("workers", [])
|
||||
if not workers:
|
||||
return "No workers running"
|
||||
|
||||
lines = []
|
||||
lines.append(f"{'PID':<10} {'AGE':<6} {'BOOTED':<8} {'LAST_BEAT'}")
|
||||
lines.append("-" * 40)
|
||||
|
||||
for w in 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}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"Total: {data.get('count', len(workers))} workers")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_dirty(data: dict) -> str:
|
||||
"""Format dirty workers output for display."""
|
||||
if not data.get("enabled"):
|
||||
return "Dirty arbiter not running"
|
||||
|
||||
lines = []
|
||||
lines.append(f"Dirty arbiter PID: {data.get('pid')}")
|
||||
lines.append("")
|
||||
|
||||
workers = data.get("workers", [])
|
||||
if workers:
|
||||
lines.append("DIRTY WORKERS:")
|
||||
lines.append(f"{'PID':<10} {'AGE':<6} {'APPS':<30} {'LAST_BEAT'}")
|
||||
lines.append("-" * 60)
|
||||
|
||||
for w in workers:
|
||||
pid = w.get("pid", "?")
|
||||
age = w.get("age", "?")
|
||||
apps = ", ".join(w.get("apps", []))[:30]
|
||||
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}")
|
||||
lines.append("")
|
||||
|
||||
apps = data.get("apps", [])
|
||||
if apps:
|
||||
lines.append("DIRTY APPS:")
|
||||
lines.append(f"{'APP':<30} {'WORKERS':<10} {'LIMIT'}")
|
||||
lines.append("-" * 50)
|
||||
|
||||
for app in apps:
|
||||
path = app.get("import_path", "?")[:30]
|
||||
current = app.get("current_workers", 0)
|
||||
limit = app.get("worker_count")
|
||||
limit_str = str(limit) if limit is not None else "none"
|
||||
|
||||
lines.append(f"{path:<30} {current:<10} {limit_str}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_stats(data: dict) -> str:
|
||||
"""Format stats output for display."""
|
||||
lines = []
|
||||
|
||||
uptime = data.get("uptime")
|
||||
if uptime:
|
||||
hours = int(uptime // 3600)
|
||||
minutes = int((uptime % 3600) // 60)
|
||||
seconds = int(uptime % 60)
|
||||
if hours:
|
||||
uptime_str = f"{hours}h {minutes}m {seconds}s"
|
||||
elif minutes:
|
||||
uptime_str = f"{minutes}m {seconds}s"
|
||||
else:
|
||||
uptime_str = f"{seconds}s"
|
||||
else:
|
||||
uptime_str = "unknown"
|
||||
|
||||
lines.append(f"Uptime: {uptime_str}")
|
||||
lines.append(f"PID: {data.get('pid', 'unknown')}")
|
||||
lines.append(f"Workers current: {data.get('workers_current', 0)}")
|
||||
lines.append(f"Workers target: {data.get('workers_target', 0)}")
|
||||
lines.append(f"Workers spawned: {data.get('workers_spawned', 0)}")
|
||||
lines.append(f"Workers killed: {data.get('workers_killed', 0)}")
|
||||
lines.append(f"Reloads: {data.get('reloads', 0)}")
|
||||
|
||||
dirty_pid = data.get("dirty_arbiter_pid")
|
||||
if dirty_pid:
|
||||
lines.append(f"Dirty arbiter: {dirty_pid}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_listeners(data: dict) -> str:
|
||||
"""Format listeners output for display."""
|
||||
listeners = data.get("listeners", [])
|
||||
if not listeners:
|
||||
return "No listeners bound"
|
||||
|
||||
lines = []
|
||||
lines.append(f"{'ADDRESS':<40} {'TYPE':<8} {'FD'}")
|
||||
lines.append("-" * 55)
|
||||
|
||||
for lnr in listeners:
|
||||
addr = lnr.get("address", "?")
|
||||
ltype = lnr.get("type", "?")
|
||||
fd = lnr.get("fd", "?")
|
||||
lines.append(f"{addr:<40} {ltype:<8} {fd}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"Total: {data.get('count', len(listeners))} listeners")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_config(data: dict) -> str:
|
||||
"""Format config output for display."""
|
||||
lines = []
|
||||
|
||||
# Sort keys for consistent output
|
||||
for key in sorted(data.keys()):
|
||||
value = data[key]
|
||||
if isinstance(value, list):
|
||||
value = ", ".join(str(v) for v in value)
|
||||
lines.append(f"{key}: {value}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_help(data: dict) -> str:
|
||||
"""Format help output for display."""
|
||||
commands = data.get("commands", {})
|
||||
lines = []
|
||||
lines.append("Available commands:")
|
||||
lines.append("")
|
||||
|
||||
# Find max command length for alignment
|
||||
max_len = max(len(cmd) for cmd in commands.keys()) if commands else 0
|
||||
|
||||
for cmd, desc in sorted(commands.items()):
|
||||
lines.append(f" {cmd:<{max_len + 2}} {desc}")
|
||||
|
||||
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'}")
|
||||
lines.append(f" {'-' * 50}")
|
||||
for w in dirty_workers:
|
||||
pid = w.get("pid", "?")
|
||||
age = w.get("age", "?")
|
||||
apps = w.get("apps", [])
|
||||
# Show each app on its own line if multiple
|
||||
if apps:
|
||||
first_app = apps[0].split(":")[-1] # Just the class name
|
||||
lines.append(f" {pid:<10} {age:<6} {first_app}")
|
||||
for app in apps[1:]:
|
||||
app_name = app.split(":")[-1]
|
||||
lines.append(f" {'':<10} {'':<6} {app_name}")
|
||||
else:
|
||||
lines.append(f" {pid:<10} {age:<6} (no apps)")
|
||||
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: # pylint: disable=too-many-return-statements
|
||||
"""
|
||||
Format response data based on command.
|
||||
|
||||
Args:
|
||||
command: Original command string
|
||||
data: Response data dictionary
|
||||
|
||||
Returns:
|
||||
Formatted string for display
|
||||
"""
|
||||
cmd_lower = command.lower().strip()
|
||||
|
||||
# Route to specific formatters
|
||||
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)
|
||||
elif cmd_lower == "show stats":
|
||||
return format_stats(data)
|
||||
elif cmd_lower == "show listeners":
|
||||
return format_listeners(data)
|
||||
elif cmd_lower == "show config":
|
||||
return format_config(data)
|
||||
elif cmd_lower == "help":
|
||||
return format_help(data)
|
||||
else:
|
||||
# Generic JSON output for other commands
|
||||
if data:
|
||||
return json.dumps(data, indent=2)
|
||||
return "OK"
|
||||
|
||||
|
||||
def run_command(socket_path: str, command: str, json_output: bool = False) -> int:
|
||||
"""
|
||||
Execute single command and exit.
|
||||
|
||||
Args:
|
||||
socket_path: Path to control socket
|
||||
command: Command to execute
|
||||
json_output: If True, output raw JSON
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success, 1 for error)
|
||||
"""
|
||||
try:
|
||||
with ControlClient(socket_path) as client:
|
||||
cmd, args = parse_command(command)
|
||||
full_command = f"{cmd} {' '.join(args)}".strip() if args else cmd
|
||||
result = client.send_command(full_command)
|
||||
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
output = format_response(cmd, result)
|
||||
print(output)
|
||||
|
||||
return 0
|
||||
|
||||
except ControlClientError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
|
||||
|
||||
def run_interactive(socket_path: str, json_output: bool = False) -> int:
|
||||
"""
|
||||
Run interactive CLI with readline support.
|
||||
|
||||
Args:
|
||||
socket_path: Path to control socket
|
||||
json_output: If True, output raw JSON
|
||||
|
||||
Returns:
|
||||
Exit code
|
||||
"""
|
||||
try:
|
||||
import readline # noqa: F401 - imported for side effects
|
||||
has_readline = True
|
||||
except ImportError:
|
||||
has_readline = False
|
||||
|
||||
try:
|
||||
client = ControlClient(socket_path)
|
||||
client.connect()
|
||||
except ControlClientError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Connected to {socket_path}")
|
||||
print("Type 'help' for available commands, 'quit' to exit.")
|
||||
print()
|
||||
|
||||
# Set up readline history
|
||||
history_file = os.path.expanduser("~/.gunicornc_history")
|
||||
if has_readline:
|
||||
try:
|
||||
readline.read_history_file(history_file)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
exit_code = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
line = input("gunicorn> ").strip()
|
||||
except EOFError:
|
||||
print()
|
||||
break
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.lower() in ('quit', 'exit', 'q'):
|
||||
break
|
||||
|
||||
try:
|
||||
cmd, args = parse_command(line)
|
||||
full_command = f"{cmd} {' '.join(args)}".strip() if args else cmd
|
||||
result = client.send_command(full_command)
|
||||
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
output = format_response(cmd, result)
|
||||
print(output)
|
||||
|
||||
except ControlClientError as e:
|
||||
print(f"Error: {e}")
|
||||
# Try to reconnect
|
||||
try:
|
||||
client.close()
|
||||
client.connect()
|
||||
except ControlClientError:
|
||||
print("Connection lost. Exiting.")
|
||||
exit_code = 1
|
||||
break
|
||||
|
||||
print()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
exit_code = 130
|
||||
finally:
|
||||
client.close()
|
||||
if has_readline:
|
||||
try:
|
||||
readline.write_history_file(history_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return exit_code
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for gunicornc CLI."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Gunicorn control interface',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
gunicornc # Interactive mode (default socket)
|
||||
gunicornc -s /tmp/myapp.ctl # Interactive mode with custom socket
|
||||
gunicornc -c "show workers" # Single command mode
|
||||
gunicornc -c "worker add 2" # Add 2 workers
|
||||
gunicornc -c "show stats" -j # Output stats as JSON
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-s', '--socket',
|
||||
default=_get_default_control_socket(),
|
||||
help='Control socket path (default: auto-detected based on XDG_RUNTIME_DIR or ~/.gunicorn/)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-c', '--command',
|
||||
help='Execute single command and exit'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-j', '--json',
|
||||
action='store_true',
|
||||
help='Output raw JSON (for scripting)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-v', '--version',
|
||||
action='store_true',
|
||||
help='Show version and exit'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.version:
|
||||
from gunicorn import __version__
|
||||
print(f"gunicornc (gunicorn {__version__})")
|
||||
return 0
|
||||
|
||||
socket_path = args.socket
|
||||
|
||||
# Make relative paths absolute from cwd
|
||||
if not os.path.isabs(socket_path):
|
||||
socket_path = os.path.join(os.getcwd(), socket_path)
|
||||
|
||||
if args.command:
|
||||
return run_command(socket_path, args.command, args.json)
|
||||
else:
|
||||
return run_interactive(socket_path, args.json)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
#
|
||||
# This file is part of gunicorn released under the MIT license.
|
||||
# See the NOTICE for more information.
|
||||
|
||||
"""
|
||||
Control Socket Client
|
||||
|
||||
Client library for connecting to gunicorn control socket.
|
||||
"""
|
||||
|
||||
import shlex
|
||||
import socket
|
||||
|
||||
from gunicorn.ctl.protocol import (
|
||||
ControlProtocol,
|
||||
make_request,
|
||||
)
|
||||
|
||||
|
||||
class ControlClientError(Exception):
|
||||
"""Control client error."""
|
||||
|
||||
|
||||
class ControlClient:
|
||||
"""
|
||||
Client for connecting to gunicorn control socket.
|
||||
|
||||
Can be used as a context manager:
|
||||
|
||||
with ControlClient('/path/to/gunicorn.ctl') as client:
|
||||
result = client.send_command('show workers')
|
||||
"""
|
||||
|
||||
def __init__(self, socket_path: str, timeout: float = 30.0):
|
||||
"""
|
||||
Initialize control client.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the Unix socket
|
||||
timeout: Socket timeout in seconds (default 30)
|
||||
"""
|
||||
self.socket_path = socket_path
|
||||
self.timeout = timeout
|
||||
self._sock = None
|
||||
self._request_id = 0
|
||||
|
||||
def connect(self):
|
||||
"""
|
||||
Connect to control socket.
|
||||
|
||||
Raises:
|
||||
ControlClientError: If connection fails
|
||||
"""
|
||||
if self._sock:
|
||||
return
|
||||
|
||||
try:
|
||||
self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self._sock.settimeout(self.timeout)
|
||||
self._sock.connect(self.socket_path)
|
||||
except socket.error as e:
|
||||
self._sock = None
|
||||
raise ControlClientError(f"Failed to connect to {self.socket_path}: {e}")
|
||||
|
||||
def close(self):
|
||||
"""Close connection."""
|
||||
if self._sock:
|
||||
try:
|
||||
self._sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._sock = None
|
||||
|
||||
def send_command(self, command: str, args: list = None) -> dict:
|
||||
"""
|
||||
Send command and wait for response.
|
||||
|
||||
Args:
|
||||
command: Command string (e.g., "show workers")
|
||||
args: Optional additional arguments
|
||||
|
||||
Returns:
|
||||
Response data dictionary
|
||||
|
||||
Raises:
|
||||
ControlClientError: If communication fails
|
||||
"""
|
||||
if not self._sock:
|
||||
self.connect()
|
||||
|
||||
self._request_id += 1
|
||||
request = make_request(self._request_id, command, args)
|
||||
|
||||
try:
|
||||
ControlProtocol.write_message(self._sock, request)
|
||||
response = ControlProtocol.read_message(self._sock)
|
||||
except Exception as e:
|
||||
self.close()
|
||||
raise ControlClientError(f"Communication error: {e}")
|
||||
|
||||
if response.get("status") == "error":
|
||||
raise ControlClientError(response.get("error", "Unknown error"))
|
||||
|
||||
return response.get("data", {})
|
||||
|
||||
def __enter__(self):
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
|
||||
def parse_command(line: str) -> tuple:
|
||||
"""
|
||||
Parse a command line into command and args.
|
||||
|
||||
Args:
|
||||
line: Command line string
|
||||
|
||||
Returns:
|
||||
Tuple of (command_string, args_list)
|
||||
"""
|
||||
parts = shlex.split(line)
|
||||
if not parts:
|
||||
return "", []
|
||||
|
||||
# Find where numeric/value args start
|
||||
command_parts = []
|
||||
args = []
|
||||
|
||||
for part in parts:
|
||||
# If we haven't hit args yet and this looks like a command word
|
||||
if not args and not part.isdigit() and not part.startswith('-'):
|
||||
command_parts.append(part)
|
||||
else:
|
||||
args.append(part)
|
||||
|
||||
return " ".join(command_parts), args
|
||||
+585
@@ -0,0 +1,585 @@
|
||||
#
|
||||
# This file is part of gunicorn released under the MIT license.
|
||||
# See the NOTICE for more information.
|
||||
|
||||
"""
|
||||
Control Interface Command Handlers
|
||||
|
||||
Provides handlers for all control commands with access to arbiter state.
|
||||
"""
|
||||
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import time
|
||||
|
||||
|
||||
class CommandHandlers:
|
||||
"""
|
||||
Command handlers with access to arbiter state.
|
||||
|
||||
All handler methods return dictionaries that will be sent
|
||||
as the response data.
|
||||
"""
|
||||
|
||||
def __init__(self, arbiter):
|
||||
"""
|
||||
Initialize handlers with arbiter reference.
|
||||
|
||||
Args:
|
||||
arbiter: The Gunicorn arbiter instance
|
||||
"""
|
||||
self.arbiter = arbiter
|
||||
|
||||
def show_workers(self) -> dict:
|
||||
"""
|
||||
Return list of HTTP workers.
|
||||
|
||||
Returns:
|
||||
Dictionary with workers list containing:
|
||||
- pid: Worker process ID
|
||||
- age: Worker age (spawn order)
|
||||
- requests: Number of requests handled (if available)
|
||||
- booted: Whether worker has finished booting
|
||||
- last_heartbeat: Seconds since last heartbeat
|
||||
"""
|
||||
workers = []
|
||||
now = time.monotonic()
|
||||
|
||||
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
|
||||
|
||||
workers.append({
|
||||
"pid": pid,
|
||||
"age": worker.age,
|
||||
"booted": worker.booted,
|
||||
"aborted": worker.aborted,
|
||||
"last_heartbeat": last_heartbeat,
|
||||
})
|
||||
|
||||
# Sort by age (oldest first)
|
||||
workers.sort(key=lambda w: w["age"])
|
||||
|
||||
return {"workers": workers, "count": len(workers)}
|
||||
|
||||
def show_dirty(self) -> dict:
|
||||
"""
|
||||
Return dirty workers and apps information.
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- enabled: Whether dirty arbiter is running
|
||||
- pid: Dirty arbiter PID
|
||||
- workers: List of dirty worker info
|
||||
- apps: List of dirty app specs
|
||||
"""
|
||||
if not self.arbiter.dirty_arbiter_pid:
|
||||
return {
|
||||
"enabled": False,
|
||||
"pid": None,
|
||||
"workers": [],
|
||||
"apps": [],
|
||||
}
|
||||
|
||||
# Get dirty arbiter reference if available
|
||||
dirty_arbiter = getattr(self.arbiter, 'dirty_arbiter', None)
|
||||
|
||||
workers = []
|
||||
apps = []
|
||||
|
||||
if dirty_arbiter and hasattr(dirty_arbiter, 'workers'):
|
||||
now = time.monotonic()
|
||||
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
|
||||
|
||||
workers.append({
|
||||
"pid": pid,
|
||||
"age": worker.age,
|
||||
"apps": getattr(worker, 'app_paths', []),
|
||||
"booted": getattr(worker, 'booted', False),
|
||||
"last_heartbeat": last_heartbeat,
|
||||
})
|
||||
|
||||
# Get app specs
|
||||
if hasattr(dirty_arbiter, 'app_specs'):
|
||||
for path, spec in dirty_arbiter.app_specs.items():
|
||||
worker_pids = list(dirty_arbiter.app_worker_map.get(path, []))
|
||||
apps.append({
|
||||
"import_path": path,
|
||||
"worker_count": spec.get('worker_count'),
|
||||
"current_workers": len(worker_pids),
|
||||
"worker_pids": worker_pids,
|
||||
})
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"pid": self.arbiter.dirty_arbiter_pid,
|
||||
"workers": workers,
|
||||
"apps": apps,
|
||||
}
|
||||
|
||||
def show_config(self) -> dict:
|
||||
"""
|
||||
Return current effective configuration.
|
||||
|
||||
Returns:
|
||||
Dictionary of configuration values
|
||||
"""
|
||||
cfg = self.arbiter.cfg
|
||||
config = {}
|
||||
|
||||
# Get commonly needed config values
|
||||
config_keys = [
|
||||
'bind', 'workers', 'worker_class', 'threads', 'timeout',
|
||||
'graceful_timeout', 'keepalive', 'max_requests',
|
||||
'max_requests_jitter', 'worker_connections', 'preload_app',
|
||||
'daemon', 'pidfile', 'proc_name', 'reload',
|
||||
'dirty_workers', 'dirty_apps', 'dirty_timeout',
|
||||
'control_socket', 'control_socket_disable',
|
||||
]
|
||||
|
||||
for key in config_keys:
|
||||
try:
|
||||
value = getattr(cfg, key)
|
||||
# Convert non-serializable types
|
||||
if callable(value):
|
||||
value = str(value)
|
||||
elif hasattr(value, '__class__') and not isinstance(
|
||||
value, (str, int, float, bool, list, dict, type(None))):
|
||||
value = str(value)
|
||||
config[key] = value
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return config
|
||||
|
||||
def show_stats(self) -> dict:
|
||||
"""
|
||||
Return server statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- uptime: Seconds since arbiter started
|
||||
- pid: Arbiter PID
|
||||
- workers_current: Current number of workers
|
||||
- workers_spawned: Total workers spawned
|
||||
- workers_killed: Total workers killed (if tracked)
|
||||
- reloads: Number of reloads (if tracked)
|
||||
"""
|
||||
stats = getattr(self.arbiter, '_stats', {})
|
||||
start_time = stats.get('start_time')
|
||||
|
||||
uptime = None
|
||||
if start_time:
|
||||
uptime = round(time.time() - start_time, 2)
|
||||
|
||||
return {
|
||||
"uptime": uptime,
|
||||
"pid": self.arbiter.pid,
|
||||
"workers_current": len(self.arbiter.WORKERS),
|
||||
"workers_target": self.arbiter.num_workers,
|
||||
"workers_spawned": stats.get('workers_spawned', 0),
|
||||
"workers_killed": stats.get('workers_killed', 0),
|
||||
"reloads": stats.get('reloads', 0),
|
||||
"dirty_arbiter_pid": self.arbiter.dirty_arbiter_pid or None,
|
||||
}
|
||||
|
||||
def show_listeners(self) -> dict:
|
||||
"""
|
||||
Return bound socket information.
|
||||
|
||||
Returns:
|
||||
Dictionary with listeners list
|
||||
"""
|
||||
listeners = []
|
||||
|
||||
for lnr in self.arbiter.LISTENERS:
|
||||
addr = str(lnr)
|
||||
listener_info = {
|
||||
"address": addr,
|
||||
"fd": lnr.fileno(),
|
||||
}
|
||||
|
||||
# Try to get socket family
|
||||
try:
|
||||
sock = lnr.sock
|
||||
if sock.family == socket.AF_UNIX:
|
||||
listener_info["type"] = "unix"
|
||||
elif sock.family == socket.AF_INET:
|
||||
listener_info["type"] = "tcp"
|
||||
elif sock.family == socket.AF_INET6:
|
||||
listener_info["type"] = "tcp6"
|
||||
except Exception:
|
||||
listener_info["type"] = "unknown"
|
||||
|
||||
listeners.append(listener_info)
|
||||
|
||||
return {"listeners": listeners, "count": len(listeners)}
|
||||
|
||||
def worker_add(self, count: int = 1) -> dict:
|
||||
"""
|
||||
Increase worker count.
|
||||
|
||||
Args:
|
||||
count: Number of workers to add (default 1)
|
||||
|
||||
Returns:
|
||||
Dictionary with added count and new total
|
||||
"""
|
||||
count = max(1, int(count))
|
||||
old_count = self.arbiter.num_workers
|
||||
self.arbiter.num_workers += count
|
||||
|
||||
# Wake up the arbiter to spawn workers
|
||||
self.arbiter.wakeup()
|
||||
|
||||
return {
|
||||
"added": count,
|
||||
"previous": old_count,
|
||||
"total": self.arbiter.num_workers,
|
||||
}
|
||||
|
||||
def worker_remove(self, count: int = 1) -> dict:
|
||||
"""
|
||||
Decrease worker count.
|
||||
|
||||
Args:
|
||||
count: Number of workers to remove (default 1)
|
||||
|
||||
Returns:
|
||||
Dictionary with removed count and new total
|
||||
"""
|
||||
count = max(1, int(count))
|
||||
old_count = self.arbiter.num_workers
|
||||
|
||||
# Don't go below 1 worker
|
||||
new_count = max(1, old_count - count)
|
||||
actual_removed = old_count - new_count
|
||||
|
||||
self.arbiter.num_workers = new_count
|
||||
|
||||
# Wake up the arbiter to kill excess workers
|
||||
self.arbiter.wakeup()
|
||||
|
||||
return {
|
||||
"removed": actual_removed,
|
||||
"previous": old_count,
|
||||
"total": new_count,
|
||||
}
|
||||
|
||||
def worker_kill(self, pid: int) -> dict:
|
||||
"""
|
||||
Gracefully terminate a specific worker.
|
||||
|
||||
Args:
|
||||
pid: Worker process ID
|
||||
|
||||
Returns:
|
||||
Dictionary with killed PID or error
|
||||
"""
|
||||
pid = int(pid)
|
||||
|
||||
if pid not in self.arbiter.WORKERS:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Worker {pid} not found",
|
||||
}
|
||||
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
return {
|
||||
"success": True,
|
||||
"killed": pid,
|
||||
}
|
||||
except OSError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def dirty_add(self, count: int = 1) -> dict:
|
||||
"""
|
||||
Spawn additional dirty workers.
|
||||
|
||||
Sends a MANAGE message to the dirty arbiter to spawn workers.
|
||||
|
||||
Args:
|
||||
count: Number of dirty workers to add (default 1)
|
||||
|
||||
Returns:
|
||||
Dictionary with added count or error
|
||||
"""
|
||||
if not self.arbiter.dirty_arbiter_pid:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Dirty arbiter not running",
|
||||
}
|
||||
|
||||
count = max(1, int(count))
|
||||
return self._send_manage_message("add", count)
|
||||
|
||||
def dirty_remove(self, count: int = 1) -> dict:
|
||||
"""
|
||||
Remove dirty workers.
|
||||
|
||||
Sends a MANAGE message to the dirty arbiter to remove workers.
|
||||
|
||||
Args:
|
||||
count: Number of dirty workers to remove (default 1)
|
||||
|
||||
Returns:
|
||||
Dictionary with removed count or error
|
||||
"""
|
||||
if not self.arbiter.dirty_arbiter_pid:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Dirty arbiter not running",
|
||||
}
|
||||
|
||||
count = max(1, int(count))
|
||||
return self._send_manage_message("remove", count)
|
||||
|
||||
def _send_manage_message(self, operation: str, count: int) -> dict:
|
||||
"""
|
||||
Send a worker management message to the dirty arbiter.
|
||||
|
||||
Args:
|
||||
operation: "add" or "remove"
|
||||
count: Number of workers to add/remove
|
||||
|
||||
Returns:
|
||||
Dictionary with result or error
|
||||
"""
|
||||
# Get socket path from arbiter object or environment
|
||||
dirty_socket_path = None
|
||||
if hasattr(self.arbiter, 'dirty_arbiter') and self.arbiter.dirty_arbiter:
|
||||
dirty_socket_path = getattr(
|
||||
self.arbiter.dirty_arbiter, 'socket_path', None
|
||||
)
|
||||
if not dirty_socket_path:
|
||||
dirty_socket_path = os.environ.get('GUNICORN_DIRTY_SOCKET')
|
||||
if not dirty_socket_path:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Cannot find dirty arbiter socket path",
|
||||
}
|
||||
|
||||
try:
|
||||
from gunicorn.dirty.protocol import (
|
||||
DirtyProtocol, MANAGE_OP_ADD, MANAGE_OP_REMOVE
|
||||
)
|
||||
|
||||
op = MANAGE_OP_ADD if operation == "add" else MANAGE_OP_REMOVE
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(10.0)
|
||||
sock.connect(dirty_socket_path)
|
||||
|
||||
# Send manage request
|
||||
request = {
|
||||
"type": DirtyProtocol.MSG_TYPE_MANAGE,
|
||||
"id": 1,
|
||||
"op": op,
|
||||
"count": count,
|
||||
}
|
||||
DirtyProtocol.write_message(sock, request)
|
||||
|
||||
# Read response
|
||||
response = DirtyProtocol.read_message(sock)
|
||||
sock.close()
|
||||
|
||||
if response.get("type") == DirtyProtocol.MSG_TYPE_RESPONSE:
|
||||
return response.get("result", {"success": True})
|
||||
elif response.get("type") == DirtyProtocol.MSG_TYPE_ERROR:
|
||||
error = response.get("error", {})
|
||||
return {
|
||||
"success": False,
|
||||
"error": error.get("message", str(error)),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Unexpected response type: {response.get('type')}",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def reload(self) -> dict:
|
||||
"""
|
||||
Trigger graceful reload (equivalent to SIGHUP).
|
||||
|
||||
Returns:
|
||||
Dictionary with status
|
||||
"""
|
||||
# Send HUP to self to trigger reload
|
||||
os.kill(self.arbiter.pid, signal.SIGHUP)
|
||||
return {"status": "reloading"}
|
||||
|
||||
def reopen(self) -> dict:
|
||||
"""
|
||||
Reopen log files (equivalent to SIGUSR1).
|
||||
|
||||
Returns:
|
||||
Dictionary with status
|
||||
"""
|
||||
os.kill(self.arbiter.pid, signal.SIGUSR1)
|
||||
return {"status": "reopening"}
|
||||
|
||||
def shutdown(self, mode: str = "graceful") -> dict:
|
||||
"""
|
||||
Initiate shutdown.
|
||||
|
||||
Args:
|
||||
mode: "graceful" (SIGTERM) or "quick" (SIGINT)
|
||||
|
||||
Returns:
|
||||
Dictionary with status
|
||||
"""
|
||||
if mode == "quick":
|
||||
os.kill(self.arbiter.pid, signal.SIGINT)
|
||||
else:
|
||||
os.kill(self.arbiter.pid, signal.SIGTERM)
|
||||
|
||||
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 info (runs in separate process)
|
||||
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",
|
||||
}
|
||||
|
||||
# Query dirty arbiter for worker info via its socket
|
||||
dirty_workers = self._query_dirty_workers()
|
||||
|
||||
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 _query_dirty_workers(self) -> list:
|
||||
"""
|
||||
Query the dirty arbiter for worker information.
|
||||
|
||||
Connects to the dirty arbiter socket and sends a status request.
|
||||
|
||||
Returns:
|
||||
List of dirty worker info dicts, or empty list on error
|
||||
"""
|
||||
# Get socket path from arbiter object or environment
|
||||
dirty_socket_path = None
|
||||
if hasattr(self.arbiter, 'dirty_arbiter') and self.arbiter.dirty_arbiter:
|
||||
dirty_socket_path = getattr(self.arbiter.dirty_arbiter, 'socket_path', None)
|
||||
if not dirty_socket_path:
|
||||
dirty_socket_path = os.environ.get('GUNICORN_DIRTY_SOCKET')
|
||||
if not dirty_socket_path:
|
||||
return []
|
||||
|
||||
try:
|
||||
from gunicorn.dirty.protocol import DirtyProtocol
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(2.0)
|
||||
sock.connect(dirty_socket_path)
|
||||
|
||||
# Send status request
|
||||
request = {
|
||||
"type": DirtyProtocol.MSG_TYPE_STATUS,
|
||||
"id": "ctl-status-1",
|
||||
}
|
||||
DirtyProtocol.write_message(sock, request)
|
||||
|
||||
# Read response
|
||||
response = DirtyProtocol.read_message(sock)
|
||||
sock.close()
|
||||
|
||||
if response.get("type") == DirtyProtocol.MSG_TYPE_RESPONSE:
|
||||
result = response.get("result", {})
|
||||
return result.get("workers", [])
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return []
|
||||
|
||||
def help(self) -> dict:
|
||||
"""
|
||||
Return list of available commands.
|
||||
|
||||
Returns:
|
||||
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",
|
||||
"show stats": "Show server statistics",
|
||||
"show listeners": "Show bound sockets",
|
||||
"worker add [N]": "Spawn N workers (default 1)",
|
||||
"worker remove [N]": "Remove N workers (default 1)",
|
||||
"worker kill <PID>": "Gracefully terminate specific worker",
|
||||
"dirty add [N]": "Spawn N dirty workers (default 1)",
|
||||
"dirty remove [N]": "Remove N dirty workers (default 1)",
|
||||
"reload": "Graceful reload (HUP)",
|
||||
"reopen": "Reopen log files (USR1)",
|
||||
"shutdown [graceful|quick]": "Shutdown server (TERM/INT)",
|
||||
"help": "Show this help message",
|
||||
}
|
||||
return {"commands": commands}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
#
|
||||
# This file is part of gunicorn released under the MIT license.
|
||||
# See the NOTICE for more information.
|
||||
|
||||
"""
|
||||
Control Socket Protocol
|
||||
|
||||
JSON-based protocol with length-prefixed framing for the control interface.
|
||||
|
||||
Message Format:
|
||||
+----------------+------------------+
|
||||
| Length (4B BE) | JSON Payload |
|
||||
+----------------+------------------+
|
||||
|
||||
Request Format:
|
||||
{"id": 1, "command": "show", "args": ["workers"]}
|
||||
|
||||
Response Format:
|
||||
{"id": 1, "status": "ok", "data": {...}}
|
||||
{"id": 1, "status": "error", "error": "message"}
|
||||
"""
|
||||
|
||||
import json
|
||||
import struct
|
||||
|
||||
|
||||
class ProtocolError(Exception):
|
||||
"""Protocol-level error."""
|
||||
|
||||
|
||||
class ControlProtocol:
|
||||
"""
|
||||
Protocol implementation for control socket communication.
|
||||
|
||||
Uses 4-byte big-endian length prefix followed by JSON payload.
|
||||
"""
|
||||
|
||||
# Maximum message size (16 MB)
|
||||
MAX_MESSAGE_SIZE = 16 * 1024 * 1024
|
||||
|
||||
@staticmethod
|
||||
def encode_message(data: dict) -> bytes:
|
||||
"""
|
||||
Encode a message for transmission.
|
||||
|
||||
Args:
|
||||
data: Dictionary to encode
|
||||
|
||||
Returns:
|
||||
Length-prefixed JSON bytes
|
||||
"""
|
||||
payload = json.dumps(data).encode('utf-8')
|
||||
length = struct.pack('>I', len(payload))
|
||||
return length + payload
|
||||
|
||||
@staticmethod
|
||||
def decode_message(data: bytes) -> dict:
|
||||
"""
|
||||
Decode a message from bytes.
|
||||
|
||||
Args:
|
||||
data: Raw bytes (length prefix + JSON payload)
|
||||
|
||||
Returns:
|
||||
Decoded dictionary
|
||||
"""
|
||||
if len(data) < 4:
|
||||
raise ProtocolError("Message too short")
|
||||
|
||||
length = struct.unpack('>I', data[:4])[0]
|
||||
if len(data) < 4 + length:
|
||||
raise ProtocolError("Incomplete message")
|
||||
|
||||
payload = data[4:4 + length]
|
||||
return json.loads(payload.decode('utf-8'))
|
||||
|
||||
@staticmethod
|
||||
def read_message(sock) -> dict:
|
||||
"""
|
||||
Read one message from a socket.
|
||||
|
||||
Args:
|
||||
sock: Socket to read from
|
||||
|
||||
Returns:
|
||||
Decoded message dictionary
|
||||
|
||||
Raises:
|
||||
ProtocolError: If message is malformed
|
||||
ConnectionError: If connection is closed
|
||||
"""
|
||||
# Read length prefix
|
||||
length_data = b''
|
||||
while len(length_data) < 4:
|
||||
chunk = sock.recv(4 - len(length_data))
|
||||
if not chunk:
|
||||
if not length_data:
|
||||
raise ConnectionError("Connection closed")
|
||||
raise ProtocolError("Incomplete length prefix")
|
||||
length_data += chunk
|
||||
|
||||
length = struct.unpack('>I', length_data)[0]
|
||||
|
||||
if length > ControlProtocol.MAX_MESSAGE_SIZE:
|
||||
raise ProtocolError(f"Message too large: {length}")
|
||||
|
||||
# Read payload
|
||||
payload_data = b''
|
||||
while len(payload_data) < length:
|
||||
chunk = sock.recv(min(length - len(payload_data), 65536))
|
||||
if not chunk:
|
||||
raise ProtocolError("Incomplete payload")
|
||||
payload_data += chunk
|
||||
|
||||
try:
|
||||
return json.loads(payload_data.decode('utf-8'))
|
||||
except json.JSONDecodeError as e:
|
||||
raise ProtocolError(f"Invalid JSON: {e}")
|
||||
|
||||
@staticmethod
|
||||
def write_message(sock, data: dict):
|
||||
"""
|
||||
Write one message to a socket.
|
||||
|
||||
Args:
|
||||
sock: Socket to write to
|
||||
data: Message dictionary to send
|
||||
"""
|
||||
message = ControlProtocol.encode_message(data)
|
||||
sock.sendall(message)
|
||||
|
||||
@staticmethod
|
||||
async def read_message_async(reader) -> dict:
|
||||
"""
|
||||
Read one message from an async reader.
|
||||
|
||||
Args:
|
||||
reader: asyncio StreamReader
|
||||
|
||||
Returns:
|
||||
Decoded message dictionary
|
||||
"""
|
||||
# Read length prefix
|
||||
length_data = await reader.readexactly(4)
|
||||
length = struct.unpack('>I', length_data)[0]
|
||||
|
||||
if length > ControlProtocol.MAX_MESSAGE_SIZE:
|
||||
raise ProtocolError(f"Message too large: {length}")
|
||||
|
||||
# Read payload
|
||||
payload_data = await reader.readexactly(length)
|
||||
|
||||
try:
|
||||
return json.loads(payload_data.decode('utf-8'))
|
||||
except json.JSONDecodeError as e:
|
||||
raise ProtocolError(f"Invalid JSON: {e}")
|
||||
|
||||
@staticmethod
|
||||
async def write_message_async(writer, data: dict):
|
||||
"""
|
||||
Write one message to an async writer.
|
||||
|
||||
Args:
|
||||
writer: asyncio StreamWriter
|
||||
data: Message dictionary to send
|
||||
"""
|
||||
message = ControlProtocol.encode_message(data)
|
||||
writer.write(message)
|
||||
await writer.drain()
|
||||
|
||||
|
||||
def make_request(request_id: int, command: str, args: list = None) -> dict:
|
||||
"""
|
||||
Create a request message.
|
||||
|
||||
Args:
|
||||
request_id: Unique request identifier
|
||||
command: Command name (e.g., "show workers")
|
||||
args: Optional list of arguments
|
||||
|
||||
Returns:
|
||||
Request dictionary
|
||||
"""
|
||||
return {
|
||||
"id": request_id,
|
||||
"command": command,
|
||||
"args": args or [],
|
||||
}
|
||||
|
||||
|
||||
def make_response(request_id: int, data: dict = None) -> dict:
|
||||
"""
|
||||
Create a success response message.
|
||||
|
||||
Args:
|
||||
request_id: Request identifier being responded to
|
||||
data: Response data
|
||||
|
||||
Returns:
|
||||
Response dictionary
|
||||
"""
|
||||
return {
|
||||
"id": request_id,
|
||||
"status": "ok",
|
||||
"data": data or {},
|
||||
}
|
||||
|
||||
|
||||
def make_error_response(request_id: int, error: str) -> dict:
|
||||
"""
|
||||
Create an error response message.
|
||||
|
||||
Args:
|
||||
request_id: Request identifier being responded to
|
||||
error: Error message
|
||||
|
||||
Returns:
|
||||
Error response dictionary
|
||||
"""
|
||||
return {
|
||||
"id": request_id,
|
||||
"status": "error",
|
||||
"error": error,
|
||||
}
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
#
|
||||
# This file is part of gunicorn released under the MIT license.
|
||||
# See the NOTICE for more information.
|
||||
|
||||
"""
|
||||
Control Socket Server
|
||||
|
||||
Runs in the arbiter process and accepts commands via Unix socket.
|
||||
Uses asyncio in a background thread to handle client connections.
|
||||
|
||||
Fork Safety:
|
||||
This server uses os.register_at_fork() to properly handle fork() calls.
|
||||
Before fork: the asyncio thread is stopped to prevent lock issues.
|
||||
After fork in parent: the server is restarted.
|
||||
After fork in child: references are cleared (workers don't need the control server).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shlex
|
||||
import threading
|
||||
|
||||
from gunicorn.ctl.handlers import CommandHandlers
|
||||
from gunicorn.ctl.protocol import (
|
||||
ControlProtocol,
|
||||
make_response,
|
||||
make_error_response,
|
||||
)
|
||||
|
||||
|
||||
# Module-level tracking of active control server instances for fork handling.
|
||||
# This is necessary because os.register_at_fork() callbacks are process-level.
|
||||
_active_servers = set()
|
||||
_module_state = {"fork_handlers_registered": False}
|
||||
|
||||
|
||||
def _register_fork_handlers():
|
||||
"""Register fork handlers once at module level."""
|
||||
if _module_state["fork_handlers_registered"]:
|
||||
return
|
||||
_module_state["fork_handlers_registered"] = True
|
||||
|
||||
os.register_at_fork(
|
||||
before=_before_fork,
|
||||
after_in_parent=_after_fork_parent,
|
||||
after_in_child=_after_fork_child,
|
||||
)
|
||||
|
||||
|
||||
def _before_fork():
|
||||
"""Called before fork() - stop all active control servers."""
|
||||
for server in list(_active_servers):
|
||||
server._stop_for_fork()
|
||||
|
||||
|
||||
def _after_fork_parent():
|
||||
"""Called in parent after fork() - restart all control servers."""
|
||||
for server in list(_active_servers):
|
||||
server._restart_after_fork()
|
||||
|
||||
|
||||
def _after_fork_child():
|
||||
"""Called in child after fork() - cleanup references."""
|
||||
# In the child process (worker), we don't need the control server.
|
||||
# Just clear the references without trying to stop anything.
|
||||
_active_servers.clear()
|
||||
|
||||
|
||||
class ControlSocketServer:
|
||||
"""
|
||||
Control socket server running in arbiter process.
|
||||
|
||||
The server runs an asyncio event loop in a background thread,
|
||||
accepting connections and dispatching commands to handlers.
|
||||
|
||||
Fork safety is handled via os.register_at_fork() - the server
|
||||
automatically stops before fork and restarts after in the parent.
|
||||
"""
|
||||
|
||||
def __init__(self, arbiter, socket_path, socket_mode=0o600):
|
||||
"""
|
||||
Initialize control socket server.
|
||||
|
||||
Args:
|
||||
arbiter: The Gunicorn arbiter instance
|
||||
socket_path: Path for the Unix socket
|
||||
socket_mode: Permission mode for socket (default 0o600)
|
||||
"""
|
||||
self.arbiter = arbiter
|
||||
self.socket_path = socket_path
|
||||
self.socket_mode = socket_mode
|
||||
|
||||
self.handlers = CommandHandlers(arbiter)
|
||||
self._server = None
|
||||
self._loop = None
|
||||
self._thread = None
|
||||
self._running = False
|
||||
self._was_running_before_fork = False
|
||||
# Set by the loop thread once self._loop and self._server are live.
|
||||
# The stop paths wait on it so a shutdown is never scheduled against a
|
||||
# not-yet-initialized loop (which would leak the thread + its fds).
|
||||
self._ready = threading.Event()
|
||||
|
||||
# Ensure fork handlers are registered
|
||||
_register_fork_handlers()
|
||||
|
||||
def start(self):
|
||||
"""Start server in background thread with asyncio event loop."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._ready = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
# Track this server for fork handling
|
||||
_active_servers.add(self)
|
||||
|
||||
def stop(self):
|
||||
"""Stop server and cleanup socket."""
|
||||
# Remove from active servers tracking
|
||||
_active_servers.discard(self)
|
||||
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
|
||||
# Wait until the loop is live so the shutdown is actually delivered.
|
||||
self._ready.wait(timeout=2.0)
|
||||
if self._loop is not None:
|
||||
# Schedule server close in the loop
|
||||
self._loop.call_soon_threadsafe(self._shutdown)
|
||||
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
|
||||
# Clean up socket file
|
||||
if os.path.exists(self.socket_path):
|
||||
try:
|
||||
os.unlink(self.socket_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _stop_for_fork(self):
|
||||
"""Stop server before fork (called by fork handler)."""
|
||||
if not self._running:
|
||||
self._was_running_before_fork = False
|
||||
return
|
||||
|
||||
self._was_running_before_fork = True
|
||||
self._running = False
|
||||
|
||||
# Wait until the loop is live before scheduling shutdown. Without this,
|
||||
# a fork that lands while the thread is still starting up skips the
|
||||
# shutdown, join() times out, and the thread is dropped while still
|
||||
# holding its selector fd and the unix socket (the #3648 leak).
|
||||
self._ready.wait(timeout=2.0)
|
||||
if self._loop is not None:
|
||||
try:
|
||||
self._loop.call_soon_threadsafe(self._shutdown)
|
||||
except RuntimeError:
|
||||
# Loop may already be closed
|
||||
pass
|
||||
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
if self._thread.is_alive() and self.arbiter.log:
|
||||
self.arbiter.log.warning(
|
||||
"control socket thread did not stop before fork")
|
||||
self._thread = None
|
||||
|
||||
self._loop = None
|
||||
self._server = None
|
||||
|
||||
def _restart_after_fork(self):
|
||||
"""Restart server in parent after fork (called by fork handler)."""
|
||||
if not self._was_running_before_fork:
|
||||
return
|
||||
|
||||
self._was_running_before_fork = False
|
||||
self._running = True
|
||||
self._ready = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _shutdown(self):
|
||||
"""Shutdown server (called from event loop thread)."""
|
||||
if self._server:
|
||||
self._server.close()
|
||||
|
||||
def _run_loop(self):
|
||||
"""Run the asyncio event loop in background thread."""
|
||||
try:
|
||||
asyncio.run(self._serve())
|
||||
except Exception as e:
|
||||
if self._running and self.arbiter.log:
|
||||
self.arbiter.log.error("Control server error: %s", e)
|
||||
finally:
|
||||
# Release any stop path waiting on readiness, even if the thread
|
||||
# never became ready (eg the server failed to bind).
|
||||
self._ready.set()
|
||||
|
||||
async def _serve(self):
|
||||
"""Main async server loop."""
|
||||
self._loop = asyncio.get_running_loop()
|
||||
|
||||
# Create parent directory if needed (for ~/.gunicorn/)
|
||||
socket_dir = os.path.dirname(self.socket_path)
|
||||
if socket_dir and not os.path.exists(socket_dir):
|
||||
os.makedirs(socket_dir, mode=0o700)
|
||||
|
||||
# Remove socket if it exists
|
||||
if os.path.exists(self.socket_path):
|
||||
os.unlink(self.socket_path)
|
||||
|
||||
# Create Unix socket server
|
||||
self._server = await asyncio.start_unix_server(
|
||||
self._handle_client,
|
||||
path=self.socket_path
|
||||
)
|
||||
|
||||
# Set socket permissions
|
||||
os.chmod(self.socket_path, self.socket_mode)
|
||||
|
||||
if self.arbiter.log:
|
||||
self.arbiter.log.info("Control socket listening at %s",
|
||||
self.socket_path)
|
||||
|
||||
# Loop and server are live; let the stop paths schedule shutdown.
|
||||
self._ready.set()
|
||||
|
||||
try:
|
||||
async with self._server:
|
||||
await self._server.serve_forever()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
if os.path.exists(self.socket_path):
|
||||
try:
|
||||
os.unlink(self.socket_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def _handle_client(self, reader, writer):
|
||||
"""
|
||||
Handle client connection.
|
||||
|
||||
Args:
|
||||
reader: asyncio StreamReader
|
||||
writer: asyncio StreamWriter
|
||||
"""
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
message = await asyncio.wait_for(
|
||||
ControlProtocol.read_message_async(reader),
|
||||
timeout=300.0 # 5 minute idle timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# Client idle too long, close connection
|
||||
break
|
||||
except asyncio.IncompleteReadError:
|
||||
# Client disconnected
|
||||
break
|
||||
except Exception:
|
||||
# Protocol error
|
||||
break
|
||||
|
||||
# Process command
|
||||
response = await self._dispatch(message)
|
||||
|
||||
# Send response
|
||||
await ControlProtocol.write_message_async(writer, response)
|
||||
|
||||
except Exception as e:
|
||||
if self.arbiter.log:
|
||||
self.arbiter.log.debug("Control client error: %s", e)
|
||||
finally:
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _dispatch(self, message: dict) -> dict:
|
||||
"""
|
||||
Dispatch command to appropriate handler.
|
||||
|
||||
Args:
|
||||
message: Request message dict
|
||||
|
||||
Returns:
|
||||
Response dictionary
|
||||
"""
|
||||
request_id = message.get("id", 0)
|
||||
command = message.get("command", "").strip()
|
||||
args = message.get("args", [])
|
||||
|
||||
if not command:
|
||||
return make_error_response(request_id, "Empty command")
|
||||
|
||||
try:
|
||||
# Parse command (e.g., "show workers" or "worker add 2")
|
||||
parts = shlex.split(command)
|
||||
if args:
|
||||
parts.extend(str(a) for a in args)
|
||||
|
||||
if not parts:
|
||||
return make_error_response(request_id, "Empty command")
|
||||
|
||||
# Route to handler
|
||||
result = self._execute_command(parts)
|
||||
return make_response(request_id, result)
|
||||
|
||||
except ValueError as e:
|
||||
return make_error_response(request_id, f"Invalid argument: {e}")
|
||||
except Exception as e:
|
||||
if self.arbiter.log:
|
||||
self.arbiter.log.exception("Command error")
|
||||
return make_error_response(request_id, f"Command failed: {e}")
|
||||
|
||||
def _execute_command(self, parts: list) -> dict: # pylint: disable=too-many-return-statements
|
||||
"""
|
||||
Execute a parsed command.
|
||||
|
||||
Args:
|
||||
parts: Command parts (e.g., ["show", "workers"])
|
||||
|
||||
Returns:
|
||||
Handler result dictionary
|
||||
"""
|
||||
if not parts:
|
||||
raise ValueError("Empty command")
|
||||
|
||||
cmd = parts[0].lower()
|
||||
rest = parts[1:]
|
||||
|
||||
# Map commands to handlers
|
||||
if cmd == "show":
|
||||
return self._handle_show(rest)
|
||||
elif cmd == "worker":
|
||||
return self._handle_worker(rest)
|
||||
elif cmd == "dirty":
|
||||
return self._handle_dirty(rest)
|
||||
elif cmd == "reload":
|
||||
return self.handlers.reload()
|
||||
elif cmd == "reopen":
|
||||
return self.handlers.reopen()
|
||||
elif cmd == "shutdown":
|
||||
mode = rest[0] if rest else "graceful"
|
||||
return self.handlers.shutdown(mode)
|
||||
elif cmd == "help":
|
||||
return self.handlers.help()
|
||||
else:
|
||||
raise ValueError(f"Unknown command: {cmd}")
|
||||
|
||||
def _handle_show(self, args: list) -> dict:
|
||||
"""Handle 'show' commands."""
|
||||
if not args:
|
||||
raise ValueError("Missing show target (all|workers|dirty|config|stats|listeners)")
|
||||
|
||||
target = args[0].lower()
|
||||
|
||||
if target == "all":
|
||||
return self.handlers.show_all()
|
||||
elif target == "workers":
|
||||
return self.handlers.show_workers()
|
||||
elif target == "dirty":
|
||||
return self.handlers.show_dirty()
|
||||
elif target == "config":
|
||||
return self.handlers.show_config()
|
||||
elif target == "stats":
|
||||
return self.handlers.show_stats()
|
||||
elif target == "listeners":
|
||||
return self.handlers.show_listeners()
|
||||
else:
|
||||
raise ValueError(f"Unknown show target: {target}")
|
||||
|
||||
def _handle_worker(self, args: list) -> dict:
|
||||
"""Handle 'worker' commands."""
|
||||
if not args:
|
||||
raise ValueError("Missing worker action (add|remove|kill)")
|
||||
|
||||
action = args[0].lower()
|
||||
action_args = args[1:]
|
||||
|
||||
if action == "add":
|
||||
count = int(action_args[0]) if action_args else 1
|
||||
return self.handlers.worker_add(count)
|
||||
elif action == "remove":
|
||||
count = int(action_args[0]) if action_args else 1
|
||||
return self.handlers.worker_remove(count)
|
||||
elif action == "kill":
|
||||
if not action_args:
|
||||
raise ValueError("Missing PID for worker kill")
|
||||
pid = int(action_args[0])
|
||||
return self.handlers.worker_kill(pid)
|
||||
else:
|
||||
raise ValueError(f"Unknown worker action: {action}")
|
||||
|
||||
def _handle_dirty(self, args: list) -> dict:
|
||||
"""Handle 'dirty' commands."""
|
||||
if not args:
|
||||
raise ValueError("Missing dirty action (add|remove)")
|
||||
|
||||
action = args[0].lower()
|
||||
action_args = args[1:]
|
||||
|
||||
if action == "add":
|
||||
count = int(action_args[0]) if action_args else 1
|
||||
return self.handlers.dirty_add(count)
|
||||
elif action == "remove":
|
||||
count = int(action_args[0]) if action_args else 1
|
||||
return self.handlers.dirty_remove(count)
|
||||
else:
|
||||
raise ValueError(f"Unknown dirty action: {action}")
|
||||
Reference in New Issue
Block a user