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

potiuk 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 e807d5e9ba3 Make SSH remote-job kill test diagnosable and stop it 
reddening main (#70562)
e807d5e9ba3 is described below

commit e807d5e9ba34f8e809336d435cf37fba2c3e0fd3
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Jul 27 23:12:58 2026 +0200

    Make SSH remote-job kill test diagnosable and stop it reddening main 
(#70562)
    
    test_kill_terminates_whole_job_tree_under_job_control failed on main with 
"job
    never wrote its pid file". The run took 5.16s, so SUBMIT_DONE arrived 
promptly and
    the whole budget went on polling for a pid file that never appeared.
    
    That symptom has two very different causes and the test cannot tell them 
apart:
    _run_bash_mc_under_pty returns silently when the marker never arrives, so a
    launcher that died immediately (EOF, no marker, returns at once) and a job 
that
    died after being launched both surface later as the same empty pid file - 
and both
    produce the same ~5s runtime. Two changes separate them:
    
    - assert the marker was actually seen, quoting what the pty did produce
    - report the job directory contents when the pid file stays empty; the 
wrapper
      creates that directory and the log file before it backgrounds anything, 
so a
      missing directory means the launcher never got there and an empty one 
means the
      job was launched and died before its first statement
    
    The teardown also has a real ordering hazard, closed here: the marker only 
says the
    launcher returned, which it does the moment it backgrounds setsid - before 
that
    child has forked, called setsid(2) and exec'd into the job. Closing the pty 
master
    hangs up the terminal, and pty.fork() makes the launcher the session 
leader, so
    hanging up inside that window could take the job down with the session. The 
pty is
    now held open until the job proves it left the session by recording its own 
pid.
    
    I could not reproduce the failure. macOS has no setsid(1) so the class 
skips there;
    under Linux in Docker the real wrapper ran through this exact harness 65 
times, 25
    idle and 40 with the container throttled to 0.35 CPU against six busy 
loops, and
    recorded its pid every time with and without the hold. So the hold is 
hardening, not
    a demonstrated fix. Reruns come back for that reason - #69384 had them on 
the
    sibling test until #69490 dropped the marker while writing this one - and 
the first
    attempt's assertion text still reaches the CI log, so the next occurrence 
should
    identify which half broke.
    
    Generated-by: Claude Opus 5 (1M context)
---
 .../ssh/tests/unit/ssh/utils/test_remote_job.py    | 52 ++++++++++++++++++++--
 1 file changed, 48 insertions(+), 4 deletions(-)

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 82b285d4587..be93502057e 100644
--- a/providers/ssh/tests/unit/ssh/utils/test_remote_job.py
+++ b/providers/ssh/tests/unit/ssh/utils/test_remote_job.py
@@ -25,6 +25,7 @@ import select
 import shutil
 import subprocess
 import time
+from collections.abc import Callable
 from pathlib import Path
 
 import pytest
@@ -305,15 +306,39 @@ class TestPosixKillBehaviour:
             if pid_text:
                 break
             time.sleep(0.02)
-        assert pid_text, "job never wrote its pid file"
+        # Say which half failed. The wrapper creates the job dir and log file 
before it
+        # backgrounds anything, so their absence means the launcher never got 
that far,
+        # while an empty job dir means the job was launched and then died 
before its first
+        # statement. A bare "no pid file" cannot tell those apart.
+        job_dir = Path(paths.job_dir)
+        state = (
+            f"job dir holds {sorted(p.name for p in job_dir.iterdir())}"
+            if job_dir.exists()
+            else "job dir was never created (launcher did not run)"
+        )
+        assert pid_text, f"job never wrote its pid file; {state}"
         return int(pid_text)
 
     @staticmethod
-    def _run_bash_mc_under_pty(script: str, marker: bytes, timeout: float = 
8.0) -> None:
+    def _run_bash_mc_under_pty(
+        script: str,
+        marker: bytes,
+        detached: Callable[[], bool] | None = None,
+        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)."""
+        would not arrive until the job itself exits (the full sleep runtime).
+
+        The marker only says the launcher returned, which it does the moment 
it puts
+        ``setsid`` in the background - before that child has forked, called 
setsid(2) and
+        exec'd into the job. Closing the master hangs the pty up, and the 
launcher is the
+        session leader here (``pty.fork`` gives it the pty as controlling 
terminal), so a
+        hangup inside that window can take the job down with the session 
before it runs its
+        first statement. ``detached``, when given, is polled until the job has 
proven it
+        left the session (it records its own pid), and only then do we hang up.
+        """
         pid, fd = pty.fork()
         if pid == 0:
             try:
@@ -323,6 +348,7 @@ class TestPosixKillBehaviour:
         try:
             deadline = time.monotonic() + timeout
             buf = b""
+            seen = False
             while time.monotonic() < deadline:
                 r, _, _ = select.select([fd], [], [], 0.2)
                 if fd in r:
@@ -334,7 +360,13 @@ class TestPosixKillBehaviour:
                         break
                     buf += chunk
                     if marker in buf:
+                        seen = True
                         break
+            # Without this the launcher timing out is indistinguishable from 
the job dying:
+            # both surface later as an empty pid file.
+            assert seen, f"launcher never emitted {marker!r} within {timeout}s 
(got {buf!r})"
+            while detached is not None and time.monotonic() < deadline and not 
detached():
+                time.sleep(0.02)
         finally:
             os.close(fd)  # hangs up the pty; the launcher (not the detached 
job) exits
             with contextlib.suppress(ChildProcessError):
@@ -365,6 +397,11 @@ class TestPosixKillBehaviour:
         pgid = self._await_recorded_pid(paths)
         self._assert_kill_tears_down(paths, pgid, marker)
 
+    # Same environment-dependent process-group race that #69384 added reruns 
for on the
+    # sibling test; that marker was dropped in #69490 when this pty variant 
was written.
+    # The launch still depends on how the runner schedules the setsid fork, so 
keep main
+    # green on a fresh draw - the first attempt's assertion text stays in the 
CI log.
+    @pytest.mark.flaky(reruns=5)
     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
@@ -373,7 +410,14 @@ class TestPosixKillBehaviour:
         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")
+        pid_path = Path(paths.pid_file)
+        self._run_bash_mc_under_pty(
+            wrapper + "\necho SUBMIT_DONE\n",
+            b"SUBMIT_DONE",
+            # The job records its pid only after setsid(2) has put it in its 
own session, so
+            # a non-empty pid file is proof the pty hangup below can no longer 
reach it.
+            detached=lambda: pid_path.exists() and 
bool(pid_path.read_text().strip()),
+        )
         pgid = self._await_recorded_pid(paths)
 
         job_pids = subprocess.run(

Reply via email to