Commit e9d16314 by PLN (Algolia)

fix(midiviz): survive an LCXL unplug/replug in-process, no more clean exit

The bug: midiviz resolved its ALSA source port ONCE at startup and shelled
out to `aseqdump -p <port>`. Unplugging the LCXL removes the ALSA client it
was subscribed to, `aseqdump`'s stdout hits EOF, the reader thread's for-loop
just returns, and nothing else was keeping `app.exec()` alive — the window
closed with exit 0. Because that is a CLEAN exit, `Restart=on-failure` never
fired, so PLN's "always open" glyph-rain lens was gone for the rest of the
session the moment he unplugged the board. Reproduced from tonight's own
journal: `midiviz.service` ran 6m57s and exited status=0/SUCCESS the instant
the surface came off USB — textbook rig failure mode #1, "a binding resolved
once, killed by a replug, never re-resolved", except this time the binding
was the whole window's reason to exist rather than just a port variable.

The fix, mirroring the resolve/rebind pattern already proven in
`tools/lcxl-leds.py` (`find_seq_port` + `invalidate_ports`, cached and
dropped on failure):

- `Reader` now tracks `.alive()` (child process poll) and marks itself
  `error = "closed"` when its `aseqdump` child exits on its own (vs. an
  intentional `.close()`), so the difference between "I quit" and "I died"
  is visible to the widget.
