fix: resolve actual port before printing --root URL when -p 0

Closes #873.

When `datasette -p 0 --root` was used, the printed auth-token URL
contained the literal placeholder port 0 instead of the OS-assigned
port that uvicorn would later bind to. Same applied to `--open`.

Fix: when `port == 0` and we need to print/open a URL before the
server starts (because of --root or --open), pre-bind a TCP socket
on (host, 0), read the assigned port via getsockname(), and hand the
bound socket to uvicorn via Server.run(sockets=[...]). uvicorn.run()'s
own `fd=` parameter assumes AF_UNIX so we use the Config/Server API
in this branch only; the existing uvicorn.run() path is unchanged.

Adds a regression test that launches `datasette --memory -p 0 --root`,
parses the printed URL, asserts the port is non-zero, and confirms a
server is actually listening on that port.
This commit is contained in:
alvinttang 2026-04-25 22:30:14 +08:00
commit 300fe125c5
2 changed files with 87 additions and 0 deletions

View file

@ -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
)

View file

@ -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()