diff --git a/datasette/cli.py b/datasette/cli.py index 93aa22ef21..c03235884a 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -1,4 +1,5 @@ import asyncio +import socket import uvicorn import click from click import formatting @@ -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 @@ -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 ) diff --git a/tests/test_cli_serve_server.py b/tests/test_cli_serve_server.py index 47f23c08a9..449c71f1f0 100644 --- a/tests/test_cli_serve_server.py +++ b/tests/test_cli_serve_server.py @@ -1,6 +1,11 @@ import httpx import pytest +import re import socket +import subprocess +import sys +import tempfile +import time @pytest.mark.serial @@ -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()