Skip to content

fix(bin): honor shutdown after remote job worker ownership loss - #1980

Open
tiago-peixoto wants to merge 3 commits into
kunchenguid:mainfrom
tiago-peixoto:fm/firstmate-remote-worker-lock-shutdown
Open

fix(bin): honor shutdown after remote job worker ownership loss#1980
tiago-peixoto wants to merge 3 commits into
kunchenguid:mainfrom
tiago-peixoto:fm/firstmate-remote-worker-lock-shutdown

Conversation

@tiago-peixoto

Copy link
Copy Markdown

Intent

Fix the pre-existing remote job worker ownership-loss shutdown defect as its own focused change and pull request, separate from unrelated PR 1716. The worker's stale-job sweep can recreate the state root, jobs, and logs without worker.lock; quarantine publication then has no ownership directory, and TERM must not be swallowed so the same unowned serving worker continues publishing ready heartbeats. Choose the safe ownership or shutdown behavior from the existing code rather than guessing, keep the product change small, and preserve existing active-execution cleanup. The regression must first fail against the unfixed product and then pass with the fix, and must deterministically construct the state by watching the atomic heartbeat inode replacement, immediately sending STOP, removing the state root while stopped, sending CONT so the stale-job sweep recreates lock-free state, then sending TERM and observing the serving process. Do not replace that synchronization with timing luck or weaken the regression. Publish this branch with one upstream push and one pull request body publication because each extra push or body edit spends another approval round-trip.

What Changed

  • worker_shutdown no longer swallows the signal when worker_publish_quarantine fails. Previously it re-armed the HUP INT TERM trap and returned 0, so a worker whose ownership directory was gone resumed serving and kept publishing ready heartbeats; it now exits 125, and because the EXIT cleanup trap is registered before the shutdown trap, active-execution cleanup still stops the command tree first.
  • Rewrote the teardown case in tests/fm-remote-job-orphan-reap.test.sh to construct the unowned state deterministically instead of by timing: a hard link pins the published worker.ready inode, a fork-free builtin-only loop watches for the atomic replacement and STOPs the serving child in that window, the state root is removed while it is stopped, CONT lets the stale-job sweep recreate jobs/logs/worker.ready without worker.lock, and TERM must then kill the child rather than let it advance the heartbeat. If a loaded host deschedules the watcher past the window, the child dies on its next heartbeat and the whole construction retries (bounded at 8) against the next serving child the supervisor spawns.
  • Added test helpers behind that: serving_child/next_serving_child match on fm-remote-job-worker.sh --serve so a supervisor's restart-backoff sleep or a transient state-preparation subshell is never mistaken for the serving child, and inode_of resolves the platform-specific stat selector once at startup instead of forking uname on every poll. The file header now documents this second leak alongside the pruned-code-root one.

Risk Assessment

✅ Low: The round-2 commit is test-only and well-bounded: it replaces the racy fork-based watcher with a fork-free hard-link/-ef loop plus a retry that can only trigger on a proven miss, preserving every assertion and the fail-against-unfixed-product property, while the reviewed three-line product fix is unchanged.

Testing

I confirmed the regression is a real first-fail/then-pass one by reverting only the product hunk to base 833a9a2 - the test then fails deterministically with the serving child surviving TERM and advancing its heartbeat inode - and restoring the fix, after which it passes 5/5 and also 3/3 under 2x CPU oversubscription, so the STOP-in-the-heartbeat-window construction and its retry are not relying on timing luck. Because a passing assertion does not show what an operator sees, I drove the real worker through the same construction and captured ps and heartbeat state on both sides: before the fix the TERMed pid stays in ps with no worker.lock and its heartbeat inode keeps advancing, after the fix that pid is gone and the supervisor's replacement holds genuine ownership with worker.lock/pid and worker.ready naming the new pid. I separately exercised the changed exit-125 path with a real in-flight job to check the intent's preserve-active-execution-cleanup constraint: on the fix the recorded command group is killed and its side effect never lands, while on the base the worker ignores TERM and the job runs to completion and mutates. tests/fm-remote-job.test.sh passes in full, covering the unchanged shutdown and quarantine paths. No product failures; the only note is pre-existing gpg-signing noise in the fixture's git commit that does not affect any assertion. Transient scratch roots and worker processes were cleaned up and the worktree is clean.

Evidence: Operator transcript - BEFORE the fix: unowned worker swallows TERM and keeps publishing ready heartbeats

--- the worker's own state root, recreated by its stale-job sweep ------- $ ls -la .../remote-jobs drwx------ jobs drwx------ logs -rw------- worker.ready worker.lock (the ownership directory) is absent: ls: .../remote-jobs/worker.lock: No such file or directory --- the serving process before TERM ------------------------------------ $ ps -o pid,ppid,state,command -p 12346 PID PPID STAT COMMAND 12346 12299 S /bin/bash .../bin/fm-remote-job-worker.sh --serve published heartbeat: inode 194257032 contents: 12346 --- operator sends TERM to the unowned serving worker ------------------- $ kill -TERM 12346 --- six seconds later -------------------------------------------------- RESULT: the process is STILL RUNNING and still owns no lock. PID PPID STAT COMMAND 12346 12299 S /bin/bash .../bin/fm-remote-job-worker.sh --serve heartbeat inode 194257032 -> 194257110 it is STILL PUBLISHING ready heartbeats: the fleet believes an unowned worker is healthy and will hand it jobs. VERDICT: DEFECT REPRODUCED - TERM was swallowed.

