Skip to content

Commit 40beac2

Browse files
kumare3claude
andauthored
feat: asyncio.run() works in sync tasks; sync-task calls from async must use .aio (#1472)
Two related changes to how sync tasks and sync-task calls behave. ## 1. `asyncio.run()` now works inside sync tasks Sync task bodies used to run inside a hidden coroutine on a per-call helper event loop (`run_sync_with_loop`, from #565). That loop was never usable — `asyncio.run()` raised "cannot be called from a running event loop" — it just made the thread look async. Sync bodies now run as plain sync code on a dedicated daemon thread (`run_sync_in_thread`). `asyncio.run()` and third-party sync wrappers (e.g. pydantic_ai `run_sync`) work there now. ## 2. Calling a sync task from async code requires `.aio` ```python @env.task def child() -> str: ... @env.task async def parent() -> str: return child() # raises SyncTaskCallInAsyncContextError return await child.aio() # correct ``` A blocking call from async code parks the event loop that also runs the controller failure watch. If the actions service goes down, the process can never observe it and the pod hangs forever. It now raises immediately with the fix in the message. ## What breaks - **Blocking sync-task calls from async code** now raise. Migrate to `await task.aio(...)`. (In-repo callers updated: one test, two examples.) - **Sync task bodies no longer see a running event loop** — `asyncio.get_running_loop()` raises there. Nothing in-repo relied on it; the agents plugin bridge doesn't depend on it (docstring updated). - **`flyte._utils.asyncify.run_sync_with_loop` → `run_sync_in_thread`** (private; papermill and docker_builder callers updated — plugin releases should pair with this SDK version). Sync → sync calls, `flyte.map`, sandbox orchestrators, and syncify-based sync APIs (`download_sync`, `storage.*`, `flyte.run`) are unaffected: they never used the caller's loop. ## Verified on a real cluster Remote run on demo with this branch baked into the image: https://demo.hosted.unionai.cloud/v2/domain/development/project/ketan/runs/ukf2hnfjhtrpvpbc4dqc — sync body sees no running loop, `asyncio.run()` returns, sync→sync blocking call works, `await leaf.aio()` from async works, and a blocking call from an async body raises `SyncTaskCallInAsyncContextError` with the `.aio` hint. `tests/flyte/test_sync_tasks.py::test_sync_task_through_runtime_taskrunner` covers the same through the real runtime entrypoint (local runs don't go through taskrunner). ## Companion `flyteplugins-union` is the only external importer of the renamed helper: unionai/flyteplugins-union#100 adopts `run_sync_in_thread` with an `ImportError` fallback, so it loads on both SDK versions. ## Tests `tests/flyte/test_sync_call_guard.py` (raises from async, `.aio` ok, sync→sync ok, end-to-end local run), `tests/flyte/utils/test_asyncify.py` updated for the new semantics. Full sweep: 4577 passed; 3 environment-dependent failures reproduce without this diff. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Ketan Umare <kumare3@users.noreply.github.com> Co-authored-by: Ketan Umare <kumare3@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 94b906e commit 40beac2

16 files changed

Lines changed: 303 additions & 106 deletions

File tree

examples/advanced/local_tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def noio_task():
101101
@env.task
102102
async def parallel_main_no_io(q: str) -> int:
103103
print("Starting parallel_main_no_io", flush=True)
104-
noio_task()
104+
await noio_task.aio()
105105
await input_trace("hello world", "blah", 42)
106106
a = await output_trace()
107107
await noio_trace()

examples/basics/dir_download_sync_repro.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ def download_directory_sync(d: Dir) -> list[str]:
4949
async def main() -> list[str]:
5050
remote_dir = await create_remote_directory()
5151
# Pass the Dir as an input to a sync task, which downloads it via download_sync().
52-
return download_directory_sync(d=remote_dir)
52+
# Sync tasks must be awaited via .aio() from an async parent — a blocking call would
53+
# stall the parent's event loop and now raises SyncTaskCallInAsyncContextError.
54+
return await download_directory_sync.aio(d=remote_dir)
5355

5456

5557
if __name__ == "__main__":

plugins/agents/core/src/flyteplugins/agents/core/_sync.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
and report I/O) is async end to end. A synchronous variant therefore has to run
66
that coroutine on some event loop. Two loops are deliberately avoided:
77
8-
- The caller's thread's loop. Inside a Flyte sync task the body executes via
9-
run_sync_with_loop, so the current thread already has a running loop and
10-
asyncio.run() would raise. This also breaks agent SDKs' own sync wrappers
11-
(for example pydantic_ai run_sync), which is why adapters need this bridge.
8+
- A per-call throwaway loop (asyncio.run). Inside a Flyte sync task the body
9+
executes as plain sync code on a dedicated thread (run_sync_in_thread), so
10+
asyncio.run() works — but it tears its loop down on every call, which breaks
11+
async resources the agent SDKs cache across calls (HTTP clients, connection
12+
pools bound to a dead loop). It also raises if the caller happens to be on a
13+
thread that already runs a loop, which is why adapters need this bridge.
1214
- The SDK-global syncify loop. That loop is reserved for short control-plane
1315
I/O (see the guidance in flyte._trace); parking a whole agent run on it
1416
stalls every other syncify user in the process and risks deadlocks when

