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

kaxil 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 578c1a61830 Fix macOS `SIGSEGV` in triggerer and DAG processor via 
`fork+exec` (#69008)
578c1a61830 is described below

commit 578c1a61830c11f39ad2601023d52dfe01614160
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Jul 1 16:47:18 2026 +0530

    Fix macOS `SIGSEGV` in triggerer and DAG processor via `fork+exec` (#69008)
    
    The triggerer and DAG processor fork worker children with a bare os.fork(),
    which is not safe on macOS: the child inherits half-initialized Objective-C
    runtime state and crashes with SIGSEGV/SIGABRT once it touches a system
    framework (DNS resolution, secret backends, HTTP clients). #64874 fixed this
    for task execution; both of these components hit the same crash because they
    run user trigger and DAG-parsing code that makes network calls.
    
    Generalize the fork+exec path so any importable entry point is rehydrated in
    the exec'd child from its module:qualname, and inherit the structured-log
    channel on a fixed fd so sync and async children are handled uniformly.
---
 .../src/airflow/dag_processing/processor.py        | 14 +++-
 .../src/airflow/jobs/triggerer_job_runner.py       | 13 +++-
 .../tests/unit/dag_processing/test_processor.py    | 46 +++++++++++-
 airflow-core/tests/unit/jobs/test_triggerer_job.py | 28 +++++++
 .../src/airflow/sdk/execution_time/supervisor.py   | 87 ++++++++++++++--------
 .../src/airflow/sdk/execution_time/task_runner.py  |  7 --
 .../task_sdk/execution_time/test_supervisor.py     | 41 ++++++++--
 7 files changed, 189 insertions(+), 47 deletions(-)

diff --git a/airflow-core/src/airflow/dag_processing/processor.py 
b/airflow-core/src/airflow/dag_processing/processor.py
index 251b7b62974..88a486f99c6 100644
--- a/airflow-core/src/airflow/dag_processing/processor.py
+++ b/airflow-core/src/airflow/dag_processing/processor.py
@@ -40,6 +40,7 @@ from airflow.dag_processing.bundles.base import 
BundleVersionLock
 from airflow.dag_processing.dagbag import BundleDagBag, DagBag
 from airflow.models.dag import DagModel
 from airflow.sdk.exceptions import TaskNotFound
+from airflow.sdk.execution_time import supervisor
 from airflow.sdk.execution_time.comms import (
     ConnectionResult,
     DeleteVariable,
@@ -586,13 +587,24 @@ class DagFileProcessorProcess(WatchedSubprocess):
     ) -> Self:
         logger = kwargs["logger"]
 
-        _pre_import_airflow_modules(os.fspath(path), logger)
+        # Parsing DAG files runs user code that can trigger macOS-unsafe ObjC
+        # initialization (secret backends, connection/variable lookups, HTTP
+        # clients). Fork+exec a clean interpreter there. Tests override 
`target`
+        # with a stub to exercise the base infrastructure; keep bare fork for 
those.
+        use_exec = target is _parse_file_entrypoint and 
supervisor._should_use_exec()
+
+        # Pre-importing only helps the bare-fork child (it inherits the 
imports via
+        # copy-on-write). An exec'd child re-imports from scratch, so skip it 
there
+        # to avoid leaking user modules into the long-lived processor manager.
+        if not use_exec:
+            _pre_import_airflow_modules(os.fspath(path), logger)
 
         proc: Self = super().start(
             target=target,
             client=client,
             bundle_name=bundle_name,
             dag_file_rel_path=dag_file_rel_path,
+            use_exec=use_exec,
             **kwargs,
         )
         proc.had_callbacks = bool(callbacks)  # Track if this process had 
callbacks
diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py 
b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
index eccd9d2fc04..355763aed5f 100644
--- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py
+++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
@@ -59,6 +59,7 @@ from airflow.models.trigger import Trigger
 from airflow.observability.metrics import stats_utils
 from airflow.sdk.api.datamodels._generated import HITLDetailResponse
 from airflow.sdk.definitions.asset import Asset
+from airflow.sdk.execution_time import supervisor
 from airflow.sdk.execution_time.comms import (
     AssetStateStoreResult,
     ClearAssetStateStoreByName,
@@ -539,7 +540,17 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
         **kwargs,
     ):
         proc_id = job.id if job is not None else uuid4()
-        proc = super().start(id=proc_id, job=job, target=cls.run_in_process, 
logger=logger, **kwargs)
+        # Triggers run user code that polls APIs / watches queues / hits HTTP
+        # endpoints -- almost always network calls that trigger macOS-unsafe 
ObjC
+        # initialization. Fork+exec a clean interpreter for the runner child.
+        proc = super().start(
+            id=proc_id,
+            job=job,
+            target=cls.run_in_process,
+            logger=logger,
+            use_exec=supervisor._should_use_exec(),
+            **kwargs,
+        )
 
         msg = messages.StartTriggerer()
         proc.send_msg(msg, request_id=0)
diff --git a/airflow-core/tests/unit/dag_processing/test_processor.py 
b/airflow-core/tests/unit/dag_processing/test_processor.py
index 599c36d47f6..3f219c1a083 100644
--- a/airflow-core/tests/unit/dag_processing/test_processor.py
+++ b/airflow-core/tests/unit/dag_processing/test_processor.py
@@ -62,13 +62,14 @@ from airflow.dag_processing.processor import (
     _execute_email_callbacks,
     _execute_task_callbacks,
     _parse_file,
+    _parse_file_entrypoint,
     _pre_import_airflow_modules,
 )
 from airflow.models import DagRun
 from airflow.sdk import DAG, BaseOperator
 from airflow.sdk.api.client import Client
 from airflow.sdk.api.datamodels._generated import ConnectionResponse, 
DagRunState, VariableResponse
-from airflow.sdk.execution_time import comms
+from airflow.sdk.execution_time import comms, supervisor
 from airflow.sdk.execution_time.comms import (
     GetConnection,
     GetTaskStates,
@@ -94,6 +95,19 @@ if TYPE_CHECKING:
 
 pytestmark = pytest.mark.db_test
 
+
[email protected](autouse=True)
+def _force_bare_fork(monkeypatch):
+    """
+    Keep the parsing child on a bare ``os.fork`` on every platform.
+
+    On macOS the DAG processor forks+execs a clean interpreter for fork-safety
+    (see ``DagFileProcessorProcess.start``).  Forcing bare fork keeps local 
macOS
+    runs aligned with Linux/CI behavior.
+    """
+    monkeypatch.setattr(supervisor, "_should_use_exec", lambda: False)
+
+
 DEFAULT_DATE = timezone.datetime(2016, 1, 1)
 
 # Filename to be used for dags that are created in an ad-hoc manner and can be 
removed/
@@ -468,6 +482,36 @@ class TestDagFileProcessor:
         assert logger.warning.call_count == 1
 
 
[email protected](
+    ("platform_uses_exec", "target", "expected_use_exec"),
+    [
+        (True, _parse_file_entrypoint, True),
+        (False, _parse_file_entrypoint, False),
+        (True, lambda: None, False),
+    ],
+)
+def test_start_opts_into_fork_exec(monkeypatch, mocker, platform_uses_exec, 
target, expected_use_exec):
+    """start() forks+execs only for the real parse entry point on fork-unsafe 
platforms."""
+    monkeypatch.setattr(supervisor, "_should_use_exec", lambda: 
platform_uses_exec)
+    base_start = mocker.patch(
+        "airflow.sdk.execution_time.supervisor.WatchedSubprocess.start", 
return_value=MagicMock()
+    )
+    
mocker.patch("airflow.dag_processing.processor._pre_import_airflow_modules")
+
+    DagFileProcessorProcess.start(
+        path="some_dag.py",
+        bundle_path=pathlib.Path("/tmp/bundle"),
+        bundle_name="testing",
+        dag_file_rel_path="some_dag.py",
+        callbacks=[],
+        client=MagicMock(spec=Client),
+        target=target,
+        logger=MagicMock(),
+    )
+
+    assert base_start.call_args.kwargs["use_exec"] is expected_use_exec
+
+
 def write_dag_in_a_fn_to_file(fn: Callable[[], None], folder: pathlib.Path) -> 
pathlib.Path:
     # Create the dag in a fn, and use inspect.getsource to write it to a file 
so that
     # a) the test dag is directly viewable here in the tests
diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py 
b/airflow-core/tests/unit/jobs/test_triggerer_job.py
index 9eced8f1f80..e8689510cf3 100644
--- a/airflow-core/tests/unit/jobs/test_triggerer_job.py
+++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py
@@ -79,6 +79,7 @@ from airflow.sdk import DAG, Asset, BaseHook, BaseOperator
 from airflow.sdk.api.client import Client
 from airflow.sdk.api.datamodels._generated import AssetStateStoreResponse
 from airflow.sdk.exceptions import ErrorType
+from airflow.sdk.execution_time import supervisor
 from airflow.sdk.execution_time.comms import (
     AssetStateStoreResult,
     ClearAssetStateStoreByName,
@@ -123,6 +124,19 @@ if TYPE_CHECKING:
 pytestmark = pytest.mark.db_test
 
 
[email protected](autouse=True)
+def _force_bare_fork(monkeypatch):
+    """
+    Keep the runner child on a bare ``os.fork`` on every platform.
+
+    On macOS the triggerer forks+execs a clean interpreter for fork-safety
+    (see ``TriggerRunnerSupervisor.start``).  These tests fork the real runner
+    with trigger classes defined in this module, which a fresh exec'd 
interpreter
+    cannot import.  Forcing bare fork makes local macOS runs match Linux/CI.
+    """
+    monkeypatch.setattr(supervisor, "_should_use_exec", lambda: False)
+
+
 @pytest.fixture(autouse=True)
 def clean_database():
     """Fixture that cleans the database before and after every test."""
@@ -232,6 +246,20 @@ def test_triggerer_job_runner_stores_team_name(team_name):
     assert runner.team_name == team_name
 
 
[email protected]("platform_uses_exec", [True, False])
+def test_start_opts_into_fork_exec(monkeypatch, mocker, platform_uses_exec):
+    """start() forks+execs the runner child on fork-unsafe platforms 
(macOS)."""
+    monkeypatch.setattr(supervisor, "_should_use_exec", lambda: 
platform_uses_exec)
+    base_start = mocker.patch(
+        "airflow.sdk.execution_time.supervisor.WatchedSubprocess.start", 
return_value=MagicMock()
+    )
+
+    TriggerRunnerSupervisor.start(job=Job(id=999), capacity=10)
+
+    assert base_start.call_args.kwargs["use_exec"] is platform_uses_exec
+    assert base_start.call_args.kwargs["target"] == 
TriggerRunnerSupervisor.run_in_process
+
+
 @pytest.fixture
 def supervisor_builder(mocker, session):
     def builder(job=None):
diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py 
b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
index 76dfd2009ac..c533a9160f4 100644
--- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
@@ -25,6 +25,7 @@ import functools
 import io
 import logging
 import os
+import pkgutil
 import selectors
 import signal
 import sys
@@ -498,42 +499,63 @@ runtime detects the corrupted state and crashes with 
SIGABRT.
 
 Calling ``os.execv`` immediately after ``os.fork`` replaces the child's address
 space, giving it clean ObjC state.  Before exec, the supervisor ``dup2``s the
-socketpairs onto FDs 0 (requests/stdin), 1 (stdout), 2 (stderr).  The 
duplicated
-FDs survive the upcoming exec because ``os.dup2(inheritable=True)`` (the 
default)
-clears ``FD_CLOEXEC`` on the destination FDs.  The log channel is obtained 
after
-startup via the existing ``ResendLoggingFD`` mechanism.
+socketpairs onto fixed FDs the exec'd child reconstructs: 0 (requests/stdin),
+1 (stdout), 2 (stderr), 3 (structured logs).  ``os.set_inheritable`` clears
+``FD_CLOEXEC`` on those FDs so they survive the upcoming exec.
 
-Currently only task execution opts in (via ``ActivitySubprocess.start``).  DAG
-processor and triggerer can also hit this crash and will need the same 
treatment
-as a follow-up (see https://github.com/apache/airflow/issues/65691).
+Task execution (``ActivitySubprocess``), the DAG processor
+(``DagFileProcessorProcess``) and the triggerer (``TriggerRunnerSupervisor``)
+all opt in -- each runs a different child entry point, named by 
``module:qualname``
+in the ``_AIRFLOW_CHILD_TARGET`` env var and rehydrated by 
:func:`_child_exec_main`.
 
 See: https://github.com/python/cpython/issues/105912
      https://github.com/apache/airflow/discussions/24463
+     https://github.com/apache/airflow/issues/65691
 """
 
 
+def _should_use_exec() -> bool:
+    """Whether forked children should ``exec`` a fresh interpreter on this 
platform."""
+    return sys.platform in _FORK_EXEC_PLATFORMS
+
+
+def _resolve_child_target(dotted: str) -> Callable[[], None]:
+    """
+    Resolve a ``module:qualname`` string to the callable the exec'd child runs.
+
+    Empty input falls back to :func:`_subprocess_main` (task execution).  The
+    qualname may name a nested attribute (e.g. ``ClassName.classmethod``) so
+    classmethod entry points like ``TriggerRunnerSupervisor.run_in_process``
+    rehydrate correctly across ``execv``.
+    """
+    if not dotted:
+        return _subprocess_main
+    return pkgutil.resolve_name(dotted)
+
+
 def _child_exec_main():
     """
     Entry point for the child process when using fork+exec (macOS).
 
-    After exec, FDs 0/1/2 are already the requests/stdout/stderr sockets
-    (dup2'd by the parent before exec).  The log channel is NOT inherited;
-    instead, the task runner requests it from the supervisor via the existing
-    ``ResendLoggingFD`` mechanism after startup.
+    After exec, FDs 0/1/2/3 are the requests/stdout/stderr/log sockets the 
parent
+    placed there via dup2.  The target to run is named in 
``_AIRFLOW_CHILD_TARGET``
+    (``module:qualname``); it is rehydrated and handed to :func:`_fork_main`, 
which
+    sets up the structured log channel from FD 3 exactly as the bare-fork path 
does.
     """
     # FDs 0, 1, 2 were dup2'd onto the socketpairs before exec.
     child_requests = socket(fileno=0)
     child_stdout = socket(fileno=1)
     child_stderr = socket(fileno=2)
 
+    target = _resolve_child_target(os.environ.pop("_AIRFLOW_CHILD_TARGET", ""))
+
     # _fork_main always exits via os._exit(), so the socket objects above are
     # never GC'd (which would close their underlying FDs). This is safe but
     # depends on that invariant -- do not refactor _fork_main to return.
     #
-    # log_fd=0 tells _fork_main to skip structured log channel setup.
-    # Signal to the task runner to request it via ResendLoggingFD after 
startup.
-    os.environ["_AIRFLOW_FORK_EXEC"] = "1"
-    _fork_main(child_requests, child_stdout, child_stderr, 0, _subprocess_main)
+    # FD 3 is the inherited structured-log socket; _fork_main configures 
logging
+    # over it just as it would with the FD inherited across a bare fork.
+    _fork_main(child_requests, child_stdout, child_stderr, 3, target)
 
 
 class NeverRaised(Exception):
@@ -669,15 +691,14 @@ class WatchedSubprocess:
         :param use_exec: If True, on platforms that need it (currently macOS),
             immediately ``os.execv`` a fresh Python interpreter after 
``os.fork``.
             This avoids macOS fork-safety issues with Objective-C frameworks.
-            Task execution opts in; DAG processor and triggerer do not.
-
-        The exec'd child always runs ``_subprocess_main``, so ``use_exec=True``
-        is only valid when ``target is _subprocess_main``.
+            ``target`` is rehydrated in the exec'd child from its 
``module:qualname``,
+            so any importable entry point (task execution, DAG processor, 
triggerer)
+            is supported.
         """
-        if use_exec and target is not _subprocess_main:
-            raise ValueError(
-                f"use_exec=True is only supported with 
target=_subprocess_main; got target={target!r}"
-            )
+        if use_exec and "<" in getattr(target, "__qualname__", "<"):
+            # Closures/lambdas (``<locals>`` / ``<lambda>`` in the qualname) 
and
+            # objects without a qualname can't be named for the exec'd child.
+            raise ValueError(f"use_exec=True requires a top-level importable 
target, got {target!r}")
         # Create socketpairs/"pipes" to connect to the stdin and out from the 
subprocess
         child_stdout, read_stdout = socketpair()
         child_stderr, read_stderr = socketpair()
@@ -700,14 +721,22 @@ class WatchedSubprocess:
 
             try:
                 if use_exec:
-                    # macOS: exec a fresh Python interpreter to replace the
-                    # inherited ObjC/CoreFoundation state that is not 
fork-safe.
-                    # dup2 copies the socketpairs onto FDs 0/1/2; os.dup2 
clears
-                    # FD_CLOEXEC on the destination FDs, so they survive exec.
-                    # The log channel is requested later via ResendLoggingFD.
+                    # macOS: exec a fresh Python interpreter to drop the 
inherited
+                    # ObjC/CoreFoundation state that is not fork-safe. 
Redirect the
+                    # socketpairs onto the fixed FDs the exec'd child 
reconstructs:
+                    # 0 (requests/stdin), 1 (stdout), 2 (stderr), 3 
(structured logs).
+                    # The source fds are always >= 3 (0/1/2 stay open in every 
launch
+                    # path), so no dup2 clobbers a not-yet-placed source. 
set_inheritable
+                    # guarantees FD_CLOEXEC is clear on all four (dup2 leaves 
it set when
+                    # a source already equals its target), so they survive 
execv. The
+                    # entry point is passed to the child by name.
+                    os.environ["_AIRFLOW_CHILD_TARGET"] = 
f"{target.__module__}:{target.__qualname__}"
                     os.dup2(child_requests.fileno(), 0)
                     os.dup2(child_stdout.fileno(), 1)
                     os.dup2(child_stderr.fileno(), 2)
+                    os.dup2(child_logs.fileno(), 3)
+                    for fd in (0, 1, 2, 3):
+                        os.set_inheritable(fd, True)
                     os.execv(
                         sys.executable,
                         [
@@ -1329,7 +1358,7 @@ class ActivitySubprocess(WatchedSubprocess):
         # Opt in to fork+exec on platforms that need it (currently macOS).
         # Tests override `target` with a local stub to exercise the base
         # infrastructure; keep bare fork for those.
-        use_exec = target is _subprocess_main and sys.platform in 
_FORK_EXEC_PLATFORMS
+        use_exec = target is _subprocess_main and _should_use_exec()
         proc: Self = super().start(
             id=what.id, client=client, target=target, logger=logger, 
use_exec=use_exec, **kwargs
         )
diff --git a/task-sdk/src/airflow/sdk/execution_time/task_runner.py 
b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
index ac8889daf02..ab79d4ba98e 100644
--- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
+++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
@@ -2304,13 +2304,6 @@ def main():
                 log.info("::group::Pre Execute")
                 startup_details = get_startup_details()
 
-                # On macOS fork+exec path, the structured log channel wasn't
-                # inherited (exec replaces the address space). Request it from
-                # the supervisor using the existing ResendLoggingFD mechanism.
-                # Must happen after get_startup_details() so we don't read the
-                # startup message as a ResendLoggingFD response.
-                if os.environ.pop("_AIRFLOW_FORK_EXEC", None) == "1":
-                    reinit_supervisor_comms()
                 span_ctx_mgr = _make_task_span(msg=startup_details)
                 span = stack.enter_context(span_ctx_mgr)
                 ti, context, log = startup(msg=startup_details)
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 8dea3d0793f..54a426450b3 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
@@ -4057,15 +4057,38 @@ def 
test_api_client_clears_dag_bag_override_when_dag_is_none():
         in_process_api_server.cache_clear()
 
 
+class TestResolveChildTarget:
+    """Test rehydrating the exec'd child's entry point from 
_AIRFLOW_CHILD_TARGET."""
+
+    def test_empty_defaults_to_subprocess_main(self):
+        assert supervisor._resolve_child_target("") is 
supervisor._subprocess_main
+
+    def test_module_level_function(self):
+        resolved = 
supervisor._resolve_child_target("airflow.sdk.execution_time.supervisor:_subprocess_main")
+        assert resolved is supervisor._subprocess_main
+
+    def test_classmethod_entry_point(self):
+        """A ``module:ClassName.method`` target rehydrates the bound 
classmethod (e.g. the triggerer's)."""
+        resolved = supervisor._resolve_child_target(
+            "airflow.sdk.execution_time.supervisor:WatchedSubprocess.start"
+        )
+        assert resolved == supervisor.WatchedSubprocess.start
+        assert callable(resolved)
+
+    def test_start_rejects_non_importable_target_under_exec(self):
+        """A closure/lambda target can't be named for the exec'd child, so 
start() fails fast."""
+        with pytest.raises(ValueError, match="top-level importable target"):
+            supervisor.WatchedSubprocess.start(target=lambda: None, 
use_exec=True)
+
+
 @pytest.mark.usefixtures("disable_capturing")
 class TestChildExecMain:
     """Test the macOS fork+exec child entry point."""
 
-    def test_uses_fds_012_and_requests_log_channel(self, monkeypatch):
-        """_child_exec_main wraps FDs 0/1/2 as sockets, passes log_fd=0, sets 
_AIRFLOW_FORK_EXEC."""
+    def test_uses_fds_0123_and_inherits_log_channel(self, monkeypatch):
+        """_child_exec_main wraps FDs 0/1/2 as sockets and passes log_fd=3 
(inherited log channel)."""
         # _child_exec_main expects FDs 0/1/2 to be sockets (dup2'd by the
-        # parent before exec).  It passes log_fd=0 to _fork_main (structured
-        # logging is requested later via ResendLoggingFD).
+        # parent before exec) and the structured log channel inherited on FD 3.
         req_a, req_b = socket.socketpair()
         out_a, out_b = socket.socketpair()
         err_a, err_b = socket.socketpair()
@@ -4075,6 +4098,9 @@ class TestChildExecMain:
         saved_1 = os.dup(1)
         saved_2 = os.dup(2)
 
+        # The parent names the entry point to run via this env var.
+        monkeypatch.setenv("_AIRFLOW_CHILD_TARGET", 
"airflow.sdk.execution_time.supervisor:_subprocess_main")
+
         try:
             os.dup2(req_a.fileno(), 0)
             os.dup2(out_a.fileno(), 1)
@@ -4101,11 +4127,10 @@ class TestChildExecMain:
             assert captured["requests_fd"] == 0
             assert captured["stdout_fd"] == 1
             assert captured["stderr_fd"] == 2
-            assert captured["log_fd"] == 0
+            assert captured["log_fd"] == 3
             assert captured["target"] is supervisor._subprocess_main
-            # _child_exec_main sets this so the task runner knows to request
-            # the log channel via ResendLoggingFD.
-            assert os.environ.pop("_AIRFLOW_FORK_EXEC") == "1"
+            # The env var is consumed (popped) so it does not leak to 
grandchildren.
+            assert "_AIRFLOW_CHILD_TARGET" not in os.environ
         finally:
             # Restore original FDs.
             os.dup2(saved_0, 0)

Reply via email to