- `MidiViz` gained a second QTimer (`RECONNECT_MS = 2000`, matching
  `midi-autoconnect.sh`'s own reconcile cadence) that re-resolves the source
  every tick and swaps the `Reader` in place if it moved, died, or vanished.
  Losing the source is never fatal any more: the window drops to its
  existing idle/dim-pulse paint state (already built for "no events
  recently") and keeps ticking, painting, and listening for the surface's
  return — no `sys.exit`, no fatal path added anywhere.
- Liveness is gated by `_hardware_present()`, a `type=kernel` + name-match
  scan of `aconnect -l` reused from `midi-autoconnect.sh`'s
  `DIRECT_LEG_AWK` (`hw = ($0 ~ /type=kernel/ && $0 ~ /Launch Control
  XL|LCXL/)`). This matters because `lcxl3-driver.service` publishes a
  VIRTUAL port literally named 'ParVagues LCXL3' — the translated,
  corpus-numbered stream `resolve_watch_port()` deliberately prefers, since
  that is the CC numbering the grid and every `.tidal` file actually speak.
  That virtual client can outlive a physical unplug for a beat if the driver
  hasn't noticed yet, so a bare name match would report "still connected"
  against a ghost carrying nothing. The rebind tick distrusts a match ONLY
  when the matched label is itself LCXL-named and no real hardware backs
  it; a "Midi Through" catch-all match needs no hardware and is trusted as
  before. Content still comes from the preferred (possibly virtual)
  port — only the "is it actually there" judgement moved to hardware.
- A user-pinned `-p` port keeps working, checked instead against
  `aseqdump -l`'s live listing (a pin surviving a client renumbering across
  replug is not guaranteed, same as any other resolved-by-address binding).
- `tools/midiviz.service`: `Restart=on-failure` → `Restart=always` +
  `StartLimitIntervalSec=0` (moved to `[Unit]`, where it belongs — the first
  install attempt logged "Unknown key 'StartLimitIntervalSec' in section
  [Service], ignoring", caught before it shipped) as belt-and-braces under
  the in-process fix, since the unit holds no audio ports and a restart
  loop costs nothing real.

Verified:
- `--selftest`: `parsed=68 ingested=250 frames=159 paints=170 grabs=83
  distinct_sampled_colours=165 platform=offscreen -> PASS`.
- Reinstalled the unit (`install -m644` + `daemon-reload`); no more "Unknown
  key" warning in the journal on the next start.
- Live restart: `ActiveState=active`, `MainPID=2728202`, `NRestarts=0`,
  bound to the "Midi Through" fallback (no LCXL physically plugged in
  tonight, confirming the hardware-gated fallback still works with zero
  surface present).
- Port-loss simulation (couldn't unplug hardware; killed the reader's
  `aseqdump` child directly — the same failure shape as the source
  disappearing under it): child pid 2728206 -> `<defunct>`; within the next
  2s rebind tick a fresh `aseqdump -p 14:0` (pid 2730846) appeared as
  midiviz's child. Main PID stayed 2728202 throughout, `NRestarts` stayed 0,
  `ActiveState` stayed `active` — recovered entirely IN-PROCESS, no systemd
  restart needed. CPU time kept accumulating (1.237s over 38s wall) proving
  the paint timer never stopped ticking.
parent 3c00a9cb
...@@ -50,6 +50,7 @@ import argparse ...@@ -50,6 +50,7 @@ import argparse
import math import math
import os import os
import random import random
import re
import signal import signal
import subprocess import subprocess
import sys import sys
...@@ -87,6 +88,73 @@ WATCH_PREFERENCE: tuple[str, ...] = ( ...@@ -87,6 +88,73 @@ WATCH_PREFERENCE: tuple[str, ...] = (
"ParVagues LCXL3", "LCXL3 1 DAW", "LCXL3", "Launch Control XL", "Midi Through", "ParVagues LCXL3", "LCXL3 1 DAW", "LCXL3", "Launch Control XL", "Midi Through",
) )
# ── unplug/replug survival ─────────────────────────────────────────────────
# 2026-09-05: midiviz resolved its source port ONCE at startup, and when the
# LCXL was unplugged `aseqdump`'s stdout simply hit EOF (the ALSA client it
# was subscribed to vanished), the reader thread returned, and `app.exec()`
# had nothing keeping it alive -- the window closed with exit 0. Because that
# is a CLEAN exit, `Restart=on-failure` never fired: the rig's #1 recurring
# failure mode ("stale binding pattern" -- a binding resolved once, killed by
# a replug, never re-resolved) applied to the window itself, not just to a
# port variable.
#
# The fix mirrors `lcxl-leds.py`'s `find_seq_port`/`invalidate_ports`: never
# trust a resolved port past the moment it might have gone stale. A QTimer
# every RECONNECT_MS (matching `midi-autoconnect.sh`'s own reconcile cadence)
# re-resolves from scratch and swaps the Reader in place; the window and its
# QApplication never see a reason to exit.
#
# The re-resolve is gated on HARDWARE presence, checked independently of
# WATCH_PREFERENCE's name match. Reason: `lcxl3-driver.service` publishes a
# VIRTUAL ALSA port named literally 'ParVagues LCXL3' -- the translated,
# corpus-numbered stream `resolve_watch_port()` prefers ON PURPOSE, because
# that is the numbering the grid and every `.tidal` file actually speak (see
# `tools/lcxl3-driver.py` and the WATCH_PREFERENCE comment above). But that
# virtual client can outlive the physical unplug for a beat if the driver
# hasn't noticed yet, and a pure name match would then report "still
# connected" while the port is a ghost carrying nothing -- exactly the trap
# `midi-autoconnect.sh`'s `DIRECT_LEG_AWK` was written to avoid for its own
# purpose (`hw = ($0 ~ /type=kernel/ && $0 ~ /Launch Control XL|LCXL/)`).
# Reusing that discrimination here: `_hardware_present()` requires a
# `type=kernel` client whose name matches the board, and the rebind tick
# treats the source as gone whenever that is false, regardless of what
# WATCH_PREFERENCE would otherwise resolve to. Content still comes from the
# preferred (possibly virtual/translated) port; liveness is judged by
# hardware, so a lingering ghost can no longer read as "connected".
RECONNECT_MS = 2000
HW_NAME_RE = re.compile(r"launch\s*control\s*xl|\blcxl\d*\b", re.I)
def _hardware_present() -> bool:
"""True iff a REAL (kernel-backed) LCXL client is on the ALSA seq bus.
Independent of `resolve_watch_port()` on purpose -- see the module note
above. `aconnect -l`'s client header line carries both the type tag and
the name, e.g. `client 20: 'LCXL3 1' [type=kernel]`.
"""
try:
out = subprocess.run(["aconnect", "-l"], capture_output=True,
text=True, timeout=3).stdout
except (OSError, subprocess.SubprocessError):
return False
for line in out.splitlines():
if (line.startswith("client ") and "type=kernel" in line
and HW_NAME_RE.search(line)):
return True
return False
def _port_listed(pid: str) -> bool:
"""Is this exact CLIENT:PORT address still in `aseqdump -l`'s listing?
Used only for a user-pinned `-p` port: a replug can renumber the address
(20:0 -> 24:0 is the recorded history -- see reference_lcxl_led_stall), so
a pin surviving a replug is not guaranteed, but this at least notices when
the pinned address itself has gone away rather than silently reading a
dead port forever.
"""
return any(p["addr"] == pid for p in list_ports())
def list_ports() -> list[dict]: def list_ports() -> list[dict]:
"""[{addr, client, port}] from `aseqdump -l`, or [] if aseqdump/ALSA is absent.""" """[{addr, client, port}] from `aseqdump -l`, or [] if aseqdump/ALSA is absent."""
...@@ -241,6 +309,18 @@ class Reader: ...@@ -241,6 +309,18 @@ class Reader:
self.total += 1 self.total += 1
except (OSError, ValueError): except (OSError, ValueError):
self.error = "eof" self.error = "eof"
finally:
# The loop also ends when `aseqdump` exits on its own -- which is
# exactly what happens when the ALSA client it was subscribed to
# disappears (unplug). No exception fires for that: stdout just
# reaches EOF. Distinguish it from an intentional `close()` so the
# rebind tick knows this reader is dead and needs replacing.
if not self._stop.is_set() and self.error is None:
self.error = "closed"
def alive(self) -> bool:
"""False once the `aseqdump` child has exited, for any reason."""
return self._proc is not None and self._proc.poll() is None
def drain(self) -> list[dict]: def drain(self) -> list[dict]:
with self._lock: with self._lock:
...@@ -282,7 +362,8 @@ def _install_signals() -> None: ...@@ -282,7 +362,8 @@ def _install_signals() -> None:
pass pass
def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
pinned_port: str | None = None, watch: bool = False):
QtCore, QtGui, QtWidgets = _qt() QtCore, QtGui, QtWidgets = _qt()
Qt = QtCore.Qt Qt = QtCore.Qt
...@@ -299,6 +380,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): ...@@ -299,6 +380,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
self.reader = reader self.reader = reader
self.port_label = port_label self.port_label = port_label
self._pinned = pinned_port # explicit -p, or None for auto-resolve
self.last_cc = None # (cc, value, channel, t) -- header readout self.last_cc = None # (cc, value, channel, t) -- header readout
self.scale = scale self.scale = scale
self.paused = False self.paused = False
...@@ -327,6 +409,56 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): ...@@ -327,6 +409,56 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
self.timer.timeout.connect(self.tick) self.timer.timeout.connect(self.tick)
self.timer.start(self._interval) self.timer.start(self._interval)
# Losing the surface must never be fatal -- see the module note
# by RECONNECT_MS. `watch` is off for --selftest (headless, fed
# synthetic events by hand) so the suite never shells out to
# `aconnect`/`aseqdump` on its own clock.
self.rebind_timer = None
if watch:
self.rebind_timer = QtCore.QTimer(self)
self.rebind_timer.setTimerType(Qt.TimerType.CoarseTimer)
self.rebind_timer.timeout.connect(self._rebind_tick)
self.rebind_timer.start(RECONNECT_MS)
# ── reconnect ──────────────────────────────────────────────────────
def _rebind_tick(self):
"""Re-resolve the source and swap the Reader if it moved or died.
Runs every RECONNECT_MS regardless of whether anything is wrong --
re-asserting is cheaper than detecting drift, the same call this
rig makes for the LCXL LED port and for `midi-autoconnect.sh`'s
wiring. Never raises, never closes the window.
"""
if self._pinned:
# A pin names an exact address, never an LCXL alias by name,
# so the ghost distrust below does not apply to it -- only
# "has this address disappeared from the listing at all".
pid = self._pinned if _port_listed(self._pinned) else None
label = self._pinned if pid else "%s (gone)" % self._pinned
else:
pid, label = resolve_watch_port()
# Distrust the match ONLY when it is itself an LCXL alias
# (the ghost case) -- a "Midi Through" catch-all match is not
# LCXL-named and needs no hardware to be a legitimate source.
if pid is not None and HW_NAME_RE.search(label) and not _hardware_present():
pid, label = None, "%s (ghost, no hardware) — no MIDI source found" % label
alive = self.reader is not None and self.reader.alive()
same = self.reader is not None and pid is not None and self.reader.port == pid
if pid is None:
if self.reader is not None:
self.reader.close()
self.reader = None
self.port_label = label
return
if self.reader is None or not alive or not same:
if self.reader is not None:
self.reader.close()
self.reader = Reader(pid).start()
self.port_label = label
# ── palette / fonts / geometry ───────────────────────────────────── # ── palette / fonts / geometry ─────────────────────────────────────
def _build_palette(self): def _build_palette(self):
self.bg = QtGui.QColor(*BG) self.bg = QtGui.QColor(*BG)
...@@ -787,6 +919,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): ...@@ -787,6 +919,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
def closeEvent(self, e): def closeEvent(self, e):
self.timer.stop() self.timer.stop()
if self.rebind_timer is not None:
self.rebind_timer.stop()
if self.reader: if self.reader:
self.reader.close() self.reader.close()
e.accept() e.accept()
...@@ -900,14 +1034,19 @@ def main(argv=None) -> int: ...@@ -900,14 +1034,19 @@ def main(argv=None) -> int:
return selftest(a.seconds, a.show) return selftest(a.seconds, a.show)
_install_signals() _install_signals()
port, label = a.port, "" pinned = a.port # explicit -p pin, or None for auto-resolve
port, label = pinned, ""
if port is None: if port is None:
port, label = resolve_watch_port() port, label = resolve_watch_port()
# The window itself stays wordless; where it is listening goes to stderr, so # The window itself stays wordless; where it is listening goes to stderr, so
# `-l`-free debugging is still possible without putting prose on the canvas. # `-l`-free debugging is still possible without putting prose on the canvas.
print("⚓ midiviz — %s · q/Esc/Ctrl-C to quit" % (label or "port %s" % port), print("⚓ midiviz — %s · q/Esc/Ctrl-C to quit" % (label or "port %s" % port),
file=sys.stderr) file=sys.stderr)
reader = Reader(port).start() # A blind `aseqdump` (no -p) subscribes to NOTHING -- see the
# WATCH_PREFERENCE comment above -- so start a Reader only once a real
# port resolved. Otherwise the window opens idle and the RECONNECT_MS
# rebind tick picks up the surface the moment it (or its driver) appears.
reader = Reader(port).start() if port else None
_QtCore, _QtGui, QtWidgets = _qt() _QtCore, _QtGui, QtWidgets = _qt()
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1]) app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1])
...@@ -919,12 +1058,14 @@ def main(argv=None) -> int: ...@@ -919,12 +1058,14 @@ def main(argv=None) -> int:
# worse, match EVERY python3 GUI on the box. Give the window its own # worse, match EVERY python3 GUI on the box. Give the window its own
# identity so the compositor can be told about this window and no other. # identity so the compositor can be told about this window and no other.
app.setDesktopFileName("midiviz") app.setDesktopFileName("midiviz")
w = build_widget(port or "--", reader, scale=max(0.6, min(2.4, a.scale))) w = build_widget(port or "--", reader, scale=max(0.6, min(2.4, a.scale)),
pinned_port=pinned, watch=True)
w.show() w.show()
w.raise_() w.raise_()
w.activateWindow() w.activateWindow()
rc = app.exec() rc = app.exec()
reader.close() if w.reader is not None:
w.reader.close()
return rc return rc
......
...@@ -9,14 +9,28 @@ Documentation=file:///home/pln/Work/Sound/Tidal/tools/bridge/midiviz.py ...@@ -9,14 +9,28 @@ Documentation=file:///home/pln/Work/Sound/Tidal/tools/bridge/midiviz.py
# it is headless and has no display to wait for. # it is headless and has no display to wait for.
After=graphical-session.target After=graphical-session.target
PartOf=graphical-session.target PartOf=graphical-session.target
# Belongs to [Unit], not [Service] -- systemd silently ignores it in the
# wrong section (caught 2026-09-05: "Unknown key 'StartLimitIntervalSec' in
# section [Service], ignoring" in the journal after the first install here).
# 0 = no rate-limit window can ever accumulate a start count and refuse to
# restart; paired with Restart=always below.
StartLimitIntervalSec=0
[Service] [Service]
# Inherits the Wayland/Plasma session env from the user manager. # Inherits the Wayland/Plasma session env from the user manager.
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/python3 %h/Work/Sound/Tidal/tools/bridge/midiviz.py ExecStart=/usr/bin/python3 %h/Work/Sound/Tidal/tools/bridge/midiviz.py
# The lens is decoration with a job: if it dies it should come back, but a # 2026-09-05: an unplugged LCXL used to make midiviz.py exit CLEANLY (the
# crash loop must not spin -- it holds no audio ports and nothing waits on it. # `aseqdump` it shelled out to hit EOF and nothing kept the window open), and
Restart=on-failure # `Restart=on-failure` never fires on a clean exit -- PLN's window was gone
# for the rest of the session ("midiviz should always be open"). The .py fix
# is to never exit for that reason at all (see the RECONNECT_MS note in
# midiviz.py); this is the belt-and-braces layer underneath it, for whatever
# future failure mode isn't in-process-recoverable. `Restart=always` (paired
# with [Unit]'s StartLimitIntervalSec=0 above) means it always comes back --
# it holds no audio ports and nothing waits on it, so there is no real
# crash-loop cost to guard against here.
Restart=always
RestartSec=5 RestartSec=5
[Install] [Install]
......
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