Repository: bokeh/bokeh

Issue:
Support --port 0 for random port
Sometimes we don't know a port that will be open apriori and we want a service to take a random open port.  The convention here is to specify port 0.  Bokeh currently reports the port as 0 when doing this:

```
mrocklin@workstation:~/workspace/bokeh$ bokeh serve examples/app/crossfilter/ --port 0
2016-11-29 10:27:45,282 Starting Bokeh server version 0.11.0dev2-4074-g9c73877-dirty
2016-11-29 10:27:45,285 Starting Bokeh server on port 0 with applications at paths ['/crossfilter']
2016-11-29 10:27:45,285 Starting Bokeh server with process id: 9766
```

It would be nice for it instead to select a random open port and report that port instead.  Tornado servers (and Python sockets generally) will follow this convention if you just call `TCPServer.listen(0)` or whatever.
Support --port 0 for random port
Sometimes we don't know a port that will be open apriori and we want a service to take a random open port.  The convention here is to specify port 0.  Bokeh currently reports the port as 0 when doing this:

```
mrocklin@workstation:~/workspace/bokeh$ bokeh serve examples/app/crossfilter/ --port 0
2016-11-29 10:27:45,282 Starting Bokeh server version 0.11.0dev2-4074-g9c73877-dirty
2016-11-29 10:27:45,285 Starting Bokeh server on port 0 with applications at paths ['/crossfilter']
2016-11-29 10:27:45,285 Starting Bokeh server with process id: 9766
```

It would be nice for it instead to select a random open port and report that port instead.  Tornado servers (and Python sockets generally) will follow this convention if you just call `TCPServer.listen(0)` or whatever.


Discussion:
OK, it looks like we're passing in port=0 as we should.  We probably need to just change the port attribute on the server object after we listen rather than before.

Here is a way to get the port from the server:

    >>> list(sa.server._http._sockets.values())[0].getsockname()[1]
    34874

@mrocklin it sounds like you already have an idea of the necessary changes, perhaps you could submit a quick PR
I do something like the following in my code:

```python
+++ b/bokeh/server/server.py
@@ -94,6 +94,7 @@ class Server(object):
         if 'port' in kwargs:
             self._port = kwargs['port']
 
+        # TODO: This fails to propogate port info to hosts if port==0
         tornado_kwargs['hosts'] = _create_hosts_whitelist(kwargs.get('host'), self._port)
         tornado_kwargs['extra_websocket_origins'] = _create_hosts_whitelist(kwargs.get('allow_websocket_origin'), self._port)
         tornado_kwargs['use_index'] = kwargs.get('use_index', True)
@@ -131,7 +132,7 @@ class Server(object):
 
     @property
     def port(self):
-        return self._port
+        return self._port or self._http._sockets.values())[0].getsockname()[1]
```

Note that handling zero ports means that Bokeh's whitelisting will fail if someone provides a hostname.  It might also be worth relaxing `_create_hosts_whitelist` to operate on any port if `port==0`


OK, it looks like we're passing in port=0 as we should.  We probably need to just change the port attribute on the server object after we listen rather than before.

Here is a way to get the port from the server:

    >>> list(sa.server._http._sockets.values())[0].getsockname()[1]
    34874

@mrocklin it sounds like you already have an idea of the necessary changes, perhaps you could submit a quick PR
I do something like the following in my code:

```python
+++ b/bokeh/server/server.py
@@ -94,6 +94,7 @@ class Server(object):
         if 'port' in kwargs:
             self._port = kwargs['port']
 
+        # TODO: This fails to propogate port info to hosts if port==0
         tornado_kwargs['hosts'] = _create_hosts_whitelist(kwargs.get('host'), self._port)
         tornado_kwargs['extra_websocket_origins'] = _create_hosts_whitelist(kwargs.get('allow_websocket_origin'), self._port)
         tornado_kwargs['use_index'] = kwargs.get('use_index', True)
@@ -131,7 +132,7 @@ class Server(object):
 
     @property
     def port(self):
-        return self._port
+        return self._port or self._http._sockets.values())[0].getsockname()[1]
```

