What you will learn

Track a background job's PID safely and detect a genuinely hung process via a heartbeat file, going beyond a bare kill -0 check that only proves a PID exists — not that it's making progress.

Before you begin

A stale PID file (left behind after a crash) is the classic failure mode this design defends against: reusing a PID that the OS has since assigned to an unrelated process.

1. Write the PID file defensively at startup

#!/usr/bin/env bash
set -Eeuo pipefail

PID_FILE="/var/run/myjob.pid"
HEARTBEAT_FILE="/var/run/myjob.heartbeat"
SELF_NAME="myjob"

if [[ -f "$PID_FILE" ]]; then
    old_pid="$(cat "$PID_FILE")"
    if kill -0 "$old_pid" 2>/dev/null && grep -q "$SELF_NAME" "/proc/${old_pid}/comm" 2>/dev/null; then
        echo "already running as pid ${old_pid}" >&2
        exit 1
    fi
    echo "stale pid file (pid ${old_pid} not running or not ours), removing" >&2
    rm -f "$PID_FILE" "$HEARTBEAT_FILE"
fi

echo $$ > "$PID_FILE"
trap 'rm -f "$PID_FILE" "$HEARTBEAT_FILE"' EXIT

Checking /proc/<pid>/comm in addition to kill -0 guards against the specific case where the PID in a stale file has since been recycled by the OS for an unrelated process — kill -0 alone would wrongly report "already running."

2. Touch a heartbeat from inside the work loop

main_loop() {
    while true; do
        do_unit_of_work
        touch "$HEARTBEAT_FILE"
        sleep 5
    done
}

do_unit_of_work() {
    : # real work goes here
}

main_loop &
wait

3. A separate checker distinguishes "alive" from "making progress"

#!/usr/bin/env bash
# heartbeat-check.sh — run from cron independently of the job itself
set -Eeuo pipefail

PID_FILE="/var/run/myjob.pid"
HEARTBEAT_FILE="/var/run/myjob.heartbeat"
STALE_AFTER_SECONDS=60

[[ -f "$PID_FILE" ]] || { echo "not running (no pid file)"; exit 1; }
pid="$(cat "$PID_FILE")"
kill -0 "$pid" 2>/dev/null || { echo "not running (pid ${pid} dead)"; exit 1; }

[[ -f "$HEARTBEAT_FILE" ]] || { echo "no heartbeat yet"; exit 1; }
last="$(stat -c %Y "$HEARTBEAT_FILE")"
now="$(date +%s)"

if (( now - last > STALE_AFTER_SECONDS )); then
    echo "HUNG: pid ${pid} alive but heartbeat stale by $((now - last))s" >&2
    exit 2
fi

echo "healthy: pid ${pid}, heartbeat ${STALE_AFTER_SECONDS}s ago or fresher"

This is the piece a plain PID check misses: a process can be alive (passing kill -0) while deadlocked on a lock or stuck in an infinite loop that never reaches the touch. The separate checker catches that from outside the process.

Example output

healthy: pid 4021, heartbeat 60s or fresher

hung case:

HUNG: pid 4021 alive but heartbeat stale by 143s

Verify the result

Send STOP (not KILL) to the job's PID with kill -STOP <pid> to freeze it without killing it, then run the checker and confirm it reports HUNG once the heartbeat goes stale — kill -0 alone would still report it as running.

Troubleshooting

If the checker always reports "not running" right after start, confirm the heartbeat file's directory survives a reboot (/var/run is often tmpfs and cleared) — use a persistent path if the check must survive a host restart before the job re-registers.

Next steps

Wire HUNG/exit code 2 from the checker into auto-heal-service-watchdog's restart logic, and into webhook-alert-dispatcher for paging.