diff --git a/docs/content/reference/settings.md b/docs/content/reference/settings.md index 84ddd16c..fc2474c5 100644 --- a/docs/content/reference/settings.md +++ b/docs/content/reference/settings.md @@ -54,7 +54,9 @@ 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. @@ -62,9 +64,9 @@ 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. diff --git a/gunicorn/config.py b/gunicorn/config.py index 997a9830..e3f37913 100644 --- a/gunicorn/config.py +++ b/gunicorn/config.py @@ -3128,13 +3128,32 @@ class DirtyWorkerExit(Setting): # 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. @@ -3142,9 +3161,9 @@ class ControlSocket(Setting): ``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. diff --git a/gunicorn/ctl/cli.py b/gunicorn/ctl/cli.py index 6e8f0783..b63d2380 100644 --- a/gunicorn/ctl/cli.py +++ b/gunicorn/ctl/cli.py @@ -13,6 +13,7 @@ import json import os import sys +from gunicorn.config import _get_default_control_socket from gunicorn.ctl.client import ControlClient, ControlClientError, parse_command @@ -405,8 +406,8 @@ Examples: 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( diff --git a/gunicorn/ctl/server.py b/gunicorn/ctl/server.py index 0fd24ee3..af490265 100644 --- a/gunicorn/ctl/server.py +++ b/gunicorn/ctl/server.py @@ -187,6 +187,11 @@ class ControlSocketServer: """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) diff --git a/tests/ctl/test_server.py b/tests/ctl/test_server.py index dc70e54e..9170f295 100644 --- a/tests/ctl/test_server.py +++ b/tests/ctl/test_server.py @@ -361,3 +361,59 @@ class TestControlSocketServerPermissions: 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() diff --git a/tests/test_config.py b/tests/test_config.py index 7ec3d8e7..b34cccbc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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 @@ -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')