Explorar el Código

[backend] Refactor Gunicorn Server Architecture with Integrated Log Listener
This PR refactors the Gunicorn server implementation by extracting functionality into separate utility modules and introducing an integrated log listener system for improved logging management and process monitoring.

Key Changes
- Code Organization & Modularity
- Integrated Log Listener System
- Thread-safe log listener (LogListenerThread) with graceful shutdown capabilities
- Configurable socket path via new LOG_LISTENER_SOCKET_NAME configuration option
- Automatic socket cleanup and directory creation with proper permissions
- Enhanced Process Management
- Comprehensive signal handling for SIGTERM, SIGHUP, SIGINT, and SIGQUIT
- Process recovery system with periodic recovery file updates for post-SIGKILL cleanup
- Configuration Enhancements
- Added LOG_LISTENER_SOCKET_NAME configuration parameter in desktop/conf.py

(cherry picked from commit f077a28ddb3ac165841a1a616b7006ab13190327)

Prakash Ranade hace 2 meses
padre
commit
b58500899f

+ 3 - 0
desktop/conf.dist/hue.ini

@@ -78,6 +78,9 @@ http_500_debug_mode=false
 # Workers still alive after the timeout (starting from the receipt of the restart signal) are force killed.
 # gunicorn_worker_graceful_timeout=900
 
+# Name of the Unix Domain Socket file for log listener communication.
+## log_listener_socket_name=hue.uds
+
 # Webserver runs as this user
 ## server_user=hue
 ## server_group=hue

+ 3 - 0
desktop/conf/pseudo-distributed.ini.tmpl

@@ -83,6 +83,9 @@
   # Workers still alive after the timeout (starting from the receipt of the restart signal) are force killed.
   # gunicorn_worker_graceful_timeout=900
 
+  # Name of the Unix Domain Socket file for log listener communication.
+  ## log_listener_socket_name=hue.uds
+
   # Webserver runs as this user
   ## server_user=hue
   ## server_group=hue

+ 7 - 0
desktop/core/src/desktop/conf.py

@@ -201,6 +201,13 @@ GUNICORN_WORKER_GRACEFUL_TIMEOUT = Config(
   type=int,
   default=900)
 
