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 4e52a36e81a Allow opting into fork+exec task processes via 
execute_tasks_new_python_interpreter (#72164)
4e52a36e81a is described below

commit 4e52a36e81a430e77948f30ce8b9add3042cf30e
Author: Gang Zhang <[email protected]>
AuthorDate: Mon Sep 14 14:02:38 2026 -0700

    Allow opting into fork+exec task processes via 
execute_tasks_new_python_interpreter (#72164)
    
    * Allow opting into fork+exec task processes via 
execute_tasks_new_python_interpreter
    
    On Linux the supervisor bare-forks the task process. If any supervisor
    thread (OTel exporter, google-auth refresh, ...) holds a C-level lock such
    as OpenSSL's at the instant of the fork, the child inherits it permanently
    locked and deadlocks at its first TLS call -- typically the DAG bundle
    download -- stuck in RUNNING forever with no timeout able to fire (#71707).
    
    exec()-ing a fresh interpreter right after fork (what _FORK_EXEC_PLATFORMS
    already forces on macOS) makes the inherited-lock state unreachable. Extend
    the existing [core] execute_tasks_new_python_interpreter config to opt into
    that path on any platform, and document the new effect.
    
    Kept opt-in because the fresh interpreter re-imports the SDK and plugins
    (measured ~9s per task on a plugin-heavy production image; it shows up in
    task duration, not queued duration).
    
    Supersedes #71723; the config approach and platform gate are unchanged from
    it. The test here patches the configuration through the environment variable
    so it reaches the task-sdk conf object the supervisor actually reads.
    
    Co-authored-by: Pulak Saha <[email protected]>
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * Re-apply PR_SET_DUMPABLE in the exec'd task child
    
    execve resets the dumpable flag that supervise_task() set before the fork, 
so an exec'd
    child was readable through /proc/<pid>/environ by a same-UID sibling. 
Re-apply it as the
    first step of _child_exec_main(), update the security docs that described 
the flag as
    purely inherited, and pin it with a Linux-only fork+exec test.
    
    * Scope execute_tasks_new_python_interpreter to the task process
    
    _should_use_exec() is also consulted by the Dag processor (one child per 
file per
    parse loop) and the triggerer runner, so reading the option there would 
exec a fresh
    interpreter per parse. Keep it as the platform gate and read the option in
    ActivitySubprocess.start() only, which is also the shape v3-2/v3-3 can 
carry (they
    check darwin inline at that spot). Document the tasks-only scope, that the 
option was
    a no-op for tasks on Airflow 3 until now, and the Edge worker's second 
interpreter.
    
    * Test the exec opt-in through conf_vars like the rest of the file
    
    conf_vars already patches the task-sdk conf once the supervisor module is 
imported, so the
    env-var route was only an inconsistency.
    
    * Reword the security docs so the spellchecker accepts them
    
    "exec'd" is neither a dictionary word nor in the wordlist; say "a child 
started
    through exec" instead.
    
    * Restore PR_SET_DUMPABLE in the exec bootstrap and pin the fork+exec 
wiring in tests
    
    The exec'd child was dumpable from execve until _child_exec_main() ran, 
i.e. for the
    whole Airflow import; a /proc/<pid>/mem descriptor or ptrace attach taken 
then survives
    the later prctl. The -c bootstrap (now _CHILD_EXEC_BOOTSTRAP) restores the 
flag with
    ctypes before importing Airflow, leaving interpreter start as the only 
window;
    _child_exec_main() keeps the logged fallback.
    
    Tests now pin what the review found unpinned: the real prelude string is 
exec'd and
    asserted to flip the flag 1 -> 0 (Linux), _child_exec_main() is checked to 
call
    _make_process_nondumpable() before _fork_main(), and 
ActivitySubprocess.start() is
    checked to pass the task-process decision as use_exec (stub targets stay 
bare fork).
    The forked test branch exits via os._exit in a finally so a failed execv 
cannot run
    pytest in the child. Docstrings no longer describe exec as macOS-only.
    
    * Config/docs: fix the runaway rst literal, note callbacks keep bare fork, 
describe the bootstrap prctl
    
    ``exec``s left the literal open until the next backticks in the 
Configuration
    Reference; reworded. Task callbacks run from a closure the exec path cannot 
name, so
    they stay on bare fork -- say so in the config text and the newsfragment. 
The security
    docs now describe the prctl in the exec bootstrap and name ptrace_scope >= 
1 as what
    covers the interpreter-start window.
    
    * Guard the bootstrap's ctypes import and run the exec bootstrap end to end 
in tests
    
    The prelude imported ctypes outside its try, so an interpreter built 
without _ctypes
    would have died before _child_exec_main() could reach the logged fallback; 
the import now
    sits inside the try like _make_process_nondumpable().
    
    test_fork_exec_bootstrap_runs_an_importable_target_end_to_end drives 
ActivitySubprocess.start
    through the real os.execv: the fresh interpreter runs 
_CHILD_EXEC_BOOTSTRAP, rebuilds FDs 0-3,
    rehydrates the target by name and its stdout comes back through the 
supervisor. It uses an
    importable probe standing in for _subprocess_main rather than the task 
runner, because the
    suite stubs _get_plugins in-process -- inherited by a bare-forked child, 
not by a fresh
    interpreter, which then fails with 'Plugins folder is not set'. Also 
pinned: the bootstrap
    starts with the prelude and ends in _child_exec_main(), and the prelude 
survives a missing
    _ctypes.
    
    * Docs: Yama scope of ptrace_scope, and that the task process reads the 
global option
    
    ptrace_scope >= 1 gates PTRACE_MODE_ATTACH only, so it covers 
/proc/<pid>/mem and ptrace
    attach for the interpreter-start window but not environ/maps (which show 
the supervisor's
    environment every task under the worker already has). The task process 
reads the global
    value of the option; Edge workers resolve it per team.
    
    * Tests: hoist the probe-target and subprocess imports to module level
    
    Both were already available at module scope (subprocess is imported at the 
top of the
    file and task_sdk is on the path for the other imports), so the local 
imports in
    test_fork_exec_bootstrap_runs_an_importable_target_end_to_end and
    test_prelude_survives_an_interpreter_without_ctypes only added noise.
    
    ---------
    
    Co-authored-by: Pulak Saha <[email protected]>
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 airflow-core/docs/security/security_model.rst      |   7 +-
 airflow-core/docs/security/workload.rst            |   7 +-
 airflow-core/newsfragments/72164.significant.rst   |  23 +++
 .../src/airflow/config_templates/config.yml        |   8 ++
 .../src/airflow/sdk/execution_time/supervisor.py   |  64 +++++++--
 .../task_sdk/execution_time/exec_probe_target.py   |  30 ++++
 .../task_sdk/execution_time/test_supervisor.py     | 160 ++++++++++++++++++++-
 7 files changed, 280 insertions(+), 19 deletions(-)

diff --git a/airflow-core/docs/security/security_model.rst 
b/airflow-core/docs/security/security_model.rst
index 8e561051070..54db6f55603 100644
--- a/airflow-core/docs/security/security_model.rst
+++ b/airflow-core/docs/security/security_model.rst
@@ -591,8 +591,11 @@ model — Airflow does not enforce these natively.
    For higher security, pass sensitive configuration values via environment 
variables rather than
    configuration files. Environment variables are inherently safer than 
configuration files in
    Airflow's worker processes because of a built-in protection: on Linux, the 
supervisor process
-   calls ``prctl(PR_SET_DUMPABLE, 0)`` before forking the task process, and 
this flag is inherited
-   by the forked child. This marks both processes as non-dumpable, which 
prevents same-UID sibling
+   calls ``prctl(PR_SET_DUMPABLE, 0)`` before forking the task process; a 
bare-forked child inherits
+   the flag, and a child started through ``exec`` restores it in its 
bootstrap, before importing
+   Airflow, because ``execve`` resets it (``kernel.yama.ptrace_scope >= 1`` 
covers ``/proc/<pid>/mem``
+   and ``ptrace`` attach for the remaining interpreter-start window). This 
marks both processes as
+   non-dumpable, which prevents same-UID sibling
    processes from reading ``/proc/<pid>/environ``, ``/proc/<pid>/mem``, or 
attaching via
    ``ptrace``. In contrast, configuration files on disk are readable by any 
process running as
    the same Unix user. Environment variables can also be scoped to individual 
processes or
diff --git a/airflow-core/docs/security/workload.rst 
b/airflow-core/docs/security/workload.rst
index 4b76169924c..c9686833213 100644
--- a/airflow-core/docs/security/workload.rst
+++ b/airflow-core/docs/security/workload.rst
@@ -85,8 +85,11 @@ Worker process memory protection (Linux)
 ''''''''''''''''''''''''''''''''''''''''
 
 On Linux, the supervisor process calls ``prctl(PR_SET_DUMPABLE, 0)`` at the 
start of
-``supervise_task()`` before forking the task process. This flag is inherited 
by the forked
-child. Marking processes as non-dumpable prevents same-UID sibling processes 
from reading
+``supervise_task()`` before forking the task process. A bare-forked child 
inherits the flag;
+a child started through ``exec`` (macOS, or ``[core] 
execute_tasks_new_python_interpreter``)
+restores it in its bootstrap, before importing Airflow, because ``execve`` 
resets it; for the remaining
+interpreter-start window, ``kernel.yama.ptrace_scope >= 1`` covers 
``/proc/<pid>/mem`` and ``ptrace``
+attach. Marking processes as non-dumpable prevents same-UID sibling processes 
from reading
 ``/proc/<pid>/mem``, ``/proc/<pid>/environ``, or ``/proc/<pid>/maps``, and 
blocks
 ``ptrace(PTRACE_ATTACH)``. This is critical because each supervisor holds a 
distinct JWT
 token in memory — without this protection, a malicious task process running as 
the same
diff --git a/airflow-core/newsfragments/72164.significant.rst 
b/airflow-core/newsfragments/72164.significant.rst
new file mode 100644
index 00000000000..29532d28699
--- /dev/null
+++ b/airflow-core/newsfragments/72164.significant.rst
@@ -0,0 +1,23 @@
+``[core] execute_tasks_new_python_interpreter`` now applies to Airflow 3 task 
processes
+
+On Airflow 3 the option had no effect on task execution (only the Edge worker 
read it). When set to
+``True``, the task supervisor now ``exec``\ s a fresh interpreter right after 
forking the task process,
+which prevents the fork from inheriting a lock held by a supervisor thread (a 
permanent hang at the
+task's first TLS call). Deployments that kept the option ``True`` from Airflow 
2 get this behaviour,
+and its per-task interpreter start-up cost, on upgrade without further action; 
set it to ``False`` to
+keep bare fork. The task process reads the global value, so a team-scoped Edge 
setting alone does not
+turn it on. Edge workers with the option ``True`` already start a fresh 
interpreter for the supervisor
+and will now start a second one for the task. The Dag processor, the triggerer 
and task
+callbacks (which the callback supervisor runs from a closure that cannot be 
named for an exec'd
+child) are not affected and keep bare fork.
+
+* Types of change
+
+  * [ ] Dag changes
+  * [x] Config changes
+  * [ ] API changes
+  * [ ] CLI changes
+  * [x] Behaviour changes
+  * [ ] Plugin changes
+  * [ ] Dependency changes
+  * [ ] Code interface changes
diff --git a/airflow-core/src/airflow/config_templates/config.yml 
b/airflow-core/src/airflow/config_templates/config.yml
index 3601a94759c..b6774cc8657 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -223,6 +223,14 @@ core:
         * ``False``: Execute via forking of the parent process
         * ``True``: Spawning a new python process, slower than fork, but means 
plugin changes picked
           up by tasks straight away
+
+        On Airflow 3 this applies to the task process only. When ``True`` the 
supervisor starts
+        the task in a fresh interpreter with ``exec`` right after forking it, 
so the task cannot
+        inherit a lock a supervisor thread held at fork time (which can 
otherwise hang it at its
+        first TLS call). The Dag processor, the triggerer and task callbacks 
are not affected and
+        keep bare fork. Each task pays an interpreter start plus re-import. 
The task process reads
+        the global value; Edge workers, which resolve it per team, already 
start a fresh interpreter
+        for the supervisor when it is ``True`` and start a second one for the 
task.
       default: "False"
       example: ~
       version_added: 2.0.0
diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py 
b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
index 0a480851285..ab4c60464a3 100644
--- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
@@ -517,6 +517,22 @@ def _should_use_exec() -> bool:
     return sys.platform in _FORK_EXEC_PLATFORMS
 
 
+def _task_process_uses_exec() -> bool:
+    """
+    Whether the task process should ``exec`` a fresh interpreter after the 
fork.
+
+    Forced where bare fork is unsafe (macOS); elsewhere a deployment opts in 
with
+    ``[core] execute_tasks_new_python_interpreter``. exec replaces the child's 
address
+    space, so it cannot inherit a lock a supervisor thread held at fork time 
(e.g.
+    OpenSSL's, which otherwise hangs the task at its first TLS call; #71707). 
Only the
+    task process reads the option -- it has always described task execution -- 
so the Dag
+    processor (one child per file per parse loop) and the triggerer keep the 
platform gate.
+    """
+    return _should_use_exec() or conf.getboolean(
+        "core", "execute_tasks_new_python_interpreter", fallback=False
+    )
+
+
 def _resolve_child_target(dotted: str) -> Callable[[], None]:
     """
     Resolve a ``module:qualname`` string to the callable the exec'd child runs.
@@ -531,15 +547,38 @@ def _resolve_child_target(dotted: str) -> Callable[[], 
None]:
     return pkgutil.resolve_name(dotted)
 
 
+# Runs in the exec'd child before anything else. execve reset PR_SET_DUMPABLE 
(4 in
+# <linux/prctl.h>); restore it before the Airflow import so the window in 
which a same-UID
+# sibling can open /proc/<pid>/mem or ptrace-attach is interpreter start only 
(a descriptor
+# or attach taken in that window survives a later prctl -- the kernel checks 
once, at open).
+# _child_exec_main() repeats the call as the logged fallback.
+_CHILD_EXEC_PRELUDE = """\
+import sys
+if sys.platform == "linux":
+    try:
+        import ctypes
+
+        ctypes.CDLL(None, use_errno=True).prctl(4, 0, 0, 0, 0)
+    except Exception:
+        pass
+"""
+_CHILD_EXEC_BOOTSTRAP = _CHILD_EXEC_PRELUDE + (
+    "from airflow.sdk.execution_time.supervisor import 
_child_exec_main\n_child_exec_main()\n"
+)
+
+
 def _child_exec_main():
     """
-    Entry point for the child process when using fork+exec (macOS).
+    Entry point for the child process when using fork+exec.
 
     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.
     """
+    # The bootstrap already restored PR_SET_DUMPABLE before importing Airflow; 
this is the
+    # logged fallback (execve had reset what supervise_task() set before the 
fork).
+    _make_process_nondumpable()
     # FDs 0, 1, 2 were dup2'd onto the socketpairs before exec.
     child_requests = socket(fileno=0)
     child_stdout = socket(fileno=1)
@@ -690,9 +729,11 @@ class WatchedSubprocess:
         """
         Fork and start a new subprocess with the specified target function.
 
-        :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.
+        :param use_exec: If True, immediately ``os.execv`` a fresh Python 
interpreter
+            after ``os.fork``: forced on platforms that need it (macOS, whose 
Objective-C
+            frameworks are not fork-safe) and opted into for the task process 
elsewhere via
+            ``[core] execute_tasks_new_python_interpreter`` (a lock a 
supervisor thread
+            held at fork time cannot survive into a fresh address space).
             ``target`` is rehydrated in the exec'd child from its 
``module:qualname``,
             so any importable entry point (task execution, DAG processor, 
triggerer)
             is supported.
@@ -742,8 +783,8 @@ class WatchedSubprocess:
 
             try:
                 if use_exec:
-                    # macOS: exec a fresh Python interpreter to drop the 
inherited
-                    # ObjC/CoreFoundation state that is not fork-safe. 
Redirect the
+                    # exec a fresh Python interpreter to drop inherited state 
that is not
+                    # fork-safe (ObjC/CoreFoundation on macOS; a held lock 
elsewhere). 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
@@ -760,12 +801,7 @@ class WatchedSubprocess:
                         os.set_inheritable(fd, True)
                     os.execv(
                         sys.executable,
-                        [
-                            sys.executable,
-                            "-c",
-                            "from airflow.sdk.execution_time.supervisor import 
_child_exec_main;"
-                            " _child_exec_main()",
-                        ],
+                        [sys.executable, "-c", _CHILD_EXEC_BOOTSTRAP],
                     )
                     # execv replaces the process -- unreachable on success
                 else:
@@ -1452,10 +1488,10 @@ class ActivitySubprocess(WatchedSubprocess):
         **kwargs,
     ) -> Self:
         """Fork and start a new subprocess to execute the given task."""
-        # Opt in to fork+exec on platforms that need it (currently macOS).
+        # fork+exec where the platform needs it (macOS) or the deployment 
opted in.
         # Tests override `target` with a local stub to exercise the base
         # infrastructure; keep bare fork for those.
-        use_exec = target is _subprocess_main and _should_use_exec()
+        use_exec = target is _subprocess_main and _task_process_uses_exec()
         proc: Self = super().start(
             id=what.id,
             client=client,
diff --git a/task-sdk/tests/task_sdk/execution_time/exec_probe_target.py 
b/task-sdk/tests/task_sdk/execution_time/exec_probe_target.py
new file mode 100644
index 00000000000..75affc0cc61
--- /dev/null
+++ b/task-sdk/tests/task_sdk/execution_time/exec_probe_target.py
@@ -0,0 +1,30 @@
+# 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.
+"""Importable entry point for the fork+exec end-to-end test; it runs inside 
the exec'd child."""
+
+from __future__ import annotations
+
+import sys
+
+from airflow.sdk.execution_time.comms import CommsDecoder
+
+
+def exec_probe_main() -> None:
+    """Stand-in for ``_subprocess_main``: consume the startup message, then 
report over stdout."""
+    CommsDecoder()._get_response()
+    print("exec-probe-ok")
+    sys.stdout.flush()
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 93422f4426a..8175e1ff228 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
@@ -51,6 +51,7 @@ from opentelemetry.trace import get_current_span
 from pytest_unordered import unordered
 from structlog.typing import FilteringBoundLogger
 from task_sdk import FAKE_BUNDLE, make_client
+from task_sdk.execution_time import exec_probe_target
 from uuid6 import uuid7
 
 from airflow.executors.workloads import BundleInfo
@@ -4479,6 +4480,34 @@ def _drop_root_if_needed():
         os.setuid(_NOBODY_UID)
 
 
[email protected](sys.platform != "linux", reason="PR_SET_DUMPABLE is 
Linux-only")
+def test_exec_bootstrap_restores_nondumpable_before_airflow_import():
+    """The real exec bootstrap prelude sets PR_SET_DUMPABLE back to 0 (execve 
resets it to 1)."""
+    probe = (
+        "import ctypes\n"
+        "libc = ctypes.CDLL(None, use_errno=True)\n"
+        "after_exec = libc.prctl(3, 0, 0, 0, 0)\n"  # PR_GET_DUMPABLE, before 
the prelude runs
+        + supervisor._CHILD_EXEC_PRELUDE
+        + "print(after_exec, libc.prctl(3, 0, 0, 0, 0))\n"
+    )
+    read_fd, write_fd = os.pipe()
+    pid = os.fork()
+    if pid == 0:  # pragma: no cover - child
+        try:
+            os.close(read_fd)
+            os.dup2(write_fd, 1)
+            _make_process_nondumpable()  # what supervise_task() does before 
the fork
+            os.execv(sys.executable, [sys.executable, "-c", probe])
+        finally:
+            os._exit(1)  # only reached if execv failed; never run pytest in 
the child
+    os.close(write_fd)
+    with os.fdopen(read_fd) as out:
+        report = out.read().split()[-2:]  # anything the exec'd interpreter 
logs first is noise
+    _, status = os.waitpid(pid, 0)
+    assert status == 0, f"exec'd child exited with {status}"
+    assert report == ["1", "0"], f"dumpable flag after exec, then after the 
prelude: {report}"
+
+
 @pytest.mark.skipif(sys.platform != "linux", reason="PR_SET_DUMPABLE is 
Linux-only")
 def test_nondumpable_blocks_sibling_proc_read():
     """A sibling process (same non-root UID) cannot read /proc/<pid>/environ 
or /proc/<pid>/mem of a nondumpable process."""
@@ -4595,6 +4624,61 @@ def 
test_api_client_clears_dag_bag_override_when_dag_is_none():
         in_process_api_server.cache_clear()
 
 
[email protected](
+    ("task_uses_exec", "target", "expected_use_exec"),
+    [
+        (True, supervisor._subprocess_main, True),
+        (False, supervisor._subprocess_main, False),
+        (True, lambda: None, False),
+    ],
+)
+def test_activity_start_opts_into_fork_exec(monkeypatch, mocker, 
task_uses_exec, target, expected_use_exec):
+    """ActivitySubprocess.start passes the task-process decision as use_exec, 
real entry point only."""
+    monkeypatch.setattr(supervisor, "_task_process_uses_exec", lambda: 
task_uses_exec)
+    base_start = mocker.patch(
+        "airflow.sdk.execution_time.supervisor.WatchedSubprocess.start", 
return_value=MagicMock()
+    )
+
+    ActivitySubprocess.start(
+        dag_rel_path=os.devnull,
+        bundle_info=FAKE_BUNDLE,
+        what=TaskInstance(
+            id="4d828a62-a417-4936-a7a6-2b3fabacecab",
+            task_id="b",
+            dag_id="c",
+            run_id="d",
+            try_number=1,
+            dag_version_id=uuid7(),
+            queue="default",
+        ),
+        client=MagicMock(spec=sdk_client.Client),
+        target=target,
+        logger=MagicMock(),
+    )
+
+    assert base_start.call_args.kwargs["use_exec"] is expected_use_exec
+    assert base_start.call_args.kwargs["target"] is target
+
+
+class TestTaskProcessUsesExec:
+    """The config opt-in for fork+exec of the task process where the platform 
does not force it."""
+
+    @pytest.mark.parametrize(
+        ("platform", "config_value", "expected"),
+        [
+            ("darwin", None, True),
+            ("darwin", "False", True),
+            ("linux", None, False),
+            ("linux", "False", False),
+            ("linux", "True", True),
+        ],
+    )
+    def test_task_process_uses_exec(self, monkeypatch, platform, config_value, 
expected):
+        monkeypatch.setattr(supervisor.sys, "platform", platform)
+        with conf_vars({("core", "execute_tasks_new_python_interpreter"): 
config_value}):
+            assert supervisor._task_process_uses_exec() is expected
+
+
 class TestResolveChildTarget:
     """Test rehydrating the exec'd child's entry point from 
_AIRFLOW_CHILD_TARGET."""
 
@@ -4620,8 +4704,82 @@ class TestResolveChildTarget:
 
 
 @pytest.mark.usefixtures("disable_capturing")
+def test_fork_exec_bootstrap_runs_an_importable_target_end_to_end(
+    captured_logs, time_machine, monkeypatch, client_with_ti_start
+):
+    """
+    Drive the real ``os.execv`` bootstrap: the fresh interpreter runs 
``_CHILD_EXEC_BOOTSTRAP``,
+    rebuilds FDs 0-3, rehydrates the target by name and hands it to 
``_fork_main``.
+
+    The probe stands in for ``_subprocess_main`` rather than running the task 
runner: the
+    suite stubs plugin loading in-process (conftest ``_get_plugins``), which a 
bare-forked
+    child inherits and a fresh interpreter cannot.
+    """
+    tests_dir = 
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+    monkeypatch.setenv(
+        "PYTHONPATH", os.pathsep.join(p for p in (tests_dir, 
os.environ.get("PYTHONPATH", "")) if p)
+    )
+    monkeypatch.setattr(supervisor, "_task_process_uses_exec", lambda: True)
+    # ActivitySubprocess.start only execs its own entry point; let the probe 
be that entry point.
+    monkeypatch.setattr(supervisor, "_subprocess_main", 
exec_probe_target.exec_probe_main)
+    time_machine.move_to(timezone.datetime(2024, 11, 7, 12, 34, 56, 78901), 
tick=False)
+
+    proc = ActivitySubprocess.start(
+        dag_rel_path=os.devnull,
+        bundle_info=FAKE_BUNDLE,
+        what=TaskInstance(
+            id="4d828a62-a417-4936-a7a6-2b3fabacecab",
+            task_id="b",
+            dag_id="c",
+            run_id="d",
+            try_number=1,
+            dag_version_id=uuid7(),
+            queue="default",
+        ),
+        client=client_with_ti_start,
+        target=exec_probe_target.exec_probe_main,
+    )
+
+    assert proc.wait() == 0, captured_logs
+    assert {
+        "logger": "task.stdout",
+        "event": "exec-probe-ok",
+        "level": "info",
+        "timestamp": "2024-11-07T12:34:56.078901Z",
+    } in captured_logs
+
+
 class TestChildExecMain:
-    """Test the macOS fork+exec child entry point."""
+    """Test the fork+exec child entry point."""
+
+    def test_bootstrap_is_prelude_then_entry_point(self):
+        assert 
supervisor._CHILD_EXEC_BOOTSTRAP.startswith(supervisor._CHILD_EXEC_PRELUDE)
+        assert 
supervisor._CHILD_EXEC_BOOTSTRAP.rstrip().endswith("_child_exec_main()")
+        compile(supervisor._CHILD_EXEC_BOOTSTRAP, "<bootstrap>", "exec")
+
+    def test_prelude_survives_an_interpreter_without_ctypes(self):
+        """Without _ctypes the prelude must fall through to the logged 
fallback, not kill the task."""
+        probe = (
+            "import sys\nsys.modules['_ctypes'] = None\n" + 
supervisor._CHILD_EXEC_PRELUDE + "print('ok')\n"
+        )
+        result = subprocess.run(
+            [sys.executable, "-c", probe], capture_output=True, text=True, 
timeout=60, check=False
+        )
+        assert result.returncode == 0, result.stderr
+        assert result.stdout.strip() == "ok"
+
+    def test_reapplies_nondumpable_before_running_the_target(self, 
monkeypatch):
+        """The logged fallback for the bootstrap prelude runs before 
_fork_main hands off."""
+        calls: list[str] = []
+        monkeypatch.setattr(supervisor, "_make_process_nondumpable", lambda: 
calls.append("nondumpable"))
+        monkeypatch.setattr(supervisor, "_fork_main", lambda *a: 
calls.append("fork_main"))
+        monkeypatch.setattr(supervisor, "_resolve_child_target", lambda 
dotted: supervisor._subprocess_main)
+        monkeypatch.setattr(supervisor, "socket", lambda fileno: MagicMock())
+        monkeypatch.setenv("_AIRFLOW_CHILD_TARGET", 
"airflow.sdk.execution_time.supervisor:_subprocess_main")
+
+        supervisor._child_exec_main()
+
+        assert calls == ["nondumpable", "fork_main"]
 
     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)."""

Reply via email to