==============================================================
worker under test: BEFORE the fix (base commit 833a9a2)
==============================================================

--- the worker's own state root, recreated by its stale-job sweep -------
$ ls -la /private/tmp/repro-worker2.enyDG9/before/remote-jobs
    total 8
    drwx------@ 5 tiago  wheel  160 Aug  8 21:28 .
    drwxr-xr-x@ 6 tiago  wheel  192 Aug  8 21:28 ..
    drwx------@ 2 tiago  wheel   64 Aug  8 21:28 jobs
    drwx------@ 2 tiago  wheel   64 Aug  8 21:28 logs
    -rw-------@ 1 tiago  wheel    6 Aug  8 21:28 worker.ready

    worker.lock (the ownership directory) is absent:
    $ ls -d /private/tmp/repro-worker2.enyDG9/before/remote-jobs/worker.lock
    ls: /private/tmp/repro-worker2.enyDG9/before/remote-jobs/worker.lock: No such file or directory

--- the serving process before TERM ------------------------------------
$ ps -o pid,ppid,state,command -p 12346
      PID  PPID STAT COMMAND
    12346 12299 S    /bin/bash /private/tmp/repro-worker2.enyDG9/before/remote-root/bin/fm-remote-job-worker.sh --serve

    published heartbeat: inode 194257032  contents: 12346

--- operator sends TERM to the unowned serving worker -------------------
$ kill -TERM 12346

--- six seconds later --------------------------------------------------
    RESULT: the process is STILL RUNNING and still owns no lock.
$ ps -o pid,ppid,state,command -p 12346
      PID  PPID STAT COMMAND
    12346 12299 S    /bin/bash /private/tmp/repro-worker2.enyDG9/before/remote-root/bin/fm-remote-job-worker.sh --serve

    heartbeat inode 194257032 -> 194257110
    it is STILL PUBLISHING ready heartbeats: the fleet believes an
    unowned worker is healthy and will hand it jobs.

    VERDICT: DEFECT REPRODUCED - TERM was swallowed.
Evidence: Operator transcript - AFTER the fix: unowned worker honors TERM, replacement reacquires real ownership

--- the serving process before TERM ------------------------------------ $ ps -o pid,ppid,state,command -p 63757 PID PPID STAT COMMAND 63757 63324 R /bin/bash .../bin/fm-remote-job-worker.sh --serve published heartbeat: inode 194234270 contents: 63757 --- operator sends TERM to the unowned serving worker ------------------- $ kill -TERM 63757 --- six seconds later -------------------------------------------------- RESULT: the process is GONE. $ ps -o pid,ppid,state,command -p 63757 PID PPID STAT COMMAND VERDICT: FIXED - the unowned worker honored TERM and stopped serving. --- what the supervisor put in its place -------------------------------- $ ps -o pid,ppid,state,command -p 66573 PID PPID STAT COMMAND 66573 63324 S /bin/bash .../bin/fm-remote-job-worker.sh --serve $ ls -la .../remote-jobs drwx------ jobs drwx------ logs -rw------- worker.identity drwx------ worker.lock -rw------- worker.pid -rw------- worker.ready ownership is genuine again - worker.lock/pid says 66573 heartbeat says 66573, and the TERMed pid 63757 is not either of them.

==============================================================
worker under test: AFTER the fix (e9f378b)
==============================================================

--- the worker's own state root, recreated by its stale-job sweep -------
$ ls -la /private/tmp/repro-worker2.enyDG9/after/remote-jobs
    total 8
    drwx------@ 5 tiago  wheel  160 Aug  8 21:26 .
    drwxr-xr-x@ 6 tiago  wheel  192 Aug  8 21:26 ..
    drwx------@ 2 tiago  wheel   64 Aug  8 21:26 jobs
    drwx------@ 2 tiago  wheel   64 Aug  8 21:26 logs
    -rw-------@ 1 tiago  wheel    6 Aug  8 21:26 worker.ready

    worker.lock (the ownership directory) is absent:
    $ ls -d /private/tmp/repro-worker2.enyDG9/after/remote-jobs/worker.lock
    ls: /private/tmp/repro-worker2.enyDG9/after/remote-jobs/worker.lock: No such file or directory

--- the serving process before TERM ------------------------------------
$ ps -o pid,ppid,state,command -p 63757
      PID  PPID STAT COMMAND
    63757 63324 R    /bin/bash /private/tmp/repro-worker2.enyDG9/after/remote-root/bin/fm-remote-job-worker.sh --serve

    published heartbeat: inode 194234270  contents: 63757

--- operator sends TERM to the unowned serving worker -------------------
$ kill -TERM 63757

--- six seconds later --------------------------------------------------
    RESULT: the process is GONE.
$ ps -o pid,ppid,state,command -p 63757
      PID  PPID STAT COMMAND

    VERDICT: FIXED - the unowned worker honored TERM and stopped serving.

--- what the supervisor put in its place --------------------------------
$ ps -o pid,ppid,state,command -p 66573
      PID  PPID STAT COMMAND
    66573 63324 S    /bin/bash /private/tmp/repro-worker2.enyDG9/after/remote-root/bin/fm-remote-job-worker.sh --serve

