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

shahar1 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 9ad8d78c277 Close SubprocessHook stdout pipe after command completes 
(#74780)
9ad8d78c277 is described below

commit 9ad8d78c27779b3cc6660d1a496676a4a195c203
Author: Dev-iL <[email protected]>
AuthorDate: Sun Oct 18 17:34:18 2026 +0300

    Close SubprocessHook stdout pipe after command completes (#74780)
    
    Release the stdout pipe after commands finish or output reading fails,
    avoiding leaked descriptors and ResourceWarnings. Keep it open on timeout
    so the child can write while handling SIGTERM.
---
 .../airflow/providers/standard/hooks/subprocess.py | 28 +++++++---
 .../tests/unit/standard/hooks/test_subprocess.py   | 63 +++++++++++++++++++++-
 2 files changed, 84 insertions(+), 7 deletions(-)

diff --git 
a/providers/standard/src/airflow/providers/standard/hooks/subprocess.py 
b/providers/standard/src/airflow/providers/standard/hooks/subprocess.py
index 11626fb451a..3251714f0f1 100644
--- a/providers/standard/src/airflow/providers/standard/hooks/subprocess.py
+++ b/providers/standard/src/airflow/providers/standard/hooks/subprocess.py
@@ -93,18 +93,34 @@ class SubprocessHook(BaseHook):
             line = ""
             if self.sub_process is None:
                 raise RuntimeError("The subprocess should be created here and 
is None!")
-            if self.sub_process.stdout is not None:
-                for raw_line in iter(self.sub_process.stdout.readline, b""):
-                    line = raw_line.decode(output_encoding, 
errors="backslashreplace").rstrip()
-                    self.log.info("%s", line)
-
-            self.sub_process.wait()
+            # Neither `with Popen(...)` nor a `finally` close, because both 
act on the
+            # execution_timeout path, where AirflowTaskTimeout (a 
BaseException) unwinds through here
+            # and on_kill() sends SIGTERM to the child only afterwards: 
__exit__ would block in
+            # wait() so that SIGTERM never arrives, and closing stdout would 
SIGPIPE a child that
+            # writes during its own TERM cleanup, since restore_signals=True 
leaves SIGPIPE at its
+            # default action. Closing on success and on Exception leaves that 
path as it was.
+            try:
+                if self.sub_process.stdout is not None:
+                    for raw_line in iter(self.sub_process.stdout.readline, 
b""):
+                        line = raw_line.decode(output_encoding, 
errors="backslashreplace").rstrip()
+                        self.log.info("%s", line)
+
+                self.sub_process.wait()
+            except Exception:
+                self._close_stdout()
+                raise
+            else:
+                self._close_stdout()
 
             self.log.info("Command exited with return code %s", 
self.sub_process.returncode)
             return_code: int = self.sub_process.returncode
 
         return SubprocessResult(exit_code=return_code, output=line)
 
+    def _close_stdout(self) -> None:
+        if self.sub_process is not None and self.sub_process.stdout is not 
None:
+            self.sub_process.stdout.close()
+
     def send_sigterm(self):
         """Send SIGTERM signal to ``self.sub_process`` if one exists."""
         self.log.info("Sending SIGTERM signal to process group")
diff --git a/providers/standard/tests/unit/standard/hooks/test_subprocess.py 
b/providers/standard/tests/unit/standard/hooks/test_subprocess.py
index 044af2c1419..7eaee37675c 100644
--- a/providers/standard/tests/unit/standard/hooks/test_subprocess.py
+++ b/providers/standard/tests/unit/standard/hooks/test_subprocess.py
@@ -18,8 +18,9 @@
 from __future__ import annotations
 
 import signal
+from io import BufferedReader
 from pathlib import Path
-from subprocess import PIPE, STDOUT
+from subprocess import PIPE, STDOUT, Popen
 from tempfile import TemporaryDirectory
 from unittest import mock
 from unittest.mock import MagicMock
@@ -32,6 +33,10 @@ OS_ENV_KEY = "SUBPROCESS_ENV_TEST"
 OS_ENV_VAL = "this-is-from-os-environ"
 
 
+class _TaskTimeout(BaseException):
+    """Stands in for ``AirflowTaskTimeout``, which is a ``BaseException`` so 
task code cannot catch it."""
+
+
 class TestSubprocessHook:
     @pytest.mark.parametrize(
         ("env", "expected"),
@@ -103,6 +108,62 @@ class TestSubprocessHook:
             stdout=PIPE,
         )
 
+    def test_run_command_closes_stdout_pipe(self):
+        hook = SubprocessHook()
+        hook.run_command(command=["bash", "-c", 'echo "stdout"'])
+        assert hook.sub_process.stdout.closed
+
+    @mock.patch(
+        "airflow.providers.standard.hooks.subprocess.Popen",
+        return_value=MagicMock(
+            spec=Popen,
+            stdout=MagicMock(spec=BufferedReader, 
readline=MagicMock(side_effect=OSError("pipe broken"))),
+        ),
+    )
+    def test_run_command_closes_stdout_pipe_on_read_error(self, mock_popen):
+        hook = SubprocessHook()
+        with pytest.raises(OSError, match="pipe broken"):
+            hook.run_command(command=["bash", "-c", 'echo "stdout"'])
+        mock_popen.return_value.stdout.close.assert_called_once()
+        mock_popen.return_value.wait.assert_not_called()
+
+    def test_run_command_keeps_stdout_open_for_sigterm_cleanup(self, tmp_path):
+        """
+        On an ``execution_timeout`` the timeout unwinds out of ``run_command`` 
and ``on_kill()``
+        sends SIGTERM only afterwards, so stdout has to still be open by then: 
the child is started
+        with ``restore_signals=True``, so a TERM handler writing to a closed 
pipe is killed by
+        SIGPIPE instead of finishing its cleanup.
+        """
+        marker = tmp_path / "cleanup-ran"
+        hook = SubprocessHook()
+
+        def raise_task_timeout(signum, frame):
+            raise _TaskTimeout()
+
+        previous_handler = signal.signal(signal.SIGALRM, raise_task_timeout)
+        try:
+            signal.setitimer(signal.ITIMER_REAL, 0.5)
+            with pytest.raises(_TaskTimeout):
+                hook.run_command(
+                    command=[
+                        "bash",
+                        "-c",
+                        f"trap 'echo cleaning up; touch {marker}; exit 1' 
TERM; "
+                        "echo started; while true; do sleep 0.1; done",
+                    ]
+                )
+        finally:
+            signal.setitimer(signal.ITIMER_REAL, 0)
+            signal.signal(signal.SIGALRM, previous_handler)
+
+        hook.send_sigterm()
+        return_code = hook.sub_process.wait(timeout=10)
+        # run_command deliberately leaves this path's pipe open, so the test 
owns closing it.
+        hook.sub_process.stdout.close()
+
+        assert marker.exists()
+        assert return_code == 1
+
     def test_task_decode(self):
         hook = SubprocessHook()
         command = ["bash", "-c", 'printf "This will cause a coding error 
\\xb1\\xa6\\x01\n"']

Reply via email to