Commit f67cf28e by PLN (Algolia)

gig-log: the MIDI leg had never once bound a port

find_seq_port defaulted to the literal string 'Launch Control XL' — the
ORIGINAL board's product name. The LCXL3 advertises itself as 'LCXL3 1', so
the resolver matched nothing from the day the hardware changed. All nine
recorded sessions from 2026-09-06 onward contain zero cc records, and every
one of their headers says "midi": true, because available() only asks whether
aseqdump is installed: the reader existed, reported itself on, and bound
nothing. run() re-resolved every 5s exactly as designed, forever, against a
name that could not match.

Now it walks the same authored preference midimon and midiviz use, translated
port first, because corpus numbering is the only numbering a log is worth
mining in. A night that falls through to the board's own DAW port is
translated through lcxl_grid.V3_TO_V2 on the way into the window, so the log
is always in corpus numbers and a reader months later never has to know which
port that night happened to bind — and an 'mbind' record now states the port,
the name and whether translation was applied, since the nine silent sessions
happened precisely because nothing recorded that the answer was none.

Coalescing is unchanged and keeps v0/v1/lo/hi/n, which is what makes 'minute
23:30 the bass is too saturated' answerable from the file.

Also adds tools/check-stale-units.py: a unit running three-day-old code is
indistinguishable from a healthy one from outside, and comparing its start
time to its ExecStart file's mtime is the only honest test.
parent 7b33c2a3
#!/usr/bin/env python3
"""Which rig units are running code older than the code on disk.
PLN, 2026-09-23, after spending a pass debugging a midiviz that had been
restarted at 20:13 the night before and therefore contained none of the night's
work: "how can i watch yesterday's build? Every new release should restart bro.
that was wasted time".
He is right, and it is a detectable condition rather than a discipline problem.
A systemd unit holds whatever the interpreter read at ExecStart; editing the
file changes nothing until something restarts it. From outside, a unit running
three-day-old code is indistinguishable from a unit running the current code --
same `active (running)`, same journal, same everything. The only honest
comparison is the unit's start time against the mtime of the file it executes.
Deliberately NOT a git comparison. The question is not "is the working tree
committed", it is "is the process executing what I can read", and an uncommitted
edit counts. mtime answers exactly that and nothing else.
WHAT --fix WILL AND WILL NOT RESTART
Restarts the `login` units: they are background lenses and recorders whose
restart costs nothing anybody hears (midiviz, the Bridge, gig-log,
perf-tray, autoroute, midi-autoconnect).
Reports, never restarts, the rest:
* parvagues-sc — restarting it is 15s of silence and a preload warm.
gig-up owns when that happens.
* lcxl3-driver — Conflicts= the v2 painter, so a blind restart with
lcxl-leds-watch the classic board plugged kills the painter that was
correctly lighting it. gig-up.sh's leds() is the
generation-aware chooser and it owns this decision
(see tools/rig_units.py for the 2026-09-05 story).
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
HERE = Path(__file__).resolve().parent
if str(HERE) not in sys.path:
sys.path.insert(0, str(HERE))
import rig_units # noqa: E402
# Restarting these is inaudible and has no partner unit to fight with.
SAFE_TO_RESTART = {"login"}
def _sh(argv: list[str]) -> tuple[int, str]:
try:
p = subprocess.run(argv, capture_output=True, text=True, timeout=10)
except (OSError, subprocess.SubprocessError):
return 1, ""
return p.returncode, (p.stdout or "") + (p.stderr or "")
def _prop(unit: str, name: str) -> str:
rc, out = _sh(["systemctl", "--user", "show", "-p", name, "--value",
f"{unit}.service"])
return out.strip() if rc == 0 else ""
def exec_paths(unit: str) -> list[Path]:
"""Every repo file named on the unit's ExecStart line.
Reads the unit rather than guessing from the unit's name: perf-tray's unit
lives outside the repo and several units exec a wrapper that is not named
after them.
"""
raw = _prop(unit, "ExecStart")
out: list[Path] = []
for tok in re.findall(r"[/\w.~%-]+\.(?:py|sh|scd)", raw):
p = Path(tok.replace("%h", str(Path.home())))
if p.is_file():
out.append(p)
return out
def started_at(unit: str) -> datetime | None:
ts = _prop(unit, "ActiveEnterTimestamp")
if not ts:
return None
# "Wed 2026-09-23 09:41:26 CEST" — drop weekday and zone, both locale noise.
m = re.search(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", ts)
if not m:
return None
try:
return datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
def survey() -> list[dict]:
out: list[dict] = []
for unit, label, _proc, policy, _why in rig_units.SERVICES:
if _prop(unit, "ActiveState") != "active":
continue
began = started_at(unit)
if began is None:
continue
newer = []
for p in exec_paths(unit):
mt = datetime.fromtimestamp(p.stat().st_mtime)
if mt > began:
newer.append((p, mt))
if not newer:
continue
path, mt = max(newer, key=lambda t: t[1])
out.append(dict(
unit=unit, label=label, policy=policy,
started=began.strftime("%Y-%m-%d %H:%M:%S"),
newest=str(path), edited=mt.strftime("%Y-%m-%d %H:%M:%S"),
behind_s=int((mt - began).total_seconds()),
restartable=policy in SAFE_TO_RESTART,
))
return out
def report(items: list[dict]) -> None:
if not items:
print("check-stale-units: ok — every running unit is executing the code "
"currently on disk.")
return
for it in items:
age = it["behind_s"]
human = (f"{age // 86400}d" if age >= 86400 else
f"{age // 3600}h" if age >= 3600 else f"{age // 60}m")
print(f"[ warn ] {it['label']} ({it['unit']}) is running code {human} old")
print(f" started {it['started']}, but "
f"{Path(it['newest']).name} was edited {it['edited']}")
if it["restartable"]:
print(f" fix: systemctl --user restart {it['unit']}")
else:
print(f" fix: NOT auto-restarted ({it['policy']}) — "
"gig-up owns when this one cycles; restart it by hand if you "
"want the new code now")
def apply(items: list[dict], dry: bool = False) -> int:
todo = [i for i in items if i["restartable"]]
if not todo:
print("check-stale-units: nothing safe to restart "
f"({len(items)} stale unit(s) need a human).")
return 0
for it in todo:
if dry:
print(f" would run: systemctl --user restart {it['unit']}")
continue
rc, out = _sh(["systemctl", "--user", "restart", f"{it['unit']}.service"])
print(f" restarted {it['unit']}" if rc == 0
else f" FAILED {it['unit']}: {out.strip()[:120]}")
return 0
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--fix", action="store_true",
help="restart the units it is safe to restart")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--json", action="store_true")
a = ap.parse_args(argv)
items = survey()
if a.json:
print(json.dumps(items, indent=2))
return 1 if items else 0
report(items)
if a.fix or a.dry_run:
apply(items, dry=a.dry_run)
return 1 if items else 0
if __name__ == "__main__":
sys.exit(main())
...@@ -135,6 +135,14 @@ import perf # noqa: E402 (rootless Thermals/_read live here already — DRY wi ...@@ -135,6 +135,14 @@ import perf # noqa: E402 (rootless Thermals/_read live here already — DRY wi
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
import ardour_session # noqa: E402 (the argv>cwd>fds ladder, shared with check-mix.py) import ardour_session # noqa: E402 (the argv>cwd>fds ladder, shared with check-mix.py)
# The v3 DAW -> v2 corpus CC map, so a night that binds the board's own port is
# still logged in the numbering the corpus speaks. Optional import: a log with
# raw numbers beats no log, so a missing grid degrades rather than refuses.
try:
from lcxl_grid import V3_TO_V2 # noqa: E402
except Exception: # pragma: no cover
V3_TO_V2 = {}
LOG_DIR = Path(os.environ.get( LOG_DIR = Path(os.environ.get(
"GIG_LOG_DIR", os.path.expanduser("~/.local/share/parvagues/gig-log"))) "GIG_LOG_DIR", os.path.expanduser("~/.local/share/parvagues/gig-log")))
...@@ -501,23 +509,43 @@ PB_RE = re.compile(r"^\s*(\d+:\d+)\s+Pitch bend\s+(\d+),\s*value\s+(-?\d+)") ...@@ -501,23 +509,43 @@ PB_RE = re.compile(r"^\s*(\d+:\d+)\s+Pitch bend\s+(\d+),\s*value\s+(-?\d+)")
PORT_RE = re.compile(r"^\s*(\d+:\d+)\s+(.+?)\s\s+") PORT_RE = re.compile(r"^\s*(\d+:\d+)\s+(.+?)\s\s+")
def find_seq_port(want: str = "Launch Control XL") -> str | None: # The SAME authored order midimon and midiviz use, and for the same reason: the
"""Resolve the LCXL's ALSA seq port by NAME, every time we (re)connect. # translated stream first, because that is the numbering the corpus speaks and
# therefore the only numbering a log is worth mining in.
Never hardcode it: this device has already moved 20:0 -> 24:0 across a replug, #
and a port number cached at startup is the stale-binding failure again. # 2026-09-23: the default here was the bare string "Launch Control XL" -- the
# ORIGINAL board's name. The LCXL3 advertises itself as "LCXL3 1", so this
# resolver had matched nothing since the hardware was replaced, and MIDI logging
# was silently dead in all NINE recorded sessions from 2026-09-06 onward while
# every header said `"midi": true`. `available()` only asks whether aseqdump is
# installed, so the reader existed, reported itself on, and bound no port.
# PLN found it by asking the right question: "do we have guaranteed gig logs
# storing the midi inputs?"
SEQ_PREFERENCE: tuple[str, ...] = (
"ParVagues LCXL3", "LCXL3 1 DAW", "LCXL3", "Launch Control XL",
)
def find_seq_port(want: str | None = None) -> tuple[str | None, str | None]:
"""(addr, matched name) for the best MIDI source, re-resolved every connect.
Never hardcode the address: this device has already moved 20:0 -> 24:0
across a replug, and a port number cached at startup is the stale-binding
failure again. `want` pins one name; None walks SEQ_PREFERENCE in order.
""" """
try: try:
r = subprocess.run(["aseqdump", "-l"], capture_output=True, text=True, r = subprocess.run(["aseqdump", "-l"], capture_output=True, text=True,
timeout=5) timeout=5)
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
return None return None, None
for line in (r.stdout or "").splitlines(): lines = (r.stdout or "").splitlines()
if want.lower() in line.lower() and "HUI" not in line: for cand in ((want,) if want else SEQ_PREFERENCE):
m = re.match(r"\s*(\d+:\d+)", line) for line in lines:
if m: if cand.lower() in line.lower() and "HUI" not in line:
return m.group(1) m = re.match(r"\s*(\d+:\d+)", line)
return None if m:
return m.group(1), cand
return None, None
class MidiReader(threading.Thread): class MidiReader(threading.Thread):
...@@ -528,11 +556,18 @@ class MidiReader(threading.Thread): ...@@ -528,11 +556,18 @@ class MidiReader(threading.Thread):
writes have to go over seq). writes have to go over seq).
""" """
def __init__(self, port: str | None = None, want: str = "Launch Control XL"): def __init__(self, port: str | None = None, want: str | None = None):
super().__init__(daemon=True) super().__init__(daemon=True)
self.want = want self.want = want
self.fixed = port self.fixed = port
self.port: str | None = None self.port: str | None = None
self.matched: str | None = None
# True while bound to the board's own DAW port, whose CC numbers are v3
# DAW indices rather than the corpus's. Translated on the way into the
# window, so a log is ALWAYS in corpus numbers and a future reader never
# has to know which port a given night happened to bind. Belt and
# suspenders, the same rule midiviz got the same morning.
self.v3_dialect = False
self.cc: dict[tuple[str, int, int], dict] = {} self.cc: dict[tuple[str, int, int], dict] = {}
self.discrete: list[dict] = [] self.discrete: list[dict] = []
self.lock = threading.Lock() self.lock = threading.Lock()
...@@ -550,6 +585,8 @@ class MidiReader(threading.Thread): ...@@ -550,6 +585,8 @@ class MidiReader(threading.Thread):
m = CC_RE.match(line) m = CC_RE.match(line)
if m: if m:
port, ch, cc, val = m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4)) port, ch, cc, val = m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4))
if self.v3_dialect and V3_TO_V2:
cc = V3_TO_V2.get(cc, cc)
key = (port, ch, cc) key = (port, ch, cc)
with self.lock: with self.lock:
e = self.cc.get(key) e = self.cc.get(key)
...@@ -606,12 +643,25 @@ class MidiReader(threading.Thread): ...@@ -606,12 +643,25 @@ class MidiReader(threading.Thread):
def run(self) -> None: def run(self) -> None:
while not self._stop.is_set(): while not self._stop.is_set():
port = self.fixed or find_seq_port(self.want) # re-resolve every time if self.fixed:
port, matched = self.fixed, self.fixed
else:
port, matched = find_seq_port(self.want) # re-resolve every time
if not port: if not port:
if self._stop.wait(5.0): if self._stop.wait(5.0):
return return
continue continue
self.port = port self.port, self.matched = port, matched
self.v3_dialect = bool(matched and "DAW" in matched)
# Say so IN THE LOG. A reader mining this file months later must not
# have to guess which port a given night bound, nor whether the CC
# numbers were translated -- the nine silent sessions happened
# precisely because nothing recorded that the answer was "none".
with self.lock:
self.discrete.append({"t": time.time(), "k": "mbind", "p": port,
"name": matched,
"v3": self.v3_dialect,
"xlate": bool(self.v3_dialect and V3_TO_V2)})
try: try:
self._proc = subprocess.Popen( self._proc = subprocess.Popen(
["aseqdump", "-p", port], stdout=subprocess.PIPE, ["aseqdump", "-p", port], stdout=subprocess.PIPE,
...@@ -1321,6 +1371,7 @@ class Recorder: ...@@ -1321,6 +1371,7 @@ class Recorder:
self._write({"t": round(time.time(), 3), "k": "end", self._write({"t": round(time.time(), 3), "k": "end",
"samples": self.samples, "samples": self.samples,
"midi_events": self.mid.events if self.mid else 0, "midi_events": self.mid.events if self.mid else 0,
"midi_port": (self.mid.matched if self.mid else None),
"xrun": self.xr.total_delta() if self.xr else None}) "xrun": self.xr.total_delta() if self.xr else None})
for t in (self.xr, self.mid, self.osc): for t in (self.xr, self.mid, self.osc):
if t: if t:
......
...@@ -479,9 +479,9 @@ def test_a_sample_is_json_serialisable_even_when_every_reading_is_none(tmp_path) ...@@ -479,9 +479,9 @@ def test_a_sample_is_json_serialisable_even_when_every_reading_is_none(tmp_path)
def test_find_seq_port_never_returns_the_hui_port(): def test_find_seq_port_never_returns_the_hui_port():
"""The LCXL exposes two ports; 'HUI' is the Mackie-emulation one and carries """The LCXL exposes two ports; 'HUI' is the Mackie-emulation one and carries
none of our control traffic.""" none of our control traffic."""
got = gl.find_seq_port("Launch Control XL") addr, name = gl.find_seq_port("Launch Control XL")
if got is not None: if addr is not None:
assert ":" in got assert ":" in addr and name
def test_the_midi_reader_re_resolves_its_port_by_name(): def test_the_midi_reader_re_resolves_its_port_by_name():
......
"""The MIDI leg of the session recorder — the leg that had never worked.
2026-09-23. PLN asked "do we have guaranteed gig logs storing the midi inputs?
I wanna ensure, when we master the recording of these 2h, you have a midi event
log to mine". The answer was no, and had been no for every recorded session:
`find_seq_port` defaulted to the string "Launch Control XL", the ORIGINAL
board's name, while the LCXL3 advertises "LCXL3 1". Nine sessions from
2026-09-06 on contain zero `cc` records and every one of their headers says
`"midi": true`, because `available()` only asks whether aseqdump is installed.
These tests are about the resolver and the dialect, not about aseqdump.
"""
from __future__ import annotations
import importlib.util
import pathlib
import sys
import pytest
TOOLS = pathlib.Path(__file__).resolve().parents[1]
for extra in (TOOLS, TOOLS / "bridge"):
if str(extra) not in sys.path:
sys.path.insert(0, str(extra))
def _load():
spec = importlib.util.spec_from_file_location("gig_log_under_test",
TOOLS / "gig-log.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
GL = _load()
# One realistic `aseqdump -l` listing, with the translated port present.
LISTING = """ Port Client name Port name
14:0 Midi Through Midi Through Port-0
24:0 LCXL3 1 LCXL3 1 MIDI In
24:1 LCXL3 1 LCXL3 1 DAW In
129:0 RtMidiOut Client RtMidi output
131:0 RtMidiOut Client ParVagues LCXL3
"""
def _fake_aseqdump(monkeypatch, stdout: str):
class R:
def __init__(self, out): self.stdout = out
monkeypatch.setattr(GL.subprocess, "run", lambda *a, **k: R(stdout))
def test_the_original_boards_name_is_what_broke_it(monkeypatch):
"""The regression test for the actual bug, stated as the bug."""
_fake_aseqdump(monkeypatch, LISTING)
assert GL.find_seq_port("Launch Control XL") == (None, None)
assert GL.find_seq_port()[0] is not None, (
"the default must not be one board's product name")
def test_the_translated_port_wins(monkeypatch):
# Corpus numbering is the only numbering a log is worth mining in.
_fake_aseqdump(monkeypatch, LISTING)
assert GL.find_seq_port() == ("131:0", "ParVagues LCXL3")
def test_it_falls_through_to_the_board_when_the_driver_is_down(monkeypatch):
_fake_aseqdump(monkeypatch, LISTING.replace(
"131:0 RtMidiOut Client ParVagues LCXL3\n", ""))
addr, name = GL.find_seq_port()
assert (addr, name) == ("24:1", "LCXL3 1 DAW")
def test_an_original_lcxl_still_resolves(monkeypatch):
_fake_aseqdump(monkeypatch, " 20:0 Launch Control XL Launch Control XL MIDI 1\n")
assert GL.find_seq_port() == ("20:0", "Launch Control XL")
def test_nothing_on_the_bus_is_not_an_exception(monkeypatch):
_fake_aseqdump(monkeypatch, " 14:0 Midi Through Midi Through Port-0\n")
assert GL.find_seq_port() == (None, None)
def test_a_hui_port_is_never_chosen(monkeypatch):
_fake_aseqdump(monkeypatch, " 28:0 LCXL3 1 HUI LCXL3 1 HUI In\n")
assert GL.find_seq_port() == (None, None)
# ── the dialect: a log is always in corpus numbers ─────────────────────────
def _reader(v3: bool):
r = GL.MidiReader.__new__(GL.MidiReader)
r.cc, r.discrete, r.events = {}, [], 0
r.lock = __import__("threading").Lock()
r.v3_dialect = v3
return r
def _cc_line(cc: int, val: int) -> str:
return f" 24:1 Control change 0, controller {cc}, value {val}\n"
def test_a_raw_daw_stream_is_logged_in_corpus_numbers():
import lcxl_grid as grid
r = _reader(True)
v3 = grid.V3_ROWS["D"][0] # fader 1: v3 CC 5
assert r.feed(_cc_line(v3, 99), 1.0) is True
logged = {k[2] for k in r.cc}
assert logged == {grid.V3_TO_V2[v3]}, (
"a fader logged under its v3 index is unmineable: nothing in the corpus "
"refers to that number")
def test_the_translated_stream_is_logged_untouched():
import lcxl_grid as grid
r = _reader(False)
cc = grid.ROW_CCS["D"][0]
r.feed(_cc_line(cc, 99), 1.0)
assert {k[2] for k in r.cc} == {cc}
def test_coalescing_still_keeps_the_range_a_master_needs():
# "minute 23:30->24:30 bass is too saturated" is answered by lo/hi/n, so
# coalescing must never flatten a sweep to its last value.
r = _reader(False)
for v in (10, 90, 40):
r.feed(_cc_line(77, v), 1.0)
(rec,) = r.drain()[0]
assert (rec["v0"], rec["v1"], rec["lo"], rec["hi"], rec["n"]) == (10, 40, 10, 90, 3)
def test_the_bind_is_recorded_so_a_future_reader_need_not_guess():
src = (TOOLS / "gig-log.py").read_text()
assert '"k": "mbind"' in src, (
"nothing records WHICH port was bound — and the nine silent sessions "
"happened precisely because nothing recorded that the answer was none")
assert '"xlate"' in src
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