+
+LOG_LISTENER_SOCKET_NAME = Config(
+  key="log_listener_socket_name",
+  help=_("Name of the Unix Domain Socket file for log listener communication."),
+  type=str,
+  default="hue.uds")
+
 HTTP_HOST = Config(
   key="http_host",
   help=_("HTTP host to bind to."),

+ 316 - 0
desktop/core/src/desktop/lib/gunicorn_cleanup_utils.py

@@ -0,0 +1,316 @@
+#!/usr/bin/env python
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Gunicorn Process Cleanup Utilities
+
+This module contains utilities for cleaning up orphaned processes and handling
+recovery from SIGKILL events in Gunicorn environments.
+"""
+
+import json
+import logging
+import os
+
+
+def write_process_recovery_file(main_pid=None, socket_path=None, pid_file=None):
+    """
+    Write process information to a recovery file for post-SIGKILL cleanup
+    """
+    try:
+        import psutil
+
+        recovery_file = os.path.join(os.getenv("DESKTOP_LOG_DIR", "/var/log/hue"), "hue_recovery.json")
+
+        if main_pid is None:
+            current_process = psutil.Process()
+            main_pid = current_process.pid
+        else:
+            current_process = psutil.Process(main_pid)
+
+        process_info = {
+            "main_pid": main_pid,
+            "start_time": current_process.create_time(),
+            "socket_path": socket_path,
+            "pid_file": pid_file,
+            "children": []
+        }
+
+        # Record all child processes
+        for child in current_process.children(recursive=True):
+            try:
+                process_info["children"].append({
+                    "pid": child.pid,
+                    "name": child.name(),
+                    "cmdline": child.cmdline(),
+                    "start_time": child.create_time()
+                })
+            except (psutil.NoSuchProcess, psutil.AccessDenied):
+                continue
+
+        with open(recovery_file, 'w') as f:
+            json.dump(process_info, f, indent=2)
+
+        logging.debug(f"Written recovery file: {recovery_file}")
+
+    except Exception as e:
+        logging.error(f"Error writing recovery file: {e}")
+
+
+def cleanup_from_recovery_file():
+    """
+    Clean up processes using information from recovery file (for post-SIGKILL cleanup)
+    """
+    try:
+        import psutil
+
+        recovery_file = os.path.join(os.getenv("DESKTOP_LOG_DIR", "/var/log/hue"), "hue_recovery.json")
+
+        if not os.path.exists(recovery_file):
+            logging.info("No recovery file found")
+            return
+
+        with open(recovery_file, 'r') as f:
+            process_info = json.load(f)
+
+        logging.info(f"Processing recovery file for main PID {process_info['main_pid']}")
+
+        # Check if main process is still running
+        try:
+            main_proc = psutil.Process(process_info['main_pid'])
+            if main_proc.is_running():
+                logging.info(f"Main process {process_info['main_pid']} is still running, skipping cleanup")
+                return
+        except psutil.NoSuchProcess:
+            logging.info(f"Main process {process_info['main_pid']} is no longer running, proceeding with cleanup")
+
+        # Clean up child processes
+        cleaned_count = 0
+        for child_info in process_info['children']:
+            try:
+                child_proc = psutil.Process(child_info['pid'])
+                if child_proc.is_running():
+                    # Verify it's the same process (check start time)
+                    if abs(child_proc.create_time() - child_info['start_time']) < 1.0:
+                        logging.info(f"Terminating orphaned child process: PID {child_info['pid']}, CMD: {' '.join(child_info['cmdline'])}")
+                        child_proc.terminate()
+                        try:
+                            child_proc.wait(timeout=5)
+                            cleaned_count += 1
+                        except psutil.TimeoutExpired:
+                            logging.warning(f"Force killing child process: PID {child_info['pid']}")
+                            child_proc.kill()
+                            cleaned_count += 1
+            except (psutil.NoSuchProcess, psutil.AccessDenied):
+                logging.info(f"Child process {child_info['pid']} is no longer running, proceeding with cleanup")
+                continue
+
+        # Clean up socket file
+        if process_info.get('socket_path') and os.path.exists(process_info['socket_path']):
+            try:
+                os.unlink(process_info['socket_path'])
+                logging.info(f"Cleaned up socket file: {process_info['socket_path']}")
+            except OSError as e:
+                logging.warning(f"Could not clean up socket file: {e}")
+
+        # Clean up PID file
+        if process_info.get('pid_file') and os.path.exists(process_info['pid_file']):
+            try:
+                os.unlink(process_info['pid_file'])
+                logging.info(f"Cleaned up PID file: {process_info['pid_file']}")
+            except OSError as e:
+                logging.warning(f"Could not clean up PID file: {e}")
+
+        # Remove recovery file
+        os.unlink(recovery_file)
+        logging.info(f"Recovery cleanup completed: {cleaned_count} processes cleaned up")
+
+    except Exception as e:
+        logging.error(f"Error during recovery cleanup: {e}")
+
+
+def cleanup_orphaned_processes():
+    """
+    Clean up any orphaned processes that might be left behind
+    """
+    try:
+        import psutil
+        current_pid = os.getpid()
+
+        # Look for processes that might be related to this Hue instance
+        for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
+            try:
+                if proc.info['pid'] == current_pid:
+                    continue
+
+                cmdline = ' '.join(proc.info['cmdline'] or [])
+
+                # Check if this looks like a related Hue process
+                if ('rungunicornserver' in cmdline or
+                    'hue' in proc.info['name'].lower() or
+                    ('python' in proc.info['name'].lower() and 'hue' in cmdline)):
+
+                    # Check if it's actually orphaned (parent is init or doesn't exist)
+                    try:
+                        parent = proc.parent()
+                        if parent is None or parent.pid == 1:
+                            logging.warning(f"Found orphaned process: PID {proc.info['pid']}, CMD: {cmdline}")
+                            proc.terminate()
+                            # Give it time to terminate gracefully
+                            try:
+                                proc.wait(timeout=5)
+                                logging.info(f"Successfully terminated orphaned process: PID {proc.info['pid']}")
+                            except psutil.TimeoutExpired:
+                                logging.warning(f"Force killing orphaned process: PID {proc.info['pid']}")
+                                proc.kill()
+                    except (psutil.NoSuchProcess, psutil.AccessDenied):
+                        continue
+
+            except (psutil.NoSuchProcess, psutil.AccessDenied, IndexError):
+                continue
+
+    except Exception as e:
+        logging.error(f"Error during orphaned process cleanup: {e}")
+
+
+def unified_cleanup():
+    """
+    Unified cleanup function that handles both recovery file cleanup and general orphan cleanup
+    """
+    logging.info("Starting unified cleanup process...")
+
+    # Step 1: Try recovery file cleanup first (more precise)
+    try:
+        recovery_file = os.path.join(os.getenv("DESKTOP_LOG_DIR", "/var/log/hue"), "hue_recovery.json")
+        if os.path.exists(recovery_file):
+            logging.info("Found recovery file, attempting recovery cleanup...")
+            cleanup_from_recovery_file()
+            return  # If recovery cleanup worked, we're done
+        else:
+            logging.info("No recovery file found, proceeding with general cleanup...")
+    except Exception as e:
+        logging.warning(f"Recovery cleanup failed: {e}, falling back to general cleanup...")
+
+    # Step 2: Fall back to general orphan cleanup
+    cleanup_orphaned_processes()
+
+
+def cleanup_recovery_file():
+    """
+    Clean up recovery file on normal shutdown
+    """
+    try:
+        recovery_file = os.path.join(os.getenv("DESKTOP_LOG_DIR", "/var/log/hue"), "hue_recovery.json")
+        if os.path.exists(recovery_file):
+            os.unlink(recovery_file)
+            logging.info("Cleaned up recovery file")
+    except Exception as e:
+        logging.warning(f"Could not clean up recovery file: {e}")
+
+
+def terminate_child_processes():
+    """
+    Terminate all child processes (gunicorn workers) with timeout and force-kill fallback
+    """
+    try:
+        import psutil
+        current_process = psutil.Process()
+        children = current_process.children(recursive=True)
+
+        if children:
+            logging.info(f"Terminating {len(children)} child processes...")
+            for child in children:
+                try:
+                    child.terminate()
+                except psutil.NoSuchProcess:
+                    pass
+
+            # Wait for children to terminate gracefully
+            gone, alive = psutil.wait_procs(children, timeout=10)
+
+            # Force kill any remaining processes
+            if alive:
+                logging.warning(f"Force killing {len(alive)} remaining child processes...")
+                for child in alive:
+                    try:
+                        child.kill()
+                    except psutil.NoSuchProcess:
+                        pass
+    except Exception as e:
+        logging.error(f"Error terminating child processes: {e}")
+
+
+def cleanup_pid_file(pid_file):
+    """
+    Clean up PID file
+    """
+    if pid_file and os.path.exists(pid_file):
+        try:
+            os.unlink(pid_file)
+            logging.info(f"Cleaned up PID file: {pid_file}")
+        except OSError as e:
+            logging.warning(f"Could not clean up PID file {pid_file}: {e}")
+
+
+def cleanup_socket_file(socket_path):
+    """
+    Clean up socket file
+    """
+    if socket_path and os.path.exists(socket_path):
+        try:
+            os.unlink(socket_path)
+            logging.info(f"Cleaned up socket file: {socket_path}")
+        except OSError as e:
+            logging.warning(f"Could not clean up socket file {socket_path}: {e}")
+
+
+def create_signal_handler(log_listener_thread, socket_path, pid_file):
+    """
+    Create a signal handler function with the necessary cleanup context
+    """
+    def signal_handler_with_log_listener(sig, frame):
+        """
+        Signal handler that properly shuts down both gunicorn and log listener
+        """
+        try:
+            logging.info(f"Received signal {sig}, shutting down log listener and server...")
+
+            # Stop log listener first
+            if log_listener_thread:
+                logging.info("Stopping log listener thread...")
+                log_listener_thread.stop()
+                log_listener_thread.join(timeout=5)  # Wait up to 5 seconds for clean shutdown
+                logging.info("Log listener thread stopped")
+        except Exception as e:
+            logging.error(f"Error stopping log listener: {e}")
+
+        # Clean up socket file
+        cleanup_socket_file(socket_path)
+
+        # Clean up PID file
+        cleanup_pid_file(pid_file)
+
+        # Terminate all child processes (gunicorn workers)
+        terminate_child_processes()
+
+        # Clean up recovery file on normal shutdown
+        cleanup_recovery_file()
+
+        logging.info("Graceful shutdown completed")
+        os._exit(0)  # Use os._exit to avoid cleanup handlers that might hang
+
+    return signal_handler_with_log_listener

+ 241 - 0
desktop/core/src/desktop/lib/gunicorn_log_utils.py

@@ -0,0 +1,241 @@
+#!/usr/bin/env python
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Gunicorn Log Listener Utilities
+
+This module contains the integrated log listener functionality for handling
+multiprocess logging in Gunicorn environments.
+"""
+
+import logging
+import os
+import pickle
+import re
+import select
+import socket
+import stat
+import struct
+import threading
+from io import StringIO as string_io
+from socketserver import StreamRequestHandler, ThreadingTCPServer
+
+from desktop import conf
+
+# Global variables for integrated log listener
+LOG_LISTENER_THREAD = None
+LOG_LISTENER_SERVER = None
+LOG_LISTENER_SOCKET_PATH = None
+
+
+class ConfigStreamHandler(StreamRequestHandler):
+    """
+    Handler for a streaming logging request.
+    This basically logs the record using whatever logging policy is
+    configured locally.
+    """
+    def handle(self):
+        """
+        Handle multiple requests - each expected to be a 4-byte length,
+        followed by the LogRecord in pickle format. Logs the record
+        according to whatever policy is configured locally.
+        """
+        while self.server.ready:
+            try:
+                chunk = self.connection.recv(4)
+                if len(chunk) < 4:
+                    break
+                slen = struct.unpack('>L', chunk)[0]
+                chunk = self.connection.recv(slen)
+                while len(chunk) < slen:
+                    chunk = chunk + self.connection.recv(slen - len(chunk))
+                obj = pickle.loads(chunk)
+                record = logging.makeLogRecord(obj)
+                self.handleLogRecord(record)
+            except Exception as e:
+                logging.debug(f"Error handling log record: {e}")
+                break
+
+    def handleLogRecord(self, record):
+        """Handle a single log record"""
+        # if a name is specified, we use the named logger rather than the one
+        # implied by the record.
+        if self.server.logname is not None:
+            name = self.server.logname
+        else:
+            name = record.name
+        logger = logging.getLogger(name)
+        logger.handle(record)
+
+
+class ConfigSocketReceiver(ThreadingTCPServer):
+    """
+    A simple TCP socket-based logging config receiver.
+    """
+    request_queue_size = 1
+
+    def __init__(self, server_address='hue.uds', handler=None,
+                 ready=None, verify=None):
+        ThreadingTCPServer.__init__(self, server_address, handler)
+        logging._acquireLock()
+        self.abort = 0
+        logging._releaseLock()
+        self.timeout = 1
+        self.ready = ready
+        self.verify = verify
+        self.logname = None
+        self.server_address = server_address
+
+    def server_bind(self):
+        """Create and bind Unix Domain Socket"""
+        self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+        self.socket.bind(self.server_address)
+        st = os.stat(self.server_address)
+        os.chmod(self.server_address, st.st_mode | stat.S_IWOTH)
+
+    def server_activate(self):
+        """Activate the server"""
+        self.socket.listen(self.request_queue_size)
+
+    def serve_until_stopped(self):
+        """Serve requests until stopped"""
+        abort = 0
+        while not abort:
+            rd, wr, ex = select.select([self.socket.fileno()],
+                           [], [],
+                           self.timeout)
+            if rd:
+                self.handle_request()
+            logging._acquireLock()
+            abort = self.abort
+            logging._releaseLock()
+        self.server_close()
+
+
+class LogListenerThread(threading.Thread):
+    """
+    Thread that runs the log listener server
+    """
+    def __init__(self, server_address, verify=None):
+        super(LogListenerThread, self).__init__()
+        self.server_address = server_address
+        self.verify = verify
+        self.ready = threading.Event()
+        self.daemon = True  # Daemon thread will be killed when main process exits
+
+    def run(self):
+        """Run the log listener server"""
+        global LOG_LISTENER_SERVER
+        server = ConfigSocketReceiver(server_address=self.server_address,
+                                      handler=ConfigStreamHandler,
+                                      ready=self.ready, verify=self.verify)
+        self.ready.set()
+        logging._acquireLock()
+        LOG_LISTENER_SERVER = server
+        logging._releaseLock()
+        server.serve_until_stopped()
+
+    def stop(self):
+        """Stop the log listener"""
+        stop_log_listener()
+        self.ready.clear()
+
+    def stopped(self):
+        """Check if the listener is stopped"""
+        return not self.ready.is_set()
+
+
+def stop_log_listener():
+    """
+    Stop the listening server which was created with start_log_listener().
+    """
+    global LOG_LISTENER_SERVER
+    logging._acquireLock()
+    try:
+        if LOG_LISTENER_SERVER:
+            LOG_LISTENER_SERVER.abort = 1
+            LOG_LISTENER_SERVER = None
+    finally:
+        logging._releaseLock()
+
+
+def start_log_listener(socket_path=None):
+    """
+    Start up a socket server on the specified unix domain socket, and listen for new
+    configurations from gunicorn worker processes.
+    """
+    global LOG_LISTENER_THREAD, LOG_LISTENER_SOCKET_PATH
+
+    if not socket_path:
+        socket_dir = os.getenv("DESKTOP_LOG_DIR", "/var/log/hue")
+        socket_name = conf.LOG_LISTENER_SOCKET_NAME.get()
+        socket_path = os.path.join(socket_dir, socket_name)
+
+    # Store socket path globally for signal handler access
+    LOG_LISTENER_SOCKET_PATH = socket_path
+
+    # Remove existing socket file if it exists
+    try:
+        os.unlink(socket_path)
+    except OSError:
+        if os.path.exists(socket_path):
+            logging.warning(f"Could not remove existing socket file: {socket_path}")
+
+    # Ensure the directory exists
+    socket_dir = os.path.dirname(socket_path)
+    if not os.path.exists(socket_dir):
+        try:
+            os.makedirs(socket_dir)
+            logging.info(f"Created log directory: {socket_dir}")
+        except OSError as e:
+            logging.error(f"Failed to create log directory {socket_dir}: {e}")
+            raise
+
+    # Configure logging for the listener
+    enable_log_listener_logging(socket_path)
+
+    # Start the listener thread
+    LOG_LISTENER_THREAD = LogListenerThread(server_address=socket_path, verify=None)
+    LOG_LISTENER_THREAD.start()
+
+    # Wait for the thread to be ready
+    LOG_LISTENER_THREAD.ready.wait()
+
+    logging.info(f"Log listener started on socket: {socket_path}")
+    return LOG_LISTENER_THREAD
+
+
+def enable_log_listener_logging(socket_path):
+    """
+    Configure logging for the log listener functionality
+    """
+    CONF_RE = re.compile('%LOG_DIR%')
+    CONF_FILE = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)),
+                                             '..', '..', '..', 'conf', 'gunicorn_log.conf'))
+    if os.path.exists(CONF_FILE):
+        log_dir = os.getenv("DESKTOP_LOG_DIR", "/var/log/hue")
+        raw = open(CONF_FILE).read()
+
+        def _repl(match):
+            if match.group(0) == '%LOG_DIR%':
+                return log_dir
+        sio = string_io(CONF_RE.sub(_repl, raw))
+        logging.config.fileConfig(sio)
+
+    root_logger = logging.getLogger()
+    root_logger.info("Starting integrated Hue Log Listener using socket file %s" % socket_path)
+    root_logger.info("Using logging.conf file %s" % CONF_FILE)

