Commit 9bb6c294 by PLN (Algolia)

docs(log): 042 — four blockers, three of them the measuring instrument

Captain's log for the loose-thread sweep, plus the new tool it produced.

tools/ardour-drop-missing-sources.py: Ardour's "Missing File" modal appears once
per unreadable source, no flag suppresses it, and "skip all missing" does not fix
it — Ardour substitutes silent stubs so the session loads, but the dead names stay
in the session file, so the modal returns on every launch. PLN hit it twice in one
evening. This drops the <Source> and every <Region> naming it, in <Regions> and in
all 14 playlists.

It refuses to act on a guess, twice over:
  - it will not write while Ardour is running, because Ardour holds the session in
    memory and would save over the edit on quit, silently undoing everything;
  - it will not drop a reference until it has PROVEN the file is gone from both the
    session tree and the Freebox, the SSOT for audio here. A reference is the last
    breadcrumb pointing at a lost take. And an unfinished search reports as
    unfinished rather than as "not found" — only one of those justifies dropping
    the breadcrumb.

Its first version rglobbed the Freebox once per missing file: 24 full traversals
over the network, and it never finished. Now one indexed os.walk per root against
a set of names, with the deadline checked per directory so a fruitless search
still stops on time.

041 is marked resolved in place: the blocker's premise was wrong.
parent 9f8d9005
> **RESOLVED 2026-09-05** — see `042-the-instrument-was-the-bug.md`.
> The blocker's premise was wrong: `is_running()` measures correctly. The
> double-launch is now prevented structurally (`3c00a9c`) and the rewire is
> reimplemented and live (`9f8d900`) — the saved patch had gone stale against
> `90afa23` and was not used.
--- ---
log: 041 log: 041
title: "PARKED — GIG UP rewire, blocked on Ardour double-launch" title: "PARKED — GIG UP rewire, blocked on Ardour double-launch"
......
---
log: 042
title: "Four blockers, three broken instruments"
date: 2026-09-05
task: "n/a loose-thread sweep after the play session"
tags: [tooling, rig, gate, ardour, midi]
shareable: true
---
## Cap (what & why)
Close every loose thread left open by the gear-failure night: the Ardour
double-launch that killed both instances, midiviz dying with the board, the four
standing NO-GO blockers on the gate, and a suspected memory leak. The rig had to
stop being something you debug before you can play it.
## Manœuvre (how)
Reproduced before repairing, every time. That order is the whole story of this
log: **three of the four things reported as broken were the measuring tool, not
the rig.**
1. **The Ardour double-launch.** Parked as "fix `is_running()` first". Measured
it cold with Ardour up: it returns `True`. argv0 is `ardour-9.7.0` (the
`/usr/bin/ardour9` wrapper is a shell script that exec's the real binary) and
the regex matches it fine. The false negative is not reproducible and nothing
logged the decision, so the cause is gone for good. So the fix stopped being
"repair the probe" and became "remove the probe's veto over an irreversible
action": a pidfile spawn guard in `$XDG_RUNTIME_DIR` (a file, not a module
global — the tray and the Bridge are two processes), identity-rechecked so a
recycled pid can't jam it shut, plus a log line per decision.
2. **"boot helpers FAIL".** `check-boot.sh` alone passed every line green while
gig-up called the same check FAIL. Under gig-up it crashed on
`int('6C1o')` — two output lines interleaved character-wise. stdout and
stderr are deliberately one merged pipe (separate capture would attribute
every error to the last block), but GHC block-buffers stdout and writes
errors unbuffered, so a marker got flushed into a chunk a stderr write had
already cut into.
3. **"surface grid drift".** Ruled out the CosmicFest false positive first —
`check-drift.sh` passes and clears all five of PLN's modified files
individually. The real failure was a migration debt: `b5ad8b6` ("finish the
#94 column remap") never touched four files, three last edited two days
before it. Direction, not volume.
4. **The 2.6 GB perf-tray leak.** systemd's own accounting: 59.6 M peak over
2h15m. The hand-off had misread virtual size as RSS. Nothing to fix.
## Prise (findings / artifacts)
- `3c00a9c` — spawn guard + launch logging + superseded-`.pending` sweep;
`tools/ardour-tidal-live.desktop` kills the session chooser at every entry
point (the default click can no longer open the ARCHIVE session by mistake).
- `e9d1631` — midiviz survives an unplug **in-process**: main PID unchanged,
`NRestarts` 0, a fresh `aseqdump` child inside one 2 s tick. It had been
exiting with status 0, which is why `Restart=on-failure` never fired.
- `e47361c` — two typos in `something_about_drums.tidal`: a stray `d1` after a
closing paren (killing the whole d2 block) and `d8 $ gF1 $ gM1` (d8 wired to
d1's mute family, so d8's mute would have taken d1 with it, live). Plus the
buffering fix, with a planted unbalanced paren proving the checker still
catches and correctly attributes a real error.
- `9381e22` — the #94 remap finished on three files. Two were **swaps**
(`^52<->^32`, `^89<->^57`); verified the rewriter resolves each line in one
`re.sub` pass against the original text, so a swap is atomic and doesn't
collapse onto one control.
- `9f8d900` — 🌊 Launch Gig → **GIG UP**, driving the same reconciler as the
Bridge. Verified: `ardour: running → focus`, ArdourGUI count 1 before and
after. The line this thread existed to produce.
- `tools/ardour-drop-missing-sources.py` — new. Ardour's "Missing File" modal
can't be suppressed by a flag, and "skip all missing" doesn't fix it: silent
stubs load, the dead names stay in the session file, the modal returns every
launch. 24 dead sources found, all one take, 12 orbits × L/R.
- Gate: **4 blockers → 2**, both now waiting on PLN's judgement rather than on
code.
## Sel (the shareable learning)
- A green check run by hand and a red check run by the gate can both be honest
about the same rig. The difference was buffering: **the merged pipe that makes
error attribution possible is the same merged pipe that lets a marker line get
torn in half.** The fix isn't to unmerge, it's to make each write atomic and
the marker impossible to misread when it isn't.
- When a probe wrongly vetoes an irreversible action *once*, unrecoverably and
unlogged, repairing the probe is the wrong instinct. Measure it: if it's fine,
the defect is that a single probe had that authority at all.
- A safety rule that makes the safe path invisible protects nothing. #116 kept
Launch Gig editor-only; the name still promised a gig, so it read as broken,
and the real launcher went unpressed for 6.7 days while the launch moved to a
hand-typed command. Arming beats crippling.
- The first version of the missing-source tool rglobbed the Freebox once per
missing file — 24 full network traversals. It never finished. One indexed walk
instead, with the deadline checked per directory, because "I didn't find it"
and "I didn't finish looking" are different answers and only one of them
justifies dropping the last reference to a lost take.
## Hameçon (hook)
Four things were broken. Three of them were the instruments. The night's real
bug count was one stray `d1`.
## Sillage (what it unlocks)
GIG UP is now a button that means it. The gate's remaining two failures are
taste calls, not defects: one fader 5.5 dB off baseline, and one track whose
grid migration would comment out live `d3` lines. Both are PLN's to make.
#!/usr/bin/env python3
"""Drop references to Ardour sources whose audio files are GONE.
Why this exists
---------------
2026-09-05, PLN: "ardour is at the screen choce Tidal Live Tidal Multi, this
must go for leaner moves" — and then, one modal later, "then again had a missing
file screen still open ardour...".
Ardour's "Missing File" dialog appears once per unreadable source and cannot be
suppressed by a flag. Answering "skip all missing" does not fix anything: Ardour
substitutes silent stubs so the session can load, but the session file keeps the
dead names, so the dialog returns on every single launch. That is a stack of
focus-stealing modals standing between a click and a playable rig — exactly what
the hot path must not contain (#136).
The honest fix is to stop referring to files that do not exist.
What it will and will not do
----------------------------
It removes, for each missing source: the <Source> entry, every <Region> that
names it (in <Regions> and inside every <Playlist>), and reports what went.
It does NOT delete audio. It cannot: the files are already gone. But it refuses
to run at all unless it can prove the missing files are missing EVERYWHERE it
knows to look, including the Freebox, which is the single source of truth for
audio in this rig. A reference is the last breadcrumb pointing at a lost take;
dropping one while the take is recoverable would turn a restore into a
forensics job. --allow-archived overrides that, deliberately loudly.
It also refuses to run while Ardour is open, because Ardour would save its
in-memory session over the edit on quit and silently undo the whole thing.
Every write is preceded by a timestamped backup beside the session file.
"""
from __future__ import annotations
import argparse
import os
import shutil
import sys
import time
import xml.etree.ElementTree as ET
from pathlib import Path
FREEBOX = Path("/mnt/freebox/PLN")
def ardour_running() -> list[int]:
"""PIDs of any running Ardour. /proc scan, never pgrep -f.
(`pgrep -f`/`pkill -f` match the calling shell's own argv in some harnesses,
which has killed the caller here before now.)
"""
out = []
me = os.getpid()
for p in Path("/proc").glob("[0-9]*"):
if p.name == str(me):
continue
try:
if (p / "comm").read_text().strip() == "ArdourGUI":
out.append(int(p.name))
except OSError:
continue
return out
def source_path(session: Path, src: ET.Element) -> Path:
"""Where Ardour would look for this source."""
name = src.get("name") or ""
origin = src.get("origin") or ""
if origin and Path(origin).is_absolute():
return Path(origin)
sub = "midifiles" if (src.get("type") == "midi") else "audiofiles"
return session.parent / "interchange" / session.stem / sub / name
def find_elsewhere(names: set[str], session: Path, budget: float) -> dict[str, str]:
"""{name: first path} for every one of `names` that survives elsewhere.
ONE walk per root, matching against a set — not one rglob per file. The first
version did the latter and spent 24 full traversals of the Freebox over the
network for a single session; it never finished. Cheap to write, and the kind
of thing that only shows up on the real corpus.
The deadline is checked per DIRECTORY, not per hit, so a fruitless search
still stops on time. A search that ran out of budget is reported as such by
the caller — "I did not find it" and "I did not finish looking" are not the
same answer, and only one of them justifies dropping a reference.
"""
found: dict[str, str] = {}
deadline = time.monotonic() + budget
for root in (session.parent, FREEBOX):
if not root.exists():
continue
for dirpath, dirnames, filenames in os.walk(root, onerror=lambda e: None):
if time.monotonic() > deadline:
return found
# The session's own audiofiles dir is where we already looked.
for fn in filenames:
if fn in names and fn not in found:
found[fn] = str(Path(dirpath) / fn)
if len(found) == len(names):
return found
return found
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("session", type=Path, help="path to the .ardour file")
ap.add_argument("--apply", action="store_true",
help="write the session (default is a dry run)")
ap.add_argument("--allow-archived", action="store_true",
help="drop references even if the file survives elsewhere")
ap.add_argument("--search-budget", type=float, default=60.0,
help="seconds to spend proving a file is gone (default 60)")
a = ap.parse_args(argv)
session = a.session.expanduser()
if not session.exists():
print(f"no such session: {session}", file=sys.stderr)
return 2
tree = ET.parse(session)
root = tree.getroot()
sources = root.findall(".//Sources/Source")
missing = [s for s in sources if not source_path(session, s).exists()]
print(f"{session.name}: {len(sources)} source(s), {len(missing)} missing")
if not missing:
print("nothing to do — no modal to remove.")
return 0
# Group the report by take, because that is how these actually go missing:
# one take's worth of orbits at a time.
takes: dict[str, list[ET.Element]] = {}
for s in missing:
takes.setdefault((s.get("name") or "?").split("_")[0], []).append(s)
for take, ss in sorted(takes.items()):
print(f" {take}: {len(ss)} source(s)")
# Prove they are gone everywhere before touching a reference.
names = {(s.get("name") or "") for s in missing}
t0 = time.monotonic()
recoverable = find_elsewhere(names, session, a.search_budget)
spent = time.monotonic() - t0
print(f"\nsearched the session tree and {FREEBOX} in {spent:.0f}s")
if recoverable:
print(f"{len(recoverable)} missing file(s) SURVIVE elsewhere:", file=sys.stderr)
for name, hit in list(recoverable.items())[:5]:
print(f" {name} -> {hit}", file=sys.stderr)
if not a.allow_archived:
print("\nRefusing: restore these instead of dropping the reference "
"(or pass --allow-archived).", file=sys.stderr)
return 1
elif spent >= a.search_budget - 1:
print("the search ran OUT OF BUDGET before finishing — 'not found' here "
"means 'not yet found'.", file=sys.stderr)
if not a.allow_archived:
print("Refusing on an unfinished search: raise --search-budget, or "
"pass --allow-archived if you know these are gone.",
file=sys.stderr)
return 1
else:
print("confirmed gone: nothing survives in the session tree or the Freebox.")
ids = {s.get("id") for s in missing}
# Regions naming a dead source, in <Regions> and in every <Playlist>.
dropped_regions = 0
for parent in list(root.iter()):
for reg in list(parent.findall("Region")):
refs = {v for k, v in reg.attrib.items() if "source-" in k}
if refs & ids:
parent.remove(reg)
dropped_regions += 1
dropped_sources = 0
for parent in list(root.iter("Sources")):
for s in list(parent.findall("Source")):
if s.get("id") in ids:
parent.remove(s)
dropped_sources += 1
print(f"\nwould drop: {dropped_sources} source(s), {dropped_regions} region(s)")
if not a.apply:
print("dry run — pass --apply to write.")
return 0
pids = ardour_running()
if pids:
print(f"\nRefusing to write: Ardour is running (pid {pids}). It holds this "
"session in memory and would save over the edit on quit.",
file=sys.stderr)
return 1
backup = session.with_suffix(session.suffix + f".missing-sweep-{time.strftime('%Y%m%d-%H%M%S')}.bak")
shutil.copy2(session, backup)
tree.write(session, encoding="UTF-8", xml_declaration=True)
print(f"written. backup: {backup.name}")
return 0
if __name__ == "__main__":
sys.exit(main())
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