Commit d701720f by PLN (Algolia)

perf-tray: one Quit, and the rig starts the tray

The tray is up exactly when the rig is up. parvagues-sc Wants it, so any
bring-up brings it; Quit is the single exit and ends the session when the rig
runs (the tray never leaves by itself under a running rig), and just closes the
tray when it does not. Drops the End session / Quit tray only split.
parent 37098d6f
......@@ -28,6 +28,12 @@ Wants=pipewire.service
# Recording follows the rig, so the rig starts it. Wants=, not Requires=: a recorder that
# cannot start must never stop the sound.
Wants=gig-record.service
# The tray is the rig's face and its one Quit, and it must exist whenever the rig does.
# perf-tray is `on-demand` in rig_units.py, which turned out to mean "only if a converge
# remembers": a tray Quit on 2026-10-11 left it dark for 42 h under a running rig. Not
# PartOf: SuperDirt stops on the exit-3 path of end_session (Ardour still open) and the
# watchdog bounces it, and the tray must survive both (GIG UP lives in it).
Wants=perf-tray.service
# Bound the restart loop (see Restart= below): three tries in five minutes, then stay
# down and stay loud. This is what makes recovery safe without flapping.
StartLimitIntervalSec=300
......
......@@ -295,32 +295,15 @@ class PerfTray:
active, _ = sc_state()
return bool(active)
def _ask_quit(self):
"""'end' | 'tray' | 'cancel'. Separate from the logic so a test can answer it."""
box = QMessageBox()
box.setWindowTitle("perf-tray")
box.setIcon(QMessageBox.Question)
box.setText("The rig is still running.")
box.setInformativeText(
"If you only quit the tray, SuperDirt, the black box recorder and the helpers "
"keep running and music mode stays on.")
end = box.addButton("End session", QMessageBox.AcceptRole)
tray = box.addButton("Quit tray only", QMessageBox.DestructiveRole)
cancel = box.addButton("Cancel", QMessageBox.RejectRole)
box.setDefaultButton(cancel)
box.exec_()
clicked = box.clickedButton()
return "end" if clicked is end else "tray" if clicked is tray else "cancel"
def _ask_end(self):
box = QMessageBox()
box.setWindowTitle("End session")
box.setWindowTitle("Quit")
box.setIcon(QMessageBox.Question)
box.setText("End the session?")
box.setText("Quit the rig?")
box.setInformativeText(
"Stops SuperDirt (the black box stops with it), then the helpers, and leaves "
"music mode. It never closes Ardour, and it refuses while a take is rolling.")
ok = box.addButton("End session", QMessageBox.AcceptRole)
ok = box.addButton("Quit", QMessageBox.AcceptRole)
cancel = box.addButton("Cancel", QMessageBox.RejectRole)
box.setDefaultButton(cancel)
box.exec_()
......@@ -334,20 +317,16 @@ class PerfTray:
"--user", "--collect", "--quiet", f"--unit=parvagues-end-session-{int(time.time())}",
"/usr/bin/python3", str(END_SESSION)])
def on_end_session(self):
if self._ask_end():
self._run_end_session()
def on_quit_clicked(self):
"""Quit asks only when there is something Quit would leave behind."""
"""The one exit. Tray up iff rig up: with the rig down Quit just closes the tray; with
the rig up it ends the session, and the session's last act stops this tray. The tray
never exits on its own while the rig runs (that was the 2026-10-11 gap: a tray Quit
left the rig up, nothing brought the tray back, and it was dark for 42 h). If the
end refuses (a take is rolling, Ardour is open) the tray stays, which is still true."""
if not self._rig_is_up():
self.app.quit()
return
choice = self._ask_quit()
if choice == "end":
elif self._ask_end():
self._run_end_session()
elif choice == "tray":
self.app.quit()
def on_tray_activated(self, reason):
"""Left/middle click opens the same menu right-click does.
......@@ -594,14 +573,9 @@ class PerfTray:
self.menu.aboutToShow.connect(self.refresh)
self.menu.addSeparator()
# Two different things, and the menu says so. "Quit" used to be the only exit and it
# only closed THIS window: SuperDirt (a headless unit), the black box and the helpers
# kept running and music mode stayed on, so "I quit everything" was not true
# (2026-10-11). End session is the honest "I am done".
self.end_act = QAction("■ End session (stop the rig)…", self.menu)
self.end_act.triggered.connect(self.on_end_session)
self.menu.addAction(self.end_act)
quit_act = QAction("&Quit perf-tray (rig keeps running)", self.menu)
# One exit. The tray is up exactly when the rig is up (parvagues-sc Wants it), so
# Quit means "I am done": it ends the rig when it runs, closes the tray when it does not.
quit_act = QAction("&Quit", self.menu)
quit_act.triggered.connect(self.on_quit_clicked)
self.menu.addAction(quit_act)
......
"""The tray's Quit must not lie about what it leaves running.
"""The tray has ONE exit, and the tray is up exactly when the rig is up.
2026-10-11: Quit closed only the tray. SuperDirt (headless), the black box and the helpers
kept running and music mode stayed on, so "I quit everything" was false and the owner got
alarms about a rig they believed was down.
2026-10-11: Quit closed only the tray; the rig kept running, nothing brought the tray back,
and it was dark for 42 h while SuperDirt and the black box ran. The contract now: a single
"Quit"; with the rig up it ends the session (which stops the tray last), with the rig down it
just closes the tray; the tray never exits by itself under a running rig; and parvagues-sc
Wants the tray so a rig bring-up always brings it.
"""
import os
import pathlib
import re
import pytest
TRAY_SRC = (pathlib.Path(__file__).resolve().parents[3] / "perf-tray.py").read_text()
ROOT = pathlib.Path(__file__).resolve().parents[3]
TRAY_SRC = (ROOT / "perf-tray.py").read_text()
def test_the_menu_offers_end_session_and_a_quit_that_says_what_it_leaves():
assert "End session" in TRAY_SRC
assert "Quit perf-tray (rig keeps running)" in TRAY_SRC
assert re.search(r"quit_act\.triggered\.connect\(self\.on_quit_clicked\)", TRAY_SRC)
def test_the_menu_has_a_single_quit_and_nothing_else_that_ends_things():
assert 'QAction("&Quit", self.menu)' in TRAY_SRC
assert "quit_act.triggered.connect(self.on_quit_clicked)" in TRAY_SRC
for gone in ("Quit tray only", "rig keeps running", "End session", "_ask_quit", "on_end_session"):
assert gone not in TRAY_SRC, gone
assert "triggered.connect(self.app.quit)" not in TRAY_SRC # no bare quit left behind
......@@ -25,14 +27,21 @@ def test_end_session_runs_in_its_own_unit_not_as_a_child_of_the_tray():
assert "systemd-run" in TRAY_SRC and "--collect" in TRAY_SRC and "--unit=parvagues-end-session" in TRAY_SRC
def test_the_rig_starts_the_tray():
sc = (ROOT / "parvagues-sc.service").read_text()
assert any(l.strip() == "Wants=perf-tray.service" for l in sc.splitlines())
assert not any(l.strip().startswith("PartOf=parvagues-sc") for l in
(ROOT / "perf-tray.service").read_text().splitlines()), \
"PartOf would kill the tray on end_session's exit-3 path and on every watchdog bounce"
@pytest.fixture
def tray(monkeypatch):
pytest.importorskip("PyQt5.QtWidgets")
monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen")
import importlib.util
from PyQt5.QtWidgets import QApplication
spec = importlib.util.spec_from_file_location("perf_tray_under_test",
pathlib.Path(__file__).resolve().parents[3] / "perf-tray.py")
spec = importlib.util.spec_from_file_location("perf_tray_under_test", ROOT / "perf-tray.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
app = QApplication.instance() or QApplication([])
......@@ -43,25 +52,17 @@ def tray(monkeypatch):
return t
def test_quit_with_the_rig_down_just_quits(tray, monkeypatch):
def test_quit_with_the_rig_down_just_closes_the_tray_without_asking(tray):
tray._rig_is_up = lambda: False
tray._ask_quit = lambda: pytest.fail("must not ask when nothing would be left running")
tray._ask_end = lambda: pytest.fail("must not ask when nothing would be ended")
tray.on_quit_clicked()
assert tray._quit_calls == [1] and tray._end_calls == []
@pytest.mark.parametrize("answer,quit_n,end_n", [("end", 0, 1), ("tray", 1, 0), ("cancel", 0, 0)])
def test_quit_with_the_rig_up_asks_and_obeys(tray, answer, quit_n, end_n):
@pytest.mark.parametrize("answer,end_n", [(True, 1), (False, 0)])
def test_quit_with_the_rig_up_ends_the_session_or_does_nothing_never_exits_the_tray(tray, answer, end_n):
tray._rig_is_up = lambda: True
tray._ask_quit = lambda: answer
tray._ask_end = lambda: answer
tray.on_quit_clicked()
assert (len(tray._quit_calls), len(tray._end_calls)) == (quit_n, end_n)
def test_end_session_menu_item_confirms_before_acting(tray):
tray._ask_end = lambda: False
tray.on_end_session()
assert tray._end_calls == []
tray._ask_end = lambda: True
tray.on_end_session()
assert tray._end_calls == [1]
assert tray._quit_calls == [] # tray up iff rig up: it must not leave by itself
assert len(tray._end_calls) == end_n
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