[watchmedo] Avoid zombie sub-processes when running shell-command without --wait (#897)

* [watchmedo] Avoid zombie sub-processes when running shell-command without --wait

* lint

* reference the issue rather than PR in the changelog

* avoid shlex.join which was added in Python 3.8

* increase test wait time

* try further increasing wait time to get tests passing on Windows

* fix is_process_running()

* still debugging on Windows...

* apparently Windows doesn't like shell-quoted Python executables
diff --git a/changelog.rst b/changelog.rst
index 747645c..7549fec 100644
--- a/changelog.rst
+++ b/changelog.rst
@@ -9,7 +9,8 @@
 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.
-- [watchmedo] Make auto-restart restart the sub-process if it terminates. (`#896 <https://github.com/gorakhargosh/watchdog/pull/896>`__)
+- [watchmedo] Make ``auto-restart`` restart the sub-process if it terminates. (`#896 <https://github.com/gorakhargosh/watchdog/pull/896>`__)
+- [watchmedo] Avoid zombie sub-processes when running ``shell-command`` without ``--wait``. (`#405 <https://github.com/gorakhargosh/watchdog/issues/405>`__)
 - 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 3303924..6e7eaf6 100644
--- a/src/watchdog/tricks/__init__.py
+++ b/src/watchdog/tricks/__init__.py
@@ -116,12 +116,14 @@
         self.shell_command = shell_command
         self.wait_for_process = wait_for_process
         self.drop_during_process = drop_during_process
+
         self.process = None
+        self._process_watchers = set()
 
     def on_any_event(self, event):
         from string import Template
 
-        if self.drop_during_process and self.process and self.process.poll() is None:
+        if self.drop_during_process and self.is_process_running():
             return
 
         if event.is_directory:
@@ -151,6 +153,15 @@
         self.process = subprocess.Popen(command, shell=True)
         if self.wait_for_process:
             self.process.wait()
+        else:
+            process_watcher = ProcessWatcher(self.process, None)
+            self._process_watchers.add(process_watcher)
+            process_watcher.process_termination_callback = \
+                functools.partial(self._process_watchers.discard, process_watcher)
+            process_watcher.start()
+
+    def is_process_running(self):
+        return self._process_watchers or (self.process is not None and self.process.poll() is None)
 
 
 class AutoRestartTrick(Trick):
diff --git a/src/watchdog/utils/process_watcher.py b/src/watchdog/utils/process_watcher.py
index 800d7f8..f195764 100644
--- a/src/watchdog/utils/process_watcher.py
+++ b/src/watchdog/utils/process_watcher.py
@@ -1,5 +1,4 @@
 import logging
-import time
 
 from watchdog.utils import BaseThread
 
@@ -15,11 +14,10 @@
 
     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)
+            if self.stopped_event.wait(timeout=0.1):
+                return
 
         try:
             self.process_termination_callback()
diff --git a/tests/test_0_watchmedo.py b/tests/test_0_watchmedo.py
index d9aaf3f..0f76e98 100644
--- a/tests/test_0_watchmedo.py
+++ b/tests/test_0_watchmedo.py
@@ -65,7 +65,7 @@
     script = make_dummy_script(tmpdir)
     a = AutoRestartTrick([sys.executable, script])
     a.start()
-    time.sleep(5)
+    time.sleep(3)
     a.stop()
     cap = capfd.readouterr()
     assert '+++++ 0' in cap.out
@@ -74,6 +74,38 @@
     # assert 'KeyboardInterrupt' in cap.err
 
 
+def test_shell_command_wait_for_completion(tmpdir, capfd):
+    from watchdog.events import FileModifiedEvent
+    from watchdog.tricks import ShellCommandTrick
+    import sys
+    import time
+    script = make_dummy_script(tmpdir, n=1)
+    command = " ".join([sys.executable, script])
+    trick = ShellCommandTrick(command, wait_for_process=True)
+    assert not trick.is_process_running()
+    start_time = time.monotonic()
+    trick.on_any_event(FileModifiedEvent("foo/bar.baz"))
+    elapsed = time.monotonic() - start_time
+    print(capfd.readouterr())
+    assert not trick.is_process_running()
+    assert elapsed >= 1
+
+
+def test_shell_command_subprocess_termination_nowait(tmpdir):
+    from watchdog.events import FileModifiedEvent
+    from watchdog.tricks import ShellCommandTrick
+    import sys
+    import time
+    script = make_dummy_script(tmpdir, n=1)
+    command = " ".join([sys.executable, script])
+    trick = ShellCommandTrick(command, wait_for_process=False)
+    assert not trick.is_process_running()
+    trick.on_any_event(FileModifiedEvent("foo/bar.baz"))
+    assert trick.is_process_running()
+    time.sleep(5)
+    assert not trick.is_process_running()
+
+
 def test_auto_restart_subprocess_termination(tmpdir, capfd):
     from watchdog.tricks import AutoRestartTrick
     import sys