Skip to content

Commit 1235897

Browse files
authored
fix: make agentcore-worker-loop compatible with OTEL threading instrumentation (#405)
* fix: make agentcore-worker-loop compatible with OTEL threading instrumentation When opentelemetry-instrumentation-threading is active (e.g. via opentelemetry-instrument in Dockerfile CMD), Thread.run() is wrapped to propagate trace context from the parent thread. This leaks the parent's running event loop state into the worker thread, causing run_forever() to raise "RuntimeError: Cannot run the event loop while another loop is running". Three changes to _run_worker_loop / _ensure_worker_loop: 1. Clear leaked running-loop state with asyncio._set_running_loop(None) at the top of the worker thread — this is the critical fix. 2. Create the event loop inside the worker thread (not the parent) to follow the canonical asyncio pattern and eliminate cross-thread state leakage. 3. Use threading.Event + loop.call_soon(ready.set) so the parent waits until the loop is truly running before returning a reference, fixing a pre-existing race condition. * style: fix ruff formatting in test file
1 parent 180a7c5 commit 1235897

2 files changed

Lines changed: 58 additions & 5 deletions

File tree

src/bedrock_agentcore/runtime/app.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -530,19 +530,37 @@ def _ensure_worker_loop(self) -> asyncio.AbstractEventLoop:
530530
return self._worker_loop
531531
with self._worker_loop_lock:
532532
if self._worker_loop is None or not self._worker_loop.is_running():
533-
self._worker_loop = asyncio.new_event_loop()
533+
ready = threading.Event()
534534
self._worker_thread = threading.Thread(
535535
target=self._run_worker_loop,
536+
args=(ready,),
536537
daemon=True,
537538
name="agentcore-worker-loop",
538539
)
539540
self._worker_thread.start()
541+
if not ready.wait(timeout=10):
542+
raise RuntimeError("agentcore-worker-loop failed to start")
540543
return self._worker_loop
541544

542-
def _run_worker_loop(self) -> None:
543-
"""Entry point for the worker loop background thread."""
544-
asyncio.set_event_loop(self._worker_loop)
545-
self._worker_loop.run_forever()
545+
def _run_worker_loop(self, ready: threading.Event) -> None:
546+
"""Entry point for the worker loop background thread.
547+
548+
The event loop is created here (inside the worker thread) rather than in
549+
the parent thread to avoid conflicts with OpenTelemetry's threading
550+
instrumentation, which propagates context from the parent thread and can
551+
cause ``RuntimeError: Cannot run the event loop while another loop is
552+
running``.
553+
"""
554+
# Clear any running-loop state that leaked from the parent thread
555+
# (e.g. via OpenTelemetry's threading instrumentation context propagation).
556+
# Without this, run_forever() raises RuntimeError because
557+
# asyncio._get_running_loop() still returns the parent's loop.
558+
asyncio._set_running_loop(None)
559+
loop = asyncio.new_event_loop()
560+
asyncio.set_event_loop(loop)
561+
self._worker_loop = loop
562+
loop.call_soon(ready.set)
563+
loop.run_forever()
546564

547565
@staticmethod
548566
async def _run_with_context(coro: Any, ctx: contextvars.Context) -> Any:

tests/bedrock_agentcore/runtime/test_app.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2715,3 +2715,38 @@ async def handler(payload):
27152715
content = response.content.decode("utf-8")
27162716
assert 'data: {"chunk": "a"}' in content
27172717
assert 'data: {"chunk": "b"}' in content
2718+
2719+
@pytest.mark.asyncio
2720+
async def test_worker_loop_compatible_with_otel_threading_instrumentation(self):
2721+
"""Worker loop starts even when a running loop leaks into the child thread.
2722+
2723+
OpenTelemetry's opentelemetry-instrumentation-threading wraps Thread.run()
2724+
to propagate trace context. This can leak the parent thread's running-loop
2725+
state into the child thread, causing:
2726+
RuntimeError: Cannot run the event loop while another loop is running
2727+
2728+
The fix clears leaked running-loop state at the top of _run_worker_loop.
2729+
"""
2730+
app = BedrockAgentCoreApp()
2731+
ready = threading.Event()
2732+
2733+
def otel_simulated_target():
2734+
"""Simulate OTEL wrapper leaking a running loop before _run_worker_loop."""
2735+
leak = asyncio.new_event_loop()
2736+
asyncio._set_running_loop(leak)
2737+
try:
2738+
app._run_worker_loop(ready)
2739+
finally:
2740+
asyncio._set_running_loop(None)
2741+
leak.close()
2742+
2743+
thread = threading.Thread(target=otel_simulated_target, daemon=True)
2744+
thread.start()
2745+
assert ready.wait(timeout=5), "Worker loop failed to start under OTEL-like wrapper"
2746+
2747+
assert app._worker_loop is not None
2748+
assert app._worker_loop.is_running()
2749+
2750+
# Verify the loop can actually execute work
2751+
future = asyncio.run_coroutine_threadsafe(asyncio.sleep(0, result="otel_ok"), app._worker_loop)
2752+
assert future.result(timeout=5) == "otel_ok"

0 commit comments

Comments
 (0)