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 bc117abce10 Fix SSHRemoteJobOperator still orphaning the remote job on
cancellation (#69490)
bc117abce10 is described below
commit bc117abce10ca3661603e60124d18f6a737b0110
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Jul 8 09:33:39 2026 +0100
Fix SSHRemoteJobOperator still orphaning the remote job on cancellation
(#69490)
The detached job runs under `setsid` so it leads its own process group, and
`on_kill` signals that group. The wrapper recorded `$!` as the group leader,
but `$!` is only the leader when `setsid(1)` runs in place. When job
control is
on in the launching shell, `setsid(1)` forks (`setsid(2)` cannot create a
new
session from an existing group leader), so `$!` names the short-lived setsid
parent, not the job. Cancellation then signals a dead group, the real
command
keeps running as an orphan, and the `exit_code` file is never written, so
the
task stays deferred until the trigger times out.
Record the job's own pid instead: right after `setsid(2)`, POSIX guarantees
`pid == pgid == sid` for the caller and that identity survives the following
`exec`, so `$$` written from inside the job script is always the true PGID,
whether or not setsid forked. The launcher no longer records `$!`, and since
the pid file is read only at cancellation time (long after submission) the
job's own write is always in place first.
Make the end-to-end kill test deterministic by forcing job control through a
real controlling terminal, exercising the exact fork path that orphaned the
job, and remove the `flaky(reruns=5)` marker. Test markers are unique per
test
and per worker so the suite stays isolated under `pytest -n auto`.
---
.../src/airflow/providers/ssh/utils/remote_job.py | 23 +++-
.../ssh/tests/unit/ssh/utils/test_remote_job.py | 135 +++++++++++++++++----
2 files changed, 126 insertions(+), 32 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 55eff9df31e..496b0179d89 100644
--- a/providers/ssh/src/airflow/providers/ssh/utils/remote_job.py
+++ b/providers/ssh/src/airflow/providers/ssh/utils/remote_job.py
@@ -184,11 +184,22 @@ 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.
+ # leader, letting cancellation signal the whole job tree instead of
orphaning the
+ # user command. We do NOT rely on the launcher's ``$!`` to identify that
group:
+ # ``setsid(1)`` forks internally when the process about to exec into it is
already
+ # a process-group leader (``setsid(2)`` cannot create a new session from a
group
+ # leader), which happens whenever job control is on in the launching
shell. When
+ # it forks, ``$!`` is the short-lived setsid parent, not the job's real
PGID, and
+ # cancellation signals a dead group. Instead the job script reports its
OWN pid:
+ # immediately after ``setsid(2)`` POSIX guarantees ``pid == pgid == sid``
for the
+ # caller, and that identity survives the following exec, so ``$$`` inside
the job
+ # script is always the true PGID regardless of whether setsid forked to
get there.
+ # The pid file is read only when cancelling
(:func:`build_posix_kill_command`),
+ # which happens long after submission, so the job's asynchronous write
lands well
+ # before any reader and the launcher does not wait for it (recording the
launcher's
+ # ``$!`` would just reintroduce the wrong-pid bug on the fork path).
Without
+ # ``setsid`` (some macOS/BSD hosts) ``$$`` is just the job's own 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}'
@@ -202,6 +213,7 @@ mkdir -p "$job_dir"
job_script='
set +e
+echo -n "$$" > "'"$pid_file"'"
export LOG_FILE="'"$log_file"'"
export STATUS_FILE="'"$status_file"'"
{env_exports}{escaped_command} >>"'"$log_file"'" 2>&1
@@ -216,7 +228,6 @@ if command -v setsid >/dev/null 2>&1; then
else
nohup bash -c "$job_script" >/dev/null 2>&1 &
fi
-echo -n $! > "$pid_file"
echo "{paths.job_id}"
"""
return wrapper
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 56fe5d7abb3..82b285d4587 100644
--- a/providers/ssh/tests/unit/ssh/utils/test_remote_job.py
+++ b/providers/ssh/tests/unit/ssh/utils/test_remote_job.py
@@ -18,7 +18,10 @@
from __future__ import annotations
import base64
+import contextlib
import os
+import pty
+import select
import shutil
import subprocess
import time
@@ -132,7 +135,7 @@ class TestBuildPosixWrapperCommand:
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."""
+ """The job launches under setsid (when available) and self-reports its
PGID."""
paths = RemoteJobPaths(job_id="test_job", remote_os="posix")
wrapper = build_posix_wrapper_command("/path/to/script.sh", paths)
@@ -140,8 +143,11 @@ class TestBuildPosixWrapperCommand:
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
+ # The job self-reports its own pid ($$ == PGID after setsid), which is
correct
+ # even when setsid(1) forks under job control -- unlike the launcher's
$!.
+ assert 'echo -n "$$" > "' in wrapper
+ # Launcher must NOT record $! (would be the short-lived setsid parent
on a fork).
+ assert 'echo -n $! > "$pid_file"' not in wrapper
class TestBuildWindowsWrapperCommand:
@@ -262,46 +268,123 @@ class TestPosixKillBehaviour:
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.
+ the trigger timed out. The job runs in its own process group and
self-reports that
+ group's PGID, so the kill signals the whole group even when setsid(1)
forks.
"""
+ def _marker(self, tag: str) -> str:
+ # Unique per (test, xdist worker): CI runs these with ``-n auto``
(default
+ # ``load`` distribution), so sibling tests can execute concurrently in
separate
+ # workers against the same OS process table. A shared literal would
let one
+ # test's ``pgrep -f`` / ``pkill -f`` match or kill another's job.
os.getpid()
+ # differs per worker; the tag differs per test.
+ return f"sleep 9{tag}{os.getpid()}"
+
@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
- # setsid only avoids forking when the launching shell is not a
process-group leader; on some
- # CI runners it forks, so the recorded $! is the short-lived setsid parent
rather than the job
- # PGID and the pre-kill pgrep -g finds an empty group. Re-launch on a
fresh draw.
- @pytest.mark.flaky(reruns=5)
- 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)
+ @staticmethod
+ def _job_running(marker: str) -> bool:
+ return subprocess.run(["pgrep", "-f", marker], capture_output=True,
check=False).returncode == 0
+
+ @staticmethod
+ def _pgid_of(pid: str) -> str:
+ return subprocess.run(
+ ["ps", "-o", "pgid=", "-p", pid], capture_output=True, text=True,
check=False
+ ).stdout.strip()
- # The launcher records $! synchronously, so the pid file is present on
return.
+ def _await_recorded_pid(self, paths) -> int:
+ # The job writes its pid asynchronously (the launcher does not wait),
so poll.
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)
+ deadline = time.monotonic() + 5
+ pid_text = ""
+ while time.monotonic() < deadline:
+ pid_text = pid_path.read_text().strip() if pid_path.exists() else
""
+ if pid_text:
+ break
+ time.sleep(0.02)
+ assert pid_text, "job never wrote its pid file"
+ return int(pid_text)
+ @staticmethod
+ def _run_bash_mc_under_pty(script: str, marker: bytes, timeout: float =
8.0) -> None:
+ """Run ``bash -mc script`` under a pty we own so job control genuinely
activates
+ (bash silently disables ``-m`` without a controlling terminal). Read
until the
+ marker, NOT to EOF: the detached job inherits the pty slave as its
stdin, so EOF
+ would not arrive until the job itself exits (the full sleep
runtime)."""
+ pid, fd = pty.fork()
+ if pid == 0:
+ try:
+ os.execvp("bash", ["bash", "-mc", script])
+ except OSError:
+ os._exit(127) # never fall through as a duplicate pytest
process
try:
- assert self._group_alive(pgid), "job tree should be running before
kill"
+ deadline = time.monotonic() + timeout
+ buf = b""
+ while time.monotonic() < deadline:
+ r, _, _ = select.select([fd], [], [], 0.2)
+ if fd in r:
+ try:
+ chunk = os.read(fd, 4096)
+ except OSError:
+ break
+ if not chunk:
+ break
+ buf += chunk
+ if marker in buf:
+ break
+ finally:
+ os.close(fd) # hangs up the pty; the launcher (not the detached
job) exits
+ with contextlib.suppress(ChildProcessError):
+ os.waitpid(pid, 0) # reap the launcher so it does not linger
as a zombie
+ def _assert_kill_tears_down(self, paths, pgid: int, marker: str) -> None:
+ try:
+ deadline = time.monotonic() + 5
+ while time.monotonic() < deadline and not self._group_alive(pgid):
+ time.sleep(0.02)
+ assert self._group_alive(pgid), "job group 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):
+ while time.monotonic() < deadline and self._job_running(marker):
time.sleep(0.05)
- assert not self._group_alive(pgid), "kill left part of the job
tree running"
+ assert not self._job_running(marker), "kill left the job running
(orphaned)"
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)
+ subprocess.run(["bash", "-c", f"pkill -9 -f '{marker}' 2>/dev/null
|| true"], check=False)
+
+ def test_kill_terminates_whole_job_tree(self, tmp_path):
+ """Default path (no job control): the job self-reports its PGID and
the kill
+ signals the whole group."""
+ marker = self._marker("1")
+ paths = RemoteJobPaths(job_id="killtree", remote_os="posix",
base_dir=str(tmp_path / "jobs"))
+ wrapper = build_posix_wrapper_command(marker, paths)
+ subprocess.run(["bash", "-c", wrapper], check=True,
capture_output=True, text=True)
+ pgid = self._await_recorded_pid(paths)
+ self._assert_kill_tears_down(paths, pgid, marker)
+
+ def test_kill_terminates_whole_job_tree_under_job_control(self, tmp_path):
+ """With job control on, setsid(1) forks and the launcher's ``$!``
would name the
+ short-lived setsid parent, not the job -- the condition the old
wrapper orphaned
+ the job under. Force it deterministically via a real controlling
terminal and
+ assert the recorded pid IS the job's true PGID and the kill reaches
the job."""
+ marker = self._marker("2")
+ paths = RemoteJobPaths(job_id="killtree_jc", remote_os="posix",
base_dir=str(tmp_path / "jobs"))
+ wrapper = build_posix_wrapper_command(marker, paths)
+ self._run_bash_mc_under_pty(wrapper + "\necho SUBMIT_DONE\n",
b"SUBMIT_DONE")
+ pgid = self._await_recorded_pid(paths)
+
+ job_pids = subprocess.run(
+ ["pgrep", "-f", marker], capture_output=True, text=True,
check=False
+ ).stdout.split()
+ assert job_pids, "job never started"
+ true_pgid = self._pgid_of(job_pids[0])
+ # Core regression assertion: recorded pid == the job's real PGID.
Under the old
+ # $!-based wrapper this differs (setsid forked) and on_kill orphans
the job.
+ assert str(pgid) == true_pgid, f"recorded pid {pgid} is not the job
PGID {true_pgid}"
+ self._assert_kill_tears_down(paths, pgid, marker)
class TestCleanupCommands: