#!/usr/bin/env bash
# parvagues-protect — keep the sound-making processes un-killable and realtime,
# for as long as they live, however many times they are restarted.
#
# WHY THIS EXISTS
# ---------------
# 2026-08-15/16: PLN got a "memory shortage avoided — parvagues-sc terminated"
# notification. Nothing in the system had killed it on purpose: systemd-oomd is
# disabled and has never run, earlyoom/nohang are inactive, and the kernel OOM
# killer left no trace in `journalctl -k` or `-b -1`.
#
# The cause was a STALE BINDING, this rig's most expensive recurring bug shape.
# perf.sh protects the rig BY PID:
#
#     SC_SERVER=$(pgrep scsynth)
#     chrt -f -p 90 $SC_SERVER
#     echo -1000 > /proc/$SC_SERVER/oom_score_adj
#
# That is correct exactly once. systemd's user manager ships
# DefaultOOMScoreAdjust=200, so EVERY restart of parvagues-sc.service — crash,
# watchdog, or `systemctl restart` — hands scsynth back at +200, the most
# attractive OOM victim on the machine, with no realtime priority either. On
# 2026-08-15 SC restarted 6+ times in one afternoon (four SIGSEGVs, see
# reference_joshpvugens_kills_scsynth) and spent the rest of the session naked.
#
# WHY IT IS A SYSTEM UNIT AND NOT A USER ONE
# ------------------------------------------
# Measured, because this is easy to assume wrongly:
#
#     OOMScoreAdjust=-1000  -> 100      OOMScoreAdjust=0    -> 100
#     OOMScoreAdjust=-500   -> 100      OOMScoreAdjust=200  -> 200
#
# A --user unit CANNOT go below 100, and it clamps SILENTLY with no error or log
# line. Putting `OOMScoreAdjust=-1000` in parvagues-sc.service would look right
# in the file, look right in `systemctl show`, and do nothing. Lowering below the
# manager's own value needs CAP_SYS_RESOURCE, so the enforcement has to run as
# root. PLN, 2026-08-16: "make it system if useful ... this device is
# parvagues-pilled".
#
# WHAT IT WILL NOT DO
# -------------------
# It never starts, stops or restarts anything — that is the watchdog's job, and
# two supervisors with opinions about the same process is how you get a fight
# nobody wins. This only ever adjusts attributes of processes that already exist.
# If it dies, the rig keeps playing, unprotected but audible.
#
# It also only ever LOWERS oom_score_adj and only RAISES scheduling. Re-running
# it is a no-op, so it composes with perf.sh instead of fighting it.
set -uo pipefail

INTERVAL="${PARVAGUES_PROTECT_INTERVAL:-2}"

# name:rtprio — the priorities perf.sh established and PLN has played on.
# scsynth is the audio server, sclang feeds it, Ardour records the stems; losing
# any of the three mid-set is a lost take.
TARGETS=(
  "scsynth:90"
  "sclang:85"
  "ardour:80"
)

OOM_TARGET=-1000

log() { printf '%s %s\n' "$(date '+%H:%M:%S')" "$*"; }

# pids_for <name> — match the BINARY (comm), never the command line.
#
# Earned 2026-08-16, immediately: `pgrep -f ardour` reported Ardour running when
# it was not, because it matched `bash /home/pln/.local/bin/tidal-ardour-
# autoroute.sh` — the routing script has "ardour" in its path. A protector that
# thinks it is guarding Ardour while actually guarding a shell script is worse
# than one that admits it found nothing.
#
# Ardour's binary is versioned (ardour8, ardour9) and the process that actually
# does the work reports comm=ArdourGUI, so the family is listed explicitly.
# `pgrep -x` matches comm exactly, which cannot be fooled by a path.
pids_for() {
  case "$1" in
    ardour) pgrep -x 'ardour|ardour[0-9]+|ArdourGUI|ardour-[0-9.]+' 2>/dev/null ;;
    *)      pgrep -x "$1" 2>/dev/null ;;
  esac
}

# protect_pid <pid> <name> <rtprio> -> echoes what it CHANGED, nothing if already fine.
# Reporting only changes is deliberate: at a 2s poll a "still fine" line would be
# 43200 journal entries a day, which is the same as no logging at all.
protect_pid() {
  local pid="$1" name="$2" prio="$3" changed=""

  local cur_oom
  cur_oom=$(cat "/proc/$pid/oom_score_adj" 2>/dev/null) || return 0
  if [ "$cur_oom" != "$OOM_TARGET" ]; then
    if echo "$OOM_TARGET" > "/proc/$pid/oom_score_adj" 2>/dev/null; then
      changed+=" oom:${cur_oom}->${OOM_TARGET}"
    else
      changed+=" oom:FAILED(need root, have uid $(id -u))"
    fi
  fi

  # Only promote. A process already at a HIGHER realtime priority than we ask
  # for was tuned by someone who knew more than this script does.
  local pol cur_prio
  pol=$(chrt -p "$pid" 2>/dev/null | sed -n '1s/.*: //p')
  cur_prio=$(chrt -p "$pid" 2>/dev/null | sed -n '2s/.*: //p')
  if [ "$pol" != "SCHED_FIFO" ] || [ "${cur_prio:-0}" -lt "$prio" ]; then
    if chrt -f -p "$prio" "$pid" 2>/dev/null; then
      changed+=" sched:${pol:-?}/${cur_prio:-?}->FIFO/${prio}"
    else
      changed+=" sched:FAILED"
    fi
  fi

  # Locked memory can never be paged out — with no swap on this box it mostly
  # matters as insurance, but it is free and perf.sh has always set it.
  prlimit --pid "$pid" --memlock=unlimited 2>/dev/null

  [ -n "$changed" ] && echo "$name[$pid]$changed"
  return 0
}

sweep() {
  local out="" name prio pid
  for t in "${TARGETS[@]}"; do
    name="${t%%:*}"; prio="${t##*:}"
    for pid in $(pids_for "$name"); do
      local r; r=$(protect_pid "$pid" "$name" "$prio")
      [ -n "$r" ] && out+="$r; "
    done
  done
  [ -n "$out" ] && log "protected: ${out%; }"
  return 0
}

# --check — read-only, unprivileged, for tools/gig-up.sh and for PLN's own eyes.
# Exits non-zero if anything that makes sound is currently killable or non-RT.
# Absent processes are NOT a failure here: "Ardour is not open yet" is a normal
# state ten minutes before doors, and the gate already checks liveness elsewhere.
check() {
  local bad=0 found=0 name prio pid adj pol pids
  for t in "${TARGETS[@]}"; do
    name="${t%%:*}"; prio="${t##*:}"
    pids=$(pids_for "$name")
    # Say so out loud. A silently-skipped target is how a protector reports all
    # clear while covering nothing — and it is exactly how `pgrep -f ardour`
    # fooled this script's first draft.
    if [ -z "$pids" ]; then
      echo "absent      $name (not running — nothing to protect)"
      continue
    fi
    for pid in $pids; do
      found=1
      adj=$(cat "/proc/$pid/oom_score_adj" 2>/dev/null || echo "?")
      pol=$(chrt -p "$pid" 2>/dev/null | sed -n '1s/.*: //p')
      if [ "$adj" != "$OOM_TARGET" ] || [ "$pol" != "SCHED_FIFO" ]; then
        echo "UNPROTECTED $name[$pid] oom_score_adj=$adj sched=${pol:-none} (want $OOM_TARGET / SCHED_FIFO)"
        bad=1
      else
        echo "ok          $name[$pid] oom_score_adj=$adj sched=$pol"
      fi
    done
  done
  [ "$found" -eq 0 ] && { echo "nothing running to protect"; return 0; }
  return $bad
}

case "${1:-}" in
  --check) check; exit $? ;;
  --once)  sweep; exit 0 ;;
  --help|-h)
    sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//'
    exit 0 ;;
  "") ;;
  *) echo "usage: $(basename "$0") [--check|--once|--help]" >&2; exit 2 ;;
esac

log "parvagues-protect: watching ${TARGETS[*]} every ${INTERVAL}s (oom=$OOM_TARGET)"
while :; do
  sweep
  sleep "$INTERVAL"
done
