Repository: gorakhargosh/watchdog
Pull request #896 (2022-06-05 13:42:39)

make auto-restart restart the sub-process if it terminates
Currently, when running `watchmedo auto-restart ...`, if the sub-process terminates, it is left as a zombie process and watchmedo continues running and waiting for filesystem events.

With this PR, the sub-process will be monitored for termination, and restarted if it terminates.

Alternatively, we could make this exit upon sub-process termination, possibly with a flag to choose between the two behaviours.

This is related to issue #405.

BoboTiG: Thanks a lot @taleinat !

A small line in the changelog, and we will be good to go :)
taleinat: > A small line in the changelog, and we will be good to go :)

Done.

Patch:
diff --git a/changelog.rst b/changelog.rst
index 279a0c5..747645c 100644
--- a/changelog.rst
+++ b/changelog.rst
@@ -9,7 +9,8 @@ Unreleased
 2022-xx-xx • `full history <https://github.com/gorakhargosh/watchdog/compare/v2.1.8...HEAD>`__
 
 - [fsevents] Fix flakey test to assert that there are no errors when stopping the emitter.
-- Thanks to our beloved contributors: @samschott
+- [watchmedo] Make auto-restart restart the sub-process if it terminates. (`#896 <https://github.com/gorakhargosh/watchdog/pull/896>`__)
+- Thanks to our beloved contributors: @samschott, @taleinat
 
 2.1.8
 ~~~~~
diff --git a/src/watchdog/tricks/__init__.py b/src/watchdog/tricks/__init__.py
index e15f970..3303924 100644
--- a/src/watchdog/tricks/__init__.py
+++ b/src/watchdog/tricks/__init__.py
@@ -48,9 +48,9 @@
 import subprocess
 import time
 
-from watchdog.utils import echo
 from watchdog.events import PatternMatchingEventHandler
-
+from watchdog.utils import echo
+from watchdog.utils.process_watcher import ProcessWatcher
 
 logger = logging.getLogger(__name__)
 echo_events = functools.partial(echo.echo, write=lambda msg: logger.info(msg))
@@ -173,16 +173,24 @@ def __init__(self, command, patterns=None, ignore_patterns=None,
         self.command = command
         self.stop_signal = stop_signal
         self.kill_after = kill_after
+
         self.process = None
+        self.process_watcher = None
 
     def start(self):
         # windows doesn't have setsid
         self.process = subprocess.Popen(self.command, preexec_fn=getattr(os, 'setsid', None))
+        self.process_watcher = ProcessWatcher(self.process, self._restart)
+        self.process_watcher.start()
 
     def stop(self):
         if self.process is None:
             return
 
+        if self.process_watcher is not None:
+            self.process_watcher.stop()
+            self.process_watcher = None
+
         def kill_process(stop_signal):
             if hasattr(os, 'getpgid') and hasattr(os, 'killpg'):
                 os.killpg(os.getpgid(self.process.pid), stop_signal)
@@ -210,5 +218,8 @@ def kill_process(stop_signal):
 
     @echo_events
     def on_any_event(self, event):
+        self._restart()
+
+    def _restart(self):
         self.stop()
         self.start()
diff --git a/src/watchdog/utils/process_watcher.py b/src/watchdog/utils/process_watcher.py
new file mode 100644
index 0000000..800d7f8
--- /dev/null
+++ b/src/watchdog/utils/process_watcher.py
@@ -0,0 +1,27 @@
+import logging
+import time
+
+from watchdog.utils import BaseThread
+
+
+logger = logging.getLogger(__name__)
+
+
+class ProcessWatcher(BaseThread):
+    def __init__(self, popen_obj, process_termination_callback):
+        super().__init__()
+        self.popen_obj = popen_obj
+        self.process_termination_callback = process_termination_callback
+
+    def run(self):
+        while True:
+            if self.stopped_event.is_set():
+                return
+            if self.popen_obj.poll() is not None:
+                break
+            time.sleep(0.1)
+
+        try:
+            self.process_termination_callback()
+        except Exception:
+            logger.exception("Error calling process termination callback")


Test patch:
diff --git a/tests/test_0_watchmedo.py b/tests/test_0_watchmedo.py
index 3ab0ac9..d9aaf3f 100644
--- a/tests/test_0_watchmedo.py
+++ b/tests/test_0_watchmedo.py
@@ -74,6 +74,19 @@ def test_kill_auto_restart(tmpdir, capfd):
     # assert 'KeyboardInterrupt' in cap.err
 
 
+def test_auto_restart_subprocess_termination(tmpdir, capfd):
+    from watchdog.tricks import AutoRestartTrick
+    import sys
+    import time
+    script = make_dummy_script(tmpdir, n=2)
+    a = AutoRestartTrick([sys.executable, script])
+    a.start()
+    time.sleep(5)
+    a.stop()
+    cap = capfd.readouterr()
+    assert cap.out.splitlines(keepends=False).count('+++++ 0') > 1
+
+
 def test_auto_restart_arg_parsing_basic():
     args = watchmedo.cli.parse_args(["auto-restart", "-d", ".", "--recursive", "--debug-force-polling", "cmd"])
     assert args.func is watchmedo.auto_restart