This is an automated email from the ASF dual-hosted git repository.

dabla pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 6a73afb1c15 Added deadlock detection in CommsDecoder (#68377)
6a73afb1c15 is described below

commit 6a73afb1c159b4ada036a4df455355d42d72d1b2
Author: David Blain <[email protected]>
AuthorDate: Fri Jul 3 06:50:41 2026 +0200

    Added deadlock detection in CommsDecoder (#68377)
    
    * refactor: Fix deadlock in CommsDecoder.asend() when sync send() runs 
concurrently on event loop
---
 task-sdk/src/airflow/sdk/execution_time/comms.py   |  52 ++++++-
 task-sdk/tests/task_sdk/execution_time/conftest.py |  12 ++
 .../tests/task_sdk/execution_time/test_comms.py    | 155 ++++++++++++++++++++-
 3 files changed, 206 insertions(+), 13 deletions(-)

diff --git a/task-sdk/src/airflow/sdk/execution_time/comms.py 
b/task-sdk/src/airflow/sdk/execution_time/comms.py
index c70b3e9b518..a97df1df8b0 100644
--- a/task-sdk/src/airflow/sdk/execution_time/comms.py
+++ b/task-sdk/src/airflow/sdk/execution_time/comms.py
@@ -51,7 +51,9 @@ from __future__ import annotations
 import asyncio
 import itertools
 import threading
+import traceback
 from collections.abc import Iterator
+from contextlib import suppress
 from datetime import datetime
 from functools import cached_property
 from pathlib import Path
@@ -118,6 +120,31 @@ SendMsgType = TypeVar("SendMsgType", bound=BaseModel)
 ReceiveMsgType = TypeVar("ReceiveMsgType", bound=BaseModel)
 
 
+class DeadlockImminentError(BaseException):
+    """
+    Raised when ``send()`` is called from the event loop thread while 
``asend()`` holds the lock.
+
+    Inherits from :class:`BaseException` so it escapes 
``contextlib.suppress(Exception)``
+    and always surfaces to the caller.
+    """
+
+    def __init__(self, msg: object) -> None:
+        self.msg_type = type(msg).__name__
+        self.stack = "".join(traceback.format_stack())
+        super().__init__()
+
+    def __str__(self) -> str:
+        return (
+            f"comms.send() called from the event loop thread for message 
'{self.msg_type}' "
+            "— deadlock is imminent (asend() is concurrently in-flight). "
+            "Likely cause: BaseHook.get_hook() or BaseHook.get_connection() 
was called "
+            "from inside an async task. "
+            "Use the async equivalents instead: "
+            "await BaseHook.aget_hook() or await BaseHook.aget_connection()."
+            f"\nOffending call stack:\n{self.stack}"
+        )
+
+
 def _msgpack_enc_hook(obj: Any) -> Any:
     import pendulum
 
@@ -204,23 +231,32 @@ class CommsDecoder(Generic[ReceiveMsgType, SendMsgType]):
 
     err_decoder: TypeAdapter[ErrorResponse] = attrs.field(factory=lambda: 
TypeAdapter(ToTask), repr=False)
 
-    # Threading lock for sync operations
     _thread_lock: threading.Lock = attrs.field(factory=threading.Lock, 
repr=False)
-    # Async lock for async operations
     _async_lock: asyncio.Lock = attrs.field(factory=asyncio.Lock, repr=False)
+    _loop_thread_id: int | None = attrs.field(default=None, repr=False, 
init=False)
 
     def _make_frame(self, msg: SendMsgType) -> _RequestFrame:
         carrier: dict[str, str] = {}
         _trace_propagator.inject(carrier)
         return _RequestFrame(id=next(self.id_counter), body=msg.model_dump(), 
context_carrier=carrier or None)
 
+    @property
+    def _is_on_loop_thread(self) -> bool:
+        if threading.get_ident() == self._loop_thread_id:
+            with suppress(RuntimeError):
+                return bool(asyncio.get_running_loop())
+        return False
+
     def send(self, msg: SendMsgType) -> ReceiveMsgType | None:
         """Send a request to the parent and block until the response is 
received."""
         frame_bytes = self._make_frame(msg).as_bytes()
 
-        # We must make sure sockets aren't intermixed between sync and async 
calls,
-        # thus we need a dual locking mechanism to ensure that.
-        with self._thread_lock:
+        # When called from the event loop thread, use non-blocking acquire to 
detect
+        # an imminent deadlock: an asend() coroutine currently holds 
_thread_lock and
+        # is waiting for the event loop to complete its I/O.
+        if not self._thread_lock.acquire(blocking=not self._is_on_loop_thread):
+            raise DeadlockImminentError(msg)
+        try:
             self.socket.sendall(frame_bytes)
             if isinstance(msg, ResendLoggingFD):
                 if recv_fds is None:
@@ -232,11 +268,13 @@ class CommsDecoder(Generic[ReceiveMsgType, SendMsgType]):
                 if TYPE_CHECKING:
                     assert isinstance(resp, SentFDs)
                 resp.fds = fds
-                # Since we know this is an expliclt SendFDs, and since this 
class is generic SendFDs might not
+                # Since we know this is an explicit ResendLoggingFD, and since 
this class is generic SentFDs might not
                 # always be in the return type union
                 return resp  # type: ignore[return-value]
 
             return self._get_response()
+        finally:
+            self._thread_lock.release()
 
     async def asend(self, msg: SendMsgType) -> ReceiveMsgType | None:
         """
@@ -244,6 +282,8 @@ class CommsDecoder(Generic[ReceiveMsgType, SendMsgType]):
 
         Uses async lock for coroutine safety and thread lock for socket safety.
         """
+        self._loop_thread_id = threading.get_ident()
+
         frame_bytes = self._make_frame(msg).as_bytes()
 
         async with self._async_lock:
diff --git a/task-sdk/tests/task_sdk/execution_time/conftest.py 
b/task-sdk/tests/task_sdk/execution_time/conftest.py
index 4a537373363..2d60b635f63 100644
--- a/task-sdk/tests/task_sdk/execution_time/conftest.py
+++ b/task-sdk/tests/task_sdk/execution_time/conftest.py
@@ -18,10 +18,22 @@
 from __future__ import annotations
 
 import sys
+from socket import socketpair
 
 import pytest
 
 
[email protected]
+def socket_pair():
+    """Yield a connected ``(read_sock, write_sock)`` pair and guarantee both 
are closed on teardown."""
+    r, w = socketpair()
+    try:
+        yield r, w
+    finally:
+        r.close()
+        w.close()
+
+
 @pytest.fixture
 def disable_capturing():
     old_in, old_out, old_err = sys.stdin, sys.stdout, sys.stderr
diff --git a/task-sdk/tests/task_sdk/execution_time/test_comms.py 
b/task-sdk/tests/task_sdk/execution_time/test_comms.py
index 51782796b82..97b9a79931f 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_comms.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_comms.py
@@ -19,7 +19,6 @@ from __future__ import annotations
 
 import threading
 import uuid
-from socket import socketpair
 
 import msgspec
 import pytest
@@ -29,6 +28,8 @@ from airflow.sdk import timezone
 from airflow.sdk.execution_time.comms import (
     BundleInfo,
     CommsDecoder,
+    DeadlockImminentError,
+    GetVariable,
     MaskSecret,
     StartupDetails,
     VariableResult,
@@ -75,8 +76,8 @@ class TestCommsDecoder:
     """Test the communication between the subprocess and the "supervisor"."""
 
     @pytest.mark.usefixtures("disable_capturing")
-    def test_recv_StartupDetails(self):
-        r, w = socketpair()
+    def test_recv_StartupDetails(self, socket_pair):
+        r, w = socket_pair
 
         msg = {
             "type": "StartupDetails",
@@ -131,8 +132,8 @@ class TestCommsDecoder:
         assert msg.bundle_info == BundleInfo(name="any-name", 
version="any-version")
         assert msg.start_date == timezone.datetime(2024, 12, 1, 1)
 
-    def test_huge_payload(self):
-        r, w = socketpair()
+    def test_huge_payload(self, socket_pair):
+        r, w = socket_pair
 
         msg = {
             "type": "XComResult",
@@ -160,8 +161,8 @@ class TestCommsDecoder:
         assert len(msg.value) == 10 * 1024 * 1024 + 1
         assert msg.value[-1] == "b"
 
-    def test_send_thread_safety(self):
-        r, w = socketpair()
+    def test_send_thread_safety(self, socket_pair):
+        r, w = socket_pair
         decoder = CommsDecoder(socket=r, log=structlog.get_logger())
         num_threads = 5
         results = [None] * num_threads
@@ -208,3 +209,143 @@ class TestCommsDecoder:
             assert errors[idx] is None, f"Thread {idx} error: {errors[idx]}"
             assert results[idx].key == f"key{idx}", f"Out-of-order or missing 
response for thread {idx}"
             assert results[idx].value == f"value{idx}", f"Incorrect value for 
thread {idx}"
+
+    @pytest.mark.asyncio
+    async def 
test_send_from_event_loop_raises_deadlock_imminent_error_when_asend_in_flight(
+        self, socket_pair
+    ):
+        """
+        Regression: send() called from the event loop thread while asend() 
holds
+        _thread_lock in a thread-pool worker must raise DeadlockImminentError
+        (deadlock_imminent=True) before attempting to acquire the lock.
+
+        DeadlockImminentError inherits from BaseException so it escapes
+        contextlib.suppress(Exception) in mask_secret() and similar helpers.
+        """
+        r, _ = socket_pair
+        decoder = CommsDecoder(socket=r, log=structlog.get_logger())
+
+        # Simulate asend() having recorded this thread as the event-loop 
thread.
+        decoder._loop_thread_id = threading.get_ident()
+
+        # Hold _thread_lock from a background thread to simulate an in-flight 
asend().
+        lock_held = threading.Event()
+        lock_release = threading.Event()
+
+        def _hold_lock():
+            decoder._thread_lock.acquire()
+            lock_held.set()
+            lock_release.wait()
+            decoder._thread_lock.release()
+
+        holder = threading.Thread(target=_hold_lock, daemon=True)
+        holder.start()
+        assert lock_held.wait(timeout=2), "Background thread never acquired 
_thread_lock"
+
+        try:
+            # send() from the event loop thread while lock is held must raise 
immediately.
+            with pytest.raises(DeadlockImminentError) as exc_info:
+                decoder.send(GetVariable(key="should_fail"))
+
+            msg = str(exc_info.value)
+            assert msg.startswith("comms.send() called from the event loop 
thread")
+            assert "deadlock is imminent" in msg
+        finally:
+            lock_release.set()
+            holder.join(timeout=2)
+
+    @pytest.mark.asyncio
+    async def test_send_from_event_loop_succeeds_when_lock_free(self, 
socket_pair):
+        """
+        send() called from the event loop thread when no asend() is currently
+        in-flight (lock not held) must proceed safely and return a result —
+        even after _loop_thread_id has been recorded by a prior asend() call.
+
+        The non-blocking acquire succeeds (lock is free), so the sync socket
+        I/O completes without needing the event loop, avoiding a deadlock.
+        DeadlockImminentError must NOT be raised in this scenario.
+        """
+        import asyncio
+
+        r, w = socket_pair
+        decoder = CommsDecoder(socket=r, log=structlog.get_logger())
+
+        def _serve_one():
+            length = int.from_bytes(w.recv(4), "big")
+            body = b""
+            while len(body) < length:
+                body += w.recv(length - len(body))
+            req = msgspec.msgpack.decode(body, type=_RequestFrame)
+            resp = {"type": "VariableResult", "key": req.body["key"], "value": 
"v"}
+            encoded = msgspec.msgpack.encode(_ResponseFrame(req.id, resp, 
None))
+            w.sendall(len(encoded).to_bytes(4, "big") + encoded)
+
+        # Phase 1: complete one asend() to record _loop_thread_id on the 
decoder
+        server = threading.Thread(target=_serve_one, daemon=True)
+        server.start()
+        await asyncio.wait_for(decoder.asend(GetVariable(key="prime")), 
timeout=5)
+        server.join(timeout=2)
+
+        # Phase 2: lock is free, _loop_thread_id is set.
+        # send() from the event loop thread must succeed (non-blocking acquire
+        # gets the free lock and the sync I/O completes without the event 
loop).
+        server2 = threading.Thread(target=_serve_one, daemon=True)
+        server2.start()
+        result = decoder.send(GetVariable(key="should_succeed"))
+        server2.join(timeout=2)
+
+        assert result is not None
+        assert result.key == "should_succeed"
+
+    def test_send_after_event_loop_closes_does_not_raise(self, socket_pair):
+        """
+        Regression test: send() called from the same thread that previously ran
+        the event loop — but after the loop has finished — must NOT raise
+        DeadlockImminentError.
+
+        Scenario: AsyncOperator.execute() runs aexecute() via
+        loop.run_until_complete(), which calls await 
SUPERVISOR_COMMS.asend(...)
+        (recording _loop_thread_id).  After execute() returns the loop is 
closed.
+        The task_runner then calls SUPERVISOR_COMMS.send() to push XCom 
results.
+        Without this fix that call raised DeadlockImminentError spuriously.
+        """
+        import asyncio
+
+        r, w = socket_pair
+        decoder = CommsDecoder(socket=r, log=structlog.get_logger())
+
+        # Helper: serve exactly one request from the given socket pair.
+        def _serve_one(sock):
+            length = int.from_bytes(sock.recv(4), "big")
+            body = b""
+            while len(body) < length:
+                body += sock.recv(length - len(body))
+            req = msgspec.msgpack.decode(body, type=_RequestFrame)
+            resp = {"type": "VariableResult", "key": req.body["key"], "value": 
"v"}
+            encoded = msgspec.msgpack.encode(_ResponseFrame(req.id, resp, 
None))
+            sock.sendall(len(encoded).to_bytes(4, "big") + encoded)
+
+        # --- Phase 1: run the async part (simulates aexecute()) ---
+        async def _async_phase():
+            server = threading.Thread(target=_serve_one, args=(w,), 
daemon=True)
+            server.start()
+            await 
asyncio.wait_for(decoder.asend(GetVariable(key="async_key")), timeout=5)
+            server.join(timeout=2)
+
+        loop = asyncio.new_event_loop()
+        try:
+            loop.run_until_complete(_async_phase())
+        finally:
+            loop.close()
+            asyncio.set_event_loop(None)
+
+        # --- Phase 2: sync send() after the loop is closed (simulates 
_push_xcom_if_needed) ---
+        # _loop_thread_id is still set from phase 1, but the loop is no longer 
running.
+        # This must NOT raise DeadlockImminentError.
+        server2 = threading.Thread(target=_serve_one, args=(w,), daemon=True)
+        server2.start()
+
+        result = decoder.send(GetVariable(key="sync_key"))
+        server2.join(timeout=2)
+
+        assert result is not None

Reply via email to