From 337341739ecc3843109f6860c3cdcb67ea4e4ca1 Mon Sep 17 00:00:00 2001 From: Farhan Date: Fri, 10 Jul 2026 23:49:57 +0500 Subject: [PATCH 1/3] fix: run plugin post_compile hooks at most once per app instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App.__call__ runs whenever the ASGI app is constructed, and it re-ran every plugin's post_compile each time. post_compile hooks mutate the app (add_middleware, mounting routes), so a second construction on the same app instance (a test harness rebuilding the ASGI app) re-applied those mutations — e.g. a middleware installed twice gates every event twice. Latch the hook loop per app instance, mirroring the once-per-process guarantees elsewhere in the compile path. The latch is set after the loop completes so a failed hook is retried on the next construction rather than silently skipped. The guarantee is documented on Plugin.post_compile so plugins don't need their own idempotency guards. Fixes ENG-9917. --- .../src/reflex_base/plugins/base.py | 4 ++ reflex/app.py | 15 ++++- tests/units/test_app.py | 59 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/plugins/base.py b/packages/reflex-base/src/reflex_base/plugins/base.py index 6673309840b..de50ec50e2f 100644 --- a/packages/reflex-base/src/reflex_base/plugins/base.py +++ b/packages/reflex-base/src/reflex_base/plugins/base.py @@ -139,6 +139,10 @@ def pre_compile(self, **context: Unpack[PreCompileContext]) -> None: def post_compile(self, **context: Unpack[PostCompileContext]) -> None: """Called after the compilation of the plugin. + Runs at most once per app instance, even when the ASGI app is + constructed repeatedly, so hooks may mutate the app (e.g. + ``add_middleware``) without their own idempotency guard. + Args: context: The context for the plugin. """ diff --git a/reflex/app.py b/reflex/app.py index 9364c547436..acc911f72ca 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -402,6 +402,11 @@ class App(MiddlewareMixin, LifespanMixin): # that already compiled and skips re-evaluation. _evaluated_pages: set[str] = dataclasses.field(default_factory=set) + # Whether the plugins' post_compile hooks already ran for this app + # instance (they mutate the app, so they must not run again when the + # ASGI app is re-constructed). + _plugins_post_compiled: bool = False + # The backend API object. _api: Starlette | None = None @@ -727,8 +732,14 @@ def __call__(self) -> ASGIApp: config = get_config() - for plugin in config.plugins: - plugin.post_compile(app=self) + # Run the hooks at most once per app instance: post_compile mutates + # the app (add_middleware, mounting routes), so re-constructing the + # ASGI app (a test harness, repeated __call__) must not re-apply them. + # Set after the loop so a failed hook is retried, not latched as done. + if not self._plugins_post_compiled: + for plugin in config.plugins: + plugin.post_compile(app=self) + self._plugins_post_compiled = True # We will not be making more vars, so we can clear the global cache to free up memory. GLOBAL_CACHE.clear() diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 270f040f684..82f4532ad31 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -3064,6 +3064,65 @@ def test_call_app(): assert isinstance(api, Starlette) +def test_call_app_runs_plugin_post_compile_once(mocker: MockerFixture): + """Constructing the ASGI app twice runs each plugin's post_compile once. + + ``post_compile`` hooks mutate the app (add middleware, mount routes) and + are not required to be idempotent, so ``App.__call__`` must not re-run + them when the ASGI app is constructed again on the same app instance + (e.g. a test harness rebuilding it). + """ + + class RecordingPlugin(rx.plugins.Plugin): + def __init__(self) -> None: + self.post_compile_calls = 0 + + def post_compile(self, **context) -> None: + self.post_compile_calls += 1 + + plugin = RecordingPlugin() + conf = rx.Config(app_name="testing", plugins=[plugin]) + mocker.patch("reflex_base.config._get_config", return_value=conf) + + app = App() + app._compile = unittest.mock.Mock() + app() + app() + + assert plugin.post_compile_calls == 1 + + +def test_call_app_retries_post_compile_after_failure(mocker: MockerFixture): + """A post_compile failure is not latched as done. + + If a hook raises (e.g. a plugin rejecting a misconfigured app), the next + ASGI construction must run the hooks again rather than silently skipping + them. + """ + + class FlakyPlugin(rx.plugins.Plugin): + def __init__(self) -> None: + self.post_compile_calls = 0 + + def post_compile(self, **context) -> None: + self.post_compile_calls += 1 + if self.post_compile_calls == 1: + msg = "misconfigured" + raise ValueError(msg) + + plugin = FlakyPlugin() + conf = rx.Config(app_name="testing", plugins=[plugin]) + mocker.patch("reflex_base.config._get_config", return_value=conf) + + app = App() + app._compile = unittest.mock.Mock() + with pytest.raises(ValueError, match="misconfigured"): + app() + app() + + assert plugin.post_compile_calls == 2 + + @pytest.fixture def upload_enabled(monkeypatch): """Fixture that enables Upload and cleans up afterward.""" From a92422ef8f2fa02553dab5a5ea19750e6cd5597e Mon Sep 17 00:00:00 2001 From: Farhan Date: Fri, 10 Jul 2026 23:50:34 +0500 Subject: [PATCH 2/3] add news fragment --- news/6733.bugfix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/6733.bugfix.md diff --git a/news/6733.bugfix.md b/news/6733.bugfix.md new file mode 100644 index 00000000000..3104e6f9090 --- /dev/null +++ b/news/6733.bugfix.md @@ -0,0 +1 @@ +Plugin `post_compile` hooks now run at most once per app instance. Previously every ASGI app construction re-ran the hooks, re-applying app mutations such as `add_middleware` — e.g. installing a duplicate middleware that gated every event twice. From bea1145284b7c76f67ca23b8cdc17bddaba5f998 Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 11 Jul 2026 00:42:40 +0500 Subject: [PATCH 3/3] fix: assemble the ASGI app at most once per app instance Gating only the plugin post_compile loop left the same repeat-mutation bug elsewhere in App.__call__: the compiled-frontend mount was appended to the persistent self._api on every construction, and a Starlette api_transformer was re-mounted with an extra CORS layer each call. A single boolean latch also re-ran already-succeeded hooks when a later plugin's hook raised, reinstalling their middleware on retry. Extract the assembly steps (plugin hooks, frontend mount, transformer wiring, top-level app) into _assemble_asgi_app, cache its result per instance, and track completed hooks per plugin so a failed hook is retried without re-running the ones that already mutated the app. _compile intentionally still runs on every call: forked prod workers re-enter __call__ to re-evaluate stateful pages backend-side. --- news/6733.bugfix.md | 2 +- .../src/reflex_base/plugins/base.py | 5 +- reflex/app.py | 47 ++++++---- tests/units/test_app.py | 89 ++++++++++++------- 4 files changed, 92 insertions(+), 51 deletions(-) diff --git a/news/6733.bugfix.md b/news/6733.bugfix.md index 3104e6f9090..a3574f94b21 100644 --- a/news/6733.bugfix.md +++ b/news/6733.bugfix.md @@ -1 +1 @@ -Plugin `post_compile` hooks now run at most once per app instance. Previously every ASGI app construction re-ran the hooks, re-applying app mutations such as `add_middleware` — e.g. installing a duplicate middleware that gated every event twice. +Re-constructing the ASGI app on the same app instance no longer re-applies app mutations. Plugin `post_compile` hooks run at most once per plugin per app instance (previously every ASGI app construction re-ran them, e.g. installing a duplicate middleware that gated every event twice), and the assembled ASGI app — including the compiled-frontend mount and `api_transformer` wiring — is built once and cached. diff --git a/packages/reflex-base/src/reflex_base/plugins/base.py b/packages/reflex-base/src/reflex_base/plugins/base.py index de50ec50e2f..a239059c666 100644 --- a/packages/reflex-base/src/reflex_base/plugins/base.py +++ b/packages/reflex-base/src/reflex_base/plugins/base.py @@ -139,8 +139,9 @@ def pre_compile(self, **context: Unpack[PreCompileContext]) -> None: def post_compile(self, **context: Unpack[PostCompileContext]) -> None: """Called after the compilation of the plugin. - Runs at most once per app instance, even when the ASGI app is - constructed repeatedly, so hooks may mutate the app (e.g. + Runs at most once per app instance — even when the ASGI app is + constructed repeatedly, and even when another plugin's failed hook + forces a retry — so hooks may mutate the app (e.g. ``add_middleware``) without their own idempotency guard. Args: diff --git a/reflex/app.py b/reflex/app.py index acc911f72ca..0b4adaa6f9c 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -42,6 +42,7 @@ ) from reflex_base.event.context import EventContext from reflex_base.event.processor import BaseStateEventProcessor, EventProcessor +from reflex_base.plugins import Plugin from reflex_base.registry import RegistrationContext from reflex_base.telemetry_context import CompileTrigger, TelemetryContext from reflex_base.utils import console, memo_paths @@ -402,10 +403,16 @@ class App(MiddlewareMixin, LifespanMixin): # that already compiled and skips re-evaluation. _evaluated_pages: set[str] = dataclasses.field(default_factory=set) - # Whether the plugins' post_compile hooks already ran for this app - # instance (they mutate the app, so they must not run again when the - # ASGI app is re-constructed). - _plugins_post_compiled: bool = False + # Plugins whose post_compile hook already ran for this app instance. + # The hooks mutate the app (add_middleware, mounting routes), so each + # must run at most once, even when another plugin's failed hook forces + # the ASGI app assembly to be retried. + _post_compiled_plugins: set[Plugin] = dataclasses.field(default_factory=set) + + # The ASGI app assembled by __call__, built at most once per app + # instance: assembly mutates persistent objects (self._api, a Starlette + # api_transformer), so re-constructing the ASGI app must not re-run it. + _cached_asgi_app: ASGIApp | None = None # The backend API object. _api: Starlette | None = None @@ -713,9 +720,6 @@ def __call__(self) -> ASGIApp: Returns: The backend api. - - Raises: - ValueError: If the app has not been initialized. """ from reflex_base.vars.base import GLOBAL_CACHE @@ -730,20 +734,29 @@ def __call__(self) -> ASGIApp: trigger=get_backend_compile_trigger(), ) - config = get_config() - - # Run the hooks at most once per app instance: post_compile mutates - # the app (add_middleware, mounting routes), so re-constructing the - # ASGI app (a test harness, repeated __call__) must not re-apply them. - # Set after the loop so a failed hook is retried, not latched as done. - if not self._plugins_post_compiled: - for plugin in config.plugins: - plugin.post_compile(app=self) - self._plugins_post_compiled = True + # Assembly runs at most once per app instance; _compile stays per-call. + if self._cached_asgi_app is None: + self._cached_asgi_app = self._assemble_asgi_app() # We will not be making more vars, so we can clear the global cache to free up memory. GLOBAL_CACHE.clear() + return self._cached_asgi_app + + def _assemble_asgi_app(self) -> ASGIApp: + """Run plugin post_compile hooks and wrap the backend api into an ASGI app. + + Returns: + The assembled ASGI app. + + Raises: + ValueError: If the app has not been initialized. + """ + for plugin in get_config().plugins: + if plugin not in self._post_compiled_plugins: + plugin.post_compile(app=self) + self._post_compiled_plugins.add(plugin) + if not self._api: msg = "The app has not been initialized." raise ValueError(msg) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 82f4532ad31..5879e3188af 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -3064,6 +3064,23 @@ def test_call_app(): assert isinstance(api, Starlette) +def _app_with_plugins(mocker: MockerFixture, *plugins: unittest.mock.Mock) -> App: + """Create an app configured with the given plugin doubles. + + Args: + mocker: The pytest-mock fixture. + plugins: Plugin doubles to install in the app's config. + + Returns: + An app with a mocked-out ``_compile``. + """ + conf = rx.Config(app_name="testing", plugins=list(plugins)) + mocker.patch("reflex_base.config._get_config", return_value=conf) + app = App() + app._compile = unittest.mock.Mock() + return app + + def test_call_app_runs_plugin_post_compile_once(mocker: MockerFixture): """Constructing the ASGI app twice runs each plugin's post_compile once. @@ -3072,55 +3089,65 @@ def test_call_app_runs_plugin_post_compile_once(mocker: MockerFixture): them when the ASGI app is constructed again on the same app instance (e.g. a test harness rebuilding it). """ - - class RecordingPlugin(rx.plugins.Plugin): - def __init__(self) -> None: - self.post_compile_calls = 0 - - def post_compile(self, **context) -> None: - self.post_compile_calls += 1 - - plugin = RecordingPlugin() - conf = rx.Config(app_name="testing", plugins=[plugin]) - mocker.patch("reflex_base.config._get_config", return_value=conf) - - app = App() - app._compile = unittest.mock.Mock() + plugin = unittest.mock.Mock(spec=rx.plugins.Plugin) + app = _app_with_plugins(mocker, plugin) app() app() - assert plugin.post_compile_calls == 1 + plugin.post_compile.assert_called_once() def test_call_app_retries_post_compile_after_failure(mocker: MockerFixture): """A post_compile failure is not latched as done. If a hook raises (e.g. a plugin rejecting a misconfigured app), the next - ASGI construction must run the hooks again rather than silently skipping - them. + ASGI construction must run the hook again rather than silently skipping + it. """ + plugin = unittest.mock.Mock(spec=rx.plugins.Plugin) + plugin.post_compile.side_effect = [ValueError("misconfigured"), None] + app = _app_with_plugins(mocker, plugin) + with pytest.raises(ValueError, match="misconfigured"): + app() + app() - class FlakyPlugin(rx.plugins.Plugin): - def __init__(self) -> None: - self.post_compile_calls = 0 + assert plugin.post_compile.call_count == 2 - def post_compile(self, **context) -> None: - self.post_compile_calls += 1 - if self.post_compile_calls == 1: - msg = "misconfigured" - raise ValueError(msg) - plugin = FlakyPlugin() - conf = rx.Config(app_name="testing", plugins=[plugin]) - mocker.patch("reflex_base.config._get_config", return_value=conf) +def test_call_app_partial_post_compile_failure(mocker: MockerFixture): + """A failed hook is retried without re-running already-succeeded hooks. - app = App() - app._compile = unittest.mock.Mock() + With multiple plugins, a failure in a later plugin's post_compile must + not cause the earlier plugins' (non-idempotent) hooks to run again on + the next ASGI construction. + """ + ok_plugin = unittest.mock.Mock(spec=rx.plugins.Plugin) + flaky_plugin = unittest.mock.Mock(spec=rx.plugins.Plugin) + flaky_plugin.post_compile.side_effect = [ValueError("misconfigured"), None] + app = _app_with_plugins(mocker, ok_plugin, flaky_plugin) with pytest.raises(ValueError, match="misconfigured"): app() app() - assert plugin.post_compile_calls == 2 + ok_plugin.post_compile.assert_called_once() + assert flaky_plugin.post_compile.call_count == 2 + + +def test_call_app_caches_asgi_app(mocker: MockerFixture): + """Repeated ASGI construction returns the same app without re-assembly. + + Assembly mutates persistent objects (``self._api``, ``api_transformer``), + e.g. appending the frontend mount or re-adding CORS middleware, so it must + run at most once per app instance while ``_compile`` still runs per call. + """ + app = _app_with_plugins(mocker) + compile_mock = unittest.mock.Mock() + app._compile = compile_mock + first = app() + second = app() + + assert first is second + assert compile_mock.call_count == 2 @pytest.fixture