This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new e2edfff0418 [v3-4-test] Run a language SDK runtime in its own process
group (#75707) (#75731)
e2edfff0418 is described below
commit e2edfff0418bb4e38e8287b278067d537845c8d3
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Tue Oct 27 17:09:30 2026 +0800
[v3-4-test] Run a language SDK runtime in its own process group (#75707)
(#75731)
Stopping a task that a language SDK runs signalled the runtime's pid alone,
so
anything the task had started was reparented to PID 1 and kept running.
While
such a child is alive it also holds the runtime open, so the runtime itself
survived until the SIGKILL escalation `killed_task_cleanup_time` later.
The runtime now starts with `process_group=0` and the supervisor is told the
child has its own group, so the existing `os.killpg` path reaches the whole
tree, as it has for Python tasks since #65505. A startup that fails signals
the group too, and a runtime that exits before connecting is already reaped,
so its group is reached by the leader's pid.
This covers every way the supervisor stops a task -- a cleared task,
`execution_timeout`,
repeated heartbeat failures and task-success overtime -- for the
TypeScript, Java and
executable runtimes alike.
(cherry picked from commit 750f849cc2372ee42dc6910541d4b6a866102ac9)
closes: #75533
Co-authored-by: Claude <[email protected]>
---
.../src/airflow/sdk/coordinators/_subprocess.py | 34 ++++-
.../src/airflow/sdk/execution_time/supervisor.py | 43 +++---
.../tests/task_sdk/coordinators/test_subprocess.py | 170 ++++++++++++++++++++-
3 files changed, 225 insertions(+), 22 deletions(-)
diff --git a/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
b/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
index 0bd84e147be..5d5223072fe 100644
--- a/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
+++ b/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
@@ -55,7 +55,12 @@ from airflow.sdk.execution_time.bundles import
initialize_ti_bundle
from airflow.sdk.execution_time.comms import RetryTask, TaskState
from airflow.sdk.execution_time.coordinator import BaseCoordinator,
TaskLaunchError
from airflow.sdk.execution_time.schema import get_schema_version_migrator
-from airflow.sdk.execution_time.supervisor import ActivitySubprocess,
NeverRaised, ProcessTracker
+from airflow.sdk.execution_time.supervisor import (
+ ActivitySubprocess,
+ NeverRaised,
+ ProcessTracker,
+ signal_process_tree,
+)
if TYPE_CHECKING:
import pathlib
@@ -206,6 +211,19 @@ def _build_runtime_env() -> dict[str, str]:
}
+def _kill_leftover_group(pid: int) -> None:
+ """
+ Kill what an exited runtime left in its process group, which ``getpgid``
can no longer resolve.
+
+ A group outlives its leader, so it is reached by the leader's pid. Not
when another process has
+ reused that pid, since the group could then be that process's.
+ """
+ if psutil.pid_exists(pid):
+ return
+ with contextlib.suppress(ProcessLookupError, PermissionError):
+ os.killpg(pid, signal.SIGKILL)
+
+
def _accept_connections(
servers: dict[str, socket.socket],
drains: dict[str, socket.socket],
@@ -305,11 +323,14 @@ class _ResourceTracker:
case socket.socket():
o.close()
case subprocess.Popen():
- o.terminate()
+ signal_process_tree(o.pid, signal.SIGTERM,
fallback=o.send_signal)
try:
o.wait(self.timeout)
except subprocess.TimeoutExpired:
- o.kill()
+ signal_process_tree(o.pid, signal.SIGKILL,
fallback=o.send_signal)
+ # A runtime that exited before connecting was already
reaped, so its group
+ # is out of reach above while what it spawned is still
running.
+ _kill_leftover_group(o.pid)
def track(self, *objects: Tracked) -> tuple[Tracked, ...]:
self.tracked.update((id(o), o) for o in objects)
@@ -365,6 +386,11 @@ class _PopenActivitySubprocess(ActivitySubprocess):
stdout=stdout_w.fileno(),
stderr=stderr_w.fileno(),
env=_build_runtime_env(),
+ # Own process group, so the supervisor can signal everything
the runtime
+ # spawned with os.killpg, as the Python task runner has done
since #65505.
+ # Popen returns after exec, so the group exists before kill().
+ # Must agree with new_process_group=True below.
+ process_group=0,
)
tracker.track(proc)
for soc in tracker.untrack(stdout_w, stderr_w):
@@ -387,6 +413,8 @@ class _PopenActivitySubprocess(ActivitySubprocess):
start_time=time.monotonic(),
stdin=socks[comm_server],
subprocess_schema_version=subprocess_schema_version,
+ # The supervisor signals the group; must agree with
process_group=0 above.
+ new_process_group=True,
comm_server=comm_server,
logs_server=logs_server,
**kwargs,
diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
index 388075858bd..0c198ab637a 100644
--- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
@@ -783,6 +783,27 @@ class PsutilTracker(ProcessTracker):
return self._impl.wait(timeout)
+def signal_process_tree(pid: int, sig: signal.Signals, *, fallback:
Callable[[signal.Signals], None]) -> None:
+ """
+ Deliver ``sig`` to *pid*'s process group, so every process it spawned is
reached too.
+
+ Calls *fallback* instead when the group cannot be resolved or signalled,
and -- critically --
+ when *pid* is still in the caller's own process group, because ``killpg``
on our own group
+ would signal the caller itself and its siblings.
+ """
+ try:
+ pgid = os.getpgid(pid)
+ except (ProcessLookupError, PermissionError):
+ pgid = None
+ if pgid is not None and pgid != os.getpgid(0):
+ try:
+ os.killpg(pgid, sig)
+ return
+ except (ProcessLookupError, PermissionError):
+ pass
+ fallback(sig)
+
+
def _validate_schema_version(instance, _, value) -> str | None:
if value is None:
return None
@@ -1264,25 +1285,13 @@ class WatchedSubprocess:
"""
Deliver ``sig`` to the child process, or to its whole process group
when it has its own.
- When ``new_process_group`` was set at ``start()`` time, the signal is
sent with
- ``os.killpg`` so subprocesses spawned by the child (venv children,
bash shells, etc.)
- are reached too (see issue #65505). Falls back to signalling the child
PID alone when
- the group cannot be resolved or signalled -- and, critically, when the
child still
- shares the supervisor's own process group (``setpgid`` failed),
because ``killpg`` on
- our own group would signal the supervisor itself and its siblings.
+ When ``new_process_group`` was set at ``start()`` time, the signal
reaches the subprocesses
+ the child spawned too (venv children, bash shells, etc.); see issue
#65505.
"""
if self._new_process_group:
- try:
- pgid = os.getpgid(self._process.pid)
- except (ProcessLookupError, PermissionError):
- pgid = None
- if pgid is not None and pgid != os.getpgid(0):
- try:
- os.killpg(pgid, sig)
- return
- except (ProcessLookupError, PermissionError):
- pass
- self._process.send_signal(sig)
+ signal_process_tree(self._process.pid, sig,
fallback=self._process.send_signal)
+ else:
+ self._process.send_signal(sig)
def cleanup_sockets_after_kill(self) -> None:
"""Drain log-bearing sockets, then close every remaining socket after
a forced kill."""
diff --git a/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
b/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
index 63eafc6d1ab..779eac3711c 100644
--- a/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
+++ b/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
@@ -524,6 +524,25 @@ class TestConnectionFromProcess:
mock_owned.assert_called_once_with(("127.0.0.1", 5000), ("127.0.0.1",
6000), 999)
+_RUNTIME_PID = 6767
+
+
[email protected]
+def runtime_process_group(monkeypatch):
+ """
+ Resolve ``_RUNTIME_PID`` to its own group and any other pid to the
supervisor's real group.
+
+ ``os.killpg`` is mocked and returned so a test can assert what was
signalled, and the runtime's
+ pid is reported as still in use so the leftover sweep stays out of the way.
+ """
+ own_group = os.getpgid(0)
+ killpg = MagicMock()
+ monkeypatch.setattr(os, "getpgid", lambda pid: _RUNTIME_PID if pid ==
_RUNTIME_PID else own_group)
+ monkeypatch.setattr(os, "killpg", killpg)
+ monkeypatch.setattr(psutil, "pid_exists", lambda pid: True)
+ return killpg
+
+
class TestResourceTracker:
"""
Unit tests for the _ResourceTracker context manager.
@@ -559,11 +578,26 @@ class TestResourceTracker:
tracker.track(sock)
sock.close.assert_called_once()
- def test_context_manager_terminates_tracked_popen_on_exit(self):
+ def test_context_manager_terminates_tracked_popen_on_exit(self,
runtime_process_group):
+ """A failed startup signals the runtime's whole process group, not
just its pid."""
+ proc = MagicMock(spec=subprocess.Popen)
+ proc.pid = _RUNTIME_PID
+ with _ResourceTracker(timeout=0.1) as tracker:
+ tracker.track(proc)
+ runtime_process_group.assert_called_once_with(_RUNTIME_PID,
signal.SIGTERM)
+ proc.send_signal.assert_not_called()
+
+ def test_context_manager_kills_tracked_popen_group_on_timeout(self,
runtime_process_group):
+ """A runtime that ignores SIGTERM during startup has its group
SIGKILLed."""
proc = MagicMock(spec=subprocess.Popen)
+ proc.pid = _RUNTIME_PID
+ proc.wait.side_effect = subprocess.TimeoutExpired(cmd="runtime",
timeout=0.1)
with _ResourceTracker(timeout=0.1) as tracker:
tracker.track(proc)
- proc.terminate.assert_called_once()
+ assert runtime_process_group.call_args_list == [
+ call(_RUNTIME_PID, signal.SIGTERM),
+ call(_RUNTIME_PID, signal.SIGKILL),
+ ]
def test_untracked_socket_not_closed_on_exit(self):
sock = MagicMock(spec=socket.socket)
@@ -1150,6 +1184,138 @@ class TestPopenActivitySubprocessClearXComs:
} in captured_logs
+_RUNTIME_SPAWNING_A_CHILD = """
+import socket
+import subprocess
+import sys
+
+def connect(flag):
+ host, _, port = next(arg for arg in sys.argv if
arg.startswith(flag)).split("=", 1)[1].rpartition(":")
+ return socket.create_connection((host, int(port)))
+
+comm = connect("--comm=")
+logs = connect("--logs=")
+child = subprocess.Popen([sys.executable, "-c", "import time;
time.sleep(300)"])
+with open(sys.argv[1], "w") as handle:
+ handle.write(str(child.pid))
+comm.settimeout(30)
+try:
+ comm.recv(1)
+except TimeoutError:
+ sys.exit(3)
+"""
+
+
+_RUNTIME_SPAWNING_A_CHILD_THEN_DYING = """
+import subprocess
+import sys
+
+child = subprocess.Popen([sys.executable, "-c", "import time;
time.sleep(300)"])
+with open(sys.argv[1], "w") as handle:
+ handle.write(str(child.pid))
+sys.exit(1)
+"""
+
+
+def _wait_until_gone(pid: int, timeout: float = 10.0) -> bool:
+ """Return whether *pid* has stopped running, treating a not-yet-reaped
zombie as gone."""
+ deadline = time.monotonic() + timeout
+ while True:
+ try:
+ if psutil.Process(pid).status() == psutil.STATUS_ZOMBIE:
+ return True
+ except psutil.NoSuchProcess:
+ return True
+ if time.monotonic() >= deadline:
+ return False
+ time.sleep(0.05)
+
+
+class TestPopenActivitySubprocessProcessGroup:
+ """A language SDK runtime runs in its own process group so a stop reaches
what it spawned."""
+
+ def test_popen_asks_for_a_new_process_group(self, mock_client):
+ mock_proc = MagicMock(spec=subprocess.Popen)
+ mock_proc.pid = 12345
+ with (
+ patch(
+ "airflow.sdk.coordinators._subprocess.subprocess.Popen",
return_value=mock_proc
+ ) as popen_mock,
+ patch(
+ "airflow.sdk.coordinators._subprocess._accept_connections",
+ side_effect=lambda servers, drains, proc, **kw: (
+ {soc: MagicMock(spec=socket.socket) for soc in
servers.values()},
+ {soc: b"" for soc in drains.values()},
+ ),
+ ),
+ patch.object(ActivitySubprocess, "_register_pipe_readers"),
+ patch.object(ActivitySubprocess, "_on_child_started"),
+ ):
+ proc = _PopenActivitySubprocess.start(
+ what=_make_ti(),
+ dag_rel_path="bundle",
+ bundle_info=MagicMock(),
+ client=mock_client,
+ command=["/bin/true"],
+ subprocess_logs_to_stdout=False,
+ )
+
+ assert popen_mock.call_args.kwargs["process_group"] == 0
+ assert proc._new_process_group is True
+
+ def
test_a_runtime_that_dies_before_connecting_does_not_leave_its_child(self,
tmp_path):
+ """The runtime is reaped before start() fails, so its group is swept
by the leader's pid."""
+ pid_file = tmp_path / "child.pid"
+ with pytest.raises(RuntimeError, match="before connecting"):
+ _PopenActivitySubprocess.start(
+ what=_make_ti(),
+ dag_rel_path="bundle",
+ bundle_info=FAKE_BUNDLE,
+ client=MagicMock(),
+ command=[
+ sys.executable,
+ "-c",
+ _RUNTIME_SPAWNING_A_CHILD_THEN_DYING,
+ str(pid_file),
+ ],
+ subprocess_logs_to_stdout=False,
+ )
+
+ spawned_pid = int(pid_file.read_text())
+ try:
+ assert _wait_until_gone(spawned_pid), "the process the runtime
spawned outlived the failure"
+ finally:
+ with contextlib.suppress(OSError):
+ os.kill(spawned_pid, signal.SIGKILL)
+
+ def test_stopping_the_task_also_stops_what_the_runtime_spawned(self,
tmp_path):
+ """Regression for #75533: SIGTERM reaches the CLI the task started,
not only the runtime."""
+ pid_file = tmp_path / "child.pid"
+ with patch.object(ActivitySubprocess, "_on_child_started"):
+ proc = _PopenActivitySubprocess.start(
+ what=_make_ti(),
+ dag_rel_path="bundle",
+ bundle_info=FAKE_BUNDLE,
+ client=MagicMock(),
+ command=[sys.executable, "-c", _RUNTIME_SPAWNING_A_CHILD,
str(pid_file)],
+ subprocess_logs_to_stdout=False,
+ )
+
+ deadline = time.monotonic() + 10.0
+ while not (reported := pid_file.read_text().strip() if
pid_file.exists() else ""):
+ assert time.monotonic() < deadline, "the runtime did not report
the pid it spawned"
+ time.sleep(0.05)
+ spawned_pid = int(reported)
+
+ try:
+ proc.kill(signal.SIGTERM, force=True, escalation_delay=5.0)
+ assert _wait_until_gone(proc.pid), "the runtime outlived the stop"
+ assert _wait_until_gone(spawned_pid), "the process the task
spawned outlived the stop"
+ finally:
+ with contextlib.suppress(OSError):
+ os.kill(spawned_pid, signal.SIGKILL)
+
+
class TestResolveArtifactBundle:
"""Execute-time resolution of the Dag bundle holding the artifacts."""