Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/content/reference/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,19 @@ A WSGI application path in pattern ``$(MODULE_NAME):$(VARIABLE_NAME)``.

**Command line:** `--control-socket PATH`

**Default:** `'/run/gunicorn.ctl'`
**Default:**

$XDG_RUNTIME_DIR/gunicorn.ctl or $HOME/.gunicorn/gunicorn.ctl

Unix socket path for control interface.

The control socket allows runtime management of Gunicorn via the
``gunicornc`` command-line tool. Commands include viewing worker
status, adjusting worker count, and graceful reload/shutdown.

By default, creates ``/run/gunicorn.ctl`` (requires write access to
``/run``). For user-level deployments, specify a different path such
as ``/tmp/gunicorn.ctl`` or ``~/.gunicorn.ctl``.
Default: ``$XDG_RUNTIME_DIR/gunicorn.ctl`` if XDG_RUNTIME_DIR is set,
otherwise ``$HOME/.gunicorn/gunicorn.ctl``. The parent directory is
created automatically if needed.

Use ``--no-control-socket`` to disable.

Expand Down
27 changes: 23 additions & 4 deletions gunicorn/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3128,23 +3128,42 @@ def dirty_worker_exit(arbiter, worker):

# Control Socket Settings


def _get_default_control_socket():
"""Get default control socket path based on available directories.

Prefers XDG_RUNTIME_DIR if available (standard on Linux, sometimes BSD),
falls back to $HOME/.gunicorn/ directory.
"""
# Prefer XDG_RUNTIME_DIR if available
xdg_runtime = os.environ.get('XDG_RUNTIME_DIR')
if xdg_runtime and os.path.isdir(xdg_runtime):
return os.path.join(xdg_runtime, 'gunicorn.ctl')

# Fall back to $HOME/.gunicorn/
home = os.path.expanduser('~')
gunicorn_dir = os.path.join(home, '.gunicorn')
return os.path.join(gunicorn_dir, 'gunicorn.ctl')


class ControlSocket(Setting):
name = "control_socket"
section = "Control"
cli = ["--control-socket"]
meta = "PATH"
validator = validate_string
default = "/run/gunicorn.ctl"
default = _get_default_control_socket()
default_doc = "$XDG_RUNTIME_DIR/gunicorn.ctl or $HOME/.gunicorn/gunicorn.ctl"
desc = """\
Unix socket path for control interface.

The control socket allows runtime management of Gunicorn via the
``gunicornc`` command-line tool. Commands include viewing worker
status, adjusting worker count, and graceful reload/shutdown.

By default, creates ``/run/gunicorn.ctl`` (requires write access to
``/run``). For user-level deployments, specify a different path such
as ``/tmp/gunicorn.ctl`` or ``~/.gunicorn.ctl``.
Default: ``$XDG_RUNTIME_DIR/gunicorn.ctl`` if XDG_RUNTIME_DIR is set,
otherwise ``$HOME/.gunicorn/gunicorn.ctl``. The parent directory is
created automatically if needed.

Use ``--no-control-socket`` to disable.

Expand Down
5 changes: 3 additions & 2 deletions gunicorn/ctl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import os
import sys

from gunicorn.config import _get_default_control_socket
from gunicorn.ctl.client import ControlClient, ControlClientError, parse_command


Expand Down Expand Up @@ -405,8 +406,8 @@ def main():

parser.add_argument(
'-s', '--socket',
default='gunicorn.ctl',
help='Control socket path (default: gunicorn.ctl in current directory)'
default=_get_default_control_socket(),
help='Control socket path (default: auto-detected based on XDG_RUNTIME_DIR or ~/.gunicorn/)'
)

parser.add_argument(
Expand Down
5 changes: 5 additions & 0 deletions gunicorn/ctl/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ 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)
Expand Down
56 changes: 56 additions & 0 deletions tests/ctl/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,59 @@ def test_socket_permissions(self):
assert mode == 0o660
finally:
server.stop()


class TestControlSocketServerDirectoryCreation:
"""Tests for automatic directory creation."""

def test_creates_parent_directory(self):
"""Test that server creates parent directory if it doesn't exist."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create a path with a non-existent subdirectory
subdir = os.path.join(tmpdir, '.gunicorn')
socket_path = os.path.join(subdir, 'gunicorn.ctl')

assert not os.path.exists(subdir)

arbiter = MockArbiter()
server = ControlSocketServer(arbiter, socket_path)

server.start()

# Wait for socket to exist
for _ in range(50):
if os.path.exists(socket_path):
break
time.sleep(0.1)

try:
# Directory should have been created
assert os.path.isdir(subdir)
# Directory should have restricted permissions (0o700)
mode = os.stat(subdir).st_mode & 0o777
assert mode == 0o700
# Socket should exist
assert os.path.exists(socket_path)
finally:
server.stop()

def test_works_with_existing_directory(self):
"""Test that server works when parent directory already exists."""
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, 'gunicorn.ctl')

arbiter = MockArbiter()
server = ControlSocketServer(arbiter, socket_path)

server.start()

# Wait for socket to exist
for _ in range(50):
if os.path.exists(socket_path):
break
time.sleep(0.1)

try:
assert os.path.exists(socket_path)
finally:
server.stop()
38 changes: 38 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
import os
import re
import sys
import tempfile

import pytest

from gunicorn import config
from gunicorn.config import _get_default_control_socket
from gunicorn.app.base import Application
from gunicorn.app.wsgiapp import WSGIApplication
from gunicorn.errors import ConfigError
Expand Down Expand Up @@ -551,3 +553,39 @@ def test_str():
assert False, 'missing expected setting lines? {}'.format(
OUTPUT_MATCH.keys()
)


# Tests for _get_default_control_socket

class TestGetDefaultControlSocket:
"""Tests for the _get_default_control_socket function."""

def test_uses_xdg_runtime_dir_when_set_and_exists(self, monkeypatch):
"""When XDG_RUNTIME_DIR is set and exists, use it."""
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.setenv('XDG_RUNTIME_DIR', tmpdir)
result = _get_default_control_socket()
assert result == os.path.join(tmpdir, 'gunicorn.ctl')

def test_falls_back_when_xdg_runtime_dir_not_exists(self, monkeypatch):
"""When XDG_RUNTIME_DIR is set but doesn't exist, fall back to home."""
monkeypatch.setenv('XDG_RUNTIME_DIR', '/nonexistent/path/that/does/not/exist')
monkeypatch.setenv('HOME', '/home/testuser')
result = _get_default_control_socket()
assert result == '/home/testuser/.gunicorn/gunicorn.ctl'

def test_falls_back_when_xdg_runtime_dir_not_set(self, monkeypatch):
"""When XDG_RUNTIME_DIR is not set, use home directory."""
monkeypatch.delenv('XDG_RUNTIME_DIR', raising=False)
monkeypatch.setenv('HOME', '/home/testuser')
result = _get_default_control_socket()
assert result == '/home/testuser/.gunicorn/gunicorn.ctl'

def test_uses_home_directory_structure(self, monkeypatch):
"""Verify the path structure uses .gunicorn subdirectory."""
monkeypatch.delenv('XDG_RUNTIME_DIR', raising=False)
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.setenv('HOME', tmpdir)
result = _get_default_control_socket()
assert result == os.path.join(tmpdir, '.gunicorn', 'gunicorn.ctl')
assert result.endswith('.gunicorn/gunicorn.ctl')