Commit abad7515 by PLN (Algolia)

fix(gig-log): resolve the Ardour session without argv — a 3-rung evidence ladder

A GUI-launched Ardour has a BARE argv (only gig-up passes the session as an
argument), so argv-matching alone left the files lens blind on the real rig.
Resolution now climbs: argv → /proc/<pid>/cwd holding a *.ardour → open-fd
symlink targets (most-fds wins on ambiguity, logged once). Injectable proc_root
makes each rung testable against a fake /proc; 9 new tests reproduce the exact
live gap (163 green). check-mix.py shares the latent argv-only bug — flagged in
the sync comment, not fixed this pass.
parent 33897540
...@@ -703,58 +703,173 @@ class RecPass: ...@@ -703,58 +703,173 @@ class RecPass:
"by": self.bytes_total} "by": self.bytes_total}
# ---- session resolution: argv > cwd > open fds -----------------------------
#
# WHY THREE RUNGS (2026-09-05, live-rig verification): the first version of
# this lens copied check-mix.py's running_session() outright — argv only —
# and it MISSED a real, currently-running Ardour on this machine, because it
# had been opened from the GUI's recent-sessions list rather than
# `ardour Foo.ardour`, so its argv was bare (`ardour-9.7.0` and nothing
# 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 is COPIED from check-mix.py's `running_session()` Session resolution climbs the argv > cwd > open-fds ladder above (see
(2026-07-28: a stale/guessed session audited as if it were live produced that block's WHY for the 2026-09-05 live-rig gap that made this three
a confidently wrong report — pgrep also doesn't work here, the process is rungs instead of one). It is duplicated from, not imported from,
`ArdourGUI`, not `ardour`). Keep the two in sync if that logic changes; check-mix.py — that file is a script, not a library, and this one stays
it is duplicated rather than imported because check-mix.py is a script, stdlib-only either way — so keep them in sync if either changes, and
not a library, and this file stays stdlib-only either way. 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 per tick: one os.scandir() (one syscall) plus one stat() per entry
already returned by it, on ONE small directory — no subprocess, no read() Cost: the resolution ladder (argv/cwd/fd-scan) runs only at lens init
of audio data, ever. Re-resolves the session every FILES_RESCAN_S rather and on the FILES_RESCAN_S retry cadence — NEVER per tick, because the
than once at startup, for the same reason GearWatch re-scans pids: a fd-scan tier globs several candidate directories and is not free. The
binding fixed once and never rechecked is this rig's most common failure. per-TICK cost stays exactly what it was: one os.scandir() (one syscall)
plus one stat() per entry already returned by it, on ONE small
directory — no subprocess, no read() of audio data, ever.
""" """
def __init__(self): def __init__(self, proc_root: Path = Path("/proc")):
self._proc_root = proc_root # overridable so tests can fake /proc
self._audiofiles: Path | None = None self._audiofiles: Path | None = None
self._last_scan = 0.0 self._last_scan = 0.0
self._sizes: dict[str, int] = {} self._sizes: dict[str, int] = {}
self._pass = RecPass() self._pass = RecPass()
self._logged_reason: str | None = "unset" # force one log at startup self._logged_reason: str | None = "unset" # force one log at startup
self._logged_ambiguity: frozenset | None = None
@staticmethod @staticmethod
def resolve_audiofiles_dir() -> Path | None: def _ardour_pids(proc_root: Path = Path("/proc")) -> list[str]:
"""<session>/interchange/<session name>/audiofiles/, or None. pids = []
Matches check-mix.py's running_session(): the live Ardour process's
OWN argv, not a hardcoded path and not `pgrep ardour` (the binary is
ArdourGUI; a pgrep match on "ardour" returns nothing).
"""
try: try:
for proc in Path("/proc").iterdir(): for proc in proc_root.iterdir():
if not proc.name.isdigit(): if not proc.name.isdigit():
continue continue
try: try:
argv = (proc / "cmdline").read_bytes().split(b"\0") argv0 = (proc / "cmdline").read_bytes().split(b"\0")[0]
except OSError: except OSError:
continue continue
if not argv or b"ardour" not in argv[0].lower(): if b"ardour" in argv0.lower():
continue pids.append(proc.name)
for arg in argv[1:]:
if arg.endswith(b".ardour"):
p = Path(arg.decode(errors="replace"))
if p.is_file():
return p.parent / "interchange" / p.stem / "audiofiles"
except OSError: except OSError:
pass pass
return pids
@classmethod
def resolve_session_file(cls, 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 cls._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 return None
@classmethod
def resolve_audiofiles_dir(cls, proc_root: Path = Path("/proc"),
log_ambiguity=None) -> Path | None:
"""<session>/interchange/<session name>/audiofiles/, or None."""
p = cls.resolve_session_file(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:
return return
...@@ -764,17 +879,27 @@ class FilesLens: ...@@ -764,17 +879,27 @@ class FilesLens:
else: else:
print(f"gig-log: files lens active — {self._audiofiles}", file=sys.stderr) print(f"gig-log: files lens active — {self._audiofiles}", file=sys.stderr)
def _log_ambiguity(self, counts: dict[Path, int]) -> None:
key = frozenset(counts.items())
if key == self._logged_ambiguity:
return
self._logged_ambiguity = key
shown = {str(k): v for k, v in counts.items()}
print(f"gig-log: files lens — ambiguous session dirs via open fds "
f"{shown} — picked the one with the most", file=sys.stderr)
def sample(self) -> dict: def sample(self) -> dict:
now_mono = time.monotonic() now_mono = time.monotonic()
if self._audiofiles is None or now_mono - self._last_scan >= FILES_RESCAN_S: if self._audiofiles is None or now_mono - self._last_scan >= FILES_RESCAN_S:
self._last_scan = now_mono self._last_scan = now_mono
found = self.resolve_audiofiles_dir() found = self.resolve_audiofiles_dir(self._proc_root,
log_ambiguity=self._log_ambiguity)
if found != self._audiofiles: if found != self._audiofiles:
self._audiofiles = found self._audiofiles = found
self._sizes = {} # a session/track swap invalidates old sizes self._sizes = {} # a session/track swap invalidates old sizes
if self._audiofiles is None: if self._audiofiles is None:
self._log("Ardour not running, or no .ardour session resolvable " self._log("Ardour not running, or no .ardour session resolvable "
"from its argv") "from its argv, cwd, or open fds")
return {"active": False, "rec": False, "el": 0.0, "by": 0} return {"active": False, "rec": False, "el": 0.0, "by": 0}
try: try:
entries = {} entries = {}
......
...@@ -1204,18 +1204,22 @@ def test_osc_lens_sample_reports_inactive_before_anything_arrives(): ...@@ -1204,18 +1204,22 @@ def test_osc_lens_sample_reports_inactive_before_anything_arrives():
# ---- FilesLens: session resolution must never crash off-stage ------------ # # ---- FilesLens: session resolution must never crash off-stage ------------ #
def test_resolve_audiofiles_dir_never_raises_when_no_ardour_is_running(): def test_resolve_audiofiles_dir_never_raises_against_the_real_machine():
"""Same contract as check-mix.py's running_session(): absence of Ardour """Same contract as check-mix.py's running_session(): absence of Ardour
degrades to None, never an exception — this runs in CI with no /proc (or of evidence for it) degrades to None, never an exception. This runs
entry for Ardour at all.""" against the REAL /proc, so on a machine where Ardour genuinely is
running this may legitimately return a Path — only the type is pinned."""
got = gl.FilesLens.resolve_audiofiles_dir() got = gl.FilesLens.resolve_audiofiles_dir()
assert got is None or isinstance(got, Path) assert got is None or isinstance(got, Path)
def test_files_lens_sample_is_inactive_and_silent_by_default_in_tests(capsys): def test_files_lens_sample_is_inactive_and_silent_with_no_evidence(tmp_path, capsys):
"""No Ardour running in the test sandbox: the lens must report inactive """An EMPTY fake /proc (no processes at all): the lens must report
(not raise, not claim a false 'recording'), logging the reason ONCE.""" inactive (not raise, not claim a false 'recording'), logging the reason
lens = gl.FilesLens() ONCE. Uses the injectable proc_root — real /proc is not touched, so this
is deterministic regardless of whether Ardour happens to be running on
the machine this test executes on."""
lens = gl.FilesLens(proc_root=tmp_path)
first = lens.sample() first = lens.sample()
assert first == {"active": False, "rec": False, "el": 0.0, "by": 0} assert first == {"active": False, "rec": False, "el": 0.0, "by": 0}
second = lens.sample() second = lens.sample()
...@@ -1225,6 +1229,139 @@ def test_files_lens_sample_is_inactive_and_silent_by_default_in_tests(capsys): ...@@ -1225,6 +1229,139 @@ def test_files_lens_sample_is_inactive_and_silent_by_default_in_tests(capsys):
assert len(err_lines) <= 1 assert len(err_lines) <= 1
# ---- the argv > cwd > open-fds evidence ladder (2026-09-05 live-rig gap) - #
#
# The bug that prompted the ladder: a real, running Ardour was missed
# because it was opened from the GUI's recent-sessions list, so its argv
# was bare (just the binary, no session path) — argv alone (check-mix.py's
# whole check) is provably insufficient. These build a fake /proc tree —
# real directories and real symlinks, no mocking — shaped exactly like the
# genuine one (numeric pid dir, `cmdline`, a `cwd` symlink, an `fd/` dir of
# symlinks) so the pid-scan and symlink-following code runs unmodified.
def _fake_proc(root: Path, pid: str = "123", argv: tuple[bytes, ...] = (b"ardour-9.7.0",),
cwd: Path | None = None, fds: tuple[Path, ...] = ()) -> Path:
proc = root / "proc"
proc.mkdir(exist_ok=True)
pdir = proc / pid
pdir.mkdir()
(pdir / "cmdline").write_bytes(b"\0".join(argv) + b"\0")
if cwd is not None:
os.symlink(cwd, pdir / "cwd")
fdd = pdir / "fd"
fdd.mkdir()
for i, target in enumerate(fds):
os.symlink(target, fdd / str(i))
return proc
def _session(root: Path, name: str = "Foo") -> Path:
"""A minimal real session dir: <root>/<name>/<name>.ardour."""
d = root / name
d.mkdir()
(d / f"{name}.ardour").write_text("<Session/>")
return d
def test_ladder_prefers_argv_when_it_is_present(tmp_path):
session = _session(tmp_path, "Argv")
other = _session(tmp_path, "Decoy") # cwd points elsewhere
proc = _fake_proc(tmp_path,
argv=(b"ardour-9.7.0", str(session / "Argv.ardour").encode()),
cwd=other)
got = gl.FilesLens.resolve_session_file(proc)
assert got == session / "Argv.ardour"
def test_ladder_falls_back_to_cwd_when_argv_is_bare(tmp_path):
"""THE live-rig bug, reproduced: argv carries only the binary name."""
session = _session(tmp_path, "GuiLaunch")
proc = _fake_proc(tmp_path, argv=(b"ardour-9.7.0",), cwd=session)
got = gl.FilesLens.resolve_session_file(proc)
assert got == session / "GuiLaunch.ardour"
def test_ladder_falls_back_to_open_fds_when_argv_and_cwd_both_fail(tmp_path):
session = _session(tmp_path, "ViaFd")
audiofiles = session / "interchange" / "ViaFd" / "audiofiles"
audiofiles.mkdir(parents=True)
wav = audiofiles / "Tidal01.wav"
wav.write_bytes(b"\0" * 64)
unrelated_cwd = tmp_path / "home"
unrelated_cwd.mkdir()
proc = _fake_proc(tmp_path, argv=(b"ardour-9.7.0",), cwd=unrelated_cwd, fds=(wav,))
got = gl.FilesLens.resolve_session_file(proc)
assert got == session / "ViaFd.ardour"
def test_ladder_returns_none_with_no_evidence_anywhere(tmp_path):
unrelated = tmp_path / "somewhere"
unrelated.mkdir()
proc = _fake_proc(tmp_path, argv=(b"ardour-9.7.0",), cwd=unrelated,
fds=(unrelated / "not-a-real-fd-target",))
assert gl.FilesLens.resolve_session_file(proc) is None
def test_ladder_ignores_processes_that_are_not_ardour(tmp_path):
session = _session(tmp_path, "NotArdour")
proc = _fake_proc(tmp_path, pid="456",
argv=(b"firefox", str(session / "NotArdour.ardour").encode()))
assert gl.FilesLens.resolve_session_file(proc) is None
def test_files_lens_log_ambiguity_dedups_the_same_counts_but_not_a_new_one(tmp_path, capsys):
"""The instance-level 'log the reason ONCE' contract, same as `_log` —
exercised directly since sample()'s FILES_RESCAN_S guard means a second
`sample()` call would not even re-run resolution to hit this path."""
lens = gl.FilesLens()
a, b = tmp_path / "a", tmp_path / "b"
lens._log_ambiguity({a: 1, b: 3})
lens._log_ambiguity({a: 1, b: 3}) # same counts: no second line
lines = [ln for ln in capsys.readouterr().err.splitlines() if "ambiguous" in ln]
assert len(lines) == 1
lens._log_ambiguity({a: 1, b: 4}) # counts CHANGED: logs again
lines = [ln for ln in capsys.readouterr().err.splitlines() if "ambiguous" in ln]
assert len(lines) == 1
def test_ladder_logs_ambiguity_once_and_picks_the_dir_with_most_open_fds(tmp_path):
"""Two distinct session dirs show up via open fds (e.g. a leftover fd
into an old session): must not pick silently — the caller is told."""
minority = _session(tmp_path, "Stale")
majority = _session(tmp_path, "Live")
proc = _fake_proc(tmp_path, argv=(b"ardour-9.7.0",),
fds=(minority / "one.wav", majority / "a.wav",
majority / "b.wav", majority / "c.wav"))
for f in (minority / "one.wav", majority / "a.wav", majority / "b.wav",
majority / "c.wav"):
f.write_bytes(b"x")
calls = []
got = gl.FilesLens.resolve_session_file(proc, log_ambiguity=calls.append)
assert got == majority / "Live.ardour"
assert len(calls) == 1
assert set(calls[0]) == {minority, majority}
def test_files_lens_end_to_end_through_the_fake_proc_root(tmp_path):
"""The full production path (not just the pure ladder helpers): a
FilesLens pointed at a fake proc_root, resolving via cwd (the live-rig
case), then actually detecting growth in the resolved audiofiles dir."""
session = _session(tmp_path, "E2E")
audiofiles = session / "interchange" / "E2E" / "audiofiles"
audiofiles.mkdir(parents=True)
wav = audiofiles / "Tidal01.wav"
wav.write_bytes(b"\0" * 100)
proc = _fake_proc(tmp_path, argv=(b"ardour-9.7.0",), cwd=session)
lens = gl.FilesLens(proc_root=proc)
first = lens.sample()
assert first["active"] is True and first["rec"] is False # no prior size yet
wav.write_bytes(b"\0" * 500) # grew
second = lens.sample()
assert second["active"] is True and second["rec"] is True
assert second["by"] == 400
# ---- Recorder.poll_rec(): the emit-on-transition-or-while-recording rule - # # ---- Recorder.poll_rec(): the emit-on-transition-or-while-recording rule - #
class _FixedLens: class _FixedLens:
......
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