datasette/tests/test_cli_serve_server.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

152 lines
5.3 KiB
Python
Raw Permalink Normal View History

import socket
import time
import httpx
import pytest
@pytest.mark.serial
def test_serve_localhost_http(ds_localhost_http_server):
response = httpx.get("http://localhost:8041/_memory.json")
assert {
"database": "_memory",
"path": "/_memory",
"tables": [],
}.items() <= response.json().items()
@pytest.mark.serial
2021-07-10 16:46:49 -07:00
@pytest.mark.skipif(
not hasattr(socket, "AF_UNIX"), reason="Requires socket.AF_UNIX support"
)
def test_serve_unix_domain_socket(ds_unix_domain_socket_server):
_, uds = ds_unix_domain_socket_server
transport = httpx.HTTPTransport(uds=uds)
client = httpx.Client(transport=transport)
response = client.get("http://localhost/_memory.json")
assert {
"database": "_memory",
"path": "/_memory",
"tables": [],
}.items() <= response.json().items()
# Shaped after datasette-litestream's startup hook, which schedules a
# background task with asyncio.get_running_loop().create_task(...):
# https://github.com/datasette/datasette-litestream
Move the serve-subprocess test plumbing into a conftest fixture The two new tests span a datasette serve subprocess by hand: each found a free port with a copy of test_playwright.py's find_free_port, built its own subprocess.Popen call, polled with its own 15 second deadline loop, and tore the process down in its own finally block. That is the third and fourth hand-rolled copy of plumbing conftest.py already owns for ds_localhost_http_server and ds_unix_domain_socket_server. Move find_free_port into conftest.py and add a serve_with_plugins factory fixture that writes plugin sources to a temporary --plugins-dir, takes a free port, waits for the server to answer, and terminates every process it started when the test ends. wait_until_responds() grows an optional process argument so a server that dies during startup fails immediately with its captured output instead of waiting out the timeout, and now catches httpx.TransportError rather than only httpx.ConnectError - a superclass, so existing callers are unaffected. Two fixes beyond the deduplication: The marker test polls until its flag flips, which meant it would also have passed if the startup hook were re-run on the serving loop by the first-request fallback - the exact bug it exists to catch. That cannot happen while invoke_startup() is idempotent, but nothing said so. The plugin now counts startup calls and the test asserts it ran exactly once, so removing that guard fails the test loudly instead of quietly turning it into a no-op. test_startup_error_fails_fast_before_port_binds passes on unmodified main, where startup already ran ahead of uvicorn.run(), so it is a characterization test rather than a regression test for this commit; its docstring now says so. Its loop re-checking that nothing was listening ran about one iteration before the process exited, and could not distinguish a pre-bind failure from a port nothing ever touched, so it is replaced by a single check with a comment about what it does and does not prove. Verified the red side is preserved: the marker test still fails on unmodified main, now in 3.7s rather than 15.2s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:52:48 -07:00
MARKER_TASK_PLUGIN = """
import asyncio
from datasette import hookimpl
from datasette.utils.asgi import Response
@hookimpl
def startup(datasette):
Move the serve-subprocess test plumbing into a conftest fixture The two new tests span a datasette serve subprocess by hand: each found a free port with a copy of test_playwright.py's find_free_port, built its own subprocess.Popen call, polled with its own 15 second deadline loop, and tore the process down in its own finally block. That is the third and fourth hand-rolled copy of plumbing conftest.py already owns for ds_localhost_http_server and ds_unix_domain_socket_server. Move find_free_port into conftest.py and add a serve_with_plugins factory fixture that writes plugin sources to a temporary --plugins-dir, takes a free port, waits for the server to answer, and terminates every process it started when the test ends. wait_until_responds() grows an optional process argument so a server that dies during startup fails immediately with its captured output instead of waiting out the timeout, and now catches httpx.TransportError rather than only httpx.ConnectError - a superclass, so existing callers are unaffected. Two fixes beyond the deduplication: The marker test polls until its flag flips, which meant it would also have passed if the startup hook were re-run on the serving loop by the first-request fallback - the exact bug it exists to catch. That cannot happen while invoke_startup() is idempotent, but nothing said so. The plugin now counts startup calls and the test asserts it ran exactly once, so removing that guard fails the test loudly instead of quietly turning it into a no-op. test_startup_error_fails_fast_before_port_binds passes on unmodified main, where startup already ran ahead of uvicorn.run(), so it is a characterization test rather than a regression test for this commit; its docstring now says so. Its loop re-checking that nothing was listening ran about one iteration before the process exited, and could not distinguish a pre-bind failure from a port nothing ever touched, so it is replaced by a single check with a comment about what it does and does not prove. Verified the red side is preserved: the marker test still fails on unmodified main, now in 3.7s rather than 15.2s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:52:48 -07:00
datasette._startup_calls = getattr(datasette, "_startup_calls", 0) + 1
async def _mark():
# Must await before setting the flag: a task with no internal
# await point could finish on the throwaway loop before it
# closed, masking the regression this test guards against.
await asyncio.sleep(0.2)
datasette._marker_task_ran = True
asyncio.get_running_loop().create_task(_mark())
@hookimpl
def register_routes():
async def marker_status(datasette):
return Response.json(
Move the serve-subprocess test plumbing into a conftest fixture The two new tests span a datasette serve subprocess by hand: each found a free port with a copy of test_playwright.py's find_free_port, built its own subprocess.Popen call, polled with its own 15 second deadline loop, and tore the process down in its own finally block. That is the third and fourth hand-rolled copy of plumbing conftest.py already owns for ds_localhost_http_server and ds_unix_domain_socket_server. Move find_free_port into conftest.py and add a serve_with_plugins factory fixture that writes plugin sources to a temporary --plugins-dir, takes a free port, waits for the server to answer, and terminates every process it started when the test ends. wait_until_responds() grows an optional process argument so a server that dies during startup fails immediately with its captured output instead of waiting out the timeout, and now catches httpx.TransportError rather than only httpx.ConnectError - a superclass, so existing callers are unaffected. Two fixes beyond the deduplication: The marker test polls until its flag flips, which meant it would also have passed if the startup hook were re-run on the serving loop by the first-request fallback - the exact bug it exists to catch. That cannot happen while invoke_startup() is idempotent, but nothing said so. The plugin now counts startup calls and the test asserts it ran exactly once, so removing that guard fails the test loudly instead of quietly turning it into a no-op. test_startup_error_fails_fast_before_port_binds passes on unmodified main, where startup already ran ahead of uvicorn.run(), so it is a characterization test rather than a regression test for this commit; its docstring now says so. Its loop re-checking that nothing was listening ran about one iteration before the process exited, and could not distinguish a pre-bind failure from a port nothing ever touched, so it is replaced by a single check with a comment about what it does and does not prove. Verified the red side is preserved: the marker test still fails on unmodified main, now in 3.7s rather than 15.2s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:52:48 -07:00
{
"marker_task_ran": getattr(datasette, "_marker_task_ran", False),
"startup_calls": getattr(datasette, "_startup_calls", 0),
}
)
return [(r"^/-/marker-task-ran$", marker_status)]
Move the serve-subprocess test plumbing into a conftest fixture The two new tests span a datasette serve subprocess by hand: each found a free port with a copy of test_playwright.py's find_free_port, built its own subprocess.Popen call, polled with its own 15 second deadline loop, and tore the process down in its own finally block. That is the third and fourth hand-rolled copy of plumbing conftest.py already owns for ds_localhost_http_server and ds_unix_domain_socket_server. Move find_free_port into conftest.py and add a serve_with_plugins factory fixture that writes plugin sources to a temporary --plugins-dir, takes a free port, waits for the server to answer, and terminates every process it started when the test ends. wait_until_responds() grows an optional process argument so a server that dies during startup fails immediately with its captured output instead of waiting out the timeout, and now catches httpx.TransportError rather than only httpx.ConnectError - a superclass, so existing callers are unaffected. Two fixes beyond the deduplication: The marker test polls until its flag flips, which meant it would also have passed if the startup hook were re-run on the serving loop by the first-request fallback - the exact bug it exists to catch. That cannot happen while invoke_startup() is idempotent, but nothing said so. The plugin now counts startup calls and the test asserts it ran exactly once, so removing that guard fails the test loudly instead of quietly turning it into a no-op. test_startup_error_fails_fast_before_port_binds passes on unmodified main, where startup already ran ahead of uvicorn.run(), so it is a characterization test rather than a regression test for this commit; its docstring now says so. Its loop re-checking that nothing was listening ran about one iteration before the process exited, and could not distinguish a pre-bind failure from a port nothing ever touched, so it is replaced by a single check with a comment about what it does and does not prove. Verified the red side is preserved: the marker test still fails on unmodified main, now in 3.7s rather than 15.2s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:52:48 -07:00
"""
Move the serve-subprocess test plumbing into a conftest fixture The two new tests span a datasette serve subprocess by hand: each found a free port with a copy of test_playwright.py's find_free_port, built its own subprocess.Popen call, polled with its own 15 second deadline loop, and tore the process down in its own finally block. That is the third and fourth hand-rolled copy of plumbing conftest.py already owns for ds_localhost_http_server and ds_unix_domain_socket_server. Move find_free_port into conftest.py and add a serve_with_plugins factory fixture that writes plugin sources to a temporary --plugins-dir, takes a free port, waits for the server to answer, and terminates every process it started when the test ends. wait_until_responds() grows an optional process argument so a server that dies during startup fails immediately with its captured output instead of waiting out the timeout, and now catches httpx.TransportError rather than only httpx.ConnectError - a superclass, so existing callers are unaffected. Two fixes beyond the deduplication: The marker test polls until its flag flips, which meant it would also have passed if the startup hook were re-run on the serving loop by the first-request fallback - the exact bug it exists to catch. That cannot happen while invoke_startup() is idempotent, but nothing said so. The plugin now counts startup calls and the test asserts it ran exactly once, so removing that guard fails the test loudly instead of quietly turning it into a no-op. test_startup_error_fails_fast_before_port_binds passes on unmodified main, where startup already ran ahead of uvicorn.run(), so it is a characterization test rather than a regression test for this commit; its docstring now says so. Its loop re-checking that nothing was listening ran about one iteration before the process exited, and could not distinguish a pre-bind failure from a port nothing ever touched, so it is replaced by a single check with a comment about what it does and does not prove. Verified the red side is preserved: the marker test still fails on unmodified main, now in 3.7s rather than 15.2s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:52:48 -07:00
STARTUP_ERROR_PLUGIN = """
from datasette import hookimpl
from datasette.utils import StartupError
@hookimpl
def startup(datasette):
raise StartupError("boom from plugin")
Move the serve-subprocess test plumbing into a conftest fixture The two new tests span a datasette serve subprocess by hand: each found a free port with a copy of test_playwright.py's find_free_port, built its own subprocess.Popen call, polled with its own 15 second deadline loop, and tore the process down in its own finally block. That is the third and fourth hand-rolled copy of plumbing conftest.py already owns for ds_localhost_http_server and ds_unix_domain_socket_server. Move find_free_port into conftest.py and add a serve_with_plugins factory fixture that writes plugin sources to a temporary --plugins-dir, takes a free port, waits for the server to answer, and terminates every process it started when the test ends. wait_until_responds() grows an optional process argument so a server that dies during startup fails immediately with its captured output instead of waiting out the timeout, and now catches httpx.TransportError rather than only httpx.ConnectError - a superclass, so existing callers are unaffected. Two fixes beyond the deduplication: The marker test polls until its flag flips, which meant it would also have passed if the startup hook were re-run on the serving loop by the first-request fallback - the exact bug it exists to catch. That cannot happen while invoke_startup() is idempotent, but nothing said so. The plugin now counts startup calls and the test asserts it ran exactly once, so removing that guard fails the test loudly instead of quietly turning it into a no-op. test_startup_error_fails_fast_before_port_binds passes on unmodified main, where startup already ran ahead of uvicorn.run(), so it is a characterization test rather than a regression test for this commit; its docstring now says so. Its loop re-checking that nothing was listening ran about one iteration before the process exited, and could not distinguish a pre-bind failure from a port nothing ever touched, so it is replaced by a single check with a comment about what it does and does not prove. Verified the red side is preserved: the marker test still fails on unmodified main, now in 3.7s rather than 15.2s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:52:48 -07:00
"""
@pytest.mark.serial
Move the serve-subprocess test plumbing into a conftest fixture The two new tests span a datasette serve subprocess by hand: each found a free port with a copy of test_playwright.py's find_free_port, built its own subprocess.Popen call, polled with its own 15 second deadline loop, and tore the process down in its own finally block. That is the third and fourth hand-rolled copy of plumbing conftest.py already owns for ds_localhost_http_server and ds_unix_domain_socket_server. Move find_free_port into conftest.py and add a serve_with_plugins factory fixture that writes plugin sources to a temporary --plugins-dir, takes a free port, waits for the server to answer, and terminates every process it started when the test ends. wait_until_responds() grows an optional process argument so a server that dies during startup fails immediately with its captured output instead of waiting out the timeout, and now catches httpx.TransportError rather than only httpx.ConnectError - a superclass, so existing callers are unaffected. Two fixes beyond the deduplication: The marker test polls until its flag flips, which meant it would also have passed if the startup hook were re-run on the serving loop by the first-request fallback - the exact bug it exists to catch. That cannot happen while invoke_startup() is idempotent, but nothing said so. The plugin now counts startup calls and the test asserts it ran exactly once, so removing that guard fails the test loudly instead of quietly turning it into a no-op. test_startup_error_fails_fast_before_port_binds passes on unmodified main, where startup already ran ahead of uvicorn.run(), so it is a characterization test rather than a regression test for this commit; its docstring now says so. Its loop re-checking that nothing was listening ran about one iteration before the process exited, and could not distinguish a pre-bind failure from a port nothing ever touched, so it is replaced by a single check with a comment about what it does and does not prove. Verified the red side is preserved: the marker test still fails on unmodified main, now in 3.7s rather than 15.2s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:52:48 -07:00
def test_startup_hook_background_task_runs_on_serving_loop(serve_with_plugins):
"""
Litestream-shaped regression test: a startup hook that does
asyncio.get_running_loop().create_task(...) must have that task
actually execute before/while the server is handling requests. This
only holds if invoke_startup() and uvicorn.Server.serve() share one
event loop. This test fails against unmodified main, where
invoke_startup() runs on a throwaway loop that is closed before
uvicorn opens its own loop to serve.
"""
Move the serve-subprocess test plumbing into a conftest fixture The two new tests span a datasette serve subprocess by hand: each found a free port with a copy of test_playwright.py's find_free_port, built its own subprocess.Popen call, polled with its own 15 second deadline loop, and tore the process down in its own finally block. That is the third and fourth hand-rolled copy of plumbing conftest.py already owns for ds_localhost_http_server and ds_unix_domain_socket_server. Move find_free_port into conftest.py and add a serve_with_plugins factory fixture that writes plugin sources to a temporary --plugins-dir, takes a free port, waits for the server to answer, and terminates every process it started when the test ends. wait_until_responds() grows an optional process argument so a server that dies during startup fails immediately with its captured output instead of waiting out the timeout, and now catches httpx.TransportError rather than only httpx.ConnectError - a superclass, so existing callers are unaffected. Two fixes beyond the deduplication: The marker test polls until its flag flips, which meant it would also have passed if the startup hook were re-run on the serving loop by the first-request fallback - the exact bug it exists to catch. That cannot happen while invoke_startup() is idempotent, but nothing said so. The plugin now counts startup calls and the test asserts it ran exactly once, so removing that guard fails the test loudly instead of quietly turning it into a no-op. test_startup_error_fails_fast_before_port_binds passes on unmodified main, where startup already ran ahead of uvicorn.run(), so it is a characterization test rather than a regression test for this commit; its docstring now says so. Its loop re-checking that nothing was listening ran about one iteration before the process exited, and could not distinguish a pre-bind failure from a port nothing ever touched, so it is replaced by a single check with a comment about what it does and does not prove. Verified the red side is preserved: the marker test still fails on unmodified main, now in 3.7s rather than 15.2s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:52:48 -07:00
_, port = serve_with_plugins({"marker_task_plugin": MARKER_TASK_PLUGIN})
# The fixture has already waited for the server to answer requests. The
# marker task deliberately awaits before setting its flag, so poll for a
# moment rather than assuming it landed before the first request arrived.
deadline = time.time() + 3.0
payload = {}
while time.time() < deadline:
payload = httpx.get(
f"http://127.0.0.1:{port}/-/marker-task-ran", timeout=1.0
).json()
if payload["marker_task_ran"]:
break
time.sleep(0.05)
assert payload.get("marker_task_ran"), (
"The startup hook's asyncio.create_task(...) never ran - "
"invoke_startup() and the server are not sharing an event loop"
)
Move the serve-subprocess test plumbing into a conftest fixture The two new tests span a datasette serve subprocess by hand: each found a free port with a copy of test_playwright.py's find_free_port, built its own subprocess.Popen call, polled with its own 15 second deadline loop, and tore the process down in its own finally block. That is the third and fourth hand-rolled copy of plumbing conftest.py already owns for ds_localhost_http_server and ds_unix_domain_socket_server. Move find_free_port into conftest.py and add a serve_with_plugins factory fixture that writes plugin sources to a temporary --plugins-dir, takes a free port, waits for the server to answer, and terminates every process it started when the test ends. wait_until_responds() grows an optional process argument so a server that dies during startup fails immediately with its captured output instead of waiting out the timeout, and now catches httpx.TransportError rather than only httpx.ConnectError - a superclass, so existing callers are unaffected. Two fixes beyond the deduplication: The marker test polls until its flag flips, which meant it would also have passed if the startup hook were re-run on the serving loop by the first-request fallback - the exact bug it exists to catch. That cannot happen while invoke_startup() is idempotent, but nothing said so. The plugin now counts startup calls and the test asserts it ran exactly once, so removing that guard fails the test loudly instead of quietly turning it into a no-op. test_startup_error_fails_fast_before_port_binds passes on unmodified main, where startup already ran ahead of uvicorn.run(), so it is a characterization test rather than a regression test for this commit; its docstring now says so. Its loop re-checking that nothing was listening ran about one iteration before the process exited, and could not distinguish a pre-bind failure from a port nothing ever touched, so it is replaced by a single check with a comment about what it does and does not prove. Verified the red side is preserved: the marker test still fails on unmodified main, now in 3.7s rather than 15.2s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:52:48 -07:00
# Polling above means this test would also pass if the startup hook were
# re-run on the serving loop by the first-request fallback - which would
# hide exactly the bug being tested. invoke_startup() is idempotent today
# so that cannot happen; assert it explicitly so that if the idempotency
# guard is ever removed this test fails loudly instead of silently
# becoming a no-op.
assert payload["startup_calls"] == 1, (
"startup hook ran {} times - the marker may have been set by a "
"re-run on the serving loop rather than by the original task".format(
payload["startup_calls"]
)
Move the serve-subprocess test plumbing into a conftest fixture The two new tests span a datasette serve subprocess by hand: each found a free port with a copy of test_playwright.py's find_free_port, built its own subprocess.Popen call, polled with its own 15 second deadline loop, and tore the process down in its own finally block. That is the third and fourth hand-rolled copy of plumbing conftest.py already owns for ds_localhost_http_server and ds_unix_domain_socket_server. Move find_free_port into conftest.py and add a serve_with_plugins factory fixture that writes plugin sources to a temporary --plugins-dir, takes a free port, waits for the server to answer, and terminates every process it started when the test ends. wait_until_responds() grows an optional process argument so a server that dies during startup fails immediately with its captured output instead of waiting out the timeout, and now catches httpx.TransportError rather than only httpx.ConnectError - a superclass, so existing callers are unaffected. Two fixes beyond the deduplication: The marker test polls until its flag flips, which meant it would also have passed if the startup hook were re-run on the serving loop by the first-request fallback - the exact bug it exists to catch. That cannot happen while invoke_startup() is idempotent, but nothing said so. The plugin now counts startup calls and the test asserts it ran exactly once, so removing that guard fails the test loudly instead of quietly turning it into a no-op. test_startup_error_fails_fast_before_port_binds passes on unmodified main, where startup already ran ahead of uvicorn.run(), so it is a characterization test rather than a regression test for this commit; its docstring now says so. Its loop re-checking that nothing was listening ran about one iteration before the process exited, and could not distinguish a pre-bind failure from a port nothing ever touched, so it is replaced by a single check with a comment about what it does and does not prove. Verified the red side is preserved: the marker test still fails on unmodified main, now in 3.7s rather than 15.2s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:52:48 -07:00
)
@pytest.mark.serial
Move the serve-subprocess test plumbing into a conftest fixture The two new tests span a datasette serve subprocess by hand: each found a free port with a copy of test_playwright.py's find_free_port, built its own subprocess.Popen call, polled with its own 15 second deadline loop, and tore the process down in its own finally block. That is the third and fourth hand-rolled copy of plumbing conftest.py already owns for ds_localhost_http_server and ds_unix_domain_socket_server. Move find_free_port into conftest.py and add a serve_with_plugins factory fixture that writes plugin sources to a temporary --plugins-dir, takes a free port, waits for the server to answer, and terminates every process it started when the test ends. wait_until_responds() grows an optional process argument so a server that dies during startup fails immediately with its captured output instead of waiting out the timeout, and now catches httpx.TransportError rather than only httpx.ConnectError - a superclass, so existing callers are unaffected. Two fixes beyond the deduplication: The marker test polls until its flag flips, which meant it would also have passed if the startup hook were re-run on the serving loop by the first-request fallback - the exact bug it exists to catch. That cannot happen while invoke_startup() is idempotent, but nothing said so. The plugin now counts startup calls and the test asserts it ran exactly once, so removing that guard fails the test loudly instead of quietly turning it into a no-op. test_startup_error_fails_fast_before_port_binds passes on unmodified main, where startup already ran ahead of uvicorn.run(), so it is a characterization test rather than a regression test for this commit; its docstring now says so. Its loop re-checking that nothing was listening ran about one iteration before the process exited, and could not distinguish a pre-bind failure from a port nothing ever touched, so it is replaced by a single check with a comment about what it does and does not prove. Verified the red side is preserved: the marker test still fails on unmodified main, now in 3.7s rather than 15.2s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:52:48 -07:00
def test_startup_error_fails_fast_before_port_binds(serve_with_plugins):
"""
A "startup" plugin hook that raises StartupError must fail fast: print
the message, exit non-zero, and never accept a connection on the port -
the failure must happen before uvicorn.Server binds the socket.
Move the serve-subprocess test plumbing into a conftest fixture The two new tests span a datasette serve subprocess by hand: each found a free port with a copy of test_playwright.py's find_free_port, built its own subprocess.Popen call, polled with its own 15 second deadline loop, and tore the process down in its own finally block. That is the third and fourth hand-rolled copy of plumbing conftest.py already owns for ds_localhost_http_server and ds_unix_domain_socket_server. Move find_free_port into conftest.py and add a serve_with_plugins factory fixture that writes plugin sources to a temporary --plugins-dir, takes a free port, waits for the server to answer, and terminates every process it started when the test ends. wait_until_responds() grows an optional process argument so a server that dies during startup fails immediately with its captured output instead of waiting out the timeout, and now catches httpx.TransportError rather than only httpx.ConnectError - a superclass, so existing callers are unaffected. Two fixes beyond the deduplication: The marker test polls until its flag flips, which meant it would also have passed if the startup hook were re-run on the serving loop by the first-request fallback - the exact bug it exists to catch. That cannot happen while invoke_startup() is idempotent, but nothing said so. The plugin now counts startup calls and the test asserts it ran exactly once, so removing that guard fails the test loudly instead of quietly turning it into a no-op. test_startup_error_fails_fast_before_port_binds passes on unmodified main, where startup already ran ahead of uvicorn.run(), so it is a characterization test rather than a regression test for this commit; its docstring now says so. Its loop re-checking that nothing was listening ran about one iteration before the process exited, and could not distinguish a pre-bind failure from a port nothing ever touched, so it is replaced by a single check with a comment about what it does and does not prove. Verified the red side is preserved: the marker test still fails on unmodified main, now in 3.7s rather than 15.2s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:52:48 -07:00
Note this is a characterization test, not a regression test: it also
passes on unmodified main, where startup already ran ahead of
uvicorn.run(). It earns its keep once startup moves into the ASGI
lifespan, where fail-fast is genuinely at risk.
"""
proc, port = serve_with_plugins(
{"startup_error_plugin": STARTUP_ERROR_PLUGIN}, wait_for_startup=False
)
Move the serve-subprocess test plumbing into a conftest fixture The two new tests span a datasette serve subprocess by hand: each found a free port with a copy of test_playwright.py's find_free_port, built its own subprocess.Popen call, polled with its own 15 second deadline loop, and tore the process down in its own finally block. That is the third and fourth hand-rolled copy of plumbing conftest.py already owns for ds_localhost_http_server and ds_unix_domain_socket_server. Move find_free_port into conftest.py and add a serve_with_plugins factory fixture that writes plugin sources to a temporary --plugins-dir, takes a free port, waits for the server to answer, and terminates every process it started when the test ends. wait_until_responds() grows an optional process argument so a server that dies during startup fails immediately with its captured output instead of waiting out the timeout, and now catches httpx.TransportError rather than only httpx.ConnectError - a superclass, so existing callers are unaffected. Two fixes beyond the deduplication: The marker test polls until its flag flips, which meant it would also have passed if the startup hook were re-run on the serving loop by the first-request fallback - the exact bug it exists to catch. That cannot happen while invoke_startup() is idempotent, but nothing said so. The plugin now counts startup calls and the test asserts it ran exactly once, so removing that guard fails the test loudly instead of quietly turning it into a no-op. test_startup_error_fails_fast_before_port_binds passes on unmodified main, where startup already ran ahead of uvicorn.run(), so it is a characterization test rather than a regression test for this commit; its docstring now says so. Its loop re-checking that nothing was listening ran about one iteration before the process exited, and could not distinguish a pre-bind failure from a port nothing ever touched, so it is replaced by a single check with a comment about what it does and does not prove. Verified the red side is preserved: the marker test still fails on unmodified main, now in 3.7s rather than 15.2s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:52:48 -07:00
stdout, _ = proc.communicate(timeout=15)
output = stdout.decode("utf-8")
assert proc.returncode not in (0, None), output
assert "boom from plugin" in output, output
# Nothing is listening on the port now the process has exited. This
# confirms the socket was not left bound; on its own it cannot prove the
# failure preceded the bind, since a port nothing ever touched also
# refuses connections.
with (
pytest.raises(OSError),
socket.create_connection(("127.0.0.1", port), timeout=0.2),
):
pass