Note that handling zero ports means that Bokeh's whitelisting will fail if someone provides a hostname.  It might also be worth relaxing `_create_hosts_whitelist` to operate on any port if `port==0`



Resolving patch:
diff --git a/bokeh/command/subcommands/serve.py b/bokeh/command/subcommands/serve.py
--- a/bokeh/command/subcommands/serve.py
+++ b/bokeh/command/subcommands/serve.py
@@ -87,6 +87,9 @@
 
     bokeh serve app_script.py --port=8080
 
+To listen on an arbitrary port, pass ``0`` as the port number.  The actual
+port number will be logged at startup.
+
 Similarly, a specific network address can be specified with the
 ``--address`` argument. For example:
 
diff --git a/bokeh/server/server.py b/bokeh/server/server.py
--- a/bokeh/server/server.py
+++ b/bokeh/server/server.py
@@ -10,6 +10,7 @@
 
 from tornado.httpserver import HTTPServer
 from tornado.ioloop import IOLoop
+from tornado import netutil
 
 from .tornado import BokehTornado
 
@@ -52,6 +53,20 @@ def _create_hosts_whitelist(host_list, port):
     return hosts
 
 
+def _bind_sockets(address, port):
+    '''Like tornado.netutil.bind_sockets(), but also returns the
+    assigned port number.
+    '''
+    ss = netutil.bind_sockets(port=port or 0, address=address)
+    assert len(ss)
+    ports = {s.getsockname()[1] for s in ss}
+    assert len(ports) == 1, "Multiple ports assigned??"
+    actual_port = ports.pop()
+    if port:
+        assert actual_port == port
+    return ss, actual_port
+
+
 class Server(object):
     ''' A Server which creates a new Session for each connection, using an Application to initialize each Session.
 
@@ -91,10 +106,6 @@ def __init__(self, applications, io_loop=None, **kwargs):
             prefix = "/" + prefix
         self._prefix = prefix
 
-        self._port = DEFAULT_SERVER_PORT
-        if 'port' in kwargs:
-            self._port = kwargs['port']
-
         self._started = False
         self._stopped = False
 
@@ -102,18 +113,8 @@ def __init__(self, applications, io_loop=None, **kwargs):
             io_loop = IOLoop.current()
         self._loop = io_loop
 
-        tornado_kwargs['io_loop'] = io_loop
-        tornado_kwargs['hosts'] = _create_hosts_whitelist(kwargs.get('host'), self._port)
-        tornado_kwargs['extra_websocket_origins'] = _create_hosts_whitelist(kwargs.get('allow_websocket_origin'), self._port)
-        tornado_kwargs['use_index'] = kwargs.get('use_index', True)
-        tornado_kwargs['redirect_root'] = kwargs.get('redirect_root', True)
-
-        self._tornado = BokehTornado(self._applications, self.prefix, **tornado_kwargs)
-        self._http = HTTPServer(self._tornado, xheaders=kwargs.get('use_xheaders', False))
-        self._address = None
-
-        if 'address' in kwargs:
-            self._address = kwargs['address']
+        port = kwargs.get('port', DEFAULT_SERVER_PORT)
+        self._address = kwargs.get('address') or None
 
         self._num_procs = kwargs.get('num_procs', 1)
         if self._num_procs != 1:
@@ -121,18 +122,37 @@ def __init__(self, applications, io_loop=None, **kwargs):
                       'User code has ran before attempting to run multiple '
                       'processes. This is considered an unsafe operation.')
 
-        # these queue a callback on the ioloop rather than
-        # doing the operation immediately (I think - havocp)
-        self._http.bind(self._port, address=self._address)
-        self._http.start(self._num_procs)
-        self._tornado.initialize(**tornado_kwargs)
+        sockets, self._port = _bind_sockets(self._address, port)
+        try:
+            tornado_kwargs['io_loop'] = io_loop
+            tornado_kwargs['hosts'] = _create_hosts_whitelist(kwargs.get('host'), self._port)
+            tornado_kwargs['extra_websocket_origins'] = _create_hosts_whitelist(kwargs.get('allow_websocket_origin'), self._port)
+            tornado_kwargs['use_index'] = kwargs.get('use_index', True)
+            tornado_kwargs['redirect_root'] = kwargs.get('redirect_root', True)
+
+            self._tornado = BokehTornado(self._applications, self.prefix, **tornado_kwargs)
+            self._tornado.initialize(**tornado_kwargs)
+            self._http = HTTPServer(self._tornado, xheaders=kwargs.get('use_xheaders', False))
+            self._http.start(self._num_procs)
+            self._http.add_sockets(sockets)
+
+        except Exception:
+            for s in sockets:
+                s.close()
+            raise
 
     @property
     def port(self):
+        '''The actual port number the server is listening on for HTTP
+        requests.
+        '''
         return self._port
 
     @property
     def address(self):
+        '''The address the server is listening on for HTTP requests
+        (may be empty or None).
+        '''
         return self._address
 
     @property


Test patch:
diff --git a/bokeh/command/subcommands/tests/test_serve.py b/bokeh/command/subcommands/tests/test_serve.py
--- a/bokeh/command/subcommands/tests/test_serve.py
+++ b/bokeh/command/subcommands/tests/test_serve.py
@@ -1,11 +1,13 @@
-from __future__ import absolute_import
+from __future__ import absolute_import, print_function
 
 import argparse
+import re
 import socket
 import subprocess
 import sys
 
 import pytest
+import requests
 
 import bokeh.command.subcommands.serve as scserve
 
@@ -187,3 +189,24 @@ def test_port_not_available():
         assert expected in out
     finally:
         sock.close()
+
+def test_actual_port_printed_out():
+    cmd = [sys.executable, "-m", "bokeh", "serve", "--port", "0"]
+    p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+    try:
+        pat = re.compile(r'Starting Bokeh server on port (\d+) with applications at paths')
+        while True:
+            line = p.stdout.readline()
+            print("child stdout>", line)
+            m = pat.search(line.decode())
+            if m is not None:
+                break
+        else:
+            pytest.fail("no matching log line in process output")
+        port = int(m.group(1))
+        assert port > 0
+        r = requests.get("http://localhost:%d/" % (port,))
+        assert r.status_code == 200
+    finally:
+        p.terminate()
+        p.wait()
diff --git a/bokeh/server/tests/test_server.py b/bokeh/server/tests/test_server.py
--- a/bokeh/server/tests/test_server.py
+++ b/bokeh/server/tests/test_server.py
@@ -531,6 +531,12 @@ def test__existing_ioloop_with_multiple_processes_exception():
         with ManagedServerLoop(application, num_procs=3):
             pass
 
+def test__actual_port_number():
+    application = Application()
+    with ManagedServerLoop(application, port=0) as server:
+        port = server.port
+        assert port > 0
+        http_get(server.io_loop, url(server))
 
 def test__ioloop_not_forcibly_stopped():
     # Issue #5494
diff --git a/bokeh/server/tests/utils.py b/bokeh/server/tests/utils.py
--- a/bokeh/server/tests/utils.py
+++ b/bokeh/server/tests/utils.py
@@ -6,10 +6,10 @@
 from tornado.websocket import websocket_connect
 
 def url(server, prefix=""):
-    return "http://localhost:" + str(server._port) + prefix + "/"
+    return "http://localhost:" + str(server.port) + prefix + "/"
 
 def ws_url(server, prefix=""):
-    return "ws://localhost:" + str(server._port) + prefix + "/ws"
+    return "ws://localhost:" + str(server.port) + prefix + "/ws"
 
 def http_get(io_loop, url, host=None):
     result = {}