Repository: ipython/ipykernel

Issue:
PPID is not necessarily 1 when parent frontend is killed
Hi, I have raised this issue separately on [jupyter-console](https://github.com/jupyter/jupyter_console/issues/221)

The problem I noticed is that when I directly close the frontend program that called "jupyter-console" (e.g. my terminal), it leaves the "ipykernel_launcher" running in the background. What I expected to happen is that the kernel session created by "jupyter-console" will be killed whenever its host frontend program is killed. I think I have some clues as to the reason why.

First to provide some of the relevant observations:
1. On a remote machine without interactive login session, killing the parent frontend (e.g. a Tmux session or terminal) hosting the Jupyter console will lead to the expected killing of ipykernel on the background. 
2. On another machine running Manjaro Gnome, killing the parent frontend (e.g. a Tmux session or terminal) will not kill the ipykernel subprocess. I can see that it's still dangling in the background.

Chasing down the rabbit hole, I found additional observations:
1. "ipykernel/parentpoller.py" : is responsible for continuous monitoring of parent process that spawned ipykernel. "Line 36" makes the assumption that if the parent of orphaned process is killed, its ID gets set to "1", in which case the kernel will exit itself.
![parentpoller_relevant_code](https://user-images.githubusercontent.com/15006047/85074085-d66ad300-b170-11ea-90c2-772bb28c553e.png)
2. After adding in a line to log the parent process ID, I found that on the Manjaro Gnome machine, the initial parent process ID transfers from the "jupyer-console":
![initial_ppid](https://user-images.githubusercontent.com/15006047/85072650-488de880-b16e-11ea-904e-2715f0b94b0b.png)
after killing the initial parent process to:
![post_parent_killed_ppid](https://user-images.githubusercontent.com/15006047/85072702-622f3000-b16e-11ea-823e-52fee09b6e62.png)

![ppid](https://user-images.githubusercontent.com/15006047/85072943-c05c1300-b16e-11ea-87c4-638dc3ff0f75.png)

A few additional [posts](https://unix.stackexchange.com/questions/149319/new-parent-process-when-the-parent-process-dies/177361#177361) suggest that the parent ID of orphaned processes may not be necessarily set to 1 if there are ancestor processes. Since [Gnome 3.34](https://blogs.gnome.org/benzea/2019/10/01/gnome-3-34-is-now-managed-using-systemd/) (and maybe a few more other distributions), there is a shift towards handing off more processes to systemd. Thus when orphaned child arises, it does not get handed off to "init" with a PID of 1. It seems ipykernel may require a special handling to prevent undead backends that consume system resources. A suggestion would be to kill the ipykernel whenever PPID == 1 or PPID == "that of systemd".


 
 


Discussion:
bluss: First PR, tell me if I did something wrong
bluss: The ci failures are similar to failures on other PRs here, so it's possible - not certain - that they are unrelated errors.
Carreau: That looks reasonable to me. 
Carreau: Downstream failures seem unrelated. Merging. 

Patch:
diff --git a/ipykernel/kernelapp.py b/ipykernel/kernelapp.py
index 2f462af..394f52a 100644
--- a/ipykernel/kernelapp.py
+++ b/ipykernel/kernelapp.py
@@ -220,7 +220,7 @@ class IPKernelApp(BaseIPythonApplication, InteractiveShellApp, ConnectionFileMix
             # PID 1 (init) is special and will never go away,
             # only be reassigned.
             # Parent polling doesn't work if ppid == 1 to start with.
-            self.poller = ParentPollerUnix()
+            self.poller = ParentPollerUnix(parent_pid=self.parent_handle)
 
     def _try_bind_socket(self, s, port):
         iface = f"{self.transport}://{self.ip}"
diff --git a/ipykernel/parentpoller.py b/ipykernel/parentpoller.py
index a6d9c75..895a785 100644
--- a/ipykernel/parentpoller.py
+++ b/ipykernel/parentpoller.py
@@ -22,9 +22,17 @@ class ParentPollerUnix(Thread):
     when the parent process no longer exists.
     """
 
-    def __init__(self):
-        """Initialize the poller."""
+    def __init__(self, parent_pid=0):
+        """Initialize the poller.
+
+        Parameters
+        ----------
+        parent_handle : int, optional
+            If provided, the program will terminate immediately when
+            process parent is no longer this original parent.
+        """
         super().__init__()
+        self.parent_pid = parent_pid
         self.daemon = True
 
     def run(self):
@@ -32,9 +40,23 @@ class ParentPollerUnix(Thread):
         # We cannot use os.waitpid because it works only for child processes.
         from errno import EINTR
 
+        # before start, check if the passed-in parent pid is valid
+        original_ppid = os.getppid()
+        if original_ppid != self.parent_pid:
+            self.parent_pid = 0
+
+        get_logger().debug(
+            "%s: poll for parent change with original parent pid=%d",
+            type(self).__name__,
+            self.parent_pid,
+        )
+
         while True:
             try:
-                if os.getppid() == 1:
+                ppid = os.getppid()
+                parent_is_init = not self.parent_pid and ppid == 1
+                parent_has_changed = self.parent_pid and ppid != self.parent_pid
+                if parent_is_init or parent_has_changed:
                     get_logger().warning("Parent appears to have exited, shutting down.")
                     os._exit(1)
                 time.sleep(1.0)


Test patch:
diff --git a/tests/test_parentpoller.py b/tests/test_parentpoller.py
index 97cd804..716c9e8 100644
--- a/tests/test_parentpoller.py
+++ b/tests/test_parentpoller.py
@@ -9,7 +9,7 @@ from ipykernel.parentpoller import ParentPollerUnix, ParentPollerWindows
 
 
 @pytest.mark.skipif(os.name == "nt", reason="only works on posix")
-def test_parent_poller_unix():
+def test_parent_poller_unix_to_pid1():
     poller = ParentPollerUnix()
     with mock.patch("os.getppid", lambda: 1):  # noqa: PT008
 
@@ -27,6 +27,22 @@ def test_parent_poller_unix():
         poller.run()
 
 
+@pytest.mark.skipif(os.name == "nt", reason="only works on posix")
+def test_parent_poller_unix_reparent_not_pid1():
+    parent_pid = 221
+    parent_pids = iter([parent_pid, parent_pid - 1])
+
+    poller = ParentPollerUnix(parent_pid=parent_pid)
+
+    with mock.patch("os.getppid", lambda: next(parent_pids)):  # noqa: PT008
+
+        def exit_mock(*args):
+            sys.exit(1)
+
+        with mock.patch("os._exit", exit_mock), pytest.raises(SystemExit):
+            poller.run()
+
+
 @pytest.mark.skipif(os.name != "nt", reason="only works on windows")
 def test_parent_poller_windows():
     poller = ParentPollerWindows(interrupt_handle=1)