Repository: voxel51/fiftyone

Issue:
Server doesn't connect to second app instance
```py
import fiftyone as fo
import fiftyone.zoo as foz

dataset = fo.load_zoo_dataset("cifar10", split="test")

session = fo.Session(dataset=dataset)
# App launched

# Do stuff, then close the App by clicking the `x` in the App window

# Try to launch another App instance with the same dataset
session = fo.Session(dataset=dataset)
# Connected to fiftyone on local port 5151
# App launched
```

Note that the second time it says `Connected to fiftyone on local port 5151`, where it didn't the first time.

The bug I observe is that, the second time, I get an App window that thinks there's no dataset:

<img width="711" alt="Screen Shot 2020-08-11 at 10 49 48 PM" src="https://user-images.githubusercontent.com/25985824/89969880-7c910180-dc25-11ea-8c58-52b3477bf6bc.png">


Discussion:
The server is still running - this is expected. @benjaminpkane would be more familiar with why the server isn't recognizing the new instance of the app.
I'm able to reproduce. I don't have a concrete explanation for this, but I will circle back to this sometime this week.
Marking as high-priority, but a fix will not be in the next release.

Resolving patch:
diff --git a/fiftyone/core/client.py b/fiftyone/core/client.py
--- a/fiftyone/core/client.py
+++ b/fiftyone/core/client.py
@@ -13,7 +13,6 @@
 import socketio
 
 import fiftyone.constants as foc
-import fiftyone.core.service as fos
 
 logging.getLogger("socketio").setLevel(logging.ERROR)
 logging.getLogger("engineio").setLevel(logging.ERROR)
@@ -97,7 +96,6 @@ class HasClient(object):
     _HC_ATTR_TYPE = None
 
     def __init__(self, port):
-        self._server_service = fos.ServerService(port)
         self._hc_sio = socketio.Client()
         # the following is a monkey patch to set threads to daemon mode
         self._hc_sio.eio.start_background_task = _start_background_task
@@ -128,13 +126,6 @@ def __setattr__(self, name, value):
         else:
             super().__setattr__(name, value)
 
-    @property
-    def server_port(self):
-        """Getter for the port number the :class:`ServerService` is listening
-        on.
-        """
-        return self._server_service.port
-
 
 def _start_background_task(target, *args, **kwargs):
     """We are monkey patching here to start threads in ``daemon`` mode.
diff --git a/fiftyone/core/session.py b/fiftyone/core/session.py
--- a/fiftyone/core/session.py
+++ b/fiftyone/core/session.py
@@ -6,6 +6,7 @@
 |
 """
 import logging
+from collections import defaultdict
 
 import fiftyone.core.client as foc
 import fiftyone.core.service as fos
@@ -14,8 +15,22 @@
 
 logger = logging.getLogger(__name__)
 
-# Global session singleton
-session = None
+#
+# Session globals
+#
+# _session is the proxy `Session` for `launch_app()` and `close_app()` calls
+# _server_services maintains active servers
+# _subscribed_sessions maintains sessions subscribed to an active server
+#
+# Both maps use port as the key, so the main python process is always aware
+# of what servers can be killed
+#
+# Note that a server process is killed via deletion of its
+# `fiftyone.core.service.ServerService` instance
+#
+_session = None
+_server_services = {}
+_subscribed_sessions = defaultdict(set)
 
 
 def launch_app(dataset=None, view=None, port=5151, remote=False):