+ 367 - 0
desktop/core/src/desktop/lib/gunicorn_server_utils.py

@@ -0,0 +1,367 @@
+#!/usr/bin/env python
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Gunicorn Server Utilities
+
+This module contains utilities for configuring and running the Gunicorn server
+with proper SSL, worker management, and process handling.
+"""
+
+import logging
+import multiprocessing
+import os
+import ssl
+import sys
+import tempfile
+import time
+
+import gunicorn.app.base
+import pkg_resources
+import psutil
+import redis
+from django.core.wsgi import get_wsgi_application
+from django.db import connection
+from gunicorn import util
+from OpenSSL import crypto
+from six import iteritems
+
+import desktop.log
+from desktop import conf
+from desktop.lib.ip_utils import fetch_ipv6_bind_address
+from desktop.lib.paths import get_desktop_root
+from desktop.lib.tls_utils import get_tls_settings
+from filebrowser.utils import parse_broker_url
+
+
+def activate_translation():
+    """Activate Django translation"""
+    from django.conf import settings
+    from django.utils import translation
+
+    # Activate the current language, because it won't get activated later.
+    try:
+        translation.activate(settings.LANGUAGE_CODE)
+    except AttributeError:
+        pass
+
+
+def number_of_workers():
+    """Calculate the number of workers for Gunicorn"""
+    return (multiprocessing.cpu_count() * 2) + 1
+
+
+def handler_app(environ, start_response):
+    """WSGI application handler"""
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "desktop.settings")
+    return get_wsgi_application()
+
+
+def post_fork(server, worker):
+    """Post-fork worker initialization"""
+    global PID_FILE
+    with open(PID_FILE, "a") as f:
+        f.write("%s\n" % worker.pid)
+
+
+def post_worker_init(worker):
+    """Initialize worker after fork"""
+    connection.connect()
+
+
+def worker_int(worker):
+    """Worker interrupt handler"""
+    connection.close()
+
+
+def enable_logging(args, options):
+    """Enable basic logging for the process"""
+    HUE_DESKTOP_VERSION = pkg_resources.get_distribution("desktop").version or "Unknown"
+    # Start basic logging as soon as possible.
+    if "HUE_PROCESS_NAME" not in os.environ:
+        _proc = os.path.basename(len(sys.argv) > 1 and sys.argv[1] or sys.argv[0])
+        os.environ["HUE_PROCESS_NAME"] = _proc
+
+    desktop.log.basic_logging(os.environ["HUE_PROCESS_NAME"])
+    logging.info("Welcome to Hue from Gunicorn server " + HUE_DESKTOP_VERSION)
+
+
+def initialize_free_disk_space_in_redis():
+    """Initialize free disk space tracking in Redis"""
+    conn_success = False
+    for retries in range(5):
+        try:
+            redis_client = parse_broker_url(desktop.conf.TASK_SERVER_V2.BROKER_URL.get())
+            free_space = psutil.disk_usage('/tmp').free
+            available_space = redis_client.get('upload_available_space')
+            if available_space is None:
+                available_space = free_space
+            else:
+                available_space = int(available_space)
+            upload_keys_exist = any(redis_client.scan_iter('upload__*'))
+            redis_client.delete('upload_available_space')
+            if not upload_keys_exist:
+                redis_client.setnx('upload_available_space', free_space)
+            else:
+                redis_client.setnx('upload_available_space', min(free_space, available_space))
+            logging.info("Successfully initialized free disk space in Redis.")
+            conn_success = True
+            break
+        except redis.ConnectionError as e:
+            logging.error(f"Redis connection error: {e}")
+            time.sleep(10)
+        except Exception as e:
+            logging.error(f"Error while initializing free disk space in Redis: {e}")
+            time.sleep(10)
+    if not conn_success:
+        logging.error("Failed to initialize free disk space in Redis after 5 retries.")
+
+
+class StandaloneApplication(gunicorn.app.base.BaseApplication):
+    """Standalone Gunicorn application"""
+
+    def __init__(self, app, options=None):
+        self.options = options or {}
+        self.app_uri = 'desktop.wsgi:application'
+        super(StandaloneApplication, self).__init__()
+
+    def load_config(self):
+        config = dict([(key, value) for key, value in iteritems(self.options)
+                       if key in self.cfg.settings and value is not None])
+        for key, value in iteritems(config):
+            self.cfg.set(key.lower(), value)
+
+    def chdir(self):
+        # chdir to the configured path before loading,
+        # default is the current dir
+        os.chdir(self.cfg.chdir)
+
+        # add the path to sys.path
+        sys.path.insert(0, self.cfg.chdir)
+
+    def load_wsgiapp(self):
+        self.chdir()
+
+        # load the app
+        return util.import_app(self.app_uri)
+
+    def load(self):
+        return self.load_wsgiapp()
+
+
+def process_arguments(args=[], options={}):
+    """Process command line arguments and options"""
+    global PID_FILE
+
+    ipv6_enabled = conf.ENABLE_IPV6.get()  # This is already a bool
+
+    if options.get('bind'):
+        http_port = "8888"
+        bind_addr = options['bind']
+        if ":" in bind_addr:
+            http_port = bind_addr.split(":")[-1]  # Use last part in case of IPv6
+        PID_FILE = f"/tmp/hue_{http_port}.pid"
+    else:
+        http_host = conf.HTTP_HOST.get()
+        http_port = str(conf.HTTP_PORT.get())
+
+        if ipv6_enabled:
+            bind_addr = fetch_ipv6_bind_address(http_host, http_port)
+        else:
+            bind_addr = f"{http_host}:{http_port}"
+            logging.info(f"IPv6 disabled, using standard format: {bind_addr}")
+
+        PID_FILE = f"/tmp/hue_{http_port}.pid"
+
+    options['bind_addr'] = bind_addr
+    options['pid_file'] = PID_FILE  # Store PID file in options for return
+
+    # Currently gunicorn does not support passphrase suppored SSL Keyfile
+    # https://github.com/benoitc/gunicorn/issues/2410
+    ssl_keyfile = None
+
+    # HUE_RUN_DIR is set for env variable. If unset, fall back to previous behaviour (for base).
+    worker_tmp_dir = os.environ.get("HUE_RUN_DIR")
+    if not worker_tmp_dir:
+        worker_tmp_dir = os.environ.get("HUE_CONF_DIR", get_desktop_root("conf"))
+        if not worker_tmp_dir:
+            worker_tmp_dir = "/tmp"
+    options['worker_tmp_dir'] = worker_tmp_dir
+
+    # Gunicorn needs chown privileges if the OS user/group does not match Gunicorn's user/group.
+    # This can happen when the OS user is not in our control to set (OpenShift).
+    if not os.environ.get("GUNICORN_USE_OS_USER"):
+        options['user'] = conf.SERVER_USER.get()
+        options['group'] = conf.SERVER_GROUP.get()
+
+    if conf.SSL_CERTIFICATE.get() and conf.SSL_PRIVATE_KEY.get():
+        ssl_password = str.encode(conf.get_ssl_password()) if conf.get_ssl_password() is not None else None
+        if ssl_password:
+            with open(conf.SSL_PRIVATE_KEY.get(), 'r') as f:
+                with tempfile.NamedTemporaryFile(dir=worker_tmp_dir, delete=False) as tf:
+                    tf.write(crypto.dump_privatekey(crypto.FILETYPE_PEM,
+                                                    crypto.load_privatekey(crypto.FILETYPE_PEM,
+                                                                           f.read(), ssl_password)))
+                    ssl_keyfile = tf.name
+        else:
+            ssl_keyfile = conf.SSL_PRIVATE_KEY.get()
+    options['ssl_keyfile'] = ssl_keyfile
+
+    return options
+
+
+def create_gunicorn_options(options):
+    """Create Gunicorn configuration options"""
+    # Get TLS configuration
+    tls_settings = get_tls_settings()
+
+    def gunicorn_ssl_context(conf, default_ssl_context_factory):
+        """
+        Create and configure SSL context for Gunicorn based on TLS settings.
+        """
+        context = default_ssl_context_factory()
+
+        try:
+            # Validate TLS settings before applying
+            if "error" in tls_settings:
+                logging.warning(f"TLS configuration error: {tls_settings['error']}")
+                return context
+
+            # Configure maximum TLS version
+            max_version = tls_settings.get("tls_maximum_version")
+            if max_version == "TLSv1.3":
+                if hasattr(ssl, "HAS_TLSv1_3") and ssl.HAS_TLSv1_3 and hasattr(ssl, "TLSVersion"):
+                    context.maximum_version = ssl.TLSVersion.TLSv1_3
+                    logging.info("Set maximum TLS version to TLSv1.3")
+                else:
+                    logging.warning("TLS 1.3 requested but not supported by system, falling back to TLS 1.2")
+                    if hasattr(ssl, "TLSVersion"):
+                        context.maximum_version = ssl.TLSVersion.TLSv1_2
+            elif max_version == "TLSv1.2":
+                if hasattr(ssl, "TLSVersion"):
+                    context.maximum_version = ssl.TLSVersion.TLSv1_2
+                    logging.info("Set maximum TLS version to TLSv1.2")
+
+            # Configure minimum TLS version
+            min_version = tls_settings.get("tls_minimum_version")
+            if min_version == "TLSv1.3":
+                if hasattr(ssl, "HAS_TLSv1_3") and ssl.HAS_TLSv1_3 and hasattr(ssl, "TLSVersion"):
+                    context.minimum_version = ssl.TLSVersion.TLSv1_3
+                    logging.info("Set minimum TLS version to TLSv1.3")
+                else:
+                    logging.warning("TLS 1.3 minimum requested but not supported, falling back to TLS 1.2")
+                    if hasattr(ssl, "TLSVersion"):
+                        context.minimum_version = ssl.TLSVersion.TLSv1_2
+            elif min_version == "TLSv1.2":
+                if hasattr(ssl, "TLSVersion"):
+                    context.minimum_version = ssl.TLSVersion.TLSv1_2
+                    logging.info("Set minimum TLS version to TLSv1.2")
+
+            # Configure ciphers (only for TLS 1.2)
+            ciphers = tls_settings.get("ciphers", "")
+            if ciphers and ciphers.strip():  # Only set if ciphers is not empty
+                try:
+                    context.set_ciphers(ciphers)
+                    logging.info(f"Successfully configured ciphers: {ciphers}")
+                except ssl.SSLError as e:
+                    logging.error(f"Invalid cipher configuration '{ciphers}': {e}")
+                except Exception as e:
+                    logging.error(f"Unexpected error setting ciphers '{ciphers}': {e}")
+            elif max_version == "TLSv1.3":
+                logging.info("TLS 1.3 enabled - cipher configuration handled automatically")
+            else:
+                logging.info("No custom ciphers configured, using system defaults")
+
+        except Exception as e:
+            logging.error(f"Error configuring SSL context: {e}")
+            logging.info("Using default SSL context configuration")
+
+        return context
+
+    gunicorn_options = {
+        'accesslog': "-",
+        'access_log_format': "%({x-forwarded-for}i)s %(h)s %(l)s %(u)s %(t)s '%(r)s' %(s)s %(b)s '%(f)s' '%(a)s'",
+        'backlog': 2048,
+        'bind': [options['bind_addr']],
+        'ca_certs': conf.SSL_CACERTS.get(),     # CA certificates file
+        'capture_output': True,
+        'cert_reqs': None,                      # Whether client certificate is required (see stdlib ssl module)
+        'certfile': conf.SSL_CERTIFICATE.get(),  # SSL certificate file
+        'chdir': None,
+        'check_config': None,
+        'config': None,
+        'daemon': None,
+        'do_handshake_on_connect': False,       # Whether to perform SSL handshake on socket connect.
+        'enable_stdio_inheritance': None,
+        'errorlog': "-",
+        'forwarded_allow_ips': None,
+        'graceful_timeout': conf.GUNICORN_WORKER_GRACEFUL_TIMEOUT.get(),
+        'group': options.get('group'),
+        'initgroups': None,
+        'keepalive': 120,                       # seconds to wait for requests on a keep-alive connection.
+        'keyfile': options['ssl_keyfile'],      # SSL key file
+        'limit_request_field_size': conf.LIMIT_REQUEST_FIELD_SIZE.get(),
+        'limit_request_fields': conf.LIMIT_REQUEST_FIELDS.get(),
+        'limit_request_line': conf.LIMIT_REQUEST_LINE.get(),
+        'loglevel': 'DEBUG' if conf.DJANGO_DEBUG_MODE.get() else 'INFO',
+        'max_requests': 1200,                   # The maximum number of requests a worker will process before restarting.
+        'max_requests_jitter': 0,
+        'paste': None,
+        'pidfile': None,
+        'preload_app': False,
+        'proc_name': "hue",
+        'proxy_allow_ips': None,
+        'proxy_protocol': None,
+        'pythonpath': None,
+        'raw_env': None,
+        'raw_paste_global_conf': None,
+        'reload': None,
+        'reload_engine': None,
+        'sendfile': True,
+        'spew': None,
+        'ssl_context': gunicorn_ssl_context,
+        'statsd_host': None,
+        'statsd_prefix': None,
+        'suppress_ragged_eofs': None,           # Suppress ragged EOFs (see stdlib ssl module)
+        'syslog': None,
+        'syslog_addr': None,
+        'syslog_facility': None,
+        'syslog_prefix': None,
+        'threads': conf.CHERRYPY_SERVER_THREADS.get(),
+        'timeout': conf.GUNICORN_WORKER_TIMEOUT.get(),
+        'umask': None,
+        'user': options.get('user'),
+        'worker_class': conf.GUNICORN_WORKER_CLASS.get(),
+        'worker_connections': 1000,
+        'worker_tmp_dir': options['worker_tmp_dir'],
+        'workers': conf.GUNICORN_NUMBER_OF_WORKERS.get() if conf.GUNICORN_NUMBER_OF_WORKERS.get() is not None else 5,
+        'post_fork': post_fork,
+        'post_worker_init': post_worker_init,
+        'worker_int': worker_int
+    }
+
+    return gunicorn_options
+
+
+def run_gunicorn_server(args=[], options={}):
+    """Run the Gunicorn server with the given options"""
+    gunicorn_options = create_gunicorn_options(options)
+    StandaloneApplication(handler_app, gunicorn_options).run()
+
+
+# Global variable for PID file (used by post_fork)
+PID_FILE = None

+ 78 - 330
desktop/core/src/desktop/management/commands/rungunicornserver.py

@@ -18,373 +18,121 @@ from __future__ import unicode_literals
 
 import atexit
 import logging
-import logging.config
-import multiprocessing
 import os
-import ssl
+import signal
 import sys
-import tempfile
+import threading
 import time
 from multiprocessing.util import _exit_function
 
-import gunicorn.app.base
-import pkg_resources
-import psutil
-import redis
+import gunicorn
 from django.core.management.base import BaseCommand
-from django.core.wsgi import get_wsgi_application
-from django.db import connection
 from django.utils.translation import gettext as _
-from gunicorn import util
-from OpenSSL import crypto
-from six import iteritems
 
-import desktop.log
-from desktop import conf
-from desktop.lib.ip_utils import fetch_ipv6_bind_address
-from desktop.lib.paths import get_desktop_root
-from desktop.lib.tls_utils import get_tls_settings
-from filebrowser.utils import parse_broker_url
+import desktop.conf
+from desktop.lib.gunicorn_cleanup_utils import create_signal_handler, unified_cleanup, write_process_recovery_file
+from desktop.lib.gunicorn_log_utils import LOG_LISTENER_SOCKET_PATH, LOG_LISTENER_THREAD, start_log_listener
+from desktop.lib.gunicorn_server_utils import (
+    activate_translation,
+    enable_logging,
+    initialize_free_disk_space_in_redis,
+    process_arguments,
+    run_gunicorn_server,
+)
 
 GUNICORN_SERVER_HELP = r"""
   Run Hue using the Gunicorn WSGI server in asynchronous mode.
 """
 
-PID_FILE = None
-
 
 class Command(BaseCommand):
-  help = _("Gunicorn Web server for Hue.")
-
-  def add_arguments(self, parser):
-    parser.add_argument('--bind', help=_("Bind Address"), action='store', default=None)
-
-  def handle(self, *args, **options):
-    start_server(args, options)
-
-  def usage(self, subcommand):
-    return GUNICORN_SERVER_HELP
-
-
-def activate_translation():
-  from django.conf import settings
-  from django.utils import translation
-
-  # Activate the current language, because it won't get activated later.
-  try:
-    translation.activate(settings.LANGUAGE_CODE)
-  except AttributeError:
-    pass
-
-
-def number_of_workers():
-  return (multiprocessing.cpu_count() * 2) + 1
-
-
-def handler_app(environ, start_response):
-  os.environ.setdefault("DJANGO_SETTINGS_MODULE", "desktop.settings")
-  return get_wsgi_application()
-
+    help = _("Gunicorn Web server for Hue.")
 
-def post_fork(server, worker):
-  global PID_FILE
-  with open(PID_FILE, "a") as f:
-    f.write("%s\n" % worker.pid)
+    def add_arguments(self, parser):
+        parser.add_argument('--bind', help=_("Bind Address"), action='store', default=None)
 
+    def handle(self, *args, **options):
+        start_server(args, options)
 
-def post_worker_init(worker):
-  connection.connect()
+    def usage(self, subcommand):
+        return GUNICORN_SERVER_HELP
 
 
-def worker_int(worker):
-  connection.close()
+def start_server(args, options):
+    """Main server startup function"""
+    global LOG_LISTENER_THREAD
 
+    # Process command line arguments
+    options = process_arguments(args, options)
+    PID_FILE = options['pid_file']  # Get PID file from processed options
 
-def enable_logging(args, options):
-  HUE_DESKTOP_VERSION = pkg_resources.get_distribution("desktop").version or "Unknown"
-  # Start basic logging as soon as possible.
-  if "HUE_PROCESS_NAME" not in os.environ:
-    _proc = os.path.basename(len(sys.argv) > 1 and sys.argv[1] or sys.argv[0])
-    os.environ["HUE_PROCESS_NAME"] = _proc
+    # Hide the Server software version in the response body
+    gunicorn.SERVER_SOFTWARE = "apache"
+    os.environ["SERVER_SOFTWARE"] = gunicorn.SERVER_SOFTWARE
 
-  desktop.log.basic_logging(os.environ["HUE_PROCESS_NAME"])
-  logging.info("Welcome to Hue from Gunicorn server " + HUE_DESKTOP_VERSION)
+    # Activate django translation
+    activate_translation()
+    enable_logging(args, options)
+    atexit.unregister(_exit_function)
 
+    # Remove old rungunicornserver processes and log listener
+    unified_cleanup()
 
-def initialize_free_disk_space_in_redis():
-  conn_success = False
-  for retries in range(5):
+    # Start the integrated log listener
     try:
-      redis_client = parse_broker_url(desktop.conf.TASK_SERVER_V2.BROKER_URL.get())
-      free_space = psutil.disk_usage('/tmp').free
-      available_space = redis_client.get('upload_available_space')
-      if available_space is None:
-        available_space = free_space
-      else:
-        available_space = int(available_space)
-      upload_keys_exist = any(redis_client.scan_iter('upload__*'))
-      redis_client.delete('upload_available_space')
-      if not upload_keys_exist:
-        redis_client.setnx('upload_available_space', free_space)
-      else:
-        redis_client.setnx('upload_available_space', min(free_space, available_space))
-      logging.info("Successfully initialized free disk space in Redis.")
-      conn_success = True
-      break
-    except redis.ConnectionError as e:
-      logging.error(f"Redis connection error: {e}")
-      time.sleep(10)
+        log_listener = start_log_listener()
+        if log_listener:
+            logging.info("Integrated log listener started successfully")
+        else:
+            logging.info("Log listener is disabled")
     except Exception as e:
-      logging.error(f"Error while initializing free disk space in Redis: {e}")
-      time.sleep(10)
-  if not conn_success:
-    logging.error("Failed to initialize free disk space in Redis after 5 retries.")
-
-
-class StandaloneApplication(gunicorn.app.base.BaseApplication):
-  def __init__(self, app, options=None):
-    self.options = options or {}
-    self.app_uri = 'desktop.wsgi:application'
-    super(StandaloneApplication, self).__init__()
-
-  def load_config(self):
-    config = dict([(key, value) for key, value in iteritems(self.options)
-                    if key in self.cfg.settings and value is not None])
-    for key, value in iteritems(config):
-      self.cfg.set(key.lower(), value)
-
-  def chdir(self):
-    # chdir to the configured path before loading,
-    # default is the current dir
-    os.chdir(self.cfg.chdir)
-
-    # add the path to sys.path
-    sys.path.insert(0, self.cfg.chdir)
-
-  def load_wsgiapp(self):
-    self.chdir()
-
-    # load the app
-    return util.import_app(self.app_uri)
-
-  def load(self):
-    return self.load_wsgiapp()
-
+        logging.error(f"Failed to start integrated log listener: {e}")
+        # Continue without log listener if it fails to start
 
-def argprocessing(args=[], options={}):
-  global PID_FILE
+    # Register signal handlers for graceful shutdown
+    signal_handler = create_signal_handler(LOG_LISTENER_THREAD, LOG_LISTENER_SOCKET_PATH, PID_FILE)
+    signal.signal(signal.SIGTERM, signal_handler)
+    signal.signal(signal.SIGHUP, signal_handler)
+    signal.signal(signal.SIGINT, signal_handler)
+    signal.signal(signal.SIGQUIT, signal_handler)
 
-  ipv6_enabled = conf.ENABLE_IPV6.get()  # This is already a bool
+    # Note: SIGKILL cannot be caught, but we can prepare for cleanup after SIGKILL
+    # by writing process info to a recovery file
 
-  if options.get('bind'):
-      http_port = "8888"
-      bind_addr = options['bind']
-      if ":" in bind_addr:
-          http_port = bind_addr.split(":")[-1]  # Use last part in case of IPv6
-      PID_FILE = f"/tmp/hue_{http_port}.pid"
-  else:
-      http_host = conf.HTTP_HOST.get()
-      http_port = str(conf.HTTP_PORT.get())
+    if desktop.conf.TASK_SERVER_V2.ENABLED.get():
+        initialize_free_disk_space_in_redis()
 
-      if ipv6_enabled:
-          bind_addr = fetch_ipv6_bind_address(http_host, http_port)
-      else:
-          bind_addr = f"{http_host}:{http_port}"
-          logging.info(f"IPv6 disabled, using standard format: {bind_addr}")
+    with open(PID_FILE, "a") as f:
+        f.write("%s\n" % os.getpid())
 
-      PID_FILE = f"/tmp/hue_{http_port}.pid"
+    # Write initial recovery file for potential post-SIGKILL cleanup
+    write_process_recovery_file(socket_path=LOG_LISTENER_SOCKET_PATH, pid_file=PID_FILE)
 
-  options['bind_addr'] = bind_addr
+    # Set up a timer to periodically update the recovery file
+    def update_recovery_file():
+        while True:
+            time.sleep(30)  # Update every 30 seconds
+            try:
+                write_process_recovery_file(socket_path=LOG_LISTENER_SOCKET_PATH, pid_file=PID_FILE)
+            except Exception as e:
+                logging.debug(f"Error updating recovery file: {e}")
 
-  # Currently gunicorn does not support passphrase suppored SSL Keyfile
-  # https://github.com/benoitc/gunicorn/issues/2410
-  ssl_keyfile = None
-
-  # HUE_RUN_DIR is set for env variable. If unset, fall back to previous behaviour (for base).
-  worker_tmp_dir = os.environ.get("HUE_RUN_DIR")
-  if not worker_tmp_dir:
-    worker_tmp_dir = os.environ.get("HUE_CONF_DIR", get_desktop_root("conf"))
-    if not worker_tmp_dir:
-      worker_tmp_dir = "/tmp"
-  options['worker_tmp_dir'] = worker_tmp_dir
-
-  # Gunicorn needs chown privileges if the OS user/group does not match Gunicorn's user/group.
-  # This can happen when the OS user is not in our control to set (OpenShift).
-  if not os.environ.get("GUNICORN_USE_OS_USER"):
-    options['user'] = conf.SERVER_USER.get()
-    options['group'] = conf.SERVER_GROUP.get()
-
-  if conf.SSL_CERTIFICATE.get() and conf.SSL_PRIVATE_KEY.get():
-    ssl_password = str.encode(conf.get_ssl_password()) if conf.get_ssl_password() is not None else None
-    if ssl_password:
-      with open(conf.SSL_PRIVATE_KEY.get(), 'r') as f:
-        with tempfile.NamedTemporaryFile(dir=worker_tmp_dir, delete=False) as tf:
-          tf.write(crypto.dump_privatekey(crypto.FILETYPE_PEM,
-                                          crypto.load_privatekey(crypto.FILETYPE_PEM,
-                                                                 f.read(), ssl_password)))
-          ssl_keyfile = tf.name
-    else:
-      ssl_keyfile = conf.SSL_PRIVATE_KEY.get()
-  options['ssl_keyfile'] = ssl_keyfile
-
-
-def rungunicornserver(args=[], options={}):
-  # Get TLS configuration
-  tls_settings = get_tls_settings()
-
-  def gunicorn_ssl_context(conf, default_ssl_context_factory):
-    """
-    Create and configure SSL context for Gunicorn based on TLS settings.
-
-    Args:
-        conf: Gunicorn configuration object
-        default_ssl_context_factory: Default SSL context factory function
-
-    Returns:
-        Configured SSL context
-    """
-    context = default_ssl_context_factory()
+    recovery_updater = threading.Thread(target=update_recovery_file, daemon=True)
+    recovery_updater.start()
 
     try:
-      # Validate TLS settings before applying
-      if "error" in tls_settings:
-        logging.warning(f"TLS configuration error: {tls_settings['error']}")
-        return context
-
-      # Configure maximum TLS version
-      max_version = tls_settings.get("tls_maximum_version")
-      if max_version == "TLSv1.3":
-        if hasattr(ssl, "HAS_TLSv1_3") and ssl.HAS_TLSv1_3 and hasattr(ssl, "TLSVersion"):
-          context.maximum_version = ssl.TLSVersion.TLSv1_3
-          logging.info("Set maximum TLS version to TLSv1.3")
-        else:
-          logging.warning("TLS 1.3 requested but not supported by system, falling back to TLS 1.2")
-          if hasattr(ssl, "TLSVersion"):
-            context.maximum_version = ssl.TLSVersion.TLSv1_2
-      elif max_version == "TLSv1.2":
-        if hasattr(ssl, "TLSVersion"):
-          context.maximum_version = ssl.TLSVersion.TLSv1_2
-          logging.info("Set maximum TLS version to TLSv1.2")
-
-      # Configure minimum TLS version
-      min_version = tls_settings.get("tls_minimum_version")
-      if min_version == "TLSv1.3":
-        if hasattr(ssl, "HAS_TLSv1_3") and ssl.HAS_TLSv1_3 and hasattr(ssl, "TLSVersion"):
-          context.minimum_version = ssl.TLSVersion.TLSv1_3
-          logging.info("Set minimum TLS version to TLSv1.3")
-        else:
-          logging.warning("TLS 1.3 minimum requested but not supported, falling back to TLS 1.2")
-          if hasattr(ssl, "TLSVersion"):
-            context.minimum_version = ssl.TLSVersion.TLSv1_2
-      elif min_version == "TLSv1.2":
-        if hasattr(ssl, "TLSVersion"):
-          context.minimum_version = ssl.TLSVersion.TLSv1_2
-          logging.info("Set minimum TLS version to TLSv1.2")
-
-      # Configure ciphers (only for TLS 1.2)
-      ciphers = tls_settings.get("ciphers", "")
-      if ciphers and ciphers.strip():  # Only set if ciphers is not empty
-        try:
-          context.set_ciphers(ciphers)
-          logging.info(f"Successfully configured ciphers: {ciphers}")
-        except ssl.SSLError as e:
-          logging.error(f"Invalid cipher configuration '{ciphers}': {e}")
-        except Exception as e:
-          logging.error(f"Unexpected error setting ciphers '{ciphers}': {e}")
-      elif max_version == "TLSv1.3":
-        logging.info("TLS 1.3 enabled - cipher configuration handled automatically")
-      else:
-        logging.info("No custom ciphers configured, using system defaults")
-
+        run_gunicorn_server(args, options)
+    except KeyboardInterrupt:
+        logging.info("Received keyboard interrupt, shutting down...")
     except Exception as e:
-      logging.error(f"Error configuring SSL context: {e}")
-      logging.info("Using default SSL context configuration")
-
-    return context
-
-  gunicorn_options = {
-      'accesslog': "-",
-      'access_log_format': "%({x-forwarded-for}i)s %(h)s %(l)s %(u)s %(t)s '%(r)s' %(s)s %(b)s '%(f)s' '%(a)s'",
-      'backlog': 2048,
-      'bind': [options['bind_addr']],
-      'ca_certs': conf.SSL_CACERTS.get(),     # CA certificates file
-      'capture_output': True,
-      'cert_reqs': None,                      # Whether client certificate is required (see stdlib ssl module)
-      'certfile': conf.SSL_CERTIFICATE.get(),  # SSL certificate file
-      'chdir': None,
-      'check_config': None,
-      'config': None,
-      'daemon': None,
-      'do_handshake_on_connect': False,       # Whether to perform SSL handshake on socket connect.
-      'enable_stdio_inheritance': None,
-      'errorlog': "-",
-      'forwarded_allow_ips': None,
-      'graceful_timeout': conf.GUNICORN_WORKER_GRACEFUL_TIMEOUT.get(),
-      'group': options.get('group'),
-      'initgroups': None,
-      'keepalive': 120,                       # seconds to wait for requests on a keep-alive connection.
-      'keyfile': options['ssl_keyfile'],      # SSL key file
-      'limit_request_field_size': conf.LIMIT_REQUEST_FIELD_SIZE.get(),
-      'limit_request_fields': conf.LIMIT_REQUEST_FIELDS.get(),
-      'limit_request_line': conf.LIMIT_REQUEST_LINE.get(),
-      'loglevel': 'DEBUG' if conf.DJANGO_DEBUG_MODE.get() else 'INFO',
-      'max_requests': 1200,                   # The maximum number of requests a worker will process before restarting.
-      'max_requests_jitter': 0,
-      'paste': None,
-      'pidfile': None,
-      'preload_app': False,
-      'proc_name': "hue",
-      'proxy_allow_ips': None,
-      'proxy_protocol': None,
-      'pythonpath': None,
-      'raw_env': None,
-      'raw_paste_global_conf': None,
-      'reload': None,
-      'reload_engine': None,
-      'sendfile': True,
-      'spew': None,
-      'ssl_context': gunicorn_ssl_context,
-      'statsd_host': None,
-      'statsd_prefix': None,
-      'suppress_ragged_eofs': None,           # Suppress ragged EOFs (see stdlib ssl module)
-      'syslog': None,
-      'syslog_addr': None,
-      'syslog_facility': None,
-      'syslog_prefix': None,
-      'threads': conf.CHERRYPY_SERVER_THREADS.get(),
-      'timeout': conf.GUNICORN_WORKER_TIMEOUT.get(),
-      'umask': None,
-      'user': options.get('user'),
-      'worker_class': conf.GUNICORN_WORKER_CLASS.get(),
-      'worker_connections': 1000,
-      'worker_tmp_dir': options['worker_tmp_dir'],
-      'workers': conf.GUNICORN_NUMBER_OF_WORKERS.get() if conf.GUNICORN_NUMBER_OF_WORKERS.get() is not None else 5,
-      'post_fork': post_fork,
-      'post_worker_init': post_worker_init,
-      'worker_int': worker_int
-  }
-
-  StandaloneApplication(handler_app, gunicorn_options).run()
-
-
-def start_server(args, options):
-  global PID_FILE
-  argprocessing(args, options)
-
-  # Hide the Server software version in the response body
-  gunicorn.SERVER_SOFTWARE = "apache"
-  os.environ["SERVER_SOFTWARE"] = gunicorn.SERVER_SOFTWARE
-
-  # Activate django translation
-  activate_translation()
-  enable_logging(args, options)
-  atexit.unregister(_exit_function)
-  if desktop.conf.TASK_SERVER_V2.ENABLED.get():
-    initialize_free_disk_space_in_redis()
-  with open(PID_FILE, "a") as f:
-    f.write("%s\n" % os.getpid())
-  rungunicornserver(args, options)
+        logging.error(f"Error running gunicorn server: {e}")
+        raise
+    finally:
+        # Ensure log listener is stopped on exit
+        if LOG_LISTENER_THREAD:
+            LOG_LISTENER_THREAD.stop()
+            LOG_LISTENER_THREAD.join(timeout=5)
 
 
 if __name__ == '__main__':
-  start_server(args=sys.argv[1:], options={})
+    start_server(args=sys.argv[1:], options={})