Commit 27f23515 by PLN (Algolia)

fix(check-mix): resolve the running Ardour session via the 3-rung ladder

check-mix.py resolved the live session from argv only, so any Ardour
launched from the GUI's recent-sessions list (bare argv) went unresolved
and check-mix silently fell back to a guessed path. gig-log.py's FilesLens
already solved this with a 3-rung evidence ladder (argv > cwd > open fds,
commit abad7515) and had a sync comment flagging check-mix as needing it.

Extracted the ladder into tools/ardour_session.py — one resolver instead
of two, per feedback_one_parser_per_concept — and made both gig-log.py and
check-mix.py import it. gig-log's FilesLens keeps its instance-level
caching/logging but delegates all evidence-gathering to the shared module;
the now-unnecessary "keep in sync" comment is gone.

Verified: gig-log's 169 pytest tests still pass unchanged (they exercise
all 3 rungs against synthetic /proc fixtures). Live: Ardour was running
("Tidal Live" session, launched with argv), so check-mix.py resolved it
for real and reported per-orbit gains — surfacing the same recurring bug
this fix was meant to catch: Tidal 07/08/10/12 sit at -inf dB in the
currently SAVED session. Rungs 2 (cwd) and 3 (open-fds) were proven against
synthetic fixtures matching the live-rig gap, since the running process's
argv already carried the session path.
parent 3668e9b9
"""ardour_session — resolve the RUNNING Ardour session file without argv alone.
ONE PARSER FOR THIS CONCEPT (feedback_one_parser_per_concept): this used to be
two independent implementations — `tools/gig-log.py`'s `FilesLens` (the full
3-rung ladder) and `tools/check-mix.py`'s `running_session()` (argv only,
never fixed to match). Two parsers for "which session is Ardour running"
diverged exactly as expected: check-mix missed every Ardour launched from the
GUI's recent-sessions list, because that leaves argv bare (`ardour-9.7.0`,
nothing else). This module is the one place both tools call into now.
THE LADDER: argv > cwd > open fds
----------------------------------
WHY THREE RUNGS (2026-09-05, live-rig verification): a real, currently-running
Ardour on this machine was missed by an argv-only check, because it had been
opened from the GUI's recent-sessions list rather than `ardour Foo.ardour`,
so its argv was bare. So: argv is tried first (strongest — Ardour was
launched WITH the session path), then cwd (Ardour chdirs into the session
dir on load — weaker, a launcher script could cd elsewhere first), then a
scan of open file descriptors (weakest, but Ardour always holds files open
under the session while it's loaded, e.g. audiofiles/peakfiles, regardless
of how it was launched or where its cwd drifted to since).
Zero non-stdlib deps, zero subprocess/audio I/O — same "no forks, no reads
of audio data" contract gig-log.py's module holds everywhere else. Callers
that need to log an ambiguous fd-scan result (more than one candidate
session dir) pass `log_ambiguity`; callers that don't care (a one-shot
audit script) just omit it.
"""
from __future__ import annotations
import os
from pathlib import Path
def session_from_argv(argv: list[bytes]) -> Path | None:
"""Strongest evidence: the session file as a literal argv entry."""
for arg in argv[1:]:
if arg.endswith(b".ardour"):
try:
p = Path(arg.decode(errors="replace"))
except ValueError:
continue
if p.is_file():
return p
return None
def _first_ardour_file(d: Path) -> Path | None:
try:
matches = sorted(d.glob("*.ardour"))
except OSError:
return None
return matches[0] if matches else None
def session_from_cwd(cwd: Path | None) -> Path | None:
"""Second rung: Ardour's cwd IS the session dir on this rig's launchers,
but a bare-argv GUI launch can still leave cwd wherever the desktop
entry started it from — hence "try", not "trust"."""
return _first_ardour_file(cwd) if cwd is not None else None
def session_from_fd_targets(targets: list[Path], max_up: int = 6
) -> tuple[Path | None, dict[Path, int]]:
"""Third, weakest rung: for each open fd's target path, walk up to
`max_up` ancestor directories looking for one holding a `*.ardour` file
(an fd under interchange/<name>/audiofiles/ is several levels below the
session dir). Returns the BEST-evidenced session file plus every
candidate's open-fd count, so a caller can log an ambiguity — e.g. a
second Ardour instance, or a stale fd into an old session — rather than
silently picking one. "Best" = most open fds: a session actually in use
has audiofiles, peakfiles, the .ardour.bak and more all open at once; a
leftover single fd into an abandoned session is exactly the case this
count is meant to out-vote.
"""
counts: dict[Path, int] = {}
for target in targets:
d = target.parent
for _ in range(max_up):
if _first_ardour_file(d) is not None:
counts[d] = counts.get(d, 0) + 1
break
if d.parent == d:
break
d = d.parent
if not counts:
return None, {}
best_dir = max(counts, key=lambda k: counts[k])
return _first_ardour_file(best_dir), counts
def ardour_pids(proc_root: Path = Path("/proc")) -> list[str]:
pids = []
try:
for proc in proc_root.iterdir():
if not proc.name.isdigit():
continue
try:
argv0 = (proc / "cmdline").read_bytes().split(b"\0")[0]
except OSError:
continue
if b"ardour" in argv0.lower():
pids.append(proc.name)
except OSError:
pass
return pids
def resolve_session_file(proc_root: Path = Path("/proc"),
log_ambiguity=None) -> Path | None:
"""The running Ardour's `.ardour` session file, or None. `proc_root`
defaults to the real /proc; tests point it at a fixture tree built the
same shape (numeric pid dirs, `cmdline`, a `cwd` symlink, an `fd/` dir
of symlinks) so this whole ladder is exercised without touching the
real system."""
for pid in ardour_pids(proc_root):
proc = proc_root / pid
try:
argv = (proc / "cmdline").read_bytes().split(b"\0")
except OSError:
argv = [b""]
got = session_from_argv(argv)
if got:
return got
try:
cwd = Path(os.readlink(proc / "cwd"))
except OSError:
cwd = None
got = session_from_cwd(cwd)
if got:
return got
targets = []
try:
for fd in (proc / "fd").iterdir():
try:
targets.append(Path(os.readlink(fd)))
except OSError:
continue
except OSError:
pass
got, counts = session_from_fd_targets(targets)
if len(counts) > 1 and log_ambiguity:
log_ambiguity(counts)
if got:
return got
return None
def resolve_audiofiles_dir(proc_root: Path = Path("/proc"),
log_ambiguity=None) -> Path | None:
"""<session>/interchange/<session name>/audiofiles/, or None."""
p = resolve_session_file(proc_root, log_ambiguity)
return (p.parent / "interchange" / p.stem / "audiofiles") if p else None
...@@ -57,38 +57,27 @@ import sys ...@@ -57,38 +57,27 @@ import sys
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import ardour_session # the argv>cwd>fds ladder, shared with gig-log.py
ARDOUR_DIR = Path.home() / "Work/Sound/Ardour" ARDOUR_DIR = Path.home() / "Work/Sound/Ardour"
# THE SESSION IS "Tidal Live". This used to be hardcoded to "Tidal Multi", which on # THE SESSION IS "Tidal Live". This used to be hardcoded to "Tidal Multi", which on
# 2026-07-28 made this tool confidently report five faders at -inf while the ACTUALLY # 2026-07-28 made this tool confidently report five faders at -inf while the ACTUALLY
# OPEN session had them up — a stale file audited as if it were live. A mixer auditor # OPEN session had them up — a stale file audited as if it were live. A mixer auditor
# reading the wrong mixer is worse than no auditor, so resolve the RUNNING session from # reading the wrong mixer is worse than no auditor, so resolve the RUNNING session
# the Ardour process's own argv and only fall back to a guess if that fails. # via tools/ardour_session.py's argv>cwd>fds ladder and only fall back to a guess if
# that fails entirely.
#
# Argv-only used to be this tool's own check, and it silently missed any Ardour
# launched from the GUI's recent-sessions list (bare argv, no session path) — the
# same gap gig-log.py's FilesLens found and fixed on 2026-09-05. Both tools now
# call the one shared resolver so they can't disagree on which session is live.
def running_session() -> Path | None: def running_session() -> Path | None:
"""The .ardour file the live Ardour process was launched with (argv), or None. """The RUNNING Ardour's `.ardour` session file, or None — see
tools/ardour_session.py for the evidence ladder (argv > cwd > open fds).
Ardour's process is `ardour-N.N.N /path/to/Foo.ardour` and the binary is named
ArdourGUI, not "ardour" — a `pgrep ardour` returns nothing, which already caused
one wrong conclusion. Match on the cmdline instead.
""" """
try: return ardour_session.resolve_session_file()
for proc in Path("/proc").iterdir():
if not proc.name.isdigit():
continue
try:
argv = (proc / "cmdline").read_bytes().split(b"\0")
except OSError:
continue
if not argv or b"ardour" not in argv[0].lower():
continue
for arg in argv[1:]:
if arg.endswith(b".ardour"):
p = Path(arg.decode(errors="replace"))
if p.is_file():
return p
except OSError:
pass
return None
def default_session() -> Path: def default_session() -> Path:
......
...@@ -86,7 +86,8 @@ two lenses because neither one alone tells the truth: ...@@ -86,7 +86,8 @@ two lenses because neither one alone tells the truth:
by byte, on disk. Nothing about Ardour's config, arm state or transport display by byte, on disk. Nothing about Ardour's config, arm state or transport display
can lie to it — either bytes landed or they didn't. But it needs a live, can lie to it — either bytes landed or they didn't. But it needs a live,
resolvable session (see check-mix.py's 2026-07-28 lesson: audit the RUNNING resolvable session (see check-mix.py's 2026-07-28 lesson: audit the RUNNING
session, from the process's own argv, never a hardcoded/guessed path). session — resolved by tools/ardour_session.py's argv>cwd>fds ladder —
never a hardcoded/guessed path).
* OSC is Ardour's own opinion: master record-arm and transport-rolling, over its * OSC is Ardour's own opinion: master record-arm and transport-rolling, over its
control-surface protocol. It works even when the files lens can't resolve a control-surface protocol. It works even when the files lens can't resolve a
session, but it is exactly the kind of "config says X" signal that has burned session, but it is exactly the kind of "config says X" signal that has burned
...@@ -131,6 +132,9 @@ from pathlib import Path ...@@ -131,6 +132,9 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "bridge")) sys.path.insert(0, str(Path(__file__).resolve().parent / "bridge"))
import perf # noqa: E402 (rootless Thermals/_read live here already — DRY with the Bridge) import perf # noqa: E402 (rootless Thermals/_read live here already — DRY with the Bridge)
sys.path.insert(0, str(Path(__file__).resolve().parent))
import ardour_session # noqa: E402 (the argv>cwd>fds ladder, shared with check-mix.py)
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")))
...@@ -714,89 +718,22 @@ class RecPass: ...@@ -714,89 +718,22 @@ class RecPass:
# ---- session resolution: argv > cwd > open fds ----------------------------- # ---- session resolution: argv > cwd > open fds -----------------------------
# #
# WHY THREE RUNGS (2026-09-05, live-rig verification): the first version of # The ladder itself (WHY three rungs, what each one trusts and why) now
# this lens copied check-mix.py's running_session() outright — argv only — # lives in tools/ardour_session.py — ONE parser for "which session is
# and it MISSED a real, currently-running Ardour on this machine, because it # Ardour running", shared with check-mix.py (feedback_one_parser_per_concept).
# had been opened from the GUI's recent-sessions list rather than # FilesLens below is a thin wrapper: it owns the per-instance caching/logging
# `ardour Foo.ardour`, so its argv was bare (`ardour-9.7.0` and nothing # state, the ladder module owns the evidence-gathering.
# else). check-mix.py has this exact same latent bug (noted there, not
# fixed there in this pass — check-mix only reads the SAVED session file
# anyway, so a wrong resolution there is a smaller blast radius than here,
# where it silences the ground-truth lens entirely). So: argv is tried
# first (strongest — Ardour was launched WITH the session path), then cwd
# (Ardour chdirs into the session dir on load — weaker, a launcher script
# could cd elsewhere first), then a scan of open file descriptors (weakest,
# but Ardour always holds files open under the session while it's loaded,
# e.g. audiofiles/peakfiles, regardless of how it was launched or where its
# cwd drifted to since).
def _session_from_argv(argv: list[bytes]) -> Path | None:
"""Strongest evidence: the session file as a literal argv entry."""
for arg in argv[1:]:
if arg.endswith(b".ardour"):
try:
p = Path(arg.decode(errors="replace"))
except ValueError:
continue
if p.is_file():
return p
return None
def _first_ardour_file(d: Path) -> Path | None:
try:
matches = sorted(d.glob("*.ardour"))
except OSError:
return None
return matches[0] if matches else None
def _session_from_cwd(cwd: Path | None) -> Path | None:
"""Second rung: Ardour's cwd IS the session dir on this rig's launchers,
but a bare-argv GUI launch can still leave cwd wherever the desktop
entry started it from — hence "try", not "trust"."""
return _first_ardour_file(cwd) if cwd is not None else None
def _session_from_fd_targets(targets: list[Path], max_up: int = 6
) -> tuple[Path | None, dict[Path, int]]:
"""Third, weakest rung: for each open fd's target path, walk up to
`max_up` ancestor directories looking for one holding a `*.ardour` file
(an fd under interchange/<name>/audiofiles/ is several levels below the
session dir). Returns the BEST-evidenced session file plus every
candidate's open-fd count, so a caller can log an ambiguity — e.g. a
second Ardour instance, or a stale fd into an old session — rather than
silently picking one. "Best" = most open fds: a session actually in use
has audiofiles, peakfiles, the .ardour.bak and more all open at once; a
leftover single fd into an abandoned session is exactly the case this
count is meant to out-vote.
"""
counts: dict[Path, int] = {}
for target in targets:
d = target.parent
for _ in range(max_up):
if _first_ardour_file(d) is not None:
counts[d] = counts.get(d, 0) + 1
break
if d.parent == d:
break
d = d.parent
if not counts:
return None, {}
best_dir = max(counts, key=lambda k: counts[k])
return _first_ardour_file(best_dir), counts
class FilesLens: class FilesLens:
""""Is Ardour recording" answered from the one place that can't lie: bytes """"Is Ardour recording" answered from the one place that can't lie: bytes
landing on disk under interchange/<session>/audiofiles/. landing on disk under interchange/<session>/audiofiles/.
Session resolution climbs the argv > cwd > open-fds ladder above (see Session resolution is tools/ardour_session.py's argv > cwd > open-fds
that block's WHY for the 2026-09-05 live-rig gap that made this three ladder (see that module's docstring for the 2026-09-05 live-rig gap that
rungs instead of one). It is duplicated from, not imported from, made this three rungs instead of one) — the exact same resolver
check-mix.py — that file is a script, not a library, and this one stays check-mix.py calls, so the two tools can never disagree on which session
stdlib-only either way — so keep them in sync if either changes, and is live.
note check-mix.py still only has the FIRST rung (a smaller blast radius
there: it audits a saved session file, it does not gate a whole lens).
Cost: the resolution ladder (argv/cwd/fd-scan) runs only at lens init Cost: the resolution ladder (argv/cwd/fd-scan) runs only at lens init
and on the FILES_RESCAN_S retry cadence — NEVER per tick, because the and on the FILES_RESCAN_S retry cadence — NEVER per tick, because the
...@@ -817,20 +754,7 @@ class FilesLens: ...@@ -817,20 +754,7 @@ class FilesLens:
@staticmethod @staticmethod
def _ardour_pids(proc_root: Path = Path("/proc")) -> list[str]: def _ardour_pids(proc_root: Path = Path("/proc")) -> list[str]:
pids = [] return ardour_session.ardour_pids(proc_root)
try:
for proc in proc_root.iterdir():
if not proc.name.isdigit():
continue
try:
argv0 = (proc / "cmdline").read_bytes().split(b"\0")[0]
except OSError:
continue
if b"ardour" in argv0.lower():
pids.append(proc.name)
except OSError:
pass
return pids
@classmethod @classmethod
def resolve_session_file(cls, proc_root: Path = Path("/proc"), def resolve_session_file(cls, proc_root: Path = Path("/proc"),
...@@ -840,44 +764,13 @@ class FilesLens: ...@@ -840,44 +764,13 @@ class FilesLens:
the same shape (numeric pid dirs, `cmdline`, a `cwd` symlink, an the same shape (numeric pid dirs, `cmdline`, a `cwd` symlink, an
`fd/` dir of symlinks) so this whole ladder is exercised without `fd/` dir of symlinks) so this whole ladder is exercised without
touching the real system.""" touching the real system."""
for pid in cls._ardour_pids(proc_root): return ardour_session.resolve_session_file(proc_root, log_ambiguity)
proc = proc_root / pid
try:
argv = (proc / "cmdline").read_bytes().split(b"\0")
except OSError:
argv = [b""]
got = _session_from_argv(argv)
if got:
return got
try:
cwd = Path(os.readlink(proc / "cwd"))
except OSError:
cwd = None
got = _session_from_cwd(cwd)
if got:
return got
targets = []
try:
for fd in (proc / "fd").iterdir():
try:
targets.append(Path(os.readlink(fd)))
except OSError:
continue
except OSError:
pass
got, counts = _session_from_fd_targets(targets)
if len(counts) > 1 and log_ambiguity:
log_ambiguity(counts)
if got:
return got
return None
@classmethod @classmethod
def resolve_audiofiles_dir(cls, proc_root: Path = Path("/proc"), def resolve_audiofiles_dir(cls, proc_root: Path = Path("/proc"),
log_ambiguity=None) -> Path | None: log_ambiguity=None) -> Path | None:
"""<session>/interchange/<session name>/audiofiles/, or None.""" """<session>/interchange/<session name>/audiofiles/, or None."""
p = cls.resolve_session_file(proc_root, log_ambiguity) return ardour_session.resolve_audiofiles_dir(proc_root, log_ambiguity)
return (p.parent / "interchange" / p.stem / "audiofiles") if p else None
def _log(self, reason: str | None) -> None: def _log(self, reason: str | None) -> None:
if reason == self._logged_reason: if reason == self._logged_reason:
......
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