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

eladkal 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 0bfc7eac0e8 Fix `SSHRemoteJobOperator` orphaning the remote job on 
cancellation (#68644)
0bfc7eac0e8 is described below

commit 0bfc7eac0e8506795d06b73d65943ccc0f4e82d5
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Jul 1 00:12:02 2026 +0530

    Fix `SSHRemoteJobOperator` orphaning the remote job on cancellation (#68644)
    
    * Fix SSHRemoteJobOperator orphaning the remote job on cancellation
    
    * Fix RST indentation error in remote_job docstrings
---
 .../src/airflow/providers/ssh/utils/remote_job.py  | 52 +++++++++++--
 .../ssh/tests/unit/ssh/utils/test_remote_job.py    | 90 ++++++++++++++++++++--
 2 files changed, 127 insertions(+), 15 deletions(-)

diff --git a/providers/ssh/src/airflow/providers/ssh/utils/remote_job.py 
b/providers/ssh/src/airflow/providers/ssh/utils/remote_job.py
index d761896651d..55eff9df31e 100644
--- a/providers/ssh/src/airflow/providers/ssh/utils/remote_job.py
+++ b/providers/ssh/src/airflow/providers/ssh/utils/remote_job.py
@@ -158,11 +158,13 @@ def build_posix_wrapper_command(
     environment: dict[str, str] | None = None,
 ) -> str:
     """
-    Build a POSIX shell wrapper that runs the command detached via nohup.
+    Build a POSIX shell wrapper that runs the command detached.
 
     The wrapper:
+
     - Creates the job directory
-    - Starts the command in the background with nohup
+    - Starts the command detached in its own session via ``setsid`` (falling 
back to
+      ``nohup`` when ``setsid`` is unavailable)
     - Redirects stdout/stderr to the log file
     - Writes the exit code atomically on completion
     - Writes the PID for potential cancellation
@@ -181,6 +183,12 @@ def build_posix_wrapper_command(
 
     escaped_command = command.replace("'", "'\"'\"'")
 
+    # Launch detached under ``setsid`` so the job is its own 
session/process-group
+    # leader. ``$!`` is then the leader PID *and* the PGID (verified: setsid 
does not
+    # fork when started as a background job), recorded synchronously just like 
before,
+    # so cancellation can signal the whole job tree instead of orphaning the 
user
+    # command. Without ``setsid`` (some macOS/BSD hosts) ``$!`` is just the 
wrapper PID
+    # and cancellation degrades to the previous single-process behaviour.
     wrapper = f"""set -euo pipefail
 job_dir='{paths.job_dir}'
 log_file='{paths.log_file}'
@@ -192,7 +200,7 @@ status_file='{paths.status_file}'
 mkdir -p "$job_dir"
 : > "$log_file"
 
-nohup bash -c '
+job_script='
 set +e
 export LOG_FILE="'"$log_file"'"
 export STATUS_FILE="'"$status_file"'"
@@ -201,8 +209,13 @@ ec=$?
 echo -n "$ec" > "'"$exit_code_tmp"'"
 mv "'"$exit_code_tmp"'" "'"$exit_code_file"'"
 exit 0
-' >/dev/null 2>&1 &
+'
 
+if command -v setsid >/dev/null 2>&1; then
+  setsid bash -c "$job_script" >/dev/null 2>&1 &
+else
+  nohup bash -c "$job_script" >/dev/null 2>&1 &
+fi
 echo -n $! > "$pid_file"
 echo "{paths.job_id}"
 """
@@ -218,6 +231,7 @@ def build_windows_wrapper_command(
     Build a PowerShell wrapper that runs the command detached via 
Start-Process.
 
     The wrapper:
+
     - Creates the job directory
     - Starts the command in a new detached PowerShell process
     - Redirects stdout/stderr to the log file
@@ -379,24 +393,48 @@ def build_posix_kill_command(pid_file: str) -> str:
     """
     Build a POSIX command to kill the remote process.
 
+    Signals the whole process group first (the negative-PID form ``kill 
-<pgid>``) so
+    the user command and anything it spawned are terminated together, not just 
the
+    wrapper. The recorded PID is the job's session/group leader when it was 
launched
+    under ``setsid`` (see :func:`build_posix_wrapper_command`); if the job is 
not a
+    group leader (host without ``setsid``), the group signal is a no-op and we 
fall
+    back to killing the single PID, matching the previous behaviour.
+
+    The pid value is validated as an integer ``> 1`` before being negated: a 
corrupt
+    or partial pid of ``0``/``1`` would otherwise turn ``kill -<pid>`` into a 
broadcast
+    to every process the SSH account can signal. Best-effort: the command 
never fails
+    the SSH call.
+
     :param pid_file: Path to the PID file
     :return: Shell command to kill the process
     """
-    return f"test -f '{pid_file}' && kill $(cat '{pid_file}') 2>/dev/null || 
true"
+    return (
+        f"if test -f '{pid_file}'; then "
+        f"p=\"$(cat '{pid_file}')\"; "
+        f'if [ "$p" -gt 1 ] 2>/dev/null; then '
+        f'kill -TERM -"$p" 2>/dev/null || kill -TERM "$p" 2>/dev/null || true; 
'
+        "fi; fi"
+    )
 
 
 def build_windows_kill_command(pid_file: str) -> str:
     """
     Build a PowerShell command to kill the remote process.
 
+    Uses ``taskkill /T`` to terminate the recorded process *and its child
+    processes* (the Windows equivalent of a process-group kill), so the user
+    command launched by the detached wrapper is not left orphaned. ``$procId`` 
is
+    used instead of ``$pid`` because ``$PID`` is a read-only automatic 
variable in
+    PowerShell.
+
     :param pid_file: Path to the PID file
     :return: PowerShell command to kill the process
     """
     escaped_path = pid_file.replace("'", "''")
     script = f"""$path = '{escaped_path}'
 if (Test-Path $path) {{
-  $pid = Get-Content $path
-  Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue
+  $procId = Get-Content $path
+  & taskkill.exe /PID $procId /T /F 2>$null
 }}"""
     script_bytes = script.encode("utf-16-le")
     encoded_script = base64.b64encode(script_bytes).decode("ascii")
diff --git a/providers/ssh/tests/unit/ssh/utils/test_remote_job.py 
b/providers/ssh/tests/unit/ssh/utils/test_remote_job.py
index 1ababb62e08..4e7eca9b697 100644
--- a/providers/ssh/tests/unit/ssh/utils/test_remote_job.py
+++ b/providers/ssh/tests/unit/ssh/utils/test_remote_job.py
@@ -18,6 +18,11 @@
 from __future__ import annotations
 
 import base64
+import os
+import shutil
+import subprocess
+import time
+from pathlib import Path
 
 import pytest
 
@@ -126,6 +131,18 @@ class TestBuildPosixWrapperCommand:
         wrapper = build_posix_wrapper_command("echo 'hello world'", paths)
         assert wrapper is not None
 
+    def test_runs_in_own_process_group(self):
+        """The job launches under setsid (when available); $! is the leader 
PID/PGID."""
+        paths = RemoteJobPaths(job_id="test_job", remote_os="posix")
+        wrapper = build_posix_wrapper_command("/path/to/script.sh", paths)
+
+        # New session/process group when setsid exists, plain detached run 
otherwise
+        assert "command -v setsid" in wrapper
+        assert "setsid bash -c" in wrapper
+        assert "nohup bash -c" in wrapper
+        # Leader PID recorded synchronously by the launcher ($! == PGID under 
setsid)
+        assert 'echo -n $! > "$pid_file"' in wrapper
+
 
 class TestBuildWindowsWrapperCommand:
     def test_basic_command(self):
@@ -210,20 +227,77 @@ class TestCompletionCheckCommands:
 
 
 class TestKillCommands:
-    def test_posix_kill(self):
-        """Test POSIX kill command."""
+    def test_posix_kill_signals_process_group_then_falls_back(self):
+        """POSIX kill targets the process group first, then a single PID as 
fallback."""
         cmd = build_posix_kill_command("/tmp/pid")
-        assert "kill" in cmd
-        assert "cat" in cmd
-
-    def test_windows_kill(self):
-        """Test Windows kill command."""
+        assert "cat '/tmp/pid'" in cmd
+        # Negative PID => signal the whole process group (kills the job's 
children too)
+        assert 'kill -TERM -"$p"' in cmd
+        # Fallback for jobs that are not group leaders (host without setsid)
+        assert 'kill -TERM "$p"' in cmd
+        # Guard against a corrupt/partial pid: -0/-1 would broadcast to every 
process
+        assert '[ "$p" -gt 1 ]' in cmd
+        assert cmd.endswith("fi")
+
+    def test_windows_kill_terminates_process_tree(self):
+        """Windows kill terminates the process and its child tree via taskkill 
/T."""
         cmd = build_windows_kill_command("C:\\temp\\pid")
         assert "powershell.exe" in cmd
         assert "-EncodedCommand" in cmd
         encoded_script = cmd.split("-EncodedCommand ")[1]
         decoded_script = base64.b64decode(encoded_script).decode("utf-16-le")
-        assert "Stop-Process" in decoded_script
+        assert "taskkill" in decoded_script
+        assert "/T" in decoded_script  # tree kill (process + children)
+        # $PID is a read-only automatic variable in PowerShell; must not be 
assigned
+        assert "$procId" in decoded_script
+        assert "$pid =" not in decoded_script
+
+
[email protected](
+    os.name != "posix" or shutil.which("setsid") is None or 
shutil.which("bash") is None,
+    reason="needs a POSIX host with bash and setsid to exercise process-group 
teardown",
+)
+class TestPosixKillBehaviour:
+    """End-to-end check that on_kill tears down the whole job tree, not just 
the wrapper.
+
+    Regression test for the orphaned-process bug: killing only the recorded 
PID left the
+    user command (and its children) running, so the exit_code file was never 
written and
+    the trigger timed out. The job now runs in its own process group and the 
kill signals
+    the group.
+    """
+
+    @staticmethod
+    def _group_alive(pgid: int) -> bool:
+        # pgrep -g matches by process-group id; rc 0 => at least one member 
alive.
+        return subprocess.run(["pgrep", "-g", str(pgid)], capture_output=True, 
check=False).returncode == 0
+
+    def test_kill_terminates_whole_job_tree(self, tmp_path):
+        paths = RemoteJobPaths(job_id="killtree", remote_os="posix", 
base_dir=str(tmp_path / "jobs"))
+        # `sleep 300` runs as a child of the wrapper subshell -> the tree the 
old kill orphaned.
+        # Run under bash, which is the remote login shell this operator 
requires (the wrapper
+        # uses `set -o pipefail`); the kill is run the same way below.
+        wrapper = build_posix_wrapper_command("sleep 300", paths)
+        subprocess.run(["bash", "-c", wrapper], check=True, 
capture_output=True, text=True)
+
+        # The launcher records $! synchronously, so the pid file is present on 
return.
+        pid_path = Path(paths.pid_file)
+        assert pid_path.exists(), "job never wrote its pid file"
+        pid_text = pid_path.read_text().strip()
+        assert pid_text, "pid file is empty"
+        pgid = int(pid_text)
+
+        try:
+            assert self._group_alive(pgid), "job tree should be running before 
kill"
+
+            subprocess.run(["bash", "-c", 
build_posix_kill_command(paths.pid_file)], check=True)
+
+            deadline = time.monotonic() + 5
+            while time.monotonic() < deadline and self._group_alive(pgid):
+                time.sleep(0.05)
+            assert not self._group_alive(pgid), "kill left part of the job 
tree running"
+        finally:
+            # Belt-and-suspenders: never leave a stray `sleep 300` behind if 
an assert fails.
+            subprocess.run(["bash", "-c", f"kill -9 -{pgid} 2>/dev/null || 
true"], check=False)
 
 
 class TestCleanupCommands:

Reply via email to