@@ -35,7 +50,7 @@ def launch_app(dataset=None, view=None, port=5151, remote=False):
     Returns:
         a :class:`Session`
     """
-    global session  # pylint: disable=global-statement
+    global _session  # pylint: disable=global-statement
     #
     # Note, we always `close_app()` here rather than just calling
     # `session.open()` if a session already exists, because the app may have
@@ -48,20 +63,19 @@ def launch_app(dataset=None, view=None, port=5151, remote=False):
     #
     close_app()
 
-    session = Session(dataset=dataset, view=view, port=port, remote=remote)
+    _session = Session(dataset=dataset, view=view, port=port, remote=remote)
 
-    return session
+    return _session
 
 
 def close_app():
     """Closes the FiftyOne App, if necessary.
     If no app is currently open, this method has no effect.
     """
-    global session  # pylint: disable=global-statement
-
-    if session is not None:
-        session.close()
-        session = None
+    global _session  # pylint: disable=global-statement
+    if _session is not None:
+        _session.close()
+        _session = None
 
 
 def _update_state(func):
@@ -118,6 +132,12 @@ class Session(foc.HasClient):
     def __init__(self, dataset=None, view=None, port=5151, remote=False):
         self._port = port
         self._remote = remote
+        global _server_services  # pylint: disable=global-statement
+        if port not in _server_services:
+            _server_services[port] = fos.ServerService(port)
+
+        global _subscribed_sessions  # pylint: disable=global-statement
+        _subscribed_sessions[port].add(self)
 
         super().__init__(self._port)
 
@@ -135,6 +155,25 @@ def __init__(self, dataset=None, view=None, port=5151, remote=False):
                 % (self.server_port, self.server_port, self.server_port)
             )
 
+    def __del__(self):
+        """Deletes the Session by removing it from the `_subscribed_sessions`
+        global and deleting (stopping) the associated
+        :class:`fiftyone.core.service.ServerService` if no other sessions are
+        subscribed.
+        """
+        try:
+            global _subscribed_sessions  # pylint: disable=global-statement
+            _subscribed_sessions[self._port].discard(self)
+
+            if len(_subscribed_sessions[self._port]) == 0:
+                global _server_services  # pylint: disable=global-statement
+                if self._port in _server_services:
+                    service = _server_services.pop(self._port)
+                    service.stop()
+        except:
+            # e.g. globals were already garbage-collected
+            pass
+
     @property
     def dataset(self):
         """The :class:`fiftyone.core.dataset.Dataset` connected to the session.
@@ -158,6 +197,12 @@ def clear_dataset(self):
         """
         self.state.dataset = None
 
+    @property
+    def server_port(self):
+        """Getter for the port number of the session.
+        """
+        return self._port
+
     @property
     def view(self):
         """The :class:`fiftyone.core.view.DatasetView` connected to the
@@ -206,7 +251,7 @@ def close(self):
         if self._remote:
             return
 
-        self.state.view.close = True
+        self.state.close = True
         self._update_state()
 
     def wait(self):


Test patch:
diff --git a/tests/isolated/server_tests.py b/tests/isolated/server_tests.py
--- a/tests/isolated/server_tests.py
+++ b/tests/isolated/server_tests.py
@@ -24,7 +24,11 @@
 import fiftyone as fo
 from fiftyone.constants import SERVER_ADDR
 import fiftyone.core.client as foc
-from fiftyone.core.session import Session
+from fiftyone.core.session import (
+    Session,
+    _server_services,
+    _subscribed_sessions,
+)
 from fiftyone.core.state import StateDescriptionWithDerivables
 
 
@@ -182,6 +186,21 @@ def step_sessions(self):
         self.wait_for_response(session=True)
         self.assertEqual(str(self.session.view), str(other_session.view))
 
+    def step_server_services(self):
+        port = 5252
+        session_one = Session(port=port, remote=True)
+        session_two = Session(port=port, remote=True)
+        self.assertEqual(len(_subscribed_sessions[port]), 2)
+        self.assertEqual(len(_subscribed_sessions), 2)
+        self.assertEqual(len(_server_services), 2)
+        session_two.__del__()
+        self.assertEqual(len(_subscribed_sessions[port]), 1)
+        self.assertEqual(len(_subscribed_sessions), 2)
+        self.assertEqual(len(_server_services), 2)
+        session_one.__del__()
+        self.assertEqual(len(_subscribed_sessions[port]), 0)
+        self.assertEqual(len(_server_services), 1)
+
     def test_steps(self):
         for name, step in self.steps():
             try: