Skip to content

Send ASGI lifespan events to api_transformer apps; drop the extra top-level Starlette app - #7624

Open
masenf wants to merge 4 commits into
mainfrom
claude/loving-dijkstra-1su8su
Open

masenf wants to merge 4 commits into
mainfrom
claude/loving-dijkstra-1su8su

Conversation

@masenf

@masenf masenf commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • This change requires a documentation update (included)

See "Behavior changes" below for the edges that could affect existing apps.

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

What and why

App.__call__ wrapped the whole backend in an extra Starlette(lifespan=...) and mounted the transformed app inside it. Starlette's Mount doesn't forward lifespan events, so the lifespan of anything passed to api_transformer never ran (a FastAPI lifespan=, on_startup handlers, a lifespan-aware middleware). That's the gap behind #5749. The extra app also added a router, a Mount and a second CORS middleware to every request (#5220).

This PR:

  • Replaces the wrapper with _RootMiddleware (reflex/app.py). It sets the Reflex contexts on every ASGI call, as _ContextMiddleware did, including during lifespan, and answers the server's lifespan events itself. All other scopes go straight to the app.
  • Sends real ASGI lifespan events to the transformer apps (_run_asgi_lifespan in reflex/app_mixins/lifespan.py), the way a server does, so it works for any ASGI app, not only Starlette:
    • The outermost transformed app gets them. Middleware returned by a callable transformer forwards them inward per the ASGI spec.
    • Starlette/FastAPI don't forward lifespan events to the apps they mount, and Reflex makes that mount("", ...) itself. So the app each Starlette transformer mounts also gets them, except Reflex's own Starlette app, whose lifespan is the registered tasks.
    • Apps a user mounts on a transformer get nothing. Only the transformers and register_lifespan_task tasks run.
    • Every app shares the server's lifespan state, so yielded state reaches request.state.
    • Like uvicorn's/granian's "auto" mode, an app that raises, returns or sends some other message instead of answering startup is skipped, with an info log. startup.failed and shutdown.failed are raised, and lifespans already started still shut down.
  • Order: Reflex lifespan tasks start first. Transformer apps start innermost first and stop in reverse, before the tasks stop.
  • CORS: the top-level CORS layer is now added only when the outermost transformer is a callable, whose own routes would otherwise have none. _api and Starlette transformers keep their own CORS as before, so the header now appears exactly once.
  • Measured: about 11% less per-request ASGI overhead on /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

  1. app() returns _RootMiddleware, not Starlette. It's typed ASGIApp and already wasn't a Starlette with OpenTelemetry on, and nothing in this repo relies on it. External code calling app().mount(...)/url_path_for(...) would break; the performance news fragment calls this out.
  2. Transformer startup code that never ran now runs: FastAPI lifespan=, on_startup, @app.on_event("startup"). That's the fix, but a transformer whose startup raises will now fail server startup.
  3. [Question] How to use asynccontextmanager #5749 workaround runs twice. Users who also registered the transformer's lifespan with register_lifespan_task would now run it twice. App.__call__ warns when a Starlette transformer's router.lifespan_context is also a registered task, and the news fragment tells users to drop the workaround.
  4. starlette_app lifespan-task argument is now Reflex's own Starlette app 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.
  5. Naive raw-ASGI middleware now sees the lifespan scope. Anything that reads 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.
  6. Error pages: an unhandled exception from a callable transformer's own app now gets the server's default 500 instead of the wrapper's ServerErrorMiddleware. Reflex's own routes are unaffected.
  7. Named-route lookup: 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's Mount exposed no routes.

I also checked granian's and uvicorn's lifespan handling (granian passes state and treats a raise after startup.failed as fatal) and the OpenTelemetry ASGI middleware (passes non-HTTP scopes through). There's no overlap with #7416, which watches for the standard lifespan.startup.complete that _RootMiddleware still sends.

Tests

  • Unit, tests/units/test_app.py:
    • start/stop order across Reflex tasks, a FastAPI transformer, a forwarding callable and a Starlette transformer;
    • state on request.state;
    • mounted apps getting no events;
    • CORS header exactly once whether the last transformer is Starlette or a callable;
    • startup and shutdown failure reach the server;
    • the double-registration warning.
  • Unit, tests/units/app_mixins/test_lifespan.py: a plain non-Starlette ASGI lifespan app, shared state, the three "unsupported" cases, and both failure modes.
  • Integration, 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.
  • Checks run locally:
    • uv run pre-commit run on the changed files: ruff, codespell, pyright, ty, pyi all pass.
    • The docs/app doc-link tests pass.
    • The full tests/units run has 262 failures, all in reflex_cli/v2 and the reflex_bench browser driver; they fail the same way on unmodified main in this sandbox (no network/browser).

Generated by Claude Code

View guided diff Turn on auto-fix

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
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
@masenf
masenf requested review from a team and Alek99 as code owners October 20, 2026 23:22

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment on lines +122 to +123
except Exception as err:
message = {"type": repr(err)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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)}

Comment on lines +138 to +143
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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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)

Comment on lines +65 to +66
`Starlette` application that serves Reflex's own routes (the one `api_transformer`
apps mount).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
`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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
async def silent_app(scope: Scope, receive: Receive, send: Send) -> None:
async def silent_app(scope: Scope, receive: Receive, send: Send) -> None: # noqa: RUF029

Comment thread tests/units/test_app.py
Comment on lines +735 to +740
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"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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"]

@greptile-apps

greptile-apps Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[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.

Findings

  1. P2 Error responses lose CORS headers ▶
  2. P2 Transformer cleanup can be interrupted ▶

Summary

Replaces the extra Starlette wrapper with _RootMiddleware and sends lifespan events through api_transformer apps.

  • Transformer apps now run their own startup and shutdown code.
  • The backend serves requests through a lighter ASGI entry point.

Reviews (1) · Last reviewed commit: "Warn when a transformer's lifespan is al..." · Reviewed by Greptile

Comment thread reflex/app.py
Comment on lines +913 to +915
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())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment on lines +143 to +144
finally:
app_task.cancel()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

@codspeed

codspeed Bot commented Oct 20, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ 14 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 148 untouched benchmarks


Comparing claude/loving-dijkstra-1su8su (27242f9) with main (d3f4ab1)

Open in CodSpeed

@masenf masenf added bug Something isn't working perf Performance-improving changes documentation Improvements or additions to documentation breaking Public API facing change considered for minor release bump pre-v1.0 labels Oct 21, 2026 — with ChatGPT Codex Connector
@masenf masenf added the roadmap https://github.com/reflex-dev/reflex/issues/2727 label Oct 22, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Public API facing change considered for minor release bump pre-v1.0 bug Something isn't working documentation Improvements or additions to documentation perf Performance-improving changes roadmap https://github.com/reflex-dev/reflex/issues/2727

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optimize lifespan middleware

2 participants