From 4af65b578042902ca2a67029fa918b0adaf819c4 Mon Sep 17 00:00:00 2001 From: hamedsh Date: Sun, 8 Mar 2026 14:49:50 +0300 Subject: [PATCH] add post stop command - post_stop_command - post_stop_command_delay - tests - document - subprocess compatibility --- docs/configuration.rst | 57 ++++ setup.py | 2 +- supervisor/options.py | 14 +- supervisor/process.py | 107 +++++++- supervisor/rpcinterface.py | 2 + supervisor/skel/sample.conf | 4 + supervisor/tests/base.py | 7 +- supervisor/tests/test_options.py | 9 +- supervisor/tests/test_process.py | 377 +++++++++++++++++++++++++++ supervisor/tests/test_supervisord.py | 2 + 10 files changed, 573 insertions(+), 8 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index ce05d34f2..93273065f 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1139,6 +1139,57 @@ where specified. *Introduced*: 3.0 +``post_stop_command`` + + A command that will be executed after the process has stopped or exited. + This command is executed by the shell, so you can use shell features + like pipes, redirection, and command substitution. The command is + executed synchronously (blocking), meaning that :program:`supervisord` + will wait for the command to complete before continuing with other tasks. + The command will be executed after waiting for ``post_stop_command_delay`` + seconds. This is useful for cleanup tasks, notifications, or any + post-processing that needs to occur after a process stops. + + .. note:: + + The post-stop command will be executed when the process transitions to + any of the following states: + + - STOPPED: as a result of a stop request (manual stop or shutdown) + - EXITED: when the process exits with an expected or unexpected exit code + - BACKOFF: when the process exits too quickly during startup + + The post-stop command will not be executed if the process transitions to + other states like STARTING, RUNNING, or UNKNOWN. + + .. warning:: + + The post-stop command has a default timeout of 30 seconds. If the + command takes longer than this, it will be terminated. Design your + post-stop commands to complete quickly to avoid blocking supervisor + operations. + + *Default*: None (no command will be executed) + + *Required*: No. + + *Introduced*: 4.x.x + +``post_stop_command_delay`` + + The number of seconds to wait after the process has stopped or exited before + executing the ``post_stop_command``. This delay occurs after the + process has transitioned to the STOPPED, EXITED, or BACKOFF state. During this delay, + :program:`supervisord` will block and wait before executing the + post-stop command. Set this to 0 to execute the command immediately + after the process stops or exits. + + *Default*: 0 + + *Required*: No. + + *Introduced*: 4.x.x + ``[program:x]`` Section Example ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1174,6 +1225,8 @@ where specified. stderr_events_enabled=false environment=A="1",B="2" serverurl=AUTO + post_stop_command=/path/to/cleanup.sh + post_stop_command_delay=5 ``[include]`` Section Settings ------------------------------ @@ -1443,6 +1496,8 @@ above constraints and additions. stderr_events_enabled=false environment=A="1",B="2" serverurl=AUTO + post_stop_command=/path/to/cleanup.sh + post_stop_command_delay=0 ``[eventlistener:x]`` Section Settings -------------------------------------- @@ -1525,6 +1580,8 @@ above constraints and additions. stderr_events_enabled=false environment=A="1",B="2" serverurl=AUTO + post_stop_command=/path/to/cleanup.sh + post_stop_command_delay=0 ``[rpcinterface:x]`` Section Settings ------------------------------------- diff --git a/setup.py b/setup.py index ea2425ee1..6cfb688ec 100644 --- a/setup.py +++ b/setup.py @@ -95,7 +95,7 @@ author="Chris McDonough", author_email="chrism@plope.com", packages=find_packages(), - install_requires=[], + install_requires=['subprocess32'] if py_version[0] == 2 else [], extras_require={ 'test': ['pytest', 'pytest-cov'] }, diff --git a/supervisor/options.py b/supervisor/options.py index 897d07876..723009b6e 100644 --- a/supervisor/options.py +++ b/supervisor/options.py @@ -913,6 +913,8 @@ def get(section, opt, *args, **kwargs): stopasgroup = boolean(get(section, 'stopasgroup', 'false')) killasgroup = boolean(get(section, 'killasgroup', stopasgroup)) exitcodes = list_of_exitcodes(get(section, 'exitcodes', '0')) + post_stop_command = get(section, 'post_stop_command', None) + post_stop_command_delay = integer(get(section, 'post_stop_command_delay', 0)) # see also redirect_stderr check in process_groups_from_parser() redirect_stderr = boolean(get(section, 'redirect_stderr','false')) numprocs = integer(get(section, 'numprocs', 1)) @@ -1019,6 +1021,11 @@ def get(section, opt, *args, **kwargs): raise ValueError( 'program section %s does not specify a command' % section) + # Expand post_stop_command if provided + expanded_post_stop_command = post_stop_command + if expanded_post_stop_command: + expanded_post_stop_command = expand(expanded_post_stop_command, expansions, 'post_stop_command') + pconfig = klass( self, name=expand(process_name, expansions, 'process_name'), @@ -1050,7 +1057,9 @@ def get(section, opt, *args, **kwargs): exitcodes=exitcodes, redirect_stderr=redirect_stderr, environment=environment, - serverurl=serverurl) + serverurl=serverurl, + post_stop_command=expanded_post_stop_command, + post_stop_command_delay=post_stop_command_delay) programs.append(pconfig) @@ -1863,7 +1872,8 @@ class ProcessConfig(Config): 'stderr_logfile_backups', 'stderr_logfile_maxbytes', 'stderr_events_enabled', 'stderr_syslog', 'stopsignal', 'stopwaitsecs', 'stopasgroup', 'killasgroup', - 'exitcodes', 'redirect_stderr' ] + 'exitcodes', 'redirect_stderr', 'post_stop_command', 'post_stop_command_delay', + ] optional_param_names = [ 'environment', 'serverurl' ] def __init__(self, options, **params): diff --git a/supervisor/process.py b/supervisor/process.py index b394be812..95da1d1ac 100644 --- a/supervisor/process.py +++ b/supervisor/process.py @@ -3,6 +3,7 @@ import os import signal import shlex +import subprocess import time import traceback @@ -30,6 +31,60 @@ from supervisor.socket_manager import SocketManager + +# Python 2/3.6 compatibility: subprocess.run doesn't exist in Python 2, and +# capture_output parameter doesn't exist in Python 3.6 +if True: # Always wrap to handle capture_output + if not hasattr(subprocess, 'run') and PY2: + import subprocess32 as subprocess + import collections + + # Create a simple result object that mimics subprocess.CompletedProcess + _CompletedProcess = collections.namedtuple( + 'CompletedProcess', ['returncode', 'stdout', 'stderr'], + ) + + _original_run = getattr(subprocess, 'run', None) + + def _subprocess_run(cmd, shell=False, capture_output=False, timeout=None, text=False, **kwargs): + """Compatibility wrapper for subprocess.run for Python 2 and Python 3.6""" + # Convert capture_output to explicit stdout/stderr pipes + if capture_output: + kwargs['stdout'] = subprocess.PIPE + kwargs['stderr'] = subprocess.PIPE + + # If native subprocess.run exists, try to use it + if _original_run is not None: + try: + return _original_run(cmd, shell=shell, timeout=timeout, text=text, **kwargs) + except TypeError: + # capture_output not supported, fall through to Popen + pass + + # Fall back to Popen for Python 2 or if run doesn't work + stdout_pipe = kwargs.get('stdout', None) + stderr_pipe = kwargs.get('stderr', None) + proc = None + + try: + proc = subprocess.Popen(cmd, shell=shell, stdout=stdout_pipe, stderr=stderr_pipe) + stdout, stderr = proc.communicate(timeout=timeout) + + if text: + stdout = stdout.decode('utf-8') if stdout else '' + stderr = stderr.decode('utf-8') if stderr else '' + + return _CompletedProcess(returncode=proc.returncode, stdout=stdout, stderr=stderr) + except subprocess.TimeoutExpired: + if proc: + proc.kill() + proc.wait() + raise + + + subprocess.run = _subprocess_run + + @functools.total_ordering class Subprocess(object): @@ -376,6 +431,51 @@ def _check_and_adjust_for_system_clock_rollback(self, test_time): if self.delay > 0 and test_time < (self.delay - self.backoff): self.delay = test_time + self.backoff + def _execute_post_stop_command(self, delay=0): + """Execute the post_stop_command after the process has been stopped. + + This method is called from finish() and will wait for the specified + delay before executing the command. This is a blocking call that + waits until the command completes before returning. + """ + if delay > 0: + time.sleep(delay) + + processname = as_string(self.config.name) + logger = self.config.options.logger + + if not self.config.post_stop_command: + return + + logger.info('executing post_stop_command for process %s: %s' % + (processname, self.config.post_stop_command)) + + try: + # Execute the command using subprocess + result = subprocess.run( + self.config.post_stop_command, + shell=True, + capture_output=True, + timeout=30, + text=True + ) + + if result.returncode == 0: + logger.info('post_stop_command completed successfully for %s' % processname) + if result.stdout: + logger.debug('post_stop_command stdout: %s' % result.stdout.strip()) + else: + logger.warn('post_stop_command failed for %s with exit code %s' % + (processname, result.returncode)) + if result.stderr: + logger.warn('post_stop_command stderr: %s' % result.stderr.strip()) + except subprocess.TimeoutExpired: + logger.error('post_stop_command timed out for process %s' % processname) + except Exception as e: + logger.error('error executing post_stop_command for %s: %s' % + (processname, str(e))) + + def stop(self): """ Administrative stop """ self.administrative_stop = True @@ -573,7 +673,6 @@ def finish(self, pid, sts): else: self.config.options.logger.warn(msg) - elif too_quickly: # the program did not stay up long enough to make it to RUNNING # implies STARTING -> BACKOFF @@ -612,6 +711,11 @@ def finish(self, pid, sts): self.change_state(ProcessStates.EXITED, expected=False) self.config.options.logger.warn(msg) + # Execute post_stop_command if configured, with delay + # This covers both EXITED states (expected and unexpected) + if self.config.post_stop_command: + self._execute_post_stop_command(delay=self.config.post_stop_command_delay) + self.pid = 0 self.config.options.close_parent_pipes(self.pipes) self.pipes = {} @@ -661,6 +765,7 @@ def transition(self): logger = self.config.options.logger + if self.config.options.mood > SupervisorStates.RESTARTING: # dont start any processes if supervisor is shutting down if state == ProcessStates.EXITED: diff --git a/supervisor/rpcinterface.py b/supervisor/rpcinterface.py index f1c7c2ce9..198bb0cdf 100644 --- a/supervisor/rpcinterface.py +++ b/supervisor/rpcinterface.py @@ -598,6 +598,8 @@ def getAllConfigInfo(self): 'stderr_logfile_maxbytes': pconfig.stderr_logfile_maxbytes, 'stderr_syslog': pconfig.stderr_syslog, 'serverurl': pconfig.serverurl, + 'post_stop_command': pconfig.post_stop_command, + 'post_stop_command_delay': pconfig.post_stop_command_delay, } # no support for these types in xml-rpc diff --git a/supervisor/skel/sample.conf b/supervisor/skel/sample.conf index 289c9cb38..60882869e 100644 --- a/supervisor/skel/sample.conf +++ b/supervisor/skel/sample.conf @@ -99,6 +99,8 @@ serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL for a unix socket ;stopwaitsecs=10 ; max num secs to wait b4 SIGKILL (default 10) ;stopasgroup=false ; send stop signal to the UNIX process group (default false) ;killasgroup=false ; SIGKILL the UNIX process group (def false) +;post_stop_command= ; command to run after process is stopped (default none) +;post_stop_command_delay=0 ; delay in seconds before running post_stop_command (default 0) ;user=chrism ; setuid to this UNIX account to run the program ;redirect_stderr=true ; redirect proc stderr to stdout (default false) ;stdout_logfile=/a/path ; stdout log path, NONE for none; default AUTO @@ -138,6 +140,8 @@ serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL for a unix socket ;stopwaitsecs=10 ; max num secs to wait b4 SIGKILL (default 10) ;stopasgroup=false ; send stop signal to the UNIX process group (default false) ;killasgroup=false ; SIGKILL the UNIX process group (def false) +;post_stop_command= ; command to run after process is stopped (default none) +;post_stop_command_delay=0 ; delay in seconds before running post_stop_command (default 0) ;user=chrism ; setuid to this UNIX account to run the program ;redirect_stderr=false ; redirect_stderr=true is not allowed for eventlisteners ;stdout_logfile=/a/path ; stdout log path, NONE for none; default AUTO diff --git a/supervisor/tests/base.py b/supervisor/tests/base.py index f608b2bea..ca5d4ee5c 100644 --- a/supervisor/tests/base.py +++ b/supervisor/tests/base.py @@ -89,6 +89,8 @@ def __init__(self): self.umaskset = None self.poller = DummyPoller(self) self.silent = False + self.post_stop_command = None + self.post_stop_command_delay = None def getLogger(self, *args, **kw): logger = DummyLogger() @@ -517,7 +519,8 @@ def __init__(self, options, name, command, directory=None, umask=None, stderr_syslog=False, redirect_stderr=False, stopsignal=None, stopwaitsecs=10, stopasgroup=False, killasgroup=False, - exitcodes=(0,), environment=None, serverurl=None): + exitcodes=(0,), environment=None, serverurl=None, + post_stop_command_delay=None, post_stop_command=None): self.options = options self.name = name self.command = command @@ -553,6 +556,8 @@ def __init__(self, options, name, command, directory=None, umask=None, self.umask = umask self.autochildlogs_created = False self.serverurl = serverurl + self.post_stop_command_delay = post_stop_command_delay + self.post_stop_command = post_stop_command def get_path(self): return ["/bin", "/usr/bin", "/usr/local/bin"] diff --git a/supervisor/tests/test_options.py b/supervisor/tests/test_options.py index d27cb0c6b..02d402212 100644 --- a/supervisor/tests/test_options.py +++ b/supervisor/tests/test_options.py @@ -3431,7 +3431,8 @@ def _makeOne(self, *arg, **kw): 'stderr_events_enabled', 'stderr_syslog', 'stopsignal', 'stopwaitsecs', 'stopasgroup', 'killasgroup', 'exitcodes', 'redirect_stderr', - 'environment'): + 'environment', 'post_stop_command', 'post_stop_command_delay', + ): defaults[name] = name for name in ('stdout_logfile_backups', 'stdout_logfile_maxbytes', 'stderr_logfile_backups', 'stderr_logfile_maxbytes'): @@ -3529,7 +3530,8 @@ def _makeOne(self, *arg, **kw): 'stderr_events_enabled', 'stderr_syslog', 'stopsignal', 'stopwaitsecs', 'stopasgroup', 'killasgroup', 'exitcodes', 'redirect_stderr', - 'environment'): + 'environment', 'post_stop_command', 'post_stop_command_delay', + ): defaults[name] = name for name in ('stdout_logfile_backups', 'stdout_logfile_maxbytes', 'stderr_logfile_backups', 'stderr_logfile_maxbytes'): @@ -3577,7 +3579,8 @@ def _makeOne(self, *arg, **kw): 'stderr_events_enabled', 'stderr_syslog', 'stopsignal', 'stopwaitsecs', 'stopasgroup', 'killasgroup', 'exitcodes', 'redirect_stderr', - 'environment'): + 'environment', 'post_stop_command', 'post_stop_command_delay', + ): defaults[name] = name for name in ('stdout_logfile_backups', 'stdout_logfile_maxbytes', 'stderr_logfile_backups', 'stderr_logfile_maxbytes'): diff --git a/supervisor/tests/test_process.py b/supervisor/tests/test_process.py index 24643b4dc..6630362c5 100644 --- a/supervisor/tests/test_process.py +++ b/supervisor/tests/test_process.py @@ -1,12 +1,17 @@ import errno import os import signal +try: + import subprocess32 as subprocess +except ImportError: + import subprocess import tempfile import time import unittest from supervisor.compat import as_bytes from supervisor.compat import maxint +from supervisor.states import ProcessStates from supervisor.tests.base import Mock, patch, sentinel from supervisor.tests.base import DummyOptions @@ -1727,6 +1732,378 @@ def test_change_state_sets_backoff_and_delay(self): self.assertEqual(instance.backoff, 1) self.assertTrue(instance.delay > 0) + def test_execute_post_stop_command_no_command_configured(self): + """Test that _execute_post_stop_command returns early if no command configured""" + options = DummyOptions() + config = DummyPConfig(options, 'test', '/test', post_stop_command=None) + instance = self._makeOne(config) + # Should not raise an error + instance._execute_post_stop_command(delay=0) + # No log messages should be added + self.assertEqual(len(options.logger.data), 0) + + def test_execute_post_stop_command_zero_delay(self): + """Test _execute_post_stop_command executes immediately with zero delay""" + options = DummyOptions() + config = DummyPConfig( + options, 'test', '/test', post_stop_command='echo "test"', + ) + instance = self._makeOne(config) + start_time = time.time() + instance._execute_post_stop_command(delay=0) + end_time = time.time() + + # Should execute immediately (less than 0.5 seconds) + elapsed = end_time - start_time + self.assertTrue(elapsed < 0.5) + + # Check that command was executed + self.assertTrue(any('executing post_stop_command' in msg for msg in options.logger.data)) + self.assertTrue(any('completed successfully' in msg for msg in options.logger.data)) + + def test_execute_post_stop_command_with_delay(self): + """Test _execute_post_stop_command waits for delay before executing""" + options = DummyOptions() + config = DummyPConfig( + options, 'test', '/test', post_stop_command='echo "delayed"', + ) + instance = self._makeOne(config) + + delay_seconds = 0.1 + start_time = time.time() + instance._execute_post_stop_command(delay=delay_seconds) + end_time = time.time() + + # Should wait at least delay_seconds + elapsed = end_time - start_time + self.assertTrue(elapsed >= delay_seconds - 0.05) # Allow small tolerance + + # Check that command was executed + self.assertTrue(any('executing post_stop_command' in msg for msg in options.logger.data)) + + @patch('supervisor.process.subprocess.run') + def test_execute_post_stop_command_success(self, mock_run): + """Test successful execution of post_stop_command""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = 'command output' + mock_result.stderr = '' + mock_run.return_value = mock_result + + options = DummyOptions() + config = DummyPConfig( + options, 'testproc', '/test', post_stop_command='cleanup.sh', + ) + instance = self._makeOne(config) + instance._execute_post_stop_command(delay=0) + + # Verify subprocess.run was called + self.assertEqual(mock_run.call_count, 1) + args, kwargs = mock_run.call_args + self.assertEqual(kwargs['shell'], True) + self.assertEqual(kwargs['capture_output'], True) + self.assertEqual(kwargs['timeout'], 30) + + # Check success message was logged + self.assertTrue(any('completed successfully' in msg for msg in options.logger.data)) + + @patch('supervisor.process.subprocess.run') + def test_execute_post_stop_command_failure(self, mock_run): + """Test failed execution of post_stop_command""" + mock_result = Mock() + mock_result.returncode = 1 + mock_result.stdout = '' + mock_result.stderr = 'error message' + mock_run.return_value = mock_result + + options = DummyOptions() + config = DummyPConfig( + options,'testproc','/test', post_stop_command='bad_command.sh', + ) + instance = self._makeOne(config) + instance._execute_post_stop_command(delay=0) + + # Check warning was logged for failure + self.assertTrue(any('failed for' in msg and 'exit code' in msg for msg in options.logger.data)) + + @patch('supervisor.process.subprocess.run') + def test_execute_post_stop_command_timeout(self, mock_run): + """Test timeout handling in post_stop_command execution""" + mock_run.side_effect = subprocess.TimeoutExpired('test', 30) + + options = DummyOptions() + config = DummyPConfig( + options, 'testproc', '/test', post_stop_command='slow_command.sh', + ) + instance = self._makeOne(config) + instance._execute_post_stop_command(delay=0) + + # Check timeout error was logged + self.assertTrue(any('timed out' in msg for msg in options.logger.data)) + + @patch('supervisor.process.subprocess.run') + def test_execute_post_stop_command_exception(self, mock_run): + """Test exception handling in post_stop_command execution""" + mock_run.side_effect = OSError('Command not found') + + options = DummyOptions() + config = DummyPConfig( + options, 'testproc', '/test', post_stop_command='nonexistent.sh', + ) + instance = self._makeOne(config) + instance._execute_post_stop_command(delay=0) + + # Check error was logged + self.assertTrue(any('error executing' in msg for msg in options.logger.data)) + + def test_finish_calls_post_stop_command_when_stopping(self): + """Test that finish() calls post_stop_command when process stops""" + options = DummyOptions() + config = DummyPConfig( + options, + 'testproc', + '/test', + post_stop_command='cleanup.sh', + post_stop_command_delay=0, + ) + instance = self._makeOne(config) + instance.pid = 123 + instance.killing = True + instance.pipes = {'stdout':'','stderr':''} + + instance.state = ProcessStates.STOPPING + + # Patch _execute_post_stop_command to track if it was called + call_log = [] + def track_call(delay=0): + call_log.append(delay) + # Don't actually execute + instance._execute_post_stop_command = track_call + + instance.finish(123, 1) + + # Verify post_stop_command was called with correct delay + self.assertEqual(len(call_log), 1) + self.assertEqual(call_log[0], 0) + self.assertEqual(instance.state, ProcessStates.STOPPED) + + def test_finish_uses_post_stop_command_delay(self): + """Test that finish() passes correct delay to post_stop_command""" + options = DummyOptions() + delay_value = 2.5 + config = DummyPConfig( + options, + 'testproc', + '/test', + post_stop_command='cleanup.sh', + post_stop_command_delay=delay_value, + ) + instance = self._makeOne(config) + instance.pid = 123 + instance.killing = True + instance.pipes = {'stdout':'','stderr':''} + + instance.state = ProcessStates.STOPPING + + # Track the delay parameter + call_log = [] + def track_call(delay=0): + call_log.append(delay) + instance._execute_post_stop_command = track_call + + instance.finish(123, 1) + + # Verify correct delay was passed + self.assertEqual(len(call_log), 1) + self.assertEqual(call_log[0], delay_value) + + def test_finish_without_post_stop_command(self): + """Test that finish() works normally when no post_stop_command configured""" + options = DummyOptions() + config = DummyPConfig( + options, 'testproc', '/test', post_stop_command=None, + ) + instance = self._makeOne(config) + instance.pid = 123 + instance.killing = True + instance.pipes = {'stdout':'','stderr':''} + + from supervisor.states import ProcessStates + instance.state = ProcessStates.STOPPING + + # Track if _execute_post_stop_command was called + call_log = [] + def track_call(delay=0): + call_log.append(delay) + instance._execute_post_stop_command = track_call + + instance.finish(123, 1) + + # Should not call _execute_post_stop_command + self.assertEqual(len(call_log), 0) + self.assertEqual(instance.state, ProcessStates.STOPPED) + + def test_finish_calls_post_stop_command_when_exited_expected(self): + """Test that finish() calls post_stop_command when process exits with expected code""" + options = DummyOptions() + config = DummyPConfig( + options, + 'testproc', + '/test', + post_stop_command='cleanup.sh', + post_stop_command_delay=1.5, + exitcodes=(0, 2), # 0 and 2 are expected exit codes + startsecs=10, + ) + instance = self._makeOne(config) + instance.pid = 123 + instance.pipes = {'stdout':'','stderr':''} + instance.laststart = time.time() - 20 # Process ran for 20 seconds + + from supervisor.states import ProcessStates + instance.state = ProcessStates.RUNNING + + # Patch _execute_post_stop_command to track if it was called + call_log = [] + def track_call(delay=0): + call_log.append(delay) + instance._execute_post_stop_command = track_call + + # Finish with exit code 0 (expected) + instance.finish(123, 0) + + # Verify post_stop_command was called with correct delay + self.assertEqual(len(call_log), 1) + self.assertEqual(call_log[0], 1.5) + self.assertEqual(instance.state, ProcessStates.EXITED) + + def test_finish_calls_post_stop_command_when_exited_unexpected(self): + """Test that finish() calls post_stop_command when process exits with unexpected code""" + options = DummyOptions() + config = DummyPConfig( + options, + 'testproc', + '/test', + post_stop_command='notify.sh', + post_stop_command_delay=0, + exitcodes=(0,), # Only 0 is expected + startsecs=10, + ) + instance = self._makeOne(config) + instance.pid = 123 + instance.pipes = {'stdout':'','stderr':''} + instance.laststart = time.time() - 20 # Process ran for 20 seconds + + from supervisor.states import ProcessStates + instance.state = ProcessStates.RUNNING + + # Track post_stop_command calls + call_log = [] + def track_call(delay=0): + call_log.append(delay) + instance._execute_post_stop_command = track_call + + # Finish with exit code 1 (unexpected) + instance.finish(123, 1) + + # Verify post_stop_command was called + self.assertEqual(len(call_log), 1) + self.assertEqual(call_log[0], 0) + self.assertEqual(instance.state, ProcessStates.EXITED) + + def test_finish_calls_post_stop_command_when_backoff(self): + """Test that finish() calls post_stop_command when process exits too quickly""" + options = DummyOptions() + config = DummyPConfig( + options, + 'testproc', + '/test', + post_stop_command='quick_restart.sh', + post_stop_command_delay=0.5, + startsecs=10, # Process must stay up for 10 seconds + ) + instance = self._makeOne(config) + instance.pid = 123 + instance.pipes = {'stdout':'','stderr':''} + instance.laststart = time.time() - 1 # Process only ran for 1 second + + from supervisor.states import ProcessStates + instance.state = ProcessStates.STARTING + + # Track post_stop_command calls + call_log = [] + def track_call(delay=0): + call_log.append(delay) + instance._execute_post_stop_command = track_call + + # Finish with exit code 0 (but too quickly) + instance.finish(123, 0) + + # Verify post_stop_command was called + self.assertEqual(len(call_log), 1) + self.assertEqual(call_log[0], 0.5) + self.assertEqual(instance.state, ProcessStates.BACKOFF) + + def test_finish_without_post_stop_command_on_exited(self): + """Test that finish() works when no post_stop_command configured for EXITED state""" + options = DummyOptions() + config = DummyPConfig( + options, + 'testproc', + '/test', + post_stop_command=None, # No post_stop_command + startsecs=10, + ) + instance = self._makeOne(config) + instance.pid = 123 + instance.pipes = {'stdout':'','stderr':''} + instance.laststart = time.time() - 20 + + from supervisor.states import ProcessStates + instance.state = ProcessStates.RUNNING + + # Track if _execute_post_stop_command was called + call_log = [] + def track_call(delay=0): + call_log.append(delay) + instance._execute_post_stop_command = track_call + + instance.finish(123, 0) + + # Should not call _execute_post_stop_command + self.assertEqual(len(call_log), 0) + self.assertEqual(instance.state, ProcessStates.EXITED) + + def test_finish_without_post_stop_command_on_backoff(self): + """Test that finish() works when no post_stop_command configured for BACKOFF state""" + options = DummyOptions() + config = DummyPConfig( + options, + 'testproc', + '/test', + post_stop_command=None, # No post_stop_command + startsecs=10, + ) + instance = self._makeOne(config) + instance.pid = 123 + instance.pipes = {'stdout':'','stderr':''} + instance.laststart = time.time() - 1 # Too quickly + + from supervisor.states import ProcessStates + instance.state = ProcessStates.STARTING + + # Track if _execute_post_stop_command was called + call_log = [] + def track_call(delay=0): + call_log.append(delay) + instance._execute_post_stop_command = track_call + + instance.finish(123, 0) + + # Should not call _execute_post_stop_command + self.assertEqual(len(call_log), 0) + self.assertEqual(instance.state, ProcessStates.BACKOFF) + class FastCGISubprocessTests(unittest.TestCase): def _getTargetClass(self): from supervisor.process import FastCGISubprocess diff --git a/supervisor/tests/test_supervisord.py b/supervisor/tests/test_supervisord.py index 4099bba6c..f6e0dcf0f 100644 --- a/supervisor/tests/test_supervisord.py +++ b/supervisor/tests/test_supervisord.py @@ -390,6 +390,7 @@ def make_pconfig(name, command, **params): 'stopasgroup': False, 'killasgroup': False, 'exitcodes': (0,), 'environment': None, 'serverurl': None, + 'post_stop_command': None, 'post_stop_command_delay': None, } result.update(params) return ProcessConfig(options, **result) @@ -459,6 +460,7 @@ def make_pconfig(name, command, **params): 'stopasgroup': False, 'killasgroup': False, 'exitcodes': (0,), 'environment': None, 'serverurl': None, + 'post_stop_command': None, 'post_stop_command_delay': None, } result.update(params) return EventListenerConfig(options, **result)