plugins/agents/core/tests/test_sync.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ async def _read() -> str:
4040

4141

4242
def test_run_coro_sync_inside_running_loop_thread():
43-
# A Flyte sync task body executes on a thread that already has a running
44-
# event loop (run_sync_with_loop), where asyncio.run() would raise. The
45-
# bridge must still work from that shape.
43+
# The bridge must also work when the calling thread already runs an event
44+
# loop (e.g. sync helper code invoked from async code), where asyncio.run()
45+
# would raise.
4646
async def _outer() -> str:
4747
return run_coro_sync(_echo("nested"))
4848

plugins/mlflow/src/flyteplugins/mlflow/_decorator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ def decorator(func: F) -> F:
481481

482482
# Task template — wrap func.func (not func.execute) so that
483483
# mlflow.start_run() runs in the same thread as the task function.
484-
# Flyte runs sync tasks in a separate thread via run_sync_with_loop;
484+
# Flyte runs sync tasks in a separate thread via run_sync_in_thread;
485485
# MLflow uses threading.local for its active run stack, so starting
486486
# the run in the async execute thread would be invisible to the task.
487487
if isinstance(func, AsyncFunctionTaskTemplate):

plugins/papermill/src/flyteplugins/papermill/task.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -655,7 +655,7 @@ async def _build():
655655
async def execute(self, *args: Any, **kwargs: Any) -> Any:
656656
"""Execute the notebook within a Flyte task context."""
657657
from flyte._context import internal_ctx
658-
from flyte._utils.asyncify import run_sync_with_loop
658+
from flyte._utils.asyncify import run_sync_in_thread
659659

660660
kwargs = self.interface.convert_to_kwargs(*args, **kwargs)
661661

@@ -674,7 +674,7 @@ def _run():
674674
literal_map = None
675675
execution_error: Optional[BaseException] = None
676676
try:
677-
literal_map = await run_sync_with_loop(_run)
677+
literal_map = await run_sync_in_thread(_run)
678678
except Exception as exc:
679679
execution_error = exc
680680
finally:

plugins/pytorch/src/flyteplugins/pytorch/task.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ async def execute(self, *args: P.args, **kwargs: P.kwargs) -> R:
348348

349349
# elastic_launch must run on the main thread so it can register
350350
# signal handlers (SIGTERM/SIGINT) for cleaning up worker
351-
# subprocesses. Running it in a thread pool (run_sync_with_loop)
351+
# subprocesses. Running it in a thread pool (run_sync_in_thread)
352352
# would cause the "Failed to register signal handlers" warning
353353
# and leave orphaned workers on exit.
354354
#

src/flyte/_internal/imagebuild/docker_builder.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
pixi_script_to_project,
5555
)
5656
from flyte._logging import logger
57-
from flyte._utils.asyncify import run_sync_with_loop
57+
from flyte._utils.asyncify import run_sync_in_thread
5858

5959
if TYPE_CHECKING:
6060
from flyte._build import ImageBuild
@@ -770,11 +770,11 @@ async def _build_from_dockerfile(self, image: Image, push: bool, wait: bool = Tr
770770

771771
try:
772772
if wait:
773-
await run_sync_with_loop(
773+
await run_sync_in_thread(
774774
subprocess.run, command, cwd=str(cast(Path, image.dockerfile).cwd()), check=True
775775
)
776776
else:
777-
await run_sync_with_loop(subprocess.Popen, command, cwd=str(cast(Path, image.dockerfile).cwd()))
777+
await run_sync_in_thread(subprocess.Popen, command, cwd=str(cast(Path, image.dockerfile).cwd()))
778778
except subprocess.CalledProcessError as e:
779779
from flyte.errors import ImageBuildError
780780

@@ -790,7 +790,7 @@ async def _ensure_buildx_builder():
790790

791791
# Check if buildx is available
792792
try:
793-
await run_sync_with_loop(
793+
await run_sync_in_thread(
794794
subprocess.run, ["docker", "buildx", "version"], check=True, stdout=subprocess.DEVNULL
795795
)
796796
except FileNotFoundError:
@@ -803,7 +803,7 @@ async def _ensure_buildx_builder():
803803
raise ImageBuildError("Docker buildx is not available. Make sure BuildKit is installed and enabled.")
804804

805805
try:
806-
result = await run_sync_with_loop(
806+
result = await run_sync_in_thread(
807807
subprocess.run, ["docker", "buildx", "ls"], capture_output=True, text=True, check=True
808808
)
809809
except subprocess.CalledProcessError as e:
@@ -817,7 +817,7 @@ async def _ensure_buildx_builder():
817817
# Check if there's any usable builder with the correct driver options
818818
if DockerImageBuilder._builder_name in builders:
819819
# Builder exists — verify it has network=host driver option
820-
inspect_result = await run_sync_with_loop(
820+
inspect_result = await run_sync_in_thread(
821821
subprocess.run,
822822
["docker", "buildx", "inspect", DockerImageBuilder._builder_name],
823823
capture_output=True,
@@ -829,7 +829,7 @@ async def _ensure_buildx_builder():
829829

830830
# Builder exists but missing network=host, remove and recreate
831831
logger.info("Buildx builder exists but missing network=host driver option, recreating...")
832-
await run_sync_with_loop(
832+
await run_sync_in_thread(
833833
subprocess.run,
834834
["docker", "buildx", "rm", DockerImageBuilder._builder_name],
835835
check=False,
@@ -838,7 +838,7 @@ async def _ensure_buildx_builder():
838838
logger.info("No buildx builder found, creating one...")
839839

840840
try:
841-
await run_sync_with_loop(
841+
await run_sync_in_thread(
842842
subprocess.run,
843843
[
844844
"docker",
@@ -955,9 +955,9 @@ async def _build_image(self, image: Image, *, push: bool = True, dry_run: bool =
955955

956956
try:
957957
if wait:
958-
await run_sync_with_loop(subprocess.run, command, check=True)
958+
await run_sync_in_thread(subprocess.run, command, check=True)
959959
else:
960-
await run_sync_with_loop(subprocess.Popen, command)
960+
await run_sync_in_thread(subprocess.Popen, command)
961961
except subprocess.CalledProcessError as e:
962962
from flyte.errors import ImageBuildError
963963

src/flyte/_task.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@
2424
)
2525

2626
from flyte._pod import PodTemplate
27-
from flyte.errors import RuntimeSystemError, RuntimeUserError, TraceDoesNotAllowNestedTasksError
27+
from flyte.errors import (
28+
RuntimeSystemError,
29+
RuntimeUserError,
30+
SyncTaskCallInAsyncContextError,
31+
TraceDoesNotAllowNestedTasksError,
32+
)
2833

2934
from ._cache import Cache, CacheRequest
3035
from ._context import internal_ctx
@@ -363,6 +368,21 @@ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, R]
363368
raise RuntimeSystemError("BadContext", "Controller is not initialized.")
364369

365370
if self._call_as_synchronous:
371+
# A blocking call is only safe from a plain thread. Sync task bodies run in one
372+
# (run_sync_in_thread), so this trips only inside async code, where blocking the
373+
# event loop can hang the run (it also services the controller failure watch).
374+
in_event_loop = True
375+
try:
376+
asyncio.get_running_loop()
377+
except RuntimeError:
378+
in_event_loop = False
379+
if in_event_loop:
380+
call_name = getattr(getattr(self, "func", None), "__name__", self.name)
381+
raise SyncTaskCallInAsyncContextError(
382+
f"Sync task '{self.name}' was called in a blocking way from async code. "
383+
f"This blocks the event loop and can hang the run. "
384+
f"Use `await {call_name}.aio(...)` instead."
385+
)
366386
fut = controller.submit_sync(self, *args, **kwargs)
367387
x = fut.result(None)
368388
return x
@@ -573,7 +593,7 @@ async def execute(self, *args: P.args, **kwargs: P.kwargs) -> R:
573593
This is the execute method that will be called when the task is invoked. It will call the actual function.
574594
# TODO We may need to keep this as the bare func execute, and need a pre and post execute some other func.
575595
"""
576-
from flyte._utils.asyncify import run_sync_with_loop
596+
from flyte._utils.asyncify import run_sync_in_thread
577597

578598
ctx = internal_ctx()
579599
assert ctx.data.task_context is not None, "Function should have already returned if not in a task context"
@@ -583,7 +603,7 @@ async def execute(self, *args: P.args, **kwargs: P.kwargs) -> R:
583603
if iscoroutinefunction(self.func):
584604
v = await self.func(*args, **kwargs)
585605
else:
586-
v = await run_sync_with_loop(self.func, *args, **kwargs)
606+
v = await run_sync_in_thread(self.func, *args, **kwargs)
587607

588608
await self.post(v)
589609
return v

src/flyte/_utils/asyncify.py

Lines changed: 20 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import inspect
66
import random
77
import threading
8+
from concurrent.futures import Future
89
from typing import Callable, TypeVar
910

1011
from typing_extensions import ParamSpec
@@ -15,19 +16,20 @@
1516
P = ParamSpec("P")
1617

1718

18-
async def run_sync_with_loop(
19+
async def run_sync_in_thread(
1920
func: Callable[P, T],
2021
*args: P.args,
2122
**kwargs: P.kwargs,
2223
) -> T:
2324
"""
24-
Run a synchronous function from an async context with its own event loop.
25+
Run a synchronous function from an async context in a dedicated daemon thread.
2526
2627
This function:
27-
- Copies the current context variables and preserves them in the sync function
28-
- Creates a new event loop in a separate thread for the sync function
29-
- Allows the sync function to potentially use asyncio operations
30-
- Returns the result without blocking the calling async event loop
28+
- Copies the current context variables into the thread, so the Flyte task context propagates
29+
- Runs the function as plain sync code: no event loop exists in the thread, so the function
30+
may freely use `asyncio.run()` or third-party sync wrappers (e.g. an SDK's `run_sync`)
31+
- Uses a daemon thread, so a stuck function cannot block interpreter exit
32+
- Returns the result without blocking the calling event loop
3133
3234
Args:
3335
func: The synchronous function to run (must not be an async function)
@@ -42,62 +44,40 @@ async def run_sync_with_loop(
4244
4345
Example:
4446
async def my_async_function():
45-
result = await run_sync_with_loop(some_sync_function, arg1, arg2)
47+
result = await run_sync_in_thread(some_sync_function, arg1, arg2)
4648
return result
4749
"""
4850
# Check if func is an async function
4951
if inspect.iscoroutinefunction(func):
5052
raise TypeError(
51-
f"Cannot call run_sync_with_loop with async function '{getattr(func, '__name__')}'. "
53+
f"Cannot call run_sync_in_thread with async function '{getattr(func, '__name__')}'. "
5254
"This utility is for running sync functions from async contexts."
5355
)
5456

5557
copied_ctx = contextvars.copy_context()
56-
execute_loop = None
57-
execute_loop_created = threading.Event()
5858

5959
# Build thread name with random suffix for uniqueness
6060
func_name = getattr(func, "__name__", "unknown")
6161
current_thread = threading.current_thread().name
6262
random_suffix = f"{random.getrandbits(32):08x}"
6363
full_thread_name = f"sync-executor-{random_suffix}_from_{current_thread}"
6464

65-
def _sync_thread_loop_runner() -> None:
66-
"""This method runs the event loop and should be invoked in a separate thread."""
67-
nonlocal execute_loop
65+
fut: Future[T] = Future()
66+
67+
def _runner() -> None:
68+
if not fut.set_running_or_notify_cancel():
69+
return
6870
try:
69-
execute_loop = asyncio.new_event_loop()
70-
asyncio.set_event_loop(execute_loop)
71-
logger.debug(f"Created event loop for function '{func_name}' in thread '{full_thread_name}'")
72-
execute_loop_created.set()
73-
execute_loop.run_forever()
74-
except Exception as e:
75-
logger.error(f"Exception in thread '{full_thread_name}' running '{func_name}': {e}", exc_info=True)
76-
raise
77-
finally:
78-
if execute_loop:
79-
logger.debug(f"Stopping event loop for function '{func_name}' in thread '{full_thread_name}'")
80-
execute_loop.stop()
81-
execute_loop.close()
82-
logger.debug(f"Cleaned up event loop for function '{func_name}' in thread '{full_thread_name}'")
71+
fut.set_result(copied_ctx.run(func, *args, **kwargs))
72+
except BaseException as e:
73+
fut.set_exception(e)
8374

8475
executor_thread = threading.Thread(
8576
name=full_thread_name,
8677
daemon=True,
87-
target=_sync_thread_loop_runner,
78+
target=_runner,
8879
)
8980
logger.debug(f"Starting executor thread '{full_thread_name}' for function '{func_name}'")
9081
executor_thread.start()
9182

92-
async def async_wrapper():
93-
res = copied_ctx.run(func, *args, **kwargs)
94-
return res
95-
96-
# Wait for the loop to be created in a thread to avoid blocking the current thread
97-
await asyncio.get_event_loop().run_in_executor(None, execute_loop_created.wait)
98-
assert execute_loop is not None
99-
fut = asyncio.run_coroutine_threadsafe(async_wrapper(), loop=execute_loop)
100-
async_fut = asyncio.wrap_future(fut)
101-
result = await async_fut
102-
103-
return result
83+
return await asyncio.wrap_future(fut)

0 commit comments

Comments
 (0)