@@ -35,8 +35,8 @@ def anyio_backend() -> str:
3535 "async_generator" ,
3636 ],
3737)
38- def test_decorator_returns_response_objects (module_path : str , variant : str ) -> None :
39- """Decorated handlers should stay sync-callable and return DatastarResponse immediately ."""
38+ def test_decorator_preserves_sync_async_semantics (module_path : str , variant : str ) -> None :
39+ """Decorated handlers should preserve sync/async nature of the original function ."""
4040
4141 mod = importlib .import_module (module_path )
4242 datastar_response = mod .datastar_response
@@ -59,12 +59,18 @@ async def handler() -> Any:
5959 async def handler () -> Any :
6060 yield SSE .patch_signals ({"ok" : True })
6161
62- result = handler ()
63- if inspect .iscoroutine (result ):
64- result .close () # avoid "coroutine was never awaited" warnings
62+ is_async_variant = variant .startswith ("async_" )
6563
66- assert not inspect .iscoroutinefunction (handler ), "Decorator should preserve sync callable semantics"
67- assert isinstance (result , DatastarResponse )
64+ # Verify the wrapper preserves sync/async nature
65+ if is_async_variant :
66+ assert inspect .iscoroutinefunction (handler ), "Async handlers should remain async"
67+ # Call and close coroutine to avoid warnings (we can't await in sync test)
68+ coro = handler ()
69+ coro .close ()
70+ else :
71+ assert not inspect .iscoroutinefunction (handler ), "Sync handlers should remain sync"
72+ result = handler ()
73+ assert isinstance (result , DatastarResponse ), "Sync handlers should return DatastarResponse directly"
6874
6975
7076async def _fetch (
@@ -125,3 +131,54 @@ async def ping(request) -> PlainTextResponse: # noqa: ANN001
125131 finally :
126132 server .should_exit = True
127133 thread .join (timeout = 2 )
134+
135+
136+ def test_async_generator_iterates_on_event_loop () -> None :
137+ """Async generators should iterate on the event loop, not spawn a thread.
138+
139+ This addresses the concern that a sync wrapper might cause async handlers
140+ to run in the threadpool. The wrapper being sync only affects where the
141+ generator object is created (trivial); iteration happens based on iterator
142+ type - Starlette's StreamingResponse detects __aiter__ and iterates async.
143+
144+ This test uses Starlette, but the same principle applies to Litestar which
145+ also uses a sync wrapper. Litestar's Stream response similarly detects
146+ async iterators and iterates them on the event loop.
147+ """
148+ from starlette .testclient import TestClient
149+
150+ from datastar_py .starlette import datastar_response
151+
152+ execution_threads : dict [str , str ] = {}
153+
154+ @datastar_response
155+ async def async_gen_handler (request ) -> Any : # noqa: ANN001
156+ execution_threads ["async_gen" ] = threading .current_thread ().name
157+ yield SSE .patch_signals ({"async" : True })
158+
159+ @datastar_response
160+ def sync_gen_handler (request ) -> Any : # noqa: ANN001
161+ execution_threads ["sync_gen" ] = threading .current_thread ().name
162+ yield SSE .patch_signals ({"sync" : True })
163+
164+ app = Starlette (routes = [
165+ Route ("/async" , async_gen_handler ),
166+ Route ("/sync" , sync_gen_handler ),
167+ ])
168+
169+ with TestClient (app ) as client :
170+ client .get ("/async" )
171+ client .get ("/sync" )
172+
173+ # Async generator runs on the asyncio portal thread (event loop context)
174+ # Sync generator runs in a separate threadpool worker
175+ # The key assertion: they run in DIFFERENT thread contexts
176+ assert execution_threads ["async_gen" ] != execution_threads ["sync_gen" ], (
177+ f"Async and sync generators should run in different thread contexts. "
178+ f"Async ran on: { execution_threads ['async_gen' ]} , Sync ran on: { execution_threads ['sync_gen' ]} "
179+ )
180+
181+ # Async generator should be on the event loop thread (asyncio-portal-* or MainThread)
182+ assert "asyncio" in execution_threads ["async_gen" ] or execution_threads ["async_gen" ] == "MainThread" , (
183+ f"Async generator should run on event loop, but ran on { execution_threads ['async_gen' ]} "
184+ )
0 commit comments