Repository navigation
Conversation
Replace the top-level Starlette app that wrapped the backend only to run lifespan tasks with a lightweight root middleware. It attaches the Reflex contexts to every ASGI call and answers lifespan events itself, using a route-less Starlette Router for the protocol. The lifespan now also enters the lifespan of each Starlette/FastAPI instance passed to api_transformer, after Reflex's own lifespan tasks and in transformer order, stopping in reverse. State they yield is merged into the ASGI lifespan state, so it reaches request.state. Apps mounted on a transformer and apps returned by a callable transformer still get no lifespan events. Requests no longer pass through an extra router, Mount and duplicate CORS middleware; CORS is added on top only when the outermost transformer is a callable, whose routes would otherwise bypass it. Closes #5220. Fixes the gap behind #5749. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjudwuP2ZkW5TQ9zdz7H9K
…e internals The previous commit started transformer lifespans by entering Starlette's router.lifespan_context, which only works for Starlette/FastAPI instances. Drive the ASGI lifespan protocol instead, the way a server does: - Lifespan events go to the outermost app the transformers produce, so middleware returned by a callable transformer gets them and forwards them inward per the spec. - Starlette does not forward lifespan events to the apps it mounts, so each app a Starlette transformer mounts (other than Reflex's own API) also gets them. Apps users mount on a transformer themselves do not. - Every app shares the server's lifespan state, so yielded state reaches request.state. - As in a server's "auto" mode, an app that raises, returns or sends another message instead of answering startup is skipped with an info log; startup.failed and shutdown.failed are raised. _RootMiddleware now answers the server's lifespan events itself instead of borrowing a route-less Starlette Router, so it can pass the server's scope through. The docs' logging middleware example now passes lifespan scopes through instead of failing on scope["path"]. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjudwuP2ZkW5TQ9zdz7H9K
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjudwuP2ZkW5TQ9zdz7H9K
Users who worked around transformer lifespans not running (#5749) by registering the same function with register_lifespan_task would now run it twice. Warn when building the app if a Starlette transformer's lifespan is also registered, and tell upgrading users in the news fragment to remove such workarounds. Also cover the server-facing shutdown.failed path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjudwuP2ZkW5TQ9zdz7H9K
There was a problem hiding this comment.
6 issues found across 9 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="docs/utility_methods/lifespan_tasks.md">
<violation number="1" location="docs/utility_methods/lifespan_tasks.md:65">
P3: The parenthetical is only accurate for Starlette `api_transformer`s. A callable transformer wraps the ASGI app and does not mount Reflex's Starlette app, so this wording misleads users with callable transformers. Reword it to state the Starlette-only condition.</violation>
</file>
<file name="news/+lighter-asgi-entrypoint.performance.md">
<violation number="1" location="news/+lighter-asgi-entrypoint.performance.md:1">
P2: This fragment describes a backwards-incompatible change (`rx.App` calls no longer return a `Starlette` instance), so it belongs in a `.breaking.md` fragment, not `.performance.md`. Rename the file to `+lighter-asgi-entrypoint.breaking.md` and keep the performance note in the text.</violation>
</file>
<file name="tests/units/app_mixins/test_lifespan.py">
<violation number="1" location="tests/units/app_mixins/test_lifespan.py:446">
P3: `silent_app` is `async` but never awaits, so ruff's RUF029 fails here. Add the same `# noqa: RUF029` that `http_only_app` already has.</violation>
</file>
<file name="reflex/app_mixins/lifespan.py">
<violation number="1" location="reflex/app_mixins/lifespan.py:122">
P2: An exception raised during ASGI lifespan startup is logged only as `repr(err)` at info level, so the traceback is lost. Log the exception with `exc_info` before treating the app as unsupported.</violation>
<violation number="2" location="reflex/app_mixins/lifespan.py:138">
P2: `_run_asgi_lifespan` treats every shutdown response except `lifespan.shutdown.failed` as success. A malformed or prematurely returning transformer can therefore make `_RootMiddleware` report shutdown complete without completing shutdown; require `lifespan.shutdown.complete` and reject missing or other messages.</violation>
</file>
<file name="tests/units/test_app.py">
<violation number="1" location="tests/units/test_app.py:735">
P3: Use a `with` block instead of calling `__enter__`/`__exit__` by hand. Manual calls leak the client's lifespan portal if the `/ping` assertion fails.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
| @@ -0,0 +1 @@ | |||
| The backend no longer wraps the app in an extra `Starlette` application to run lifespan tasks, which removes a routing layer and a duplicate CORS middleware from every request. As a result, calling the `rx.App` instance returns a plain ASGI app rather than a `Starlette` instance. | |||
There was a problem hiding this comment.
P2: This fragment describes a backwards-incompatible change (rx.App calls no longer return a Starlette instance), so it belongs in a .breaking.md fragment, not .performance.md. Rename the file to +lighter-asgi-entrypoint.breaking.md and keep the performance note in the text.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At news/+lighter-asgi-entrypoint.performance.md, line 1:
<comment>This fragment describes a backwards-incompatible change (`rx.App` calls no longer return a `Starlette` instance), so it belongs in a `.breaking.md` fragment, not `.performance.md`. Rename the file to `+lighter-asgi-entrypoint.breaking.md` and keep the performance note in the text.</comment>
<file context>
@@ -0,0 +1 @@
+The backend no longer wraps the app in an extra `Starlette` application to run lifespan tasks, which removes a routing layer and a duplicate CORS middleware from every request. As a result, calling the `rx.App` instance returns a plain ASGI app rather than a `Starlette` instance.
</file context>
| except Exception as err: | ||
| message = {"type": repr(err)} |
There was a problem hiding this comment.
P2: An exception raised during ASGI lifespan startup is logged only as repr(err) at info level, so the traceback is lost. Log the exception with exc_info before treating the app as unsupported.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At reflex/app_mixins/lifespan.py, line 122:
<comment>An exception raised during ASGI lifespan startup is logged only as `repr(err)` at info level, so the traceback is lost. Log the exception with `exc_info` before treating the app as unsupported.</comment>
<file context>
@@ -66,6 +67,83 @@ def _log_cleanup_error(task: asyncio.Task) -> None:
+ await received.put({"type": "lifespan.startup"})
+ try:
+ message = await _receive_message(app_task, sent) or {"type": "no answer"}
+ except Exception as err:
+ message = {"type": repr(err)}
+ if message["type"] == "lifespan.startup.failed":
</file context>
| except Exception as err: | |
| message = {"type": repr(err)} | |
| except Exception as err: | |
| logger.debug(f"Lifespan startup of {app!r} raised.", exc_info=err) | |
| message = {"type": repr(err)} |
| if message is not None and message["type"] == "lifespan.shutdown.failed": | ||
| msg = ( | ||
| f"Lifespan shutdown of {app!r} failed: {message.get('message', '')}" | ||
| ) | ||
| raise RuntimeError(msg) | ||
| finally: |
There was a problem hiding this comment.
P2: _run_asgi_lifespan treats every shutdown response except lifespan.shutdown.failed as success. A malformed or prematurely returning transformer can therefore make _RootMiddleware report shutdown complete without completing shutdown; require lifespan.shutdown.complete and reject missing or other messages.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At reflex/app_mixins/lifespan.py, line 138:
<comment>`_run_asgi_lifespan` treats every shutdown response except `lifespan.shutdown.failed` as success. A malformed or prematurely returning transformer can therefore make `_RootMiddleware` report shutdown complete without completing shutdown; require `lifespan.shutdown.complete` and reject missing or other messages.</comment>
<file context>
@@ -66,6 +67,83 @@ def _log_cleanup_error(task: asyncio.Task) -> None:
+ finally:
+ await received.put({"type": "lifespan.shutdown"})
+ message = await _receive_message(app_task, sent)
+ if message is not None and message["type"] == "lifespan.shutdown.failed":
+ msg = (
+ f"Lifespan shutdown of {app!r} failed: {message.get('message', '')}"
</file context>
| if message is not None and message["type"] == "lifespan.shutdown.failed": | |
| msg = ( | |
| f"Lifespan shutdown of {app!r} failed: {message.get('message', '')}" | |
| ) | |
| raise RuntimeError(msg) | |
| finally: | |
| if message is None or message["type"] != "lifespan.shutdown.complete": | |
| msg = ( | |
| f"Lifespan shutdown of {app!r} did not complete: " | |
| f"{message.get('message', 'no response') if message else 'no response'}" | |
| ) | |
| raise RuntimeError(msg) |
| `Starlette` application that serves Reflex's own routes (the one `api_transformer` | ||
| apps mount). |
There was a problem hiding this comment.
P3: The parenthetical is only accurate for Starlette api_transformers. A callable transformer wraps the ASGI app and does not mount Reflex's Starlette app, so this wording misleads users with callable transformers. Reword it to state the Starlette-only condition.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/utility_methods/lifespan_tasks.md, line 65:
<comment>The parenthetical is only accurate for Starlette `api_transformer`s. A callable transformer wraps the ASGI app and does not mount Reflex's Starlette app, so this wording misleads users with callable transformers. Reword it to state the Starlette-only condition.</comment>
<file context>
@@ -62,7 +62,8 @@ If the task accepts the special argument, `app`, it will be passed the Reflex ap
If the task accepts the special argument, `starlette_app`, it will be passed the
-underlying `Starlette` application instance.
+`Starlette` application that serves Reflex's own routes (the one `api_transformer`
+apps mount).
</file context>
| `Starlette` application that serves Reflex's own routes (the one `api_transformer` | |
| apps mount). | |
| `Starlette` application that serves Reflex's own routes. When a Starlette | |
| `api_transformer` is used, it mounts this app. |
| async def http_only_app(scope: Scope, receive: Receive, send: Send) -> None: # noqa: RUF029 | ||
| assert scope["type"] == "http" | ||
|
|
||
| async def silent_app(scope: Scope, receive: Receive, send: Send) -> None: |
There was a problem hiding this comment.
P3: silent_app is async but never awaits, so ruff's RUF029 fails here. Add the same # noqa: RUF029 that http_only_app already has.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/units/app_mixins/test_lifespan.py, line 446:
<comment>`silent_app` is `async` but never awaits, so ruff's RUF029 fails here. Add the same `# noqa: RUF029` that `http_only_app` already has.</comment>
<file context>
@@ -360,3 +363,138 @@ async def run():
+ async def http_only_app(scope: Scope, receive: Receive, send: Send) -> None: # noqa: RUF029
+ assert scope["type"] == "http"
+
+ async def silent_app(scope: Scope, receive: Receive, send: Send) -> None:
+ pass
+
</file context>
| async def silent_app(scope: Scope, receive: Receive, send: Send) -> None: | |
| async def silent_app(scope: Scope, receive: Receive, send: Send) -> None: # noqa: RUF029 |
| client = TestClient(app()) | ||
| client.__enter__() | ||
| assert client.get("/ping").status_code == 200 | ||
| with pytest.raises(RuntimeError, match="boom"): | ||
| client.__exit__(None, None, None) | ||
| assert stopped == ["reflex"] |
There was a problem hiding this comment.
P3: Use a with block instead of calling __enter__/__exit__ by hand. Manual calls leak the client's lifespan portal if the /ping assertion fails.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/units/test_app.py, line 735:
<comment>Use a `with` block instead of calling `__enter__`/`__exit__` by hand. Manual calls leak the client's lifespan portal if the `/ping` assertion fails.</comment>
<file context>
@@ -586,14 +587,193 @@ def test_admin_dashboard_routes_remain_reversible(
+ app.register_lifespan_task(reflex_task)
+ mocker.patch.object(app, "_compile")
+
+ client = TestClient(app())
+ client.__enter__()
+ assert client.get("/ping").status_code == 200
</file context>
| client = TestClient(app()) | |
| client.__enter__() | |
| assert client.get("/ping").status_code == 200 | |
| with pytest.raises(RuntimeError, match="boom"): | |
| client.__exit__(None, None, None) | |
| assert stopped == ["reflex"] | |
| with pytest.raises(RuntimeError, match="boom"), TestClient(app()) as client: | |
| assert client.get("/ping").status_code == 200 | |
| assert stopped == ["reflex"] |
[Medium impact] The change reaches Reflex backend startup and request handling for apps configured with API transformers. The PR appears safe to merge, with non-blocking fixes recommended for CORS on error responses and transformer cleanup. FindingsSummaryReplaces the extra Starlette wrapper with
Reviews (1) · Last reviewed commit: "Warn when a transformer's lifespan is al..." · Reviewed by Greptile |
| if api_transformers and not isinstance(api_transformers[-1], Starlette): | ||
| # Routes added by a callable transformer bypass the CORS of the apps it wraps. | ||
| asgi_app = cors.CORSMiddleware(asgi_app, **App._cors_options()) |
There was a problem hiding this comment.
Error responses lose CORS headers
With no transformer, or an outermost Starlette transformer, unhandled route errors now escape CORS. Starlette's ServerErrorMiddleware creates its 500 response outside the CORS layer added by add_middleware. The removed parent layer covered that response; _RootMiddleware does not. Cross-origin browser clients can no longer read its status or body.
Keep one CORS layer outside the outermost Starlette error handler, and test an unhandled route error with an Origin header.
| finally: | ||
| app_task.cancel() |
There was a problem hiding this comment.
Transformer cleanup can be interrupted
_run_asgi_lifespan cancels app_task after receiving its shutdown reply, without waiting for the task to finish. A callable transformer can use try/finally or an async context manager to close resources after its inner app sends lifespan.shutdown.complete. This cancellation can interrupt that cleanup while Reflex moves on to stopping other apps and tasks.
Give the task time to finish, then await any cancellation before leaving the context.
Merging this PR will not alter performance
|
All Submissions:
Type of change
See "Behavior changes" below for the edges that could affect existing apps.
Changes To Core Features:
What and why
App.__call__wrapped the whole backend in an extraStarlette(lifespan=...)and mounted the transformed app inside it. Starlette'sMountdoesn't forward lifespan events, so thelifespanof anything passed toapi_transformernever ran (a FastAPIlifespan=,on_startuphandlers, a lifespan-aware middleware). That's the gap behind #5749. The extra app also added a router, aMountand a second CORS middleware to every request (#5220).This PR:
_RootMiddleware(reflex/app.py). It sets the Reflex contexts on every ASGI call, as_ContextMiddlewaredid, including during lifespan, and answers the server's lifespan events itself. All other scopes go straight to the app._run_asgi_lifespaninreflex/app_mixins/lifespan.py), the way a server does, so it works for any ASGI app, not only Starlette:mount("", ...)itself. So the app each Starlette transformer mounts also gets them, except Reflex's ownStarletteapp, whose lifespan is the registered tasks.register_lifespan_tasktasks run.state, so yielded state reachesrequest.state.startup.failedandshutdown.failedare raised, and lifespans already started still shut down._apiand Starlette transformers keep their own CORS as before, so the header now appears exactly once./ping, roughly 175 µs down to 155 µs in a direct ASGI loop.Closes #5220. Fixes the problem in #5749.
Behavior changes reviewers should weigh
app()returns_RootMiddleware, notStarlette. It's typedASGIAppand already wasn't aStarlettewith OpenTelemetry on, and nothing in this repo relies on it. External code callingapp().mount(...)/url_path_for(...)would break; the performance news fragment calls this out.lifespan=,on_startup,@app.on_event("startup"). That's the fix, but a transformer whose startup raises will now fail server startup.register_lifespan_taskwould now run it twice.App.__call__warns when a Starlette transformer'srouter.lifespan_contextis also a registered task, and the news fragment tells users to drop the workaround.starlette_applifespan-task argument is now Reflex's ownStarletteapp instead of the internal wrapper. Code that searched the wrapper's routes for the transformer (the other workaround suggested in [Question] How to use asynccontextmanager #5749) won't find it; the news fragment covers this.scope["path"]unconditionally raises on it. The server still starts; Reflex logs that the app appears not to support lifespan, and lifespans inside it don't run (they never did before). I fixed the docs' logging middleware example accordingly.ServerErrorMiddleware. Reflex's own routes are unaffected.scope["router"]is now the outermost Starlette in the chain instead of the wrapper. Admin reverse routing is unchanged (tests cover it with and without a transformer), and it now also works behind a callable transformer, where the wrapper'sMountexposed no routes.I also checked granian's and uvicorn's lifespan handling (granian passes
stateand treats a raise afterstartup.failedas fatal) and the OpenTelemetry ASGI middleware (passes non-HTTP scopes through). There's no overlap with #7416, which watches for the standardlifespan.startup.completethat_RootMiddlewarestill sends.Tests
tests/units/test_app.py:request.state;tests/units/app_mixins/test_lifespan.py: a plain non-Starlette ASGI lifespan app, shared state, the three "unsupported" cases, and both failure modes.tests/integration/test_lifespan.py: the FastAPI transformer now has a lifespan whose start and stop are counted. Not run locally: the sandbox's ChromeDriver doesn't match its Chromium, so this PR's CI is the first run. I checked the same flow under a real uvicorn server instead.uv run pre-commit runon the changed files: ruff, codespell, pyright, ty, pyi all pass.docs/appdoc-link tests pass.tests/unitsrun has 262 failures, all inreflex_cli/v2and thereflex_benchbrowser driver; they fail the same way on unmodifiedmainin this sandbox (no network/browser).Generated by Claude Code