2026-07-25 15:47:08 -07:00
|
|
|
import socket
|
2026-07-30 17:25:55 -07:00
|
|
|
import time
|
2026-07-25 15:47:08 -07:00
|
|
|
|
2021-02-11 16:53:20 -08:00
|
|
|
import httpx
|
2021-04-02 20:42:28 -07:00
|
|
|
import pytest
|
2021-02-11 16:53:20 -08:00
|
|
|
|
|
|
|
|
|
2021-04-02 20:42:28 -07:00
|
|
|
@pytest.mark.serial
|
2021-02-11 16:53:20 -08:00
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2021-07-10 16:37:30 -07:00
|
|
|
@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"
|
|
|
|
|
)
|
2021-07-10 16:37:30 -07:00
|
|
|
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()
|
2026-07-30 17:25:55 -07:00
|
|
|
|
|
|
|
|
|
2026-08-31 11:39:40 -07:00
|
|
|
# Shaped after datasette-litestream's startup hook, which schedules a
|
2026-07-30 17:25:55 -07:00
|
|
|
# background task with asyncio.get_running_loop().create_task(...):
|
2026-08-31 11:39:40 -07:00
|
|
|
# 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 = """
|
2026-07-30 17:25:55 -07:00
|
|
|
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
|
|
|
|
|
|
2026-07-30 17:25:55 -07:00
|
|
|
async def _mark():
|
2026-08-31 11:39:40 -07:00
|
|
|
# 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.
|
2026-07-30 17:25:55 -07:00
|
|
|
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),
|
|
|
|
|
}
|
2026-07-30 17:25:55 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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
|
|
|
"""
|
2026-07-30 17:25:55 -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 = """
|
2026-07-30 17:25:55 -07:00
|
|
|
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
|
|
|
"""
|
2026-07-30 17:25:55 -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):
|
2026-07-30 17:25:55 -07:00
|
|
|
"""
|
|
|
|
|
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"
|
2026-07-30 17:25:55 -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
|
|
|
# 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"]
|
2026-07-30 17:25:55 -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
|
|
|
)
|
2026-07-30 17:25:55 -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):
|
2026-07-30 17:25:55 -07:00
|
|
|
"""
|
|
|
|
|
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
|
2026-07-30 17:25:55 -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
|
|
|
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.
|
2026-08-31 13:17:44 -07:00
|
|
|
with (
|
|
|
|
|
pytest.raises(OSError),
|
|
|
|
|
socket.create_connection(("127.0.0.1", port), timeout=0.2),
|
|
|
|
|
):
|
|
|
|
|
pass
|