Repository: streamlit/streamlit

Issue:
Streamlit should fail gracefully if `server.port` is already in use.
# Problem

It is annoying when I already have Streamlit running in one terminal which blocks the port and prevents Streamlit from running in another port.

# Steps to repro

1. In one terminal, type `streamlit run foo.py`
2. In another terminal, type `streamlit run bar.py`

What happens: the second Streamlit call fails to run because the port is already taken
Desired: we somehow switch ports

# Desired solution

When the port is already taken, print this to the terminal:

```
ERROR: Port %(port)s is already in use.
Please enter another port, or press Enter to exit:
```
Then, on `Enter`:
* If the user leaves this blank, exit
* If the user types an invalid string or invalid port, print:
  ```
  "%(input)s" is not a valid port.
  Please enter another port, or press Enter to exit:
  ```
* If the user types a valid port but it's already taken, print the original message from the top of this section.
* If the user types a valid port, continue.

## Possible additions

In our old repo we have [a larger discussions around this](https://github.com/streamlit/streamlit-old-private/issues/901). We may want to implement some of those solutions later. But for now, let's just do the MVP above.




Discussion:
While this bug is not fixed, a quick (very imperfect) fix is to kill all processes containing the work `streamlit` by running `pkill -f streamlit` as follows:
```
$ streamlit run my_app.py # <- trying to run for the first time
Traceback (most recent call last):
...
OSError: [Errno 48] Address already in use
$ pkill -f streamlit
$ streamlit run my_app.py # <- this time it should work
```
When running multiple instances of the same thing I usually don't care which port it is running at, shouldn't it default to the next available port? Nothing in the website part or the server part makes me think knowing the port -or deciding which port it is going to be- relevant 
@dnuske : Yes. That would be even better.


Resolving patch:
diff --git a/lib/streamlit/server/Server.py b/lib/streamlit/server/Server.py
--- a/lib/streamlit/server/Server.py
+++ b/lib/streamlit/server/Server.py
@@ -15,6 +15,8 @@
 
 import logging
 import threading
+import sys
+import errno
 from enum import Enum
 
 import tornado.concurrent
@@ -57,6 +59,10 @@
 # up before the first browser connects.
 PREHEATED_REPORT_SESSION = "PREHEATED_REPORT_SESSION"
 
+# When server.port is not available it will look for the next available port
+# up to MAX_PORT_SEARCH_RETRIES.
+MAX_PORT_SEARCH_RETRIES = 100
+
 
 class SessionInfo(object):
     """Type stored in our _report_sessions dict.
@@ -86,6 +92,51 @@ class State(Enum):
     STOPPED = "STOPPED"
 
 
+class RetriesExceeded(Exception):
+    pass
+
+
+def server_port_is_manually_set():
+    return config.is_manually_set("server.port")
+
+
+def start_listening(app, call_count=0):
+    """Takes the server start listening at the configured port.
+
+    In case the port is already taken it tries listening to the next available port.
+    It will error after MAX_PORT_SEARCH_RETRIES attempts.
+
+    """
+
+    port = config.get_option("server.port")
+    try:
+        app.listen(port)
+    except OSError as e:
+        if e.errno == errno.EADDRINUSE:
+            if call_count >= MAX_PORT_SEARCH_RETRIES:
+                raise RetriesExceeded(
+                    "Cannot start Streamlit server. Port %s is already in use, and Streamlit was unable to find a free port after %s attempts.",
+                    port,
+                    MAX_PORT_SEARCH_RETRIES,
+                )
+            if server_port_is_manually_set():
+                LOGGER.error("Port %s is already in use", port)
+                sys.exit(1)
+            else:
+                LOGGER.debug(
+                    "Port %s already in use, trying to use the next one.", port
+                )
+                port += 1
+                # Save port 3000 because it is used for the development server in the front end.
+                if port == 3000:
+                    port += 1
+
+                config._set_option("server.port", port, "server initialization")
+                start_listening(app, call_count + 1)
+        else:
+            raise
+
+
 class Server(object):
 
     _singleton = None
@@ -141,9 +192,12 @@ def start(self, on_started):
             raise RuntimeError("Server has already been started")
 
         LOGGER.debug("Starting server...")
+
         app = self._create_app()
+        start_listening(app)
+
         port = config.get_option("server.port")
-        app.listen(port)
+
         LOGGER.debug("Server started on port %s", port)
 
         self._ioloop.spawn_callback(self._loop_coroutine, on_started)


Test patch:
diff --git a/lib/tests/streamlit/Server_test.py b/lib/tests/streamlit/Server_test.py
--- a/lib/tests/streamlit/Server_test.py
+++ b/lib/tests/streamlit/Server_test.py
@@ -18,20 +18,27 @@
 import unittest
 
 import mock
+import pytest
 import tornado.testing
 import tornado.web
 import tornado.websocket
+import errno
 from mock import MagicMock
 from mock import patch
 from tornado import gen
 
+import streamlit.server.Server
 from streamlit import config
+from streamlit.server.Server import server_port_is_manually_set
+from streamlit.server.Server import MAX_PORT_SEARCH_RETRIES
 from streamlit.ForwardMsgCache import ForwardMsgCache
 from streamlit.ForwardMsgCache import populate_hash_if_needed
 from streamlit.elements import data_frame_proto
 from streamlit.proto.BlockPath_pb2 import BlockPath
 from streamlit.proto.ForwardMsg_pb2 import ForwardMsg
 from streamlit.server.Server import State
+from streamlit.server.Server import start_listening
+from streamlit.server.Server import RetriesExceeded
 from streamlit.server.routes import DebugHandler
 from streamlit.server.routes import HealthHandler
 from streamlit.server.routes import MessageCacheHandler
@@ -41,6 +48,10 @@
 from streamlit.server.server_util import serialize_forward_msg
 from tests.ServerTestCase import ServerTestCase
 
+from streamlit.logger import get_logger
+
+LOGGER = get_logger(__name__)
+
 
 def _create_dataframe_msg(df, id=1):
     msg = ForwardMsg()
@@ -306,6 +317,59 @@ def test_healthz(self):
         self.assertEqual(503, response.code)
 
 
+class PortRotateAHundredTest(unittest.TestCase):
+    """Tests port rotation handles a MAX_PORT_SEARCH_RETRIES attempts then sys exits"""
+
+    def get_app(self):
+        app = mock.MagicMock()
+
+        app.listen = mock.Mock()
+        app.listen.side_effect = OSError(errno.EADDRINUSE, "test", "asd")
+
+        return app
+
+    def test_rotates_a_hundred_ports(self):
+        app = self.get_app()
+        with pytest.raises(SystemExit) as pytest_wrapped_e:
+            start_listening(app)
+
+            assert pytest_wrapped_e.type == SystemExit
+            assert pytest_wrapped_e.value.code == errno.EADDRINUSE
+
+            assert app.listen.call_count == MAX_PORT_SEARCH_RETRIES
+
+
+class PortRotateOneTest(unittest.TestCase):
+    """Tests port rotates one port"""
+
+    which_port = mock.Mock()
+
+    def get_app(self):
+        app = mock.MagicMock()
+
+        app.listen = mock.Mock()
+        app.listen.side_effect = OSError(errno.EADDRINUSE, "test", "asd")
+
+        return app
+
+    @mock.patch("streamlit.server.Server.config._set_option")
+    @mock.patch("streamlit.server.Server.server_port_is_manually_set")
+    def test_rotates_one_port(
+        self, patched_server_port_is_manually_set, patched__set_option
+    ):
+        app = self.get_app()
+
+        patched_server_port_is_manually_set.return_value = False
+        with pytest.raises(RetriesExceeded) as pytest_wrapped_e:
+            start_listening(app)
+
+            PortRotateOneTest.which_port.assert_called_with(8502)
+
+            patched__set_option.assert_called_with(
+                "server.port", 8501, "server initialization"
+            )
+
+
 class MetricsHandlerTest(tornado.testing.AsyncHTTPTestCase):
     """Tests the /metrics endpoint"""