Skip to content

Fix SSHRemoteJobOperator still orphaning the remote job on cancellation - #69490

Merged
kaxil merged 1 commit into
apache:mainfrom
astronomer:fix-ssh-remote-job-pgid-orphan
Jul 8, 2026
Merged

Fix SSHRemoteJobOperator still orphaning the remote job on cancellation#69490
kaxil merged 1 commit into
apache:mainfrom
astronomer:fix-ssh-remote-job-pgid-orphan

Conversation

@kaxil

@kaxil kaxil commented Jul 6, 2026

Copy link
Copy Markdown
Member

When an SSHRemoteJobOperator task is cancelled, on_kill signals the process group recorded in the job's pid file so the whole job tree is torn down. The detached job launches under setsid to lead its own process group, and the wrapper recorded $! as that group's leader.

$! is the group leader only when setsid(1) runs in place. If job control is on in the launching shell, setsid(1) forks (setsid(2) cannot create a new session from an existing process-group leader), so $! names the short-lived setsid parent instead of the job. Cancellation then signals a dead group, the real command keeps running as an orphan, and because the wrapper subshell is what writes the exit_code file, that file never appears and the task stays deferred until the trigger times out.

Fix

Record the job's own pid instead of the launcher's $!. 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 process-group id, whether or not setsid forked. The launcher no longer records $! at all, and build_posix_kill_command is unchanged.

The pid file is read only at cancellation time, in on_kill, which runs long after submission, so the job's own write is reliably in place before any reader and the launcher does not need to wait for it.

Relationship to #68644

#68644 added the setsid + process-group-kill behavior but assumed $! was always the group leader. That holds only when the launching shell is not already a process-group leader. Submission runs with get_pty=False, so the default path is unaffected. The orphaning surfaces on remotes where job control ends up on: a controlling terminal, a ForceCommand wrapper, or set -m in the remote user's shell init.

Regression test (follow-up to #69384)

The end-to-end kill test was marked @pytest.mark.flaky(reruns=5) in #69384 because it depended on incidental process-group timing. It now forces job control through a real controlling terminal (pty.fork() + bash -mc), exercising the exact fork path that orphaned the job, and asserts the recorded pid equals the job's true process-group id before checking teardown. The flaky marker is removed; test markers are unique per test and per worker so the suite stays isolated under pytest -n auto.

Gotcha

On hosts without setsid (some macOS/BSD images) the job is not a process-group leader, so $$ is just its own pid and cancellation degrades to the previous single-process kill, unchanged from before.

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`.
@kaxil
kaxil merged commit bc117ab into apache:main Jul 8, 2026
83 checks passed
@kaxil
kaxil deleted the fix-ssh-remote-job-pgid-orphan branch July 8, 2026 08:33
potiuk added a commit to potiuk/airflow that referenced this pull request Jul 27, 2026
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 - apache#69384 had them on the
sibling test until apache#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)
potiuk added a commit that referenced this pull request Jul 27, 2026
…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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants