Commit 3c00a9cb by PLN (Algolia)

fix(rig): make a duplicate Ardour spawn structurally impossible, and log the decision

The parked blocker was wrong about its own cause, which turned out to be the
more useful finding.

RIG UP answered one press with "ardour: launched — launched Ardour" while Ardour
was already up. The two instances collided over the session lock and BOTH exited,
taking all twelve orbit links with them mid-evening. The obvious suspect was
is_running()'s `exe` regex, so the GIG UP rewire was parked behind "fix the
detection first".

Measured cold, the detection is fine. With Ardour running, is_running() returns
True: argv0 is `ardour-9.7.0` (the wrapper /usr/bin/ardour9 is a shell script
that exec's /usr/lib/ardour9/ardour-9.7.0) and `[Aa]rdour[-\d.]*$` matches it.
Two presses in a row now correctly answer "running -> focus". The false negative
is not reproducible, and -- the actual defect -- nothing anywhere recorded the
decision, so the cause cannot be recovered. The incident had to be reconstructed
from a chat transcript.

So stop repairing a probe that measures correctly, and remove the probe's veto
over an irreversible action:

- A spawn guard keyed on a pidfile in $XDG_RUNTIME_DIR, consulted only after
  is_running() says no. It blocks while the pid we spawned is alive, plus a 20s
  window covering fork->exec->/proc visibility -- the one interval in which
  is_running() is legitimately blind. A pidfile rather than a module global
  because the two faces are two processes: the web Bridge and the perf-tray both
  call launch(), and an in-memory note in one is invisible to the other.
- The guard re-checks process IDENTITY, not just liveness, so a recycled pid
  cannot jam it shut forever. A guard that fails closed permanently is its own
  outage.
- _log() writes every launch decision to stderr -> the journal.
- Ardour additionally sweeps a SUPERSEDED .pending aside before launching. No
  flag suppresses the "recover from crash?" modal -- only the file's absence does
  -- and that modal is exactly what the hot path must not contain (#136). It
  moves (never deletes, into the session's own dead/) and only when the session
  was SAVED AFTER the pending was written, i.e. a later save already superseded
  it. A .pending newer than the save holds unsaved captures; that one deserves a
  human, so its dialog is left to appear.

Verified live, in this order:
  1. cold launch          -> SPAWNED pid 2708769, session path passed, 1 ArdourGUI
  2. immediate 2nd press  -> "running -> focus", still 1 ArdourGUI
  3. is_running monkeypatched to lie False (the incident's exact condition)
                          -> "NOT spawning -- we started pid 2708769 14.9s ago",
                             still 1 ArdourGUI
  4. same, 52s later (past SPAWN_GRACE) -> still refuses, on pid liveness
  5. dead pid + old timestamp -> guard stands down, launching is possible again

Also: the session chooser is gone from every entry point. Stock ardour9.desktop
runs `ardour9` with no argument, so every launch from the menu asked "Tidal Live
or Tidal Multi?" -- a modal in the hot path, and a chance to open the ARCHIVE by
mistake (that same mixup produced a confidently wrong fader report on
2026-07-28). A user-local override sends the default click straight into Tidal
Live and keeps the chooser as a right-click action.

Stale comment corrected: the real binary is 9.7.0, not the 9.2.0 recorded there.
parent 1236fe63
[Desktop Entry]
# Overrides /usr/share/applications/ardour9.desktop (a user-local file of the
# same basename wins). Reason: stock Exec=ardour9 with no argument always opens
# the session chooser, so every launch from the menu asked "Tidal Live or Tidal
# Multi?" — a modal in the hot path (design rule #136) and, worse, a chance to
# open the ARCHIVE session by mistake. "Tidal Multi" holds the historical
# per-orbit recordings; auditing it as if it were live already produced a
# confidently wrong fader report on 2026-07-28. So the default click is now
# unambiguous, and the chooser survives as a right-click action for the rare
# time PLN actually wants the archive.
Name=Ardour — Tidal Live
Comment=THE performing session (records every orbit as a stem)
Exec=ardour9 "/home/pln/Work/Sound/Ardour/Tidal Live/Tidal Live.ardour"
Icon=ardour9
Terminal=false
MimeType=application/x-ardour;
Type=Application
Categories=AudioVideo;Audio;AudioEditing;X-Recorders;X-Multitrack;X-Jack;
StartupWMClass=Ardour
X-NSM-Capable=true
X-NSM-Exec=ardour9
Actions=chooser;
[Desktop Action chooser]
Name=Open the session chooser (archive, other sessions)…
Exec=ardour9
...@@ -16,6 +16,8 @@ import re ...@@ -16,6 +16,8 @@ import re
import shutil import shutil
import socket import socket
import subprocess import subprocess
import sys
import time
from pathlib import Path from pathlib import Path
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
...@@ -62,7 +64,7 @@ LAUNCHERS = [ ...@@ -62,7 +64,7 @@ LAUNCHERS = [
"candidates": ["pulsar"], "args": [str(TIDAL)], "exe": r"pulsar$"}, "candidates": ["pulsar"], "args": [str(TIDAL)], "exe": r"pulsar$"},
# `ardour9` was missing from the candidates, so this entry could never actually # `ardour9` was missing from the candidates, so this entry could never actually
# start the installed Ardour (9.2.0) — it reported "unavailable" while Ardour was # start the installed Ardour (9.2.0) — it reported "unavailable" while Ardour was
# running. Newest first, and the version-suffixed real binary (`ardour-9.2.0`) is # running. Newest first, and the version-suffixed real binary (`ardour-9.7.0` as of 2026-09-05) is
# what the exe check has to match. # what the exe check has to match.
{"key": "ardour", "name": "Ardour", "blurb": "DAW — the Tidal Live session (records the stems)", {"key": "ardour", "name": "Ardour", "blurb": "DAW — the Tidal Live session (records the stems)",
"candidates": ["ardour9", "ardour8", "ardour7", "ardour6", "ardour"], "candidates": ["ardour9", "ardour8", "ardour7", "ardour6", "ardour"],
...@@ -264,6 +266,132 @@ def _argv(spec): ...@@ -264,6 +266,132 @@ def _argv(spec):
return _term_argv(inner) if spec.get("terminal") else inner return _term_argv(inner) if spec.get("terminal") else inner
# ---------------------------------------------------------------------------
# Spawn guard
#
# 2026-09-05: RIG UP answered a press with "ardour: launched — launched Ardour"
# while Ardour was already up. Two instances then collided over the session lock
# and BOTH exited, taking all twelve orbit links with them. The obvious suspect
# was is_running()'s `exe` regex, so it was parked as the blocker — but measured
# cold with Ardour running it returns True (argv0 is `ardour-9.7.0`, and
# `[Aa]rdour[-\d.]*$` matches it). The false negative is NOT reproducible, and
# there was no log of the decision, so the cause cannot be recovered.
#
# That is the actual lesson: a single presence probe decided whether to start a
# DAW, and when it was wrong the mistake was unrecoverable and unrecorded. So
# rather than repair a probe that measures fine, make a duplicate spawn
# structurally impossible whatever the probe says, and write down every decision.
#
# The guard is a pidfile in the runtime dir, not a module global, because the two
# faces are two PROCESSES: the web Bridge and the perf-tray both call launch(),
# so an in-memory note in one is invisible to the other. It blocks while the pid
# we spawned is alive, plus a short window after the spawn to cover the gap
# between Popen returning and exec landing the new argv0 in /proc — the one
# interval in which is_running() is legitimately blind.
_RUNDIR = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") / "parvagues" / "launch"
SPAWN_GRACE = 20.0 # seconds; covers fork→exec→/proc visibility
def _log(msg):
"""Every launch decision, to stderr → the unit's journal.
The double-launch that killed both Ardours left no trace anywhere; the whole
incident had to be reconstructed from a chat transcript. A launcher that can
start a DAW is allowed to cost one line of journal per press.
"""
print(f"launchers: {msg}", file=sys.stderr, flush=True)
def _pid_alive(pid, spec):
"""Is `pid` alive AND still the thing we spawned?
The identity re-check matters: pids are recycled, and a stale pidfile whose
number now belongs to an unrelated process would refuse to ever launch again
— a guard that fails closed forever is its own outage.
"""
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
line = f.read().replace(b"\x00", b" ").decode("utf-8", "replace").strip()
except OSError:
return False
if not line:
return False
argv0 = line.split(" ", 1)[0].rsplit("/", 1)[-1]
exe, pat = spec.get("exe"), spec.get("pgrep")
if exe:
try:
return bool(re.match(exe, argv0))
except re.error:
return False
if pat:
try:
return bool(re.search(pat, line))
except re.error:
return False
return False
def _recent_spawn(spec):
"""(pid, age) if WE started this launcher and it may still be coming up."""
f = _RUNDIR / f"{spec['key']}.pid"
try:
pid_s, _, ts_s = f.read_text().partition(" ")
pid, ts = int(pid_s), float(ts_s)
except (OSError, ValueError):
return None
age = time.time() - ts
if _pid_alive(pid, spec):
return (pid, age)
if age < SPAWN_GRACE:
return (pid, age) # exec may not have landed in /proc yet
return None
def _note_spawn(spec, pid):
try:
_RUNDIR.mkdir(parents=True, exist_ok=True)
(_RUNDIR / f"{spec['key']}.pid").write_text(f"{pid} {time.time()}")
except OSError as e:
_log(f"could not record spawn of {spec['key']}: {e}") # non-fatal
def _sweep_stale_pending(spec):
"""Move a SUPERSEDED Ardour crash-recovery file aside so no modal appears.
No Ardour flag suppresses the "Recover from crash?" dialog — only the absence
of the `.pending` file does (a clean quit removes it). That dialog is exactly
the kind of focus-stealing modal the hot path must not contain (#136).
But a `.pending` is sometimes real recovery data, so this deliberately does
NOT delete unconditionally: it moves one aside only when the session was
SAVED AFTER the pending was written, which means a later save already
superseded it and the dialog can only offer staler state. A `.pending` newer
than the save holds unsaved captures — that one deserves a human, so the
dialog is left to appear.
Moved, never deleted, and into the session's own `dead/`: this runs
unattended and an unattended process does not get to destroy takes.
"""
session = Path(spec["args"][0]) if spec.get("args") else None
if not session or not session.exists():
return
try:
saved = session.stat().st_mtime
except OSError:
return
dead = session.parent / "dead"
for pend in sorted(session.parent.glob("*.pending")):
try:
if pend.stat().st_mtime >= saved:
_log(f"keeping {pend.name}: newer than the last save — a human should decide")
continue
dead.mkdir(exist_ok=True)
dest = dead / f"{pend.name}.superseded-{int(time.time())}"
pend.rename(dest)
_log(f"moved superseded {pend.name} to dead/{dest.name} (no crash modal)")
except OSError as e:
_log(f"could not sweep {pend.name}: {e}")
def launch(key): def launch(key):
"""Spawn the launcher detached. Returns {ok, status, msg, url?}.""" """Spawn the launcher detached. Returns {ok, status, msg, url?}."""
spec = BY_KEY.get(key) spec = BY_KEY.get(key)
...@@ -288,20 +416,42 @@ def launch(key): ...@@ -288,20 +416,42 @@ def launch(key):
if not spec.get("terminal") and is_running(spec): if not spec.get("terminal") and is_running(spec):
# Already up: FOCUS it rather than reporting at it. Spawning a duplicate # Already up: FOCUS it rather than reporting at it. Spawning a duplicate
# would be worse than useless during a set — a second Pulsar means a # would be worse than useless during a set — a second Pulsar means a
# second GHCi and a fight over port 6010. # second GHCi and a fight over port 6010, and a second Ardour kills BOTH
# over the session lock.
_log(f"{key}: running → focus")
if focus_window(spec.get("focus") or spec["key"]): if focus_window(spec.get("focus") or spec["key"]):
return {"ok": True, "status": "focused", return {"ok": True, "status": "focused",
"msg": f"{spec['name']} brought to front"} "msg": f"{spec['name']} brought to front"}
return {"ok": True, "status": "already-running", return {"ok": True, "status": "already-running",
"msg": f"{spec['name']} is already running (could not focus)"} "msg": f"{spec['name']} is already running (could not focus)"}
# is_running() said no. Before believing it, ask whether WE started this thing
# moments ago — the one question a /proc snapshot cannot answer, and the one
# that would have prevented the 2026-09-05 double-Ardour whatever went wrong
# upstream of it.
recent = _recent_spawn(spec)
if recent:
pid, age = recent
_log(f"{key}: NOT spawning — we started pid {pid} {age:.1f}s ago")
if focus_window(spec.get("focus") or spec["key"]):
return {"ok": True, "status": "focused",
"msg": f"{spec['name']} was already started ({age:.0f}s ago) — brought to front"}
return {"ok": True, "status": "already-running",
"msg": f"{spec['name']} was already started {age:.0f}s ago (pid {pid})"}
if key == "ardour":
_sweep_stale_pending(spec)
argv = _argv(spec) argv = _argv(spec)
if not argv: if not argv:
return {"ok": False, "status": "no-terminal", "msg": "no terminal emulator found"} return {"ok": False, "status": "no-terminal", "msg": "no terminal emulator found"}
try: try:
subprocess.Popen(argv, cwd=str(TIDAL), start_new_session=True, proc = subprocess.Popen(argv, cwd=str(TIDAL), start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError as e: except OSError as e:
_log(f"{key}: spawn failed — {e}")
return {"ok": False, "status": "error", "msg": str(e)} return {"ok": False, "status": "error", "msg": str(e)}
_note_spawn(spec, proc.pid)
_log(f"{key}: SPAWNED pid {proc.pid} — {' '.join(argv)}")
return {"ok": True, "status": "launched", "msg": f"launched {spec['name']}"} return {"ok": True, "status": "launched", "msg": f"launched {spec['name']}"}
......
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