diff --git a/datasette/app.py b/datasette/app.py index 298523d6..e1321db8 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -3110,12 +3110,12 @@ ORDER BY allowed.parent, allowed.child on_startup=[self._startup_sequence, self._launch_background_tasks], on_shutdown=[self.invoke_shutdown], ) + for wrapper in pm.hook.asgi_wrapper(datasette=self): + asgi = wrapper(asgi) asgi = AsgiRunOnFirstRequest( asgi, on_startup=[self._startup_sequence, self._launch_background_tasks], ) - for wrapper in pm.hook.asgi_wrapper(datasette=self): - asgi = wrapper(asgi) return asgi diff --git a/docs/internals.rst b/docs/internals.rst index d3439254..4b646449 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -1426,7 +1426,7 @@ Three trigger paths ~~~~~~~~~~~~~~~~~~~ - **``datasette serve`` (CLI)** — startup and ``uvicorn.Server.serve()`` both run inside a single ``asyncio.run()`` call, so there is exactly one event loop for the whole life of the process. -- **ASGI lifespan** — ``Datasette.app()`` wires startup and background-task launch into the ``on_startup`` list, and shutdown into the ``on_shutdown`` list, of an internal ``AsgiLifespan`` wrapper. A spec-compliant ASGI server (uvicorn, hypercorn, and others) sends the ``lifespan.startup`` message and waits for ``lifespan.startup.complete`` before delivering any ``http`` or ``websocket`` scope, so startup — including every plugin's own internal-database migrations — is guaranteed to have finished before any request reaches Datasette. If a ``startup`` hook raises, ``AsgiLifespan`` sends ``lifespan.startup.failed`` with the exception message instead of hanging or crashing ambiguously, so the host can abort the boot cleanly. +- **ASGI lifespan** — ``Datasette.app()`` wires startup and background-task launch into the ``on_startup`` list, and shutdown into the ``on_shutdown`` list, of an internal ``AsgiLifespan`` wrapper. A spec-compliant ASGI server (uvicorn, hypercorn, and others) sends the ``lifespan.startup`` message and waits for ``lifespan.startup.complete`` before delivering any ``http`` or ``websocket`` scope, so startup — including every plugin's own internal-database migrations — is guaranteed to have finished before any request reaches Datasette, including requests seen by plugin :ref:`asgi_wrapper ` middleware. If a ``startup`` hook raises, ``AsgiLifespan`` sends ``lifespan.startup.failed`` with the exception message instead of hanging or crashing ambiguously, so the host can abort the boot cleanly. - **First-request fallback** — an internal ``AsgiRunOnFirstRequest`` wrapper runs the same startup work as a safety net for hosts that never send ASGI lifespan events at all: some ASGI mounts, a bare ``app()`` embedded inside another framework, and :ref:`datasette.client ` / test clients, which drive requests directly over ``httpx2.ASGITransport`` without ever emitting ``lifespan.startup``. It runs startup exactly once, the first time any non-lifespan scope arrives, guarded by a lock so that concurrent early requests can't run it twice. All three paths call the same idempotent internal methods, so it is safe for more than one of them to fire — lifespan startup completing and then a first request arriving afterwards is a no-op the second time. A host that never sends lifespan events and never goes through the CLI degrades to first-request timing: startup runs on the first request instead of before it, exactly as Datasette always worked prior to this lifecycle guarantee. This is a deliberate fallback rather than a regression — see :ref:`datasette_add_background_task` for how to opt out of launching background tasks (the ``--get`` CLI path) or drive startup and launch explicitly (tests, headless embedders). diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py index b01e3178..3a25ccbc 100644 --- a/tests/test_lifespan.py +++ b/tests/test_lifespan.py @@ -257,3 +257,88 @@ async def test_setup_db_still_runs_when_invoke_startup_ran_first(tmp_path, monke # Idempotency: a second call must not recompute. await ds._startup_sequence() assert call_count["n"] == 1 + + +@pytest.mark.asyncio +async def test_asgi_wrapper_runs_after_startup_fallback_path(): + class AssertStartupPlugin: + __name__ = "AssertStartupPlugin" + + @hookimpl + def asgi_wrapper(self, datasette): + def wrap(app): + async def check_startup(scope, receive, send): + if scope["type"] == "http": + assert ( + datasette._startup_invoked is True + ), "asgi_wrapper saw an http scope before startup completed" + await app(scope, receive, send) + + return check_startup + + return wrap + + ds = Datasette(memory=True) + pm.register(AssertStartupPlugin(), name="assert_startup_plugin") + try: + assert ds._startup_invoked is False + app = ds.app() + transport = httpx2.ASGITransport(app=app) + async with httpx2.AsyncClient( + transport=transport, base_url="http://localhost" + ) as client: + response = await client.get("/-/versions.json") + assert response.status_code == 200 + finally: + pm.unregister(name="assert_startup_plugin") + + assert ds._startup_invoked is True + + +@pytest.mark.asyncio +async def test_short_circuit_wrapper_no_longer_defers_startup(): + # Middleware that returns a response before getting to the rest of + # Datasette should still cause _startup_invoked=True + class ShortCircuitPlugin: + __name__ = "ShortCircuitPlugin" + + @hookimpl + def asgi_wrapper(self, datasette): + def wrap(app): + async def forbidden(scope, receive, send): + if scope["type"] != "http": + await app(scope, receive, send) + return + await send( + { + "type": "http.response.start", + "status": 403, + "headers": [[b"content-type", b"text/plain"]], + } + ) + await send( + { + "type": "http.response.body", + "body": b"Forbidden", + } + ) + + return forbidden + + return wrap + + ds = Datasette(memory=True) + pm.register(ShortCircuitPlugin(), name="short_circuit_plugin") + try: + assert ds._startup_invoked is False + app = ds.app() + transport = httpx2.ASGITransport(app=app) + async with httpx2.AsyncClient( + transport=transport, base_url="http://localhost" + ) as client: + response = await client.get("/-/versions.json") + assert response.status_code == 403 + finally: + pm.unregister(name="short_circuit_plugin") + + assert ds._startup_invoked is True