Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions datasette/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import socket
import uvicorn
import click
from click import formatting
Expand Down Expand Up @@ -705,6 +706,18 @@ def serve(
return

# Start the server
# If port is 0 and we need to print/open a URL before the server starts
# (because of --root or --open), pre-bind a TCP socket so we know the
# OS-assigned port. See https://github.com/simonw/datasette/issues/873
pre_bound_socket = None
if port == 0 and not uds and (root or open_browser):
family = socket.AF_INET6 if host and ":" in host else socket.AF_INET
pre_bound_socket = socket.socket(family=family, type=socket.SOCK_STREAM)
pre_bound_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
pre_bound_socket.bind((host, 0))
pre_bound_socket.set_inheritable(True)
port = pre_bound_socket.getsockname()[1]

url = None
if root:
ds.root_enabled = True
Expand All @@ -718,6 +731,19 @@ def serve(
path = run_sync(lambda: initial_path_for_datasette(ds))
url = f"http://{host}:{port}{path}"
webbrowser.open(url)
if pre_bound_socket is not None:
# Hand the pre-bound socket to uvicorn via Server.run(sockets=[...])
# since uvicorn.run()'s `fd=` parameter assumes AF_UNIX.
config_kwargs = dict(
host=host, port=port, log_level="info", lifespan="on", workers=1
)
if ssl_keyfile:
config_kwargs["ssl_keyfile"] = ssl_keyfile
if ssl_certfile:
config_kwargs["ssl_certfile"] = ssl_certfile
config = uvicorn.Config(ds.app(), **config_kwargs)
uvicorn.Server(config).run(sockets=[pre_bound_socket])
return
uvicorn_kwargs = dict(
host=host, port=port, log_level="info", lifespan="on", workers=1
)
Expand Down
61 changes: 61 additions & 0 deletions tests/test_cli_serve_server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import httpx
import pytest
import re
import socket
import subprocess
import sys
import tempfile
import time


@pytest.mark.serial
Expand All @@ -27,3 +32,59 @@ def test_serve_unix_domain_socket(ds_unix_domain_socket_server):
"path": "/_memory",
"tables": [],
}.items() <= response.json().items()


@pytest.mark.serial
def test_serve_root_url_uses_actual_port_when_port_is_zero():
# Regression test for https://github.com/simonw/datasette/issues/873
# `datasette -p 0 --root` printed http://127.0.0.1:0/... instead of
# the OS-assigned port.
proc = subprocess.Popen(
[sys.executable, "-m", "datasette", "--memory", "-p", "0", "--root"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=tempfile.gettempdir(),
text=True,
)
try:
# Read lines until we see the auth-token URL or time out
url_line = None
deadline = time.time() + 10.0
while time.time() < deadline:
line = proc.stdout.readline()
if not line:
if proc.poll() is not None:
break
continue
if "/-/auth-token?token=" in line:
url_line = line.strip()
break
assert url_line, "Did not see auth-token URL in datasette output"
match = re.match(r"http://127\.0\.0\.1:(\d+)/-/auth-token\?token=", url_line)
assert match, f"Unexpected auth-token URL format: {url_line!r}"
printed_port = int(match.group(1))
assert printed_port != 0, (
"datasette -p 0 --root should print the OS-assigned port, "
"not the placeholder 0"
)
# Confirm a server is actually listening on that printed port
deadline2 = time.time() + 5.0
last_err = None
while time.time() < deadline2:
try:
response = httpx.get(f"http://127.0.0.1:{printed_port}/_memory.json")
assert response.status_code == 200
break
except httpx.ConnectError as exc:
last_err = exc
time.sleep(0.1)
else:
raise AssertionError(
f"Could not connect to printed port {printed_port}: {last_err}"
)
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
Loading