Skip to content

Commit 888ff04

Browse files
Honor execute_tasks_new_python_interpreter in the task supervisor
The config currently stops one fork short: the executor honors it when launching the supervisor, but the Task SDK supervisor then bare-forks the task runner anyway. On a long-running multithreaded supervisor (OTel exporter, google-auth refresh threads, OpenLineage listener) that fork can inherit OpenSSL's global lock mid-held by a sibling thread, and every SSLContext construction in the child then blocks forever (issue #71707). Reuse the existing fork+exec machinery (already used on macOS) so Linux deployments can opt into a fresh interpreter per task with the same [core] execute_tasks_new_python_interpreter knob, matching what the Edge worker did in #65943.
1 parent 2811044 commit 888ff04

3 files changed

Lines changed: 82 additions & 2 deletions

File tree

airflow-core/src/airflow/config_templates/config.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,11 @@ core:
223223
* ``False``: Execute via forking of the parent process
224224
* ``True``: Spawning a new python process, slower than fork, but means plugin changes picked
225225
up by tasks straight away
226+
227+
A fresh interpreter also avoids fork-safety hazards of a long-running,
228+
multithreaded supervisor: a bare ``fork()`` can inherit C-library locks held
229+
by a sibling thread at fork time (e.g. OpenSSL's, taken while building an
230+
``SSLContext``), which deadlocks later TLS setup in the task process.
226231
default: "False"
227232
example: ~
228233
version_added: 2.0.0

task-sdk/src/airflow/sdk/execution_time/supervisor.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,18 @@ def _should_use_exec() -> bool:
517517
return sys.platform in _FORK_EXEC_PLATFORMS
518518

519519

520+
def _task_exec_configured() -> bool:
521+
"""
522+
Whether ``[core] execute_tasks_new_python_interpreter`` asks for a fresh interpreter per task.
523+
524+
Opt-in fork-safety escape hatch: a bare ``os.fork()`` of a multithreaded
525+
supervisor can inherit a C-library lock mid-held by a sibling thread
526+
(OpenSSL's global lock, taken inside ``SSL_CTX_new``), which then blocks
527+
every ``SSLContext`` construction in the child forever.
528+
"""
529+
return conf.getboolean("core", "execute_tasks_new_python_interpreter", fallback=False)
530+
531+
520532
def _resolve_child_target(dotted: str) -> Callable[[], None]:
521533
"""
522534
Resolve a ``module:qualname`` string to the callable the exec'd child runs.
@@ -1412,10 +1424,13 @@ def start( # type: ignore[override]
14121424
**kwargs,
14131425
) -> Self:
14141426
"""Fork and start a new subprocess to execute the given task."""
1415-
# Opt in to fork+exec on platforms that need it (currently macOS).
1427+
# Opt in to fork+exec on platforms that need it (currently macOS), or
1428+
# when a fresh interpreter per task is configured (fork of a
1429+
# multithreaded supervisor can inherit held C-library locks, e.g.
1430+
# OpenSSL's, and deadlock the child; see #71707).
14161431
# Tests override `target` with a local stub to exercise the base
14171432
# infrastructure; keep bare fork for those.
1418-
use_exec = target is _subprocess_main and _should_use_exec()
1433+
use_exec = target is _subprocess_main and (_should_use_exec() or _task_exec_configured())
14191434
proc: Self = super().start(
14201435
id=what.id,
14211436
client=client,

task-sdk/tests/task_sdk/execution_time/test_supervisor.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4479,6 +4479,66 @@ def test_api_client_clears_dag_bag_override_when_dag_is_none():
44794479
in_process_api_server.cache_clear()
44804480

44814481

4482+
class TestActivitySubprocessExecSelection:
4483+
"""Which launch mode ``ActivitySubprocess.start()`` picks for the task runner."""
4484+
4485+
@pytest.fixture
4486+
def captured_kwargs(self, monkeypatch):
4487+
captured = {}
4488+
4489+
@classmethod
4490+
def fake_start(cls, **kwargs):
4491+
captured.update(kwargs)
4492+
return MagicMock(spec=supervisor.ActivitySubprocess)
4493+
4494+
monkeypatch.setattr(supervisor.WatchedSubprocess, "start", fake_start)
4495+
return captured
4496+
4497+
def _start(self, **kwargs):
4498+
args = {
4499+
"what": TaskInstance(
4500+
id="4d828a62-a417-4936-a7a6-2b3fabacecab",
4501+
task_id="b",
4502+
dag_id="c",
4503+
run_id="d",
4504+
try_number=1,
4505+
dag_version_id=uuid7(),
4506+
queue="default",
4507+
),
4508+
"dag_rel_path": os.devnull,
4509+
"bundle_info": FAKE_BUNDLE,
4510+
"client": MagicMock(spec=sdk_client.Client),
4511+
}
4512+
args.update(kwargs)
4513+
return ActivitySubprocess.start(**args)
4514+
4515+
def test_bare_fork_by_default(self, monkeypatch, captured_kwargs):
4516+
monkeypatch.setattr(supervisor, "_should_use_exec", lambda: False)
4517+
with conf_vars({("core", "execute_tasks_new_python_interpreter"): "false"}):
4518+
self._start()
4519+
assert captured_kwargs["use_exec"] is False
4520+
4521+
def test_exec_when_config_requests_fresh_interpreter(self, monkeypatch, captured_kwargs):
4522+
"""Linux + execute_tasks_new_python_interpreter=True still execs (issue #71707)."""
4523+
monkeypatch.setattr(supervisor, "_should_use_exec", lambda: False)
4524+
with conf_vars({("core", "execute_tasks_new_python_interpreter"): "true"}):
4525+
self._start()
4526+
assert captured_kwargs["use_exec"] is True
4527+
4528+
def test_exec_on_fork_unsafe_platform_regardless_of_config(self, monkeypatch, captured_kwargs):
4529+
monkeypatch.setattr(supervisor, "_should_use_exec", lambda: True)
4530+
with conf_vars({("core", "execute_tasks_new_python_interpreter"): "false"}):
4531+
self._start()
4532+
assert captured_kwargs["use_exec"] is True
4533+
4534+
def test_stub_target_keeps_bare_fork_even_when_config_set(self, monkeypatch, captured_kwargs):
4535+
"""Tests override `target` with local stubs; those can't be rehydrated across exec."""
4536+
monkeypatch.setattr(supervisor, "_should_use_exec", lambda: False)
4537+
with conf_vars({("core", "execute_tasks_new_python_interpreter"): "true"}):
4538+
self._start(target=lambda: None)
4539+
assert captured_kwargs["use_exec"] is False
4540+
4541+
44824542
class TestResolveChildTarget:
44834543
"""Test rehydrating the exec'd child's entry point from _AIRFLOW_CHILD_TARGET."""
44844544

0 commit comments

Comments
 (0)