Commit 901b43a6 by PLN (Algolia)

fix(rig): the LED watcher had three lifecycles and two could run at once — now it is gear

The board's colours only persist because a daemon holds a model of every control
and repaints from it. That daemon had no home. It could be started three ways:

  1. by hand,  tools/lcxl-leds.py --watch
  2. by gig-up.sh, which `setsid`-spawned its own copy (GIG_LEDS=watch)
  3. as a *transient* systemd unit, which is how it was actually running today
     (systemd-run --unit=lcxl-leds-watch)

Every one of those is wrong in a different way. (1) dies with the terminal. (2)
does not know about (3), so launching gig-up on a machine that already had the
watcher up gave you TWO processes writing SysEx to the same LCXL, fighting over
every LED — and neither of them wrong enough to look broken, which is the worst
kind of bug this rig produces. (3) has no file on disk, so it evaporates at the
next reboot and the board silently stops persisting colours.

PLN, on being shown the three: "watcher must be a saved tool part of gear indeed".

So: tools/lcxl-leds-watch.service, symlinked into ~/.config/systemd/user/ the same
way parvagues-bridge.service already is, enabled, WantedBy=default.target — it
starts at boot with linger, before any login. gig-up.sh no longer spawns anything;
it `systemctl --user restart`s the unit, which is idempotent AND guarantees exactly
one owner even if a stale watcher survived a crash. One owner of the board, always.

Two details worth the ink:

- StartLimitIntervalSec=0 belongs in [Unit], not [Service]. Put in [Service] systemd
  says "Unknown key ... ignoring" — a warning in the journal nobody reads — and the
  default limit of 5 restarts in 10 s stays in force. The LCXL is hot-pluggable and
  usually absent at boot, so with Restart=always/RestartSec=10 the unit would burn
  its five retries and fall into `failed`, board dark for the rest of the session.
  A silent failure one section heading away from working. Caught it because the
  first install DID log the warning; fixed and re-verified with systemd-analyze.

- Cost, measured from the transient unit's own accounting before replacing it:
  2.140 s CPU over 1 h 53 m wall = 0.03% of a core, 14.5 M peak RSS. The watcher
  forks a helper per LED write, which is a real throughput problem for the 1-2 s
  paint lag — but it is emphatically not a load problem, so it can stay Nice=5 /
  CPUWeight=20 and never be a candidate when hunting xruns.

Verified: unit enabled + active, systemd-analyze verify clean, no Unknown-key
warning on reload, `bash -n gig-up.sh` clean, and exactly one watcher process
owned by the unit (MainPID matches, NRestarts=0). Note `pgrep -cf 'lcxl-leds.py
--watch'` reports 2 — it counts the shell running the pgrep pipeline itself. Read
the process list, not the count.

Closes #85.
parent 959bdcd7
......@@ -168,10 +168,15 @@ done
# broken hardware. The no-track paint is the channel CONVENTION, so there is always
# something to look at; tidal-remote repaints per-track on each boot.
#
# GIG_LEDS=watch also starts the touch-reactive daemon, which keeps a model of every
# control and repaints from it — that is what makes the colours PERSIST after you let
# go of a knob. It is SysEx-out only and reads input read-only via aseqdump, so it
# never steals MIDI from SuperDirt and never sends a CC at anything.
# The touch-reactive daemon — which keeps a model of every control and repaints from
# it, so colours PERSIST after you let go of a knob — is NOT started from here any
# more (#85). It is a systemd --user unit, `lcxl-leds-watch.service`, enabled and
# started at boot. That matters because this script used to `setsid` its own copy:
# with a unit already running you got TWO watchers writing SysEx to the same board,
# fighting over every LED, and neither one wrong enough to look broken. One owner.
#
# The daemon is SysEx-out only and reads input read-only via aseqdump, so it never
# steals MIDI from SuperDirt and never sends a CC at anything.
leds(){
[ "${GIG_LEDS:-paint}" = off ] && { info "leds: disabled (GIG_LEDS=off)."; return; }
if ! "$DIR/tools/lcxl-leds.py" --map -q 2>/tmp/gig-leds.err; then
......@@ -180,11 +185,13 @@ leds(){
return
fi
ok "leds: surface painted (convention colours). LOOK AT IT — a clean exit is not a lit LED."
if [ "${GIG_LEDS:-paint}" = watch ]; then
setsid "$DIR/tools/lcxl-leds.py" --watch -q >"$DIR/gig-leds.log" 2>&1 &
ok "leds: --watch daemon started (touch-reactive + persistent; log → gig-leds.log)."
# Restart, not start: idempotent, and it guarantees exactly one watcher owns the
# board even if a stale one survived a crash. Fresh boot also re-reads the track.
if systemctl --user restart lcxl-leds-watch.service 2>/tmp/gig-leds-unit.err; then
ok "leds: watcher unit running (lcxl-leds-watch.service — persists across reboot)."
else
info "leds: run 'tools/lcxl-leds.py --watch <track>' (or GIG_LEDS=watch) for touch-reactive colours."
warn "leds: watcher unit failed — $(head -2 /tmp/gig-leds-unit.err | tr '\n' ' ')"
warn "leds: colours will not persist after you let go of a knob. journalctl --user -u lcxl-leds-watch"
fi
}
leds
......
[Unit]
Description=LCXL LED watcher (track-aware surface paint, #78/#85)
Documentation=file:///home/pln/Work/Sound/Tidal/tools/lcxl-leds.py
# The LCXL is hot-pluggable and often absent at boot. Rather than bind to a
# device unit (whose name changes with the USB topology), this unit simply
# retries forever. Plug the board in and it lights up within RestartSec.
#
# StartLimitIntervalSec=0 lives HERE, in [Unit] — systemd silently ignores it in
# [Service] ("Unknown key ... ignoring"), which would leave the default limit of
# 5 restarts / 10 s in force and let the unit fall into `failed` with the board
# dark for the rest of the session. Exactly the silent-failure shape this rig
# keeps getting bitten by, one section heading away.
StartLimitIntervalSec=0
[Service]
Type=simple
WorkingDirectory=/home/pln/Work/Sound/Tidal
ExecStart=/usr/bin/python3 /home/pln/Work/Sound/Tidal/tools/lcxl-leds.py --watch -q
Restart=always
RestartSec=10
# It must never be the reason audio stutters. The watcher forks a helper per LED
# write (a known cost — one fork+exec per LED), so cap it below every audio thread.
# Measured: 2.1 s CPU over 1 h 53 m wall = 0.03% of a core. It is not the problem.
Nice=5
CPUWeight=20
IOWeight=20
[Install]
WantedBy=default.target
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