Repository: learningequality/kolibri

Issue:
kolibri.utils.server.PortCache can read $KOLIBRI_HOME/port_cache file in such a way as to block server startup
<!--
Instructions:
 * Fill out the sections below, replace …'s with information about your issue
 * Use the 'preview' function above this text box to verify formatting before submitting
-->

## Observed behavior
<!--
Description of the behavior that was observed, including screenshots or other references when applicable
-->

I tried to start up my devserver and kept getting errors that 8000 wasn't available. It only happened on a specific KOLIBRI_HOME but not on another.

I was digging around and noticed that the `$KOLIBIR_HOME/port_cache` file in the failing one [(gist with cat of options and port_cache files)](https://gist.github.com/nucleogenesis/0e267ece5ffb02c99619675132b954f2) had the values:

```
8000
XXXXX
```

But the succeeding one [(see gist of whole run w/ cat of options.ini and port_cache files)](https://gist.github.com/nucleogenesis/6f3d1f173972a2667624bd6212ae4c7c):

```
XXXXX
8000
```

## Expected behavior
<!--
Description of what behavior was expected but did not occur
-->

PortCache should load/save/read/etc the port cache without blocking server startup.

## Steps to reproduce
<!--
Precise steps that someone else can follow in order to see this behavior
-->

In your $KOLIBRI_HOME directory, make sure the number `8000` is first in the `$KOLIBRI_HOME/port_cache` file and try running `yarn devserver`.

## Context
<!--
Tell us about your environment, including:
 * Kolibri version
 * Operating system
 * Browser
-->

`develop`
Debian 10



Discussion:

Resolving patch:
diff --git a/kolibri/utils/server.py b/kolibri/utils/server.py
--- a/kolibri/utils/server.py
+++ b/kolibri/utils/server.py
@@ -128,8 +128,13 @@ def check_port_availability(host, port):
 class PortCache:
     def __init__(self):
         self.values = {}
+        self.occupied_ports = set()
         self.load()
 
+    def lock_port(self, port):
+        if port:
+            self.occupied_ports.add(port)
+
     def register_port(self, port):
         self.values[port] = True
         self.save()
@@ -137,7 +142,11 @@ def register_port(self, port):
     def get_port(self, host):
         if self.values:
             try:
-                port = next(p for p in self.values if not self.values[p])
+                port = next(
+                    p
+                    for p in self.values
+                    if not self.values[p] and p not in self.occupied_ports
+                )
                 if port:
                     if check_port_availability(host, port):
                         self.values[port] = True
@@ -513,7 +522,9 @@ def start(port=0, zip_port=0, serve_http=True, background=False):
     :param: port: Port number (default: 0) - assigned by free port
     """
     port = int(port)
+    port_cache.lock_port(port)
     zip_port = int(zip_port)
+    port_cache.lock_port(zip_port)
     # On Mac, Python crashes when forking the process, so prevent daemonization until we can figure out
     # a better fix. See https://github.com/learningequality/kolibri/issues/4821
     if sys.platform == "darwin":
@@ -577,12 +588,13 @@ def _read_pid_file(filename):
     Reads a pid file and returns the contents. PID files have 1 or 2 lines;
      - first line is always the pid
      - second line is the port the server is listening on.
-     - third line is the status of the server process
+     - third line is the port the alternate origin server is listening on
+     - fourth line is the status of the server process
 
     :param filename: Path of PID to read
-    :return: (pid, port, status): with the PID in the file and the port number
-                          if it exists. If the port number doesn't exist, then
-                          port is None.
+    :return: (pid, port, zip_port, status): with the PID in the file, the port numbers
+                          if they exist. If the port number doesn't exist, then
+                          port is None. Lastly, the status code is returned.
     """
     if not os.path.isfile(PID_FILE):
         return None, None, None, STATUS_STOPPED


Test patch: