Commit 5a97d0ed by PLN (Algolia)

feat(rig): parvagues-protect — keep scsynth un-killable across every restart

PLN got "memory shortage avoided — parvagues-sc terminated because the system is
low on memory". Nothing in the system had decided to kill it: systemd-oomd is
disabled and has NEVER run, earlyoom/nohang inactive, and the kernel OOM killer
left no trace in `journalctl -k` or `-b -1`. The string "memory shortage" appears
in no journal at all, so the notification came from an app — source still
unidentified, and currently the only warning that exists.

THE MECHANISM — a stale binding, this rig's most expensive recurring shape.
perf.sh:545 protects the rig BY PID (chrt -f 90, oom_score_adj -1000). systemd's
user manager ships DefaultOOMScoreAdjust=200, so EVERY restart of parvagues-sc —
crash, watchdog, or manual — hands scsynth back at +200, the most attractive OOM
victim on the machine, with no realtime priority either. perf.sh is correct
exactly once. On 2026-08-15 SC restarted 6+ times (four SIGSEGVs, e65e3a87) and
spent the rest of the session naked. The tell: live processes read -1000 while
`systemctl show` still says 200 — that gap IS the bug.

WHY A SYSTEM UNIT. Measured, because it 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 — no error, no log.
Writing -1000 into parvagues-sc.service would look right in the file, look right
in `systemctl show`, and do nothing. Lowering past the manager's own value needs
CAP_SYS_RESOURCE. PLN: "make it system if useful ... this device is
parvagues-pilled" / "i can sudo any good shit".

WHAT SHIPPED
  tools/parvagues-protect.sh       2s sweep; only ever lowers oom, only ever
                                   raises priority, so it is idempotent and
                                   composes with perf.sh instead of fighting it.
                                   Logs ONLY changes — at 2s, "still fine" would
                                   be 43k journal lines a day, i.e. no logging.
                                   --check is read-only and unprivileged.
  tools/parvagues-protect.service  root, hardened (three capabilities, no more),
                                   Nice=10 + IOSchedulingClass=idle so the
                                   protector is never itself a cost.
  tools/install-protect.sh         COPIES the script to /usr/local/bin root:root
                                   0755 rather than running it from the repo — a
                                   root unit with ExecStart under /home/pln is a
                                   privesc hole, unlike the user-owned watchdog.
  tools/gig-up.sh                  new SOFT check "SC un-killable". SOFT is a
                                   judgement, not an oversight: an unprotected
                                   rig still makes sound, and this gate's own
                                   rule is that HARD means "there is no gig".

It never starts or stops anything — that stays the watchdog's job, and two
supervisors with opinions about one process is a fight nobody wins.

TWO BUGS FOUND WHILE BUILDING IT
- `pgrep -f ardour` reported Ardour RUNNING when it was closed: it matched
  `bash .../tidal-ardour-autoroute.sh`, which has "ardour" in its path. I had
  already repeated that false positive to PLN as "Ardour up but engine not
  started". Now matches comm exactly, and --check PRINTS absent targets rather
  than skipping them silently.
- `tools/check-audio-graph.sh` was +x on disk but git recorded 100644, failing
  the gate's own "tools executable" check. Fixed with update-index --chmod=+x
  (feedback_chmod_is_not_a_fix: chmod fixes your tree, not the repo).

VERIFIED, both directions, because a check that cannot fail is decoration:
--check returns 0 on the live protected rig, and against a decoy process at
oom_score_adj=200/SCHED_OTHER it prints UNPROTECTED and exits 1 — that decoy
signature is exactly what a freshly restarted scsynth looks like.

NOT YET PROVEN: recovery after a real restart, which needs the unit installed
(root). install-protect.sh ends by printing that verification.
parent f92f4455
File mode changed from 100644 to 100755
......@@ -245,7 +245,8 @@ run "tools executable" \
for f in tools/gig-up.sh tools/check-boot.sh tools/check-tracks.sh \
tools/check-preload.sh tools/sc-watchdog.sh tools/setlist.py \
tools/check-drift.sh tools/take-segments.py tools/fader-baseline.py \
tools/check-audio-graph.sh; do
tools/check-audio-graph.sh tools/parvagues-protect.sh \
tools/install-protect.sh; do
[ -e "$f" ] || continue
[ -x "$f" ] || { echo "not executable on disk: $f"; bad=1; }
m=$(git ls-files -s "$f" 2>/dev/null | awk "{print \$1}")
......@@ -401,6 +402,17 @@ if (( LIVE )); then
"check the autoroute: systemctl --user restart tidal-ardour-autoroute" \
bash -c 'n=$(pw-link -l 2>/dev/null | grep -ci supercollider); echo "supercollider links: $n"; [ "$n" -ge 24 ]'
# SOFT, and the severity is a judgement not an oversight: an unprotected rig
# still makes sound, so by this gate's own rule ("HARD = there is no gig") it
# cannot be HARD. But it is why the rig died on 2026-08-15 — systemd's user
# manager ships DefaultOOMScoreAdjust=200, so every restart of parvagues-sc
# hands scsynth back as the most killable process on the machine, and after
# four crashes that afternoon it was terminated for memory. perf.sh sets -1000
# BY PID, which is correct exactly once.
soft "SC un-killable" \
"sudo tools/install-protect.sh # or re-run perf.sh; check with tools/parvagues-protect.sh --check" \
tools/parvagues-protect.sh --check
soft "Tidal :6010" \
"open the set in Pulsar and boot Tidal (nothing is sending patterns yet)" \
bash -c 'ss -lunp 2>/dev/null | grep -q ":6010"'
......
#!/usr/bin/env bash
# install-protect — put parvagues-protect where root can safely run it.
#
# sudo tools/install-protect.sh install + enable + start
# sudo tools/install-protect.sh --uninstall
#
# WHY THE SCRIPT IS COPIED INSTEAD OF SYMLINKED
# ---------------------------------------------
# The user unit for the watchdog runs straight out of the repo, which is fine —
# it runs as PLN, and PLN owns the repo. This one runs as ROOT. A root systemd
# unit whose ExecStart lives under /home/pln is a privilege-escalation hole:
# anything able to write that file (a bad npm postinstall, a stray script, a
# future me) gets root on the next boot. So the script is COPIED to
# /usr/local/bin and owned by root:root 755. The repo keeps the source of truth;
# re-run this after editing it.
set -euo pipefail
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN=/usr/local/bin/parvagues-protect
UNIT=/etc/systemd/system/parvagues-protect.service
if [ "$(id -u)" -ne 0 ]; then
echo "needs root: sudo $0 $*" >&2
exit 1
fi
if [ "${1:-}" = "--uninstall" ]; then
systemctl disable --now parvagues-protect.service 2>/dev/null || true
rm -f "$BIN" "$UNIT"
systemctl daemon-reload
echo "removed $BIN and $UNIT"
exit 0
fi
install -o root -g root -m 0755 "$SRC_DIR/parvagues-protect.sh" "$BIN"
install -o root -g root -m 0644 "$SRC_DIR/parvagues-protect.service" "$UNIT"
systemctl daemon-reload
systemctl enable --now parvagues-protect.service
sleep 3
echo
systemctl --no-pager --lines=8 status parvagues-protect.service || true
echo
echo "--- current protection ---"
"$BIN" --check || true
cat <<'EOF'
--- prove it actually recovers (the whole point) ---
The failure this fixes only appears AFTER a restart, so verifying it at rest
proves nothing. Restart SuperDirt and watch the score come back on its own:
systemctl --user restart parvagues-sc
sleep 12 && /usr/local/bin/parvagues-protect --check
journalctl -u parvagues-protect -n 20 --no-pager
Expect a journal line naming the NEW pid and `oom:200->-1000`. If instead the
check says UNPROTECTED, the daemon is not seeing the process — look at pids_for.
EOF
[Unit]
# The protection half of the rig's supervision, and the only piece that must be
# root. See /usr/local/bin/parvagues-protect for the full story; the short
# version is that systemd's user manager ships DefaultOOMScoreAdjust=200, a
# --user unit cannot go below 100 (it clamps SILENTLY), and so every restart of
# parvagues-sc.service handed scsynth back as the most killable process on the
# machine until something re-protected it by hand.
#
# SEPARATE FROM THE WATCHDOG ON PURPOSE. The watchdog decides WHETHER a process
# should be running; this decides HOW it runs once it is. Folding them together
# would put a root daemon on the restart path, where a bug stops the music — out
# here, a bug is merely noisy and the rig keeps playing unprotected.
#
# NO PartOf=/BindsTo= on the user unit, for the reason the watchdog documents at
# length: PartOf propagates restarts, and a supervisor that dies with the thing
# it supervises launders its own state. This one is independent of everything —
# it holds no ports, makes no sound, and only ever edits attributes of processes
# that already exist.
Description=ParVagues process protection (OOM immunity + realtime priority)
Documentation=file:/usr/local/bin/parvagues-protect
After=multi-user.target
[Service]
Type=simple
ExecStart=/usr/local/bin/parvagues-protect
# It adjusts other processes; it must not be a realtime process itself, and it
# must never be the reason the machine is short of anything.
Nice=10
IOSchedulingClass=idle
# If it dies it is a protection outage, not an audio outage — come straight back.
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=pv-protect
# It needs exactly two powers: lower oom_score_adj past the caller's floor, and
# set SCHED_FIFO on processes it does not own. Nothing else.
CapabilityBoundingSet=CAP_SYS_RESOURCE CAP_SYS_NICE CAP_IPC_LOCK
AmbientCapabilities=CAP_SYS_RESOURCE CAP_SYS_NICE CAP_IPC_LOCK
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
[Install]
WantedBy=multi-user.target
#!/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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment