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
...@@ -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