$ ls -la /private/tmp/repro-worker2.enyDG9/after/remote-jobs
    total 24
    drwx------@ 8 tiago  wheel  256 Aug  8 21:27 .
    drwxr-xr-x@ 6 tiago  wheel  192 Aug  8 21:26 ..
    drwx------@ 2 tiago  wheel   64 Aug  8 21:26 jobs
    drwx------@ 2 tiago  wheel   64 Aug  8 21:26 logs
    -rw-------@ 1 tiago  wheel  123 Aug  8 21:27 worker.identity
    drwx------@ 5 tiago  wheel  160 Aug  8 21:27 worker.lock
    -rw-------@ 1 tiago  wheel    6 Aug  8 21:27 worker.pid
    -rw-------@ 1 tiago  wheel    6 Aug  8 21:27 worker.ready

    ownership is genuine again - worker.lock/pid says 66573
    heartbeat says 66573, and the TERMed pid 63757 is not either of them.
Evidence: Active-execution cleanup on the changed exit-125 path - AFTER the fix (mid-flight job stopped, no side effect)

--- a real job is mid-flight on an owned worker ------------------------- PID PPID PGID STAT COMMAND 2963 2647 2647 S .../fm-remote-job-worker.sh --serve 4291 2963 4291 S .../fm-shutdown-job.sh .../job-started .../job-side-effect job job-pjUJVn state: running its command tree is recorded at jobs/job-pjUJVn/.claim/group = 4291 --- remove the ownership directory, so shutdown cannot quarantine ------- $ rm -rf .../remote-jobs/worker.lock --- TERM the serving worker -------------------------------------------- $ kill -TERM 2963 the serving worker honored TERM and exited. --- did exit cleanup still stop the active command tree? ---------------- $ ps -o pid,ppid,pgid,state,command -p 4291 PID PPID PGID STAT COMMAND the recorded command tree and the job command itself are both gone. waiting past the job's own sleep to prove it never completed its work... $ ls .../job-side-effect ls: .../job-side-effect: No such file or directory VERDICT: active execution cleanup PRESERVED - the mid-flight command tree was stopped and never mutated anything after shutdown.

==============================================================
worker under test: AFTER the fix (e9f378b)
==============================================================

--- a real job is mid-flight on an owned worker -------------------------
$ ps -o pid,ppid,pgid,state,command -p 2963 -p 4291 -p 4291
      PID  PPID  PGID STAT COMMAND
     2963  2647  2647 S    /bin/bash /private/tmp/repro-active2.aucAKp/after/remote-root/bin/fm-remote-job-worker.sh --serve
     4291  2963  4291 S    /bin/bash /private/tmp/repro-active2.aucAKp/after/remote-root/bin/fm-shutdown-job.sh /private/tmp/repro-active2.aucAKp/after/job-started /private/tmp/repro-active2.aucAKp/after/job-side-effect

    job job-pjUJVn state: running
    its command tree is recorded at jobs/job-pjUJVn/.claim/group = 4291

--- remove the ownership directory, so shutdown cannot quarantine -------
$ rm -rf /private/tmp/repro-active2.aucAKp/after/remote-jobs/worker.lock
    total 24
    drwx------@ 7 tiago  wheel  224 Aug  8 21:32 .
    drwxr-xr-x@ 7 tiago  wheel  224 Aug  8 21:32 ..
    drwx------@ 3 tiago  wheel   96 Aug  8 21:32 jobs
    drwx------@ 3 tiago  wheel   96 Aug  8 21:32 logs
    -rw-------@ 1 tiago  wheel  123 Aug  8 21:32 worker.identity
    -rw-------@ 1 tiago  wheel    5 Aug  8 21:32 worker.pid
    -rw-------@ 1 tiago  wheel    5 Aug  8 21:32 worker.ready

--- TERM the serving worker --------------------------------------------
$ kill -TERM 2963

    the serving worker honored TERM and exited.

--- did exit cleanup still stop the active command tree? ----------------
$ ps -o pid,ppid,pgid,state,command -p 4291 -p 4291
      PID  PPID  PGID STAT COMMAND


    the recorded command tree and the job command itself are both gone.
    waiting past the job's own sleep to prove it never completed its work...
$ ls /private/tmp/repro-active2.aucAKp/after/job-side-effect
    ls: /private/tmp/repro-active2.aucAKp/after/job-side-effect: No such file or directory

    VERDICT: active execution cleanup PRESERVED - the mid-flight command
    tree was stopped and never mutated anything after shutdown.
Evidence: Active-execution cleanup - BEFORE the fix (worker ignores TERM, job runs on and mutates)

--- TERM the serving worker -------------------------------------------- $ kill -TERM 18787 the serving worker IGNORED TERM and is still running: PID PPID STAT COMMAND 18787 18701 S /bin/bash .../fm-remote-job-worker.sh --serve --- did exit cleanup still stop the active command tree? ---------------- VERDICT: the job still produced its side effect at .../job-side-effect - cleanup failed.

==============================================================
worker under test: BEFORE the fix (base commit 833a9a2)
==============================================================

--- a real job is mid-flight on an owned worker -------------------------
$ ps -o pid,ppid,pgid,state,command -p 18787 -p 19303 -p 19303
      PID  PPID  PGID STAT COMMAND
    18787 18701 18701 S    /bin/bash /private/tmp/repro-active2.aucAKp/before/remote-root/bin/fm-remote-job-worker.sh --serve
    19303 18787 19303 S    /bin/bash /private/tmp/repro-active2.aucAKp/before/remote-root/bin/fm-shutdown-job.sh /private/tmp/repro-active2.aucAKp/before/job-started /private/tmp/repro-active2.aucAKp/before/job-side-effect

    job job-M7DAzC state: running
    its command tree is recorded at jobs/job-M7DAzC/.claim/group = 19303

--- remove the ownership directory, so shutdown cannot quarantine -------
$ rm -rf /private/tmp/repro-active2.aucAKp/before/remote-jobs/worker.lock
    total 24
    drwx------@ 7 tiago  wheel  224 Aug  8 21:33 .
    drwxr-xr-x@ 7 tiago  wheel  224 Aug  8 21:33 ..
    drwx------@ 3 tiago  wheel   96 Aug  8 21:33 jobs
    drwx------@ 3 tiago  wheel   96 Aug  8 21:33 logs
    -rw-------@ 1 tiago  wheel  123 Aug  8 21:33 worker.identity
    -rw-------@ 1 tiago  wheel    6 Aug  8 21:33 worker.pid
    -rw-------@ 1 tiago  wheel    6 Aug  8 21:33 worker.ready

--- TERM the serving worker --------------------------------------------
$ kill -TERM 18787

    the serving worker IGNORED TERM and is still running:
      PID  PPID STAT COMMAND
    18787 18701 S    /bin/bash /private/tmp/repro-active2.aucAKp/before/remote-root/bin/fm-remote-job-worker.sh --serve

--- did exit cleanup still stop the active command tree? ----------------
$ ps -o pid,ppid,pgid,state,command -p 19303 -p 19303
      PID  PPID  PGID STAT COMMAND


    the recorded command tree and the job command itself are both gone.
    waiting past the job's own sleep to prove it never completed its work...
    VERDICT: the job still produced its side effect at /private/tmp/repro-active2.aucAKp/before/job-side-effect - cleanup failed.
Evidence: First-fail proof: regression against the unfixed worker (product hunk reverted to base 833a9a2)
$ bash tests/fm-remote-job-orphan-reap.test.sh; echo "exit=$?"
exit=1

ok - the Linux start path puts the whole worker tree in its own process group
not ok - the lock-free serving child survived TERM and advanced heartbeat 194129551->194133103

(4 runs, identical failure each time; with the fix restored, 5/5 runs exit 0 and report
"ok - a serving child honors TERM after its state root is recreated without the ownership lock")
Evidence: Repro harness: unowned-worker TERM construction (heartbeat-inode watch -> STOP -> state removal -> CONT -> TERM)
#!/usr/bin/env bash
# Manual end-to-end reproduction of the remote job worker ownership-loss
# shutdown defect, run against one worker binary and printed as an operator
# would observe it: `ps` for the serving process and the published heartbeat
# it keeps advancing.
#
# usage: repro-unowned-worker-term.sh <repo-root> <worker-script> <label> <scratch-dir>
set -u

ROOT=$1
WORKER_SRC=$2
LABEL=$3
# The worker refuses a non-physical account home, and /tmp is a symlink on
# macOS, so resolve the scratch root the way the real test fixture does.
mkdir -p "$4"
SCRATCH=$(cd "$4" && pwd -P)

STATE="$SCRATCH/remote-jobs"
ACCOUNT="$SCRATCH/account"
CODE="$SCRATCH/remote-root"
READY="$STATE/worker.ready"
WITNESS="$SCRATCH/ready.witness"

case "$(uname -s)" in Darwin) STAT_INODE=(-f %i) ;; *) STAT_INODE=(-c %i) ;; esac
inode_of() { stat "${STAT_INODE[@]}" "$1" 2>/dev/null; }
alive() { kill -0 "$1" 2>/dev/null; }
stopped() { case "$(ps -p "$1" -o state= 2>/dev/null | tr -d '[:space:]')" in T*) return 0 ;; esac; return 1; }
serving_child() { pgrep -P "$1" -f 'fm-remote-job-worker\.sh --serve' 2>/dev/null | head -n 1; }
say() { printf '%s\n' "$*"; }

cleanup() {
  [ -n "${SUPERVISOR:-}" ] && { kill -KILL -- "-$SUPERVISOR" 2>/dev/null; kill -KILL "$SUPERVISOR" 2>/dev/null; }
  return 0
}
trap cleanup EXIT

mkdir -p "$ACCOUNT" "$CODE/bin"
cp "$ROOT/bin/fm-remote-job-lib.sh" "$CODE/bin/"
cp "$WORKER_SRC" "$CODE/bin/fm-remote-job-worker.sh"
chmod +x "$CODE/bin"/*.sh
printf 'fixture\n' > "$CODE/AGENTS.md"
git -C "$CODE" init -q -b main
git -C "$CODE" config user.email test@example.com
git -C "$CODE" config user.name Test
git -C "$CODE" config commit.gpgsign false
git -C "$CODE" add AGENTS.md bin
git -C "$CODE" commit -qm 'remote job fixture'

SUPERVISOR=$(
  export FM_REMOTE_JOB_STATE_ROOT="$STATE"
  export FM_REMOTE_JOB_PLATFORM_OVERRIDE=Linux
  export FM_REMOTE_JOB_ORPHAN_GRACE_SECONDS=1
  . "$ROOT/bin/fm-remote-job-lib.sh"
  fm_remote_job_start_linux_worker "$CODE" "$ACCOUNT" >/dev/null 2>&1 || exit 1
  pgrep -f "^/bin/bash $CODE/bin/fm-remote-job-worker.sh\$" | head -n 1
) || { say "FATAL: could not start the fixture worker"; exit 1; }

say "=============================================================="
say "worker under test: $LABEL"
say "=============================================================="
say ""

# Build the unowned-but-serving state: STOP the serving child the instant its
# atomic heartbeat replaces the published inode, remove the state root while it
# cannot run, CONT so its stale-job sweep recreates jobs/ logs/ and the
# heartbeat without ever recreating worker.lock.
attempt=0
while :; do
  attempt=$((attempt + 1))
  [ "$attempt" -le 8 ] || { say "FATAL: STOP never landed in the heartbeat window"; exit 1; }
  SERVE=$(serving_child "$SUPERVISOR")
  [ -n "$SERVE" ] || { sleep 0.2; attempt=$((attempt - 1)); continue; }

  deadline=$((SECONDS + 30))
  while [ ! -f "$READY" ]; do
    [ "$SECONDS" -lt "$deadline" ] || { say "FATAL: no initial heartbeat"; exit 1; }
    sleep 0.01
  done
  rm -f -- "$WITNESS"; ln "$READY" "$WITNESS" || exit 1
  while [ "$READY" -ef "$WITNESS" ]; do
    alive "$SERVE" || break
    [ "$SECONDS" -lt "$deadline" ] || { say "FATAL: heartbeat never replaced"; exit 1; }
  done
  kill -STOP "$SERVE" 2>/dev/null || continue
  until stopped "$SERVE"; do
    alive "$SERVE" || break
    [ "$SECONDS" -lt "$deadline" ] || break
    sleep 0.01
  done
  alive "$SERVE" || { kill -CONT "$SERVE" 2>/dev/null; sleep 0.5; continue; }

  rm -rf "$STATE"
  kill -CONT "$SERVE" 2>/dev/null || continue

  deadline=$((SECONDS + 30))
  recreated=0
  while [ "$SECONDS" -lt "$deadline" ]; do
    if [ -d "$STATE/jobs" ] && [ -d "$STATE/logs" ] && [ ! -e "$STATE/worker.lock" ] && [ -f "$READY" ]; then
      recreated=1; break
    fi
    alive "$SERVE" || break
    sleep 0.01
  done
  [ "$recreated" -eq 1 ] && break
  say "(retry $attempt: the STOP landed after the sweep had already recreated state)"
  sleep 0.5
done

say "--- the worker's own state root, recreated by its stale-job sweep -------"
say "\$ ls -la $STATE"
ls -la "$STATE" | sed 's/^/    /'
say ""
say "    worker.lock (the ownership directory) is absent:"
say "    \$ ls -d $STATE/worker.lock"
ls -d "$STATE/worker.lock" 2>&1 | sed 's/^/    /'
say ""
say "--- the serving process before TERM ------------------------------------"
say "\$ ps -o pid,ppid,state,command -p $SERVE"
ps -o pid,ppid,state,command -p "$SERVE" | sed 's/^/    /'
say ""
say "    published heartbeat: inode $(inode_of "$READY")  contents: $(tr -d '\n' < "$READY" 2>/dev/null)"
say ""
say "--- operator sends TERM to the unowned serving worker -------------------"
say "\$ kill -TERM $SERVE"
kill -TERM "$SERVE"
before_inode=$(inode_of "$READY")

deadline=$(( $(date +%s) + 6 ))
while [ "$(date +%s)" -lt "$deadline" ]; do
  alive "$SERVE" || break
  sleep 0.1
done
say ""
say "--- six seconds later --------------------------------------------------"
if alive "$SERVE"; then
  say "    RESULT: the process is STILL RUNNING and still owns no lock."
  say "\$ ps -o pid,ppid,state,command -p $SERVE"
  ps -o pid,ppid,state,command -p "$SERVE" | sed 's/^/    /'
  sleep 2
  after_inode=$(inode_of "$READY")
  say ""
  say "    heartbeat inode $before_inode -> $after_inode"
  if [ "$before_inode" != "$after_inode" ]; then
    say "    it is STILL PUBLISHING ready heartbeats: the fleet believes an"
    say "    unowned worker is healthy and will hand it jobs."
  fi
  say ""
  say "    VERDICT: DEFECT REPRODUCED - TERM was swallowed."
else
  say "    RESULT: the process is GONE."
  say "\$ ps -o pid,ppid,state,command -p $SERVE"
  ps -o pid,ppid,state,command -p "$SERVE" 2>&1 | sed 's/^/    /'
  say ""
  say "    VERDICT: FIXED - the unowned worker honored TERM and stopped serving."
  say ""
  say "--- what the supervisor put in its place --------------------------------"
  # The supervisor legitimately restarts a worker, and that replacement goes
  # through the normal startup path, so it holds real ownership again. Any
  # heartbeat published from here on belongs to that owned worker, not to the
  # unowned one TERM just stopped.
  deadline=$(( $(date +%s) + 15 ))
  while [ "$(date +%s)" -lt "$deadline" ]; do
    [ -d "$STATE/worker.lock" ] && [ -n "$(serving_child "$SUPERVISOR")" ] && break
    sleep 0.1
  done
  REPLACEMENT=$(serving_child "$SUPERVISOR")
  say "\$ ps -o pid,ppid,state,command -p ${REPLACEMENT:-0}"
  ps -o pid,ppid,state,command -p "${REPLACEMENT:-0}" 2>&1 | sed 's/^/    /'
  say ""
  say "\$ ls -la $STATE"
  ls -la "$STATE" | sed 's/^/    /'
  say ""
  say "    ownership is genuine again - worker.lock/pid says $(tr -d '\n' < "$STATE/worker.lock/pid" 2>/dev/null)"
  say "    heartbeat says $(tr -d '\n' < "$READY" 2>/dev/null), and the TERMed pid $SERVE is not either of them."
fi
say ""
Evidence: Repro harness: active-execution cleanup on the exit-125 shutdown path
#!/usr/bin/env bash
# The intent requires the new exit-125 shutdown path to preserve active
# execution cleanup. This drives the changed path directly - the ownership
# directory is removed so worker_publish_quarantine fails - while a real job is
# mid-flight, and shows whether the job's command tree is still stopped.
#
# usage: repro-active-execution-cleanup.sh <repo-root> <worker-script> <label> <scratch-dir>
set -u

ROOT=$1
WORKER_SRC=$2
LABEL=$3
mkdir -p "$4"
SCRATCH=$(cd "$4" && pwd -P)

STATE="$SCRATCH/remote-jobs"
ACCOUNT="$SCRATCH/account"
HOME_DIR="$SCRATCH/remote-home"
CODE="$SCRATCH/remote-root"
STARTED="$SCRATCH/job-started"
SIDE_EFFECT="$SCRATCH/job-side-effect"

alive() { kill -0 "$1" 2>/dev/null; }
say() { printf '%s\n' "$*"; }

cleanup() {
  [ -n "${SUPERVISOR:-}" ] && { kill -KILL -- "-$SUPERVISOR" 2>/dev/null; kill -KILL "$SUPERVISOR" 2>/dev/null; }
  [ -n "${JOB_TREE:-}" ] && { kill -KILL -- "-$JOB_TREE" 2>/dev/null; kill -KILL "$JOB_TREE" 2>/dev/null; }
  return 0
}
trap cleanup EXIT

mkdir -p "$ACCOUNT" "$HOME_DIR" "$CODE/bin"
cp "$ROOT/bin/fm-remote-job-lib.sh" "$CODE/bin/"
cp "$WORKER_SRC" "$CODE/bin/fm-remote-job-worker.sh"
# The same shape the suite's shutdown fixture uses: it ignores TERM itself, so
# only a real stop of the command tree can prevent the later side effect.
cat > "$CODE/bin/fm-shutdown-job.sh" <<'SH'
#!/bin/bash
trap '' HUP INT TERM
printf 'started\n' > "$1"
sleep 5
printf 'ran\n' > "$2"
SH
chmod +x "$CODE/bin"/*.sh
printf 'fixture\n' > "$CODE/AGENTS.md"
git -C "$CODE" init -q -b main
git -C "$CODE" config user.email test@example.com
git -C "$CODE" config user.name Test
git -C "$CODE" config commit.gpgsign false
git -C "$CODE" add AGENTS.md bin
git -C "$CODE" commit -qm 'remote job fixture'

export FM_REMOTE_JOB_STATE_ROOT="$STATE"
export FM_REMOTE_JOB_PLATFORM_OVERRIDE=Linux
export FM_REMOTE_JOB_TIMEOUT=120
export FM_REMOTE_JOB_QUEUE_TIMEOUT=60
# shellcheck source=bin/fm-remote-job-lib.sh
. "$ROOT/bin/fm-remote-job-lib.sh"

say "=============================================================="
say "worker under test: $LABEL"
say "=============================================================="
say ""

fm_remote_job_start_linux_worker "$CODE" "$ACCOUNT" >/dev/null 2>&1 ||
  { say "FATAL: could not start the fixture worker: ${FM_REMOTE_JOB_ERROR:-}"; exit 1; }
SUPERVISOR=$(pgrep -f "^/bin/bash $CODE/bin/fm-remote-job-worker.sh\$" | head -n 1)
deadline=$(( $(date +%s) + 20 ))
while [ ! -f "$STATE/worker.ready" ]; do
  [ "$(date +%s)" -lt "$deadline" ] || { say "FATAL: worker never became ready"; exit 1; }
  sleep 0.05
done

fm_remote_job_stage "$ACCOUNT" "$CODE" "$HOME_DIR" fm-shutdown-job.sh "$STARTED" "$SIDE_EFFECT" \
  < /dev/null > /dev/null || { say "FATAL: could not stage the job"; exit 1; }
JOB_ID=$FM_REMOTE_JOB_ID
deadline=$(( $(date +%s) + 30 ))
while [ ! -f "$STARTED" ]; do
  [ "$(date +%s)" -lt "$deadline" ] || { say "FATAL: the job never began executing"; exit 1; }
  sleep 0.05
done

SERVE=$(tr -d '\n' < "$STATE/worker.pid")
# The worker records the command tree as a process group in .claim/group.
JOB_TREE=$(tr -d '\n' < "$STATE/jobs/$JOB_ID/.claim/group" 2>/dev/null)
JOB_CMD=$(pgrep -f "fm-shutdown-job\.sh $STARTED" | head -n 1)

say "--- a real job is mid-flight on an owned worker -------------------------"
say "\$ ps -o pid,ppid,pgid,state,command -p $SERVE -p $JOB_TREE -p $JOB_CMD"
ps -o pid,ppid,pgid,state,command -p "$SERVE" -p "$JOB_TREE" -p "$JOB_CMD" | sed 's/^/    /'
say ""
say "    job $JOB_ID state: $(cat "$STATE/jobs/$JOB_ID/state" 2>/dev/null)"
say "    its command tree is recorded at jobs/$JOB_ID/.claim/group = $JOB_TREE"
say ""

say "--- remove the ownership directory, so shutdown cannot quarantine -------"
say "\$ rm -rf $STATE/worker.lock"
rm -rf "$STATE/worker.lock"
ls -la "$STATE" | sed 's/^/    /'
say ""

say "--- TERM the serving worker --------------------------------------------"
say "\$ kill -TERM $SERVE"
kill -TERM "$SERVE"
deadline=$(( $(date +%s) + 10 ))
while [ "$(date +%s)" -lt "$deadline" ]; do
  alive "$SERVE" || break
  sleep 0.1
done
say ""
if alive "$SERVE"; then
  say "    the serving worker IGNORED TERM and is still running:"
  ps -o pid,ppid,state,command -p "$SERVE" | sed 's/^/    /'
else
  say "    the serving worker honored TERM and exited."
fi
say ""

say "--- did exit cleanup still stop the active command tree? ----------------"
say "\$ ps -o pid,ppid,pgid,state,command -p $JOB_TREE -p $JOB_CMD"
ps -o pid,ppid,pgid,state,command -p "$JOB_TREE" -p "$JOB_CMD" 2>&1 | sed 's/^/    /'
if alive "$JOB_TREE" || alive "$JOB_CMD" || kill -0 -- "-$JOB_TREE" 2>/dev/null; then
  say ""
  say "    VERDICT: the job's command tree SURVIVED - active execution cleanup was lost."
else
  say ""
  say ""
  say "    the recorded command tree and the job command itself are both gone."
  say "    waiting past the job's own sleep to prove it never completed its work..."
  sleep 10
  if [ -e "$SIDE_EFFECT" ]; then
    say "    VERDICT: the job still produced its side effect at $SIDE_EFFECT - cleanup failed."
  else
    say "\$ ls $SIDE_EFFECT"
    ls "$SIDE_EFFECT" 2>&1 | sed 's/^/    /'
    say ""
    say "    VERDICT: active execution cleanup PRESERVED - the mid-flight command"
    say "    tree was stopped and never mutated anything after shutdown."
  fi
fi
say ""
- Outcome: ⚠️ 1 info across 1 run (12m50s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • 🚨 tests/fm-remote-job-orphan-reap.test.sh:137 - The STOP has to land in the ~7.4ms between the heartbeat mv and prepare_state re-creating the state root, but the detection loop's granularity is ~14ms, so the regression fails roughly half the time. main() writes the heartbeat and then immediately runs fm_remote_job_reap_stale -> fm_remote_job_prepare_state; I measured that prefix (validate_settings, canonical_existing_dir on the account home, normalize/dirname/basename, canonical_existing_dir on the parent) at ~7.4ms, and one iteration of this loop (inode_of subshell + uname fork + stat fork + sleep 0.001) at ~14ms. A synthetic replay of the same cadence detected the inode change within 7.4ms in only 54% of heartbeats, with p90 at 294ms. When the STOP lands late, rm -rf + CONT leaves prepare_state to chmod/mkdir a removed root, it returns 1, and the next worker_write_heartbeat mktemp fails, so main() exits 1 and line 163 fails with "the serving child exited before recreating the lock-free state". Fix additively rather than by weakening the sync: wrap the whole construct-and-TERM sequence in a retry that, when the serving child dies before recreating the lock-free state, waits for the supervisor to respawn a new child (pgrep -P "$WORKER") and tries again; and hoist the uname -s check out of inode_of (compute the stat flag once at line 44) so each poll costs one fork instead of three.
  • ℹ️ bin/fm-remote-job-worker.sh:300 - The fix makes TERM authoritative for a worker that lost its ownership directory, which is the defect the intent scopes. The adjacent exposure remains and is reachable: an unowned worker that is never signalled keeps publishing worker.ready and processing jobs from a state root it no longer owns, and because worker.lock is gone a second worker can mkdir it and serve the same queue concurrently. The serving loop never re-validates that it still holds the lock. Noting as the natural follow-up; closing it would need a per-iteration ownership check in main(), which is outside the intent's explicit "keep the product change small".
  • ℹ️ bin/fm-remote-job-worker.sh:300 - Exiting 125 rather than 0 makes worker_supervise_linux treat this shutdown as a child failure: it increments failures, applies backoff, and counts toward FM_REMOTE_JOB_SUPERVISOR_MAX_RESTARTS. That is the right semantic here (the respawned child re-acquires ownership cleanly, and the test asserts the respawn), and a child that stayed up FM_REMOTE_JOB_SUPERVISOR_HEALTHY_SECONDS resets the counter, so it is self-limiting. Recording the tradeoff, not asking for a change.

🔧 Fix: make heartbeat-window watcher fork-free and retryable
2 infos still open:

  • ℹ️ tests/fm-remote-job-orphan-reap.test.sh:190 - The inode watcher spins with no sleep and no yield, which is the point (it is what shrinks detection latency to syscall speed) but is worth recording as a tradeoff: in the normal case it burns one core for the ~65ms between heartbeats, and on the pathological path where the heartbeat stops advancing it burns one core for the full 30s deadline before failing. Under a cgroup CPU quota the spin can get throttled, which makes a miss more likely - but that is exactly what the bounded retry at line 222 covers, so the outcome is a slower test rather than a wrong one. No action; noting it so the cost is a known property rather than a surprise.
  • ℹ️ tests/fm-remote-job-orphan-reap.test.sh:225 - The retry-exhaustion message says "the STOP never landed between the heartbeat and the stale-job sweep in N attempts", but the only condition that consumes an attempt is line 208 - the serving child died before recreating the lock-free state. Eight consecutive deaths are far more likely to mean the product started dying on a lock-free sweep than that scheduling missed the window eight times in a row, so the message points a maintainer at a scheduling ghost. Appending the observed cause ("the serving child died before recreating the lock-free state on every attempt") keeps the diagnosis honest without touching the contract.
⚠️ **Test** - 1 info
  • ℹ️ tests/fm-remote-job-orphan-reap.test.sh:110 - tests/fm-remote-job-orphan-reap.test.sh (and tests/fm-remote-job.test.sh) build their fixture code root with git commit but never set commit.gpgsign false, so on a machine with global commit signing enabled the fixture commit fails and prints gpg: signing failed: No secret key / fatal: failed to write commit object into the test output. The assertions still pass because the worker only needs the git index, not a commit, so this is cosmetic - but it makes passing runs look broken and could hide a genuine failure. Pre-existing on the base commit, not introduced by this change.
  • bash tests/fm-remote-job-orphan-reap.test.sh with the fix - 5 consecutive runs, exit 0 each
  • bash tests/fm-remote-job-orphan-reap.test.sh with bin/fm-remote-job-worker.sh reverted to base 833a9a2 - 4 runs, all not ok - the lock-free serving child survived TERM and advanced heartbeat ..., exit 1
  • bash tests/fm-remote-job-orphan-reap.test.sh under 2x CPU oversubscription (28 spinner processes on 14 cores) - 3 runs, exit 0, retry construction never exhausted its 8 attempts
  • bash tests/fm-remote-job.test.sh - 22 assertions pass, covering worker shutdown terminates the active command tree before replacement, failed shutdown quarantines ownership against replacement workers, quarantine clears only after recorded execution has stopped
  • Manual end-to-end repro repro-unowned-worker-term.sh run against both the base and fixed worker: starts a real worker via fm_remote_job_start_linux_worker, pins the published heartbeat inode with a hard link, STOPs the serving child on atomic inode replacement, removes the state root while stopped, CONTs so the stale-job sweep recreates jobs/logs/worker.ready without worker.lock, then TERMs and records ps plus heartbeat inode movement
  • Manual verification repro-active-execution-cleanup.sh against both workers: stages a real job whose command ignores TERM, removes only worker.lock so worker_publish_quarantine fails, TERMs the serving worker, then checks the recorded .claim/group command tree and whether the job's post-sleep side effect ever appears
  • Code trace confirming trap worker_exit_cleanup EXIT (bin/fm-remote-job-worker.sh:678) is registered before trap worker_shutdown HUP INT TERM (line 687), so the new exit 125 still runs active-execution cleanup
⚠️ **Document** - 1 info
  • ℹ️ bin/fm-remote-job-worker.sh:18 - The remote job worker's ownership state machine has no authoritative owner document: worker.lock, its quarantine marker, and the distinct shutdown outcomes (exit 75 when ownership is quarantined after an unconfirmed shutdown, exit 125 for a shutdown that could not guard or stop cleanly, and how the Linux supervisor treats each) exist only as scattered code in bin/fm-remote-job-worker.sh and bin/fm-remote-job-lib.sh. This change's own invariant is correctly captured as a call-site comment, so nothing here is stale or wrong today. A follow-up consolidating that state machine into the bin/fm-remote-job-worker.sh header (the tier-7 mechanics owner, alongside the abandonment and supervisor-backoff paragraphs already there) would give the contract one owner, but writing it means restating behaviour this change did not touch and is out of scope for a focused fix.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@kunchenguid

kunchenguid commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Automated reminder: thanks for the PR! This branch currently has a merge conflict with the base branch.

When you get a chance, please rebase onto (or merge) the latest base branch, resolve the conflict, and push. After that, checks will re-run and the PR will get looked at again.

Noted for firstmate#1980 at 81129bcd.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants