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

henry3260 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 79a1dd12ee5 Fix dag.test() hanging when a task reads a Variable in a 
subprocess (#65587)
79a1dd12ee5 is described below

commit 79a1dd12ee557614a7692120f77d165ddbd887a6
Author: Henry Chen <[email protected]>
AuthorDate: Sat Nov 7 20:06:02 2026 +0800

    Fix dag.test() hanging when a task reads a Variable in a subprocess (#65587)
    
    * Fix dag.test() hanging when a task reads a Variable in a subprocess
    
    Under dag.test() the Execution API server and the task share one process, 
so the
    API's own route handlers can see SUPERVISOR_COMMS. They took that as proof 
they
    were client-side and issued a fresh Execution API request, calling back into
    themselves instead of reading the metastore.
    
    Operators that spawn their own child process — PythonVirtualenvOperator and
    ExternalPythonOperator — have that child reconnect to the supervisor over a
    socket. The in-process supervisor answered every request on an in-memory 
queue
    the child cannot read, so it stayed blocked on a response frame that was 
never
    written: no error, no timeout, no traceback.
    
    * Keep in-process comms docs and server-context API in step with behavior
    
    The response-sink docstring claimed the socket thread never sets a sink,
    yet mask_secret re-enters the comms on that thread; a reader trusting it
    could swap the sink for a thread check and misroute MaskSecret replies.
    The architecture doc and diagram still described deques and no sockets,
    and override_process_context accepted a "client" value that did nothing,
    silently falling back to server behavior for anyone relying on it.
    
    * Cover secret masking on the socket thread in in-process supervisor tests
    
    Every socket test patched mask_secret out, so the one path where a child's
    request re-enters the in-process comms on the socket thread was never
    exercised, and a regression there would silently hand the child the wrong
    frame. The concurrent test's docstring also described a socket scenario it
    never ran, and a hung first request only surfaced as an opaque dict diff.
    
    * Narrow the a2wsgi middleware cast to a2wsgi's ASGIApp
    
    a2wsgi types ASGI scopes and messages as TypedDict unions and Starlette
    as mutable mappings, so the Starlette-typed wrapper never matches the
    ASGIApp that ASGIMiddleware declares. Casting to that type instead of
    Any ties the call to a2wsgi's signature, so a change there shows up in
    mypy. The wrapper's own signature stays checked where atransport passes
    it to httpx2.ASGITransport.
---
 .../api_fastapi/execution_api/in_process.py        |  30 +++-
 airflow-core/src/airflow/models/connection.py      |   6 +-
 airflow-core/src/airflow/models/variable.py        |  10 +-
 airflow-core/src/airflow/process_context.py        |  53 +++++++
 .../unit/api_fastapi/execution_api/test_app.py     |  33 ++++
 .../versions/head/test_connections.py              |  34 +++++
 .../execution_api/versions/head/test_variables.py  |  63 ++++++++
 airflow-core/tests/unit/models/test_connection.py  |  21 +++
 .../31_task_execution_architecture.rst             |   7 +-
 .../diagram_task_sdk_execution_architecture.md5sum |   2 +-
 .../diagram_task_sdk_execution_architecture.png    | Bin 184731 -> 170906 bytes
 .../diagram_task_sdk_execution_architecture.py     |   8 +-
 .../src/airflow/sdk/execution_time/supervisor.py   |  52 +++++--
 .../task_sdk/execution_time/test_supervisor.py     | 166 +++++++++++++++++++++
 14 files changed, 452 insertions(+), 33 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/in_process.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/in_process.py
index eb428196d43..4405e387b76 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/in_process.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/in_process.py
@@ -23,15 +23,19 @@ import threading
 import weakref
 from contextlib import AsyncExitStack
 from functools import cached_property
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, cast
 
 import attrs
 import structlog
 from starlette.requests import Request
 
+from airflow.process_context import force_server_context
+
 if TYPE_CHECKING:
     import httpx2
+    from a2wsgi.asgi_typing import ASGIApp
     from fastapi import FastAPI
+    from starlette.types import Receive, Scope, Send
 
 logger = structlog.get_logger(logger_name=__name__)
 
@@ -89,6 +93,17 @@ def _configure_in_process_auth(app: FastAPI, *, 
triggerer_id: int | None = None)
     app.dependency_overrides[has_xcom_access] = always_allow
 
 
+class _RequestScopedServerContextApp:
+    """Wrap an ASGI app so in-process requests behave like server-side API 
handling."""
+
+    def __init__(self, app: FastAPI) -> None:
+        self.app = app
+
+    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> 
None:
+        with force_server_context():
+            await self.app(scope, receive, send)
+
+
 @attrs.define()
 class InProcessExecutionAPI:
     """
@@ -96,6 +111,10 @@ class InProcessExecutionAPI:
 
     The sync version of this makes use of a2wsgi which runs the async loop in 
a separate thread. This is
     needed so that we can use the sync httpx2 client
+
+    Requests are always dispatched in a server process context, with no way to 
opt out: this app *is*
+    the server side of the Execution API, and in a single process its route 
handlers would otherwise
+    read the caller's ``SUPERVISOR_COMMS`` and re-enter the API through the 
Task SDK.
     """
 
     _app: FastAPI | None = None
@@ -131,7 +150,10 @@ class InProcessExecutionAPI:
         thread = threading.Thread(target=loop.run_forever, 
name="InProcessExecutionAPI-loop", daemon=True)
         thread.start()
 
-        middleware = ASGIMiddleware(self.app, loop=loop)
+        app = self.app
+        # a2wsgi types ASGI scopes and messages as TypedDict unions and 
Starlette as mutable mappings,
+        # so no app typed against Starlette matches a2wsgi's ``ASGIApp``.
+        middleware = ASGIMiddleware(cast("ASGIApp", 
_RequestScopedServerContextApp(app)), loop=loop)
 
         # https://github.com/abersheeran/a2wsgi/discussions/64
         async def start_lifespan(cm: AsyncExitStack, app: FastAPI):
@@ -141,7 +163,7 @@ class InProcessExecutionAPI:
         cm = AsyncExitStack()
 
         # Wait for startup so callers see a ready app and the finalizer can 
close an entered context.
-        asyncio.run_coroutine_threadsafe(start_lifespan(cm, self.app), 
loop).result()
+        asyncio.run_coroutine_threadsafe(start_lifespan(cm, app), 
loop).result()
 
         transport = httpx2.WSGITransport(app=middleware)  # type: 
ignore[arg-type]
 
@@ -155,4 +177,4 @@ class InProcessExecutionAPI:
     def atransport(self) -> httpx2.ASGITransport:
         import httpx2
 
-        return httpx2.ASGITransport(app=self.app)
+        return 
httpx2.ASGITransport(app=_RequestScopedServerContextApp(self.app))
diff --git a/airflow-core/src/airflow/models/connection.py 
b/airflow-core/src/airflow/models/connection.py
index 52573a750e9..413902030f9 100644
--- a/airflow-core/src/airflow/models/connection.py
+++ b/airflow-core/src/airflow/models/connection.py
@@ -20,7 +20,6 @@ from __future__ import annotations
 import json
 import logging
 import re
-import sys
 import warnings
 from contextlib import suppress
 from json import JSONDecodeError
@@ -36,6 +35,7 @@ from airflow._shared.secrets_masker import mask_secret
 from airflow.exceptions import AirflowException, AirflowNotFoundException
 from airflow.models.base import ID_LEN, Base
 from airflow.models.crypto import FernetFieldsMixin, get_fernet
+from airflow.process_context import should_use_task_sdk_api_path
 
 # AirflowSecretsBackendAccessDenied was added to task-sdk in 1.2.2. When
 # airflow-core is installed alongside an older published task-sdk (e.g. 1.2.1 
or earlier),
@@ -535,7 +535,7 @@ class Connection(Base, FernetFieldsMixin, LoggingMixin):
 
         # If this is set it means are in some kind of execution context (Task, 
Dag Parse or Triggerer perhaps)
         # and should use the Task SDK API server path
-        if hasattr(sys.modules.get("airflow.sdk.execution_time.task_runner"), 
"SUPERVISOR_COMMS"):
+        if should_use_task_sdk_api_path():
             from airflow.sdk import Connection as TaskSDKConnection
             from airflow.sdk.exceptions import AirflowRuntimeError, ErrorType
 
@@ -626,7 +626,7 @@ class Connection(Base, FernetFieldsMixin, LoggingMixin):
 
     @classmethod
     def from_json(cls, value, conn_id=None) -> Connection:
-        if hasattr(sys.modules.get("airflow.sdk.execution_time.task_runner"), 
"SUPERVISOR_COMMS"):
+        if should_use_task_sdk_api_path():
             from airflow.sdk import Connection as TaskSDKConnection
 
             warnings.warn(
diff --git a/airflow-core/src/airflow/models/variable.py 
b/airflow-core/src/airflow/models/variable.py
index 1c53237aa16..bf7138545f3 100644
--- a/airflow-core/src/airflow/models/variable.py
+++ b/airflow-core/src/airflow/models/variable.py
@@ -20,7 +20,6 @@ from __future__ import annotations
 import contextlib
 import json
 import logging
-import sys
 import warnings
 from typing import TYPE_CHECKING, Any
 
@@ -33,6 +32,7 @@ from airflow._shared.secrets_masker import mask_secret
 from airflow.configuration import conf, ensure_secrets_loaded
 from airflow.models.base import ID_LEN, Base
 from airflow.models.crypto import get_fernet
+from airflow.process_context import should_use_task_sdk_api_path
 
 # AirflowSecretsBackendAccessDenied was added to task-sdk in 1.2.2. When
 # airflow-core is installed alongside an older published task-sdk (e.g. 1.2.1 
or earlier),
@@ -188,7 +188,7 @@ class Variable(Base, LoggingMixin):
 
         # If this is set it means we are in some kind of execution context 
(Task, Dag Parse or Triggerer perhaps)
         # and should use the Task SDK API server path
-        if hasattr(sys.modules.get("airflow.sdk.execution_time.task_runner"), 
"SUPERVISOR_COMMS"):
+        if should_use_task_sdk_api_path():
             if session is not None:
                 raise ValueError(
                     "Variable.get() cannot use a metadata database session 
from an execution context; "
@@ -255,7 +255,7 @@ class Variable(Base, LoggingMixin):
 
         # If this is set it means we are in some kind of execution context 
(Task, Dag Parse or Triggerer perhaps)
         # and should use the Task SDK API server path
-        if hasattr(sys.modules.get("airflow.sdk.execution_time.task_runner"), 
"SUPERVISOR_COMMS"):
+        if should_use_task_sdk_api_path():
             warnings.warn(
                 "Using Variable.set from `airflow.models` is deprecated."
                 "Please use `set` on Variable from sdk(`airflow.sdk.Variable`) 
instead",
@@ -342,7 +342,7 @@ class Variable(Base, LoggingMixin):
 
         # If this is set it means are in some kind of execution context (Task, 
Dag Parse or Triggerer perhaps)
         # and should use the Task SDK API server path
-        if hasattr(sys.modules.get("airflow.sdk.execution_time.task_runner"), 
"SUPERVISOR_COMMS"):
+        if should_use_task_sdk_api_path():
             warnings.warn(
                 "Using Variable.update from `airflow.models` is deprecated."
                 "Please use `set` on Variable from sdk(`airflow.sdk.Variable`) 
instead as it is an upsert.",
@@ -408,7 +408,7 @@ class Variable(Base, LoggingMixin):
 
         # If this is set it means are in some kind of execution context (Task, 
Dag Parse or Triggerer perhaps)
         # and should use the Task SDK API server path
-        if hasattr(sys.modules.get("airflow.sdk.execution_time.task_runner"), 
"SUPERVISOR_COMMS"):
+        if should_use_task_sdk_api_path():
             warnings.warn(
                 "Using Variable.delete from `airflow.models` is deprecated."
                 "Please use `delete` on Variable from 
sdk(`airflow.sdk.Variable`) instead",
diff --git a/airflow-core/src/airflow/process_context.py 
b/airflow-core/src/airflow/process_context.py
new file mode 100644
index 00000000000..af506bc00a2
--- /dev/null
+++ b/airflow-core/src/airflow/process_context.py
@@ -0,0 +1,53 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import sys
+from collections.abc import Generator
+from contextlib import contextmanager
+from contextvars import ContextVar
+
+__all__ = [
+    "force_server_context",
+    "should_use_task_sdk_api_path",
+]
+
+_FORCE_SERVER_CONTEXT: ContextVar[bool] = 
ContextVar("_AIRFLOW_FORCE_SERVER_CONTEXT", default=False)
+
+
+@contextmanager
+def force_server_context() -> Generator[None, None, None]:
+    """Handle the active execution flow as server-side, even if 
``SUPERVISOR_COMMS`` is set."""
+    token = _FORCE_SERVER_CONTEXT.set(True)
+    try:
+        yield
+    finally:
+        _FORCE_SERVER_CONTEXT.reset(token)
+
+
+def should_use_task_sdk_api_path() -> bool:
+    """Return True when execution-context helpers should route through Task 
SDK APIs."""
+    # Only the ContextVar, never the ``_AIRFLOW_PROCESS_CONTEXT`` env var: 
that env var is
+    # process-wide and inherited by children (``action_cli`` sets it around 
the whole
+    # ``airflow dags test`` body, and PythonVirtualenvOperator passes it to 
the venv child), so
+    # letting it win here would send worker-side code straight to the 
metastore. ``SUPERVISOR_COMMS``
+    # keeps precedence over it, matching ``ensure_secrets_backend_loaded()`` 
in the Task SDK.
+    if _FORCE_SERVER_CONTEXT.get():
+        return False
+
+    task_runner_module = 
sys.modules.get("airflow.sdk.execution_time.task_runner")
+    return bool(getattr(task_runner_module, "SUPERVISOR_COMMS", None))
diff --git a/airflow-core/tests/unit/api_fastapi/execution_api/test_app.py 
b/airflow-core/tests/unit/api_fastapi/execution_api/test_app.py
index 5f708651294..e4c9199e3b6 100644
--- a/airflow-core/tests/unit/api_fastapi/execution_api/test_app.py
+++ b/airflow-core/tests/unit/api_fastapi/execution_api/test_app.py
@@ -231,6 +231,39 @@ def 
test_in_process_task_xcom_write_uses_its_attempt_id(create_task_instance, se
     assert stored.value == "correct"
 
 
+def test_in_process_execution_api_does_not_reenter_task_sdk(session):
+    """A request served in-process must not route back out through the Task 
SDK.
+
+    Under ``dag.test()`` the API server and the task share one process, so 
``SUPERVISOR_COMMS`` is
+    visible to the route handler too. Without a request-scoped server context 
the handler reads it,
+    decides it is client-side, and issues another Execution API request -- 
looping until the caller
+    is killed. No env var is set here on purpose: ``dag.test()`` from a script 
sets none, so the
+    ContextVar is the only thing preventing re-entry.
+    """
+    from airflow.models.variable import Variable
+
+    # Patched below, not by decorator: this setup must run before 
``SUPERVISOR_COMMS`` is visible.
+    Variable.set(key="inproc_key", value="VALUE", session=session)
+    session.commit()
+
+    api = InProcessExecutionAPI()
+    with (
+        mock.patch.dict(
+            "sys.modules",
+            {"airflow.sdk.execution_time.task_runner": 
mock.Mock(spec=["SUPERVISOR_COMMS"])},
+        ),
+        mock.patch(
+            "airflow.sdk.Variable.get",
+            side_effect=AssertionError("in-process Execution API re-entered 
the Task SDK path"),
+        ),
+        httpx2.Client(transport=api.transport) as client,
+    ):
+        response = client.get("http://localhost/variables/inproc_key";)
+
+    assert response.status_code == 200
+    assert response.json() == {"key": "inproc_key", "value": "VALUE"}
+
+
 def test_in_process_execution_api_transport_lifecycle():
     """The background loop + thread lifecycle is tied to the ``.transport``, 
not the factory instance.
 
diff --git 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_connections.py
 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_connections.py
index 8d5da4fa3ae..d5cb7fca7f5 100644
--- 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_connections.py
+++ 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_connections.py
@@ -17,12 +17,14 @@
 
 from __future__ import annotations
 
+import sys
 from unittest import mock
 
 import pytest
 from fastapi import FastAPI, HTTPException, status
 
 from airflow.models.connection import Connection
+from airflow.process_context import force_server_context
 
 pytestmark = pytest.mark.db_test
 
@@ -137,6 +139,38 @@ class TestGetConnection:
             "extra": '{"headers": "header"}',
         }
 
+    @mock.patch.dict(
+        "os.environ",
+        {
+            "AIRFLOW_CONN_TEST_CONN_SERVER": '{"uri": 
"http://root:admin@localhost:8080/https?headers=header"}',
+        },
+    )
+    @mock.patch.dict(
+        sys.modules,
+        {"airflow.sdk.execution_time.task_runner": 
mock.Mock(spec=["SUPERVISOR_COMMS"])},
+    )
+    @mock.patch(
+        "airflow.sdk.Connection.get",
+        side_effect=AssertionError(
+            "Execution API should not route through Task SDK Connection.get in 
server context"
+        ),
+    )
+    def 
test_connection_get_uses_server_path_when_supervisor_comms_exists(self, 
mock_sdk_get, client):
+        with force_server_context():
+            response = client.get("/execution/connections/test_conn_server")
+
+        assert response.status_code == 200
+        assert response.json() == {
+            "conn_id": "test_conn_server",
+            "conn_type": "http",
+            "host": "localhost",
+            "login": "root",
+            "password": "admin",
+            "schema": "https",
+            "port": 8080,
+            "extra": '{"headers": "header"}',
+        }
+
     def test_connection_get_not_found(self, client):
         response = client.get("/execution/connections/non_existent_test_conn")
 
diff --git 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_variables.py
 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_variables.py
index f078d6c2fe0..e7e8fc0e717 100644
--- 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_variables.py
+++ 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_variables.py
@@ -26,6 +26,7 @@ from fastapi.routing import Mount
 from sqlalchemy import select
 
 from airflow.models.variable import Variable
+from airflow.process_context import force_server_context
 
 from tests_common.test_utils.db import clear_db_variables
 
@@ -96,6 +97,24 @@ class TestGetVariable:
         assert response.status_code == 200
         assert response.json() == {"key": "key1", "value": "VALUE"}
 
+    @mock.patch.dict("os.environ", {"AIRFLOW_VAR_KEY1": "VALUE"})
+    @mock.patch.dict(
+        "sys.modules",
+        {"airflow.sdk.execution_time.task_runner": 
mock.Mock(spec=["SUPERVISOR_COMMS"])},
+    )
+    @mock.patch(
+        "airflow.sdk.Variable.get",
+        side_effect=AssertionError(
+            "Execution API should not route through Task SDK Variable.get in 
server context"
+        ),
+    )
+    def test_variable_get_uses_server_path_when_supervisor_comms_exists(self, 
mock_sdk_get, client):
+        with force_server_context():
+            response = client.get("/execution/variables/key1")
+
+        assert response.status_code == 200
+        assert response.json() == {"key": "key1", "value": "VALUE"}
+
     @pytest.mark.parametrize(
         "key",
         [
@@ -158,6 +177,26 @@ class TestPutVariable:
         if "description" in payload:
             assert var_from_db.description == payload["description"]
 
+    @mock.patch.dict(
+        "sys.modules",
+        {"airflow.sdk.execution_time.task_runner": 
mock.Mock(spec=["SUPERVISOR_COMMS"])},
+    )
+    @mock.patch(
+        "airflow.sdk.Variable.set",
+        side_effect=AssertionError(
+            "Execution API should not route through Task SDK Variable.set in 
server context"
+        ),
+    )
+    def test_variable_put_uses_server_path_when_supervisor_comms_exists(self, 
mock_sdk_set, client, session):
+        with force_server_context():
+            response = client.put("/execution/variables/var_server_only", 
json={"value": "server_value"})
+
+        assert response.status_code == 201
+        assert response.json()["message"] == "Variable successfully set"
+        var_from_db = session.scalars(select(Variable).where(Variable.key == 
"var_server_only")).first()
+        assert var_from_db is not None
+        assert var_from_db.val == "server_value"
+
     @pytest.mark.parametrize(
         ("key", "payload", "error_type"),
         [
@@ -342,3 +381,27 @@ class TestDeleteVariable:
 
         vars = session.scalars(select(Variable)).all()
         assert len(vars) == 1
+
+    def 
test_variable_delete_uses_server_path_when_supervisor_comms_exists(self, 
client, session):
+        # Patched below, not by decorator: this setup must run before 
``SUPERVISOR_COMMS`` is visible.
+        Variable.set(key="var_server_delete", value="to_delete", 
session=session)
+        session.commit()
+
+        with (
+            force_server_context(),
+            mock.patch.dict(
+                "sys.modules",
+                {"airflow.sdk.execution_time.task_runner": 
mock.Mock(spec=["SUPERVISOR_COMMS"])},
+            ),
+            mock.patch(
+                "airflow.sdk.Variable.delete",
+                side_effect=AssertionError(
+                    "Execution API should not route through Task SDK 
Variable.delete in server context"
+                ),
+            ),
+        ):
+            response = client.delete("/execution/variables/var_server_delete")
+
+        assert response.status_code == 204
+        session.expire_all()
+        assert session.scalar(select(Variable).where(Variable.key == 
"var_server_delete")) is None
diff --git a/airflow-core/tests/unit/models/test_connection.py 
b/airflow-core/tests/unit/models/test_connection.py
index 3e83a20cf2c..c4b7a728310 100644
--- a/airflow-core/tests/unit/models/test_connection.py
+++ b/airflow-core/tests/unit/models/test_connection.py
@@ -27,6 +27,7 @@ from structlog.testing import capture_logs
 
 from airflow.exceptions import AirflowException, AirflowNotFoundException
 from airflow.models import Connection
+from airflow.process_context import force_server_context
 from airflow.sdk.exceptions import AirflowRuntimeError, ErrorType
 from airflow.sdk.execution_time.comms import ErrorResponse
 
@@ -565,6 +566,26 @@ class TestConnection:
             with pytest.raises(AirflowNotFoundException):
                 Connection.get_connection_from_secrets("test_conn")
 
+    @mock.patch.dict(
+        sys.modules,
+        {"airflow.sdk.execution_time.task_runner": 
mock.Mock(spec=["SUPERVISOR_COMMS"])},
+    )
+    @mock.patch(
+        "airflow.sdk.Connection.from_json",
+        side_effect=AssertionError(
+            "Connection.from_json should not route through Task SDK in server 
context"
+        ),
+    )
+    def test_connection_from_json_uses_core_path_when_server_context(self, 
mock_sdk_from_json):
+        """Server context should prefer core Connection.from_json even if 
comms exist."""
+        with force_server_context():
+            result = Connection.from_json('{"conn_type": "http", "host": 
"localhost"}', conn_id="test_conn")
+
+        assert isinstance(result, Connection)
+        assert result.conn_id == "test_conn"
+        assert result.conn_type == "http"
+        assert result.host == "localhost"
+
     @mock.patch.dict(sys.modules, {"airflow.sdk.execution_time.task_runner": 
None})
     @mock.patch("airflow.sdk.Connection")
     
@mock.patch("airflow.secrets.environment_variables.EnvironmentVariablesBackend.get_connection")
diff --git a/contributing-docs/31_task_execution_architecture.rst 
b/contributing-docs/31_task_execution_architecture.rst
index 6b12ddd0a14..14d4670dc66 100644
--- a/contributing-docs/31_task_execution_architecture.rst
+++ b/contributing-docs/31_task_execution_architecture.rst
@@ -41,9 +41,10 @@ The two processes talk over a socket, and the Supervisor is 
the only side that e
 task JWT or talks to the *Execution API* — the user's code never sees the 
token and never touches the
 database.
 
-The same runtime can also run *in-process* (a single Python process, no fork, 
no sockets, no HTTP) for
-``dag.test()`` and local runs. The diagram below contrasts the two paths and 
marks where each Python process
-lives:
+The same runtime can also run *in-process* (one Python process for the task 
itself, no HTTP) for
+``dag.test()`` and local runs. A supervisor socket is still set up, because 
operators such as
+``PythonVirtualenvOperator`` spawn their own child process that has to 
reconnect to ask for Connections
+and Variables. The diagram below contrasts the two paths and marks where each 
Python process lives:
 
 .. image:: images/diagram_task_sdk_execution_architecture.png
 
diff --git 
a/contributing-docs/images/diagram_task_sdk_execution_architecture.md5sum 
b/contributing-docs/images/diagram_task_sdk_execution_architecture.md5sum
index f5ddfd6b4c9..c6e01eb7524 100644
--- a/contributing-docs/images/diagram_task_sdk_execution_architecture.md5sum
+++ b/contributing-docs/images/diagram_task_sdk_execution_architecture.md5sum
@@ -1 +1 @@
-3347c12823e4feee7dd92e378b5b76e8
+24a90b983c117809b10b03ae2db45159
diff --git 
a/contributing-docs/images/diagram_task_sdk_execution_architecture.png 
b/contributing-docs/images/diagram_task_sdk_execution_architecture.png
index 7550e570489..0c97f44c153 100644
Binary files 
a/contributing-docs/images/diagram_task_sdk_execution_architecture.png and 
b/contributing-docs/images/diagram_task_sdk_execution_architecture.png differ
diff --git 
a/contributing-docs/images/diagram_task_sdk_execution_architecture.py 
b/contributing-docs/images/diagram_task_sdk_execution_architecture.py
index cc483cc03d7..bdb5f3e5475 100644
--- a/contributing-docs/images/diagram_task_sdk_execution_architecture.py
+++ b/contributing-docs/images/diagram_task_sdk_execution_architecture.py
@@ -226,12 +226,12 @@ def generate_task_sdk_execution_architecture_diagram():
     )
 
     # ------------------------------------------------------------------ #
-    # Native / in-process path — one Python process, no fork, no sockets.
+    # Native / in-process path — one Python process for the task itself, no 
HTTP.
     # ------------------------------------------------------------------ #
     with g.subgraph(name="cluster_native") as nat:
         nat.attr(
             label="Native (in-process) path — dag.test() / local run"
-            "   ·   ONE Python process · no fork · no sockets · no HTTP",
+            "   ·   ONE Python process for the task itself · no HTTP",
             labelloc="t",
             style="rounded,filled",
             fillcolor="#faf3fc",
@@ -253,7 +253,7 @@ def generate_task_sdk_execution_architecture_diagram():
             nat,
             "in_comms",
             "InProcessSupervisorComms",
-            "in-memory deques, not sockets",
+            "direct call for the task · supervisor socket<br/>for child 
processes (e.g. virtualenv)",
             shape="box",
             theme=NATIVE,
         )
@@ -269,7 +269,7 @@ def generate_task_sdk_execution_architecture_diagram():
         nat.edge(
             "in_runner",
             "in_comms",
-            label="ToTask / ToSupervisor messages\nvia deque.append / popleft",
+            label="ToTask / ToSupervisor messages\nresponse returned to the 
caller",
             color=NATIVE[1],
             fontcolor=NATIVE[1],
             dir="both",
diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py 
b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
index 38c14782351..3b1219a4f14 100644
--- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
@@ -36,6 +36,7 @@ import weakref
 from collections import deque
 from collections.abc import Callable, Generator
 from contextlib import contextmanager, nullcontext, suppress
+from contextvars import ContextVar
 from datetime import UTC, datetime
 from enum import Enum, auto
 from http import HTTPStatus
@@ -2528,26 +2529,44 @@ def in_process_api_server():
     return api
 
 
+_IN_PROCESS_RESPONSE_SINK: ContextVar[deque[BaseModel | None] | None] = 
ContextVar(
+    "in_process_response_sink", default=None
+)
+"""Where :meth:`InProcessTestSupervisor.send_msg` must deliver the response it 
is about to send.
+
+Only :meth:`InProcessSupervisorComms.send` sets it, and it gets a fresh sink 
per call, so a response
+can never reach a caller other than the one that is waiting for it. When no 
sink is set, the request
+came over the socket from a child process (read by the thread started in
+``_setup_subprocess_socket``) and is answered on the socket instead.
+
+Whether a sink is set depends on the call, not the thread, so the sink must 
not be replaced with a
+thread-identity check: handling a child's ``GetVariable``/``GetConnection`` on 
the socket thread calls
+``mask_secret``, which re-enters :meth:`InProcessSupervisorComms.send` on that 
same thread. The nested
+``MaskSecret`` response lands in the nested sink, which is reset before the 
outer response is written
+to the socket.
+"""
+
+
 @attrs.define(kw_only=True)
 class InProcessSupervisorComms:
-    """In-process communication handler that uses deques instead of sockets."""
+    """In-process communication handler that returns each response directly to 
its caller."""
 
     log: FilteringBoundLogger = attrs.field(repr=False, 
factory=structlog.get_logger)
     supervisor: InProcessTestSupervisor
-    messages: deque[BaseModel | None] = attrs.field(factory=deque)
-
-    def _get_response(self) -> BaseModel | None:
-        """Get a message from the supervisor. Blocks until a message is 
available."""
-        return self.messages.popleft()
 
-    def send(self, msg: BaseModel):
-        """Send a request to the supervisor."""
+    def send(self, msg: BaseModel) -> BaseModel | None:
+        """Send a request to the supervisor and return its response."""
         self.log.debug("Sending request", msg=msg)
 
-        with set_supervisor_comms(None):
-            self.supervisor._handle_request(msg, log, 0)  # type: 
ignore[arg-type]
+        responses: deque[BaseModel | None] = deque()
+        token = _IN_PROCESS_RESPONSE_SINK.set(responses)
+        try:
+            with set_supervisor_comms(None):
+                self.supervisor._handle_request(msg, log, 0)  # type: 
ignore[arg-type]
+        finally:
+            _IN_PROCESS_RESPONSE_SINK.reset(token)
 
-        return self._get_response()
+        return responses.popleft() if responses else None
 
 
 @attrs.define
@@ -2722,8 +2741,15 @@ class InProcessTestSupervisor(ActivitySubprocess):
     def send_msg(
         self, msg: BaseModel | None, request_id: int, error: ErrorResponse | 
None = None, **dump_opts
     ):
-        """Override to use in-process comms."""
-        self.comms.messages.append(msg)
+        """Deliver the response in-process, or over the socket when the 
request came from there."""
+        sink = _IN_PROCESS_RESPONSE_SINK.get()
+        if sink is None:
+            # A real child process (a virtualenv operator's, say) sent this 
request over the socket
+            # set up by `_setup_subprocess_socket`. It is blocked reading a 
response frame, so
+            # anything queued in-process would never reach it.
+            super().send_msg(msg, request_id, error=error, **dump_opts)
+            return
+        sink.append(msg)
 
     @classmethod
     def run_trigger_in_process(cls, *, trigger, ti):
diff --git a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py 
b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
index d2381dc6046..e8621d625ce 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
@@ -27,6 +27,7 @@ import signal
 import socket
 import subprocess
 import sys
+import threading
 import time
 from contextlib import nullcontext
 from dataclasses import dataclass, field
@@ -74,6 +75,7 @@ from airflow.sdk.api.datamodels._generated import (
     TaskInstance,
     TaskInstanceState,
     TIRunContext,
+    VariableResponse,
 )
 from airflow.sdk.definitions._internal.loop import create_loop
 from airflow.sdk.exceptions import AirflowRuntimeError, ErrorType, 
TaskAlreadyRunningError
@@ -5098,6 +5100,170 @@ class TestInProcessTestSupervisor:
         assert isinstance(response, VariableResult)
         assert response.value == "value"
 
+    @pytest.fixture
+    def socket_supervisor(self, mocker, socket_pair):
+        """An in-process supervisor wired to a socket, as 
``_setup_subprocess_socket`` leaves it."""
+        read_end, write_end = socket_pair
+
+        supervisor = InProcessTestSupervisor(
+            id=TI_ID,
+            pid=12345,
+            process=mocker.Mock(),
+            process_log=mocker.MagicMock(),
+            client=mocker.MagicMock(spec=sdk_client.Client),
+        )
+        supervisor.comms = InProcessSupervisorComms(supervisor=supervisor)
+        supervisor.stdin = write_end
+        supervisor.client.variables.get.return_value = 
VariableResult(key="test_key", value="test_value")
+
+        return supervisor, read_end
+
+    @patch("airflow.sdk.execution_time.request_handlers.mask_secret")
+    @pytest.mark.parametrize("req_id", [0, 42], ids=["first_request", 
"later_request"])
+    def test_socket_request_is_answered_over_the_socket(
+        self, mock_mask_secret, socket_supervisor, mocker, req_id
+    ):
+        """A virtualenv operator under ``dag.test()`` runs in a real child 
process that reconnects to
+        the supervisor over ``__AIRFLOW_SUPERVISOR_FD``, so its requests can 
only be answered with a
+        response frame on that socket. ``req_id=0`` is deliberate: the child's 
``CommsDecoder``
+        numbers its requests from 0, so the id cannot tell the two paths apart.
+        """
+        supervisor, read_end = socket_supervisor
+
+        generator = supervisor.handle_requests(log=mocker.Mock())
+        next(generator)
+        generator.send(_RequestFrame(id=req_id, 
body=GetVariable(key="test_key").model_dump()))
+
+        read_end.settimeout(1)
+        frame_len = int.from_bytes(read_end.recv(4), "big")
+        frame = 
msgspec.msgpack.Decoder(_ResponseFrame).decode(read_end.recv(frame_len))
+
+        assert frame.id == req_id
+        assert frame.body == {"key": "test_key", "value": "test_value", 
"type": "VariableResult"}
+
+    @patch("airflow.sdk.execution_time.request_handlers.mask_secret")
+    def test_in_process_request_is_not_written_to_the_socket(self, 
mock_mask_secret, socket_supervisor):
+        """The task running in this process reads its response from the queue, 
not the socket."""
+        supervisor, read_end = socket_supervisor
+
+        response = supervisor.comms.send(GetVariable(key="test_key"))
+
+        assert response == VariableResult(key="test_key", value="test_value")
+        read_end.settimeout(0.1)
+        with pytest.raises(TimeoutError):
+            read_end.recv(1)
+
+    def test_concurrent_in_process_requests_get_their_own_response(self, 
mocker):
+        """In-process requests in flight at the same time must not be answered 
with each other's response.
+
+        Both requests go through ``comms.send`` from different threads, with 
the first one still
+        being handled when the second arrives, so each must read its response 
from its own sink.
+        """
+        first_queued = threading.Event()
+        second_answered = threading.Event()
+
+        class ConcurrentSupervisor(InProcessTestSupervisor):
+            def _handle_request(self, msg, log, req_id):
+                self.send_msg(VariableResult(key=msg.key, value=msg.key), 
req_id)
+                if msg.key == "first":
+                    first_queued.set()
+                    second_answered.wait(10)
+
+        supervisor = ConcurrentSupervisor(
+            id=TI_ID,
+            pid=12345,
+            process=mocker.Mock(),
+            process_log=mocker.MagicMock(),
+            client=mocker.MagicMock(spec=sdk_client.Client),
+        )
+        supervisor.comms = InProcessSupervisorComms(supervisor=supervisor)
+
+        answers: dict[str, Any] = {}
+
+        def ask(key):
+            answers[key] = supervisor.comms.send(GetVariable(key=key))
+
+        first = threading.Thread(target=ask, args=("first",), daemon=True)
+        first.start()
+        assert first_queued.wait(10)
+
+        ask("second")
+        second_answered.set()
+        first.join(10)
+        assert not first.is_alive(), "first in-process request never got its 
response"
+
+        assert answers == {
+            "first": VariableResult(key="first", value="first"),
+            "second": VariableResult(key="second", value="second"),
+        }
+
+    @patch("airflow.sdk.execution_time.request_handlers.mask_secret")
+    def test_child_process_gets_a_response_through_the_real_socket(self, 
mock_mask_secret, mocker):
+        """The same round trip, driven through the socket machinery a real 
child process uses."""
+        supervisor = InProcessTestSupervisor(
+            id=TI_ID,
+            pid=12345,
+            process=mocker.Mock(),
+            process_log=mocker.MagicMock(),
+            client=mocker.MagicMock(spec=sdk_client.Client),
+        )
+        supervisor.comms = InProcessSupervisorComms(supervisor=supervisor)
+        supervisor.client.variables.get.return_value = 
VariableResult(key="test_key", value="test_value")
+
+        received: list[Any] = []
+        with supervisor._setup_subprocess_socket() as child_sock:
+            comms = CommsDecoder(socket=child_sock)
+            # `CommsDecoder._read_frame` forces the socket back to blocking 
mode, so a lost response
+            # hangs forever -- read it from a thread we can give up on to get 
a failure instead.
+            reader = threading.Thread(
+                target=lambda: 
received.append(comms.send(GetVariable(key="test_key"))), daemon=True
+            )
+            reader.start()
+            reader.join(10)
+
+            assert not reader.is_alive(), "supervisor never answered the 
request sent on its socket"
+
+        assert received == [VariableResult(key="test_key", value="test_value")]
+
+    @patch("airflow.sdk.log._secrets_masker")
+    def 
test_child_request_masking_secret_on_socket_thread_gets_only_its_response(
+        self, mock_secrets_masker, mocker
+    ):
+        """Handling a child's ``GetVariable`` on the socket thread calls 
``mask_secret``, which
+        re-enters ``comms.send`` on that same thread with a sink set; the 
nested ``MaskSecret`` reply
+        must stay in that sink and only the ``VariableResult`` may reach the 
child's socket.
+        """
+        supervisor = InProcessTestSupervisor(
+            id=TI_ID,
+            pid=12345,
+            process=mocker.Mock(),
+            process_log=mocker.MagicMock(),
+            client=mocker.MagicMock(spec=sdk_client.Client),
+        )
+        supervisor.comms = InProcessSupervisorComms(supervisor=supervisor)
+        supervisor.client.variables.get.return_value = 
VariableResponse(key="test_key", value="test_value")
+        comms_send = mocker.spy(InProcessSupervisorComms, "send")
+
+        received: list[Any] = []
+        with (
+            set_supervisor_comms(supervisor.comms),
+            supervisor._setup_subprocess_socket() as child_sock,
+        ):
+            comms = CommsDecoder(socket=child_sock)
+            reader = threading.Thread(
+                target=lambda: 
received.append(comms.send(GetVariable(key="test_key"))), daemon=True
+            )
+            reader.start()
+            reader.join(10)
+
+            assert not reader.is_alive(), "supervisor never answered the 
request sent on its socket"
+            child_sock.settimeout(0.1)
+            with pytest.raises(TimeoutError):
+                child_sock.recv(1)
+
+        assert received == [VariableResult(key="test_key", value="test_value")]
+        comms_send.assert_called_once_with(supervisor.comms, 
MaskSecret(value="test_value", name="test_key"))
+
     def test_inprocess_failure_callback_receives_exception(
         self,
         monkeypatch,

Reply via email to