Commit b9677d23 by PLN (Algolia)

feat(tray): restart SuperDirt from the tray, but not by accident

PLN asked for a restart-SC command in the tray menu. The reason it is worth
having: a headless sclang has no post window and no eval-over-OSC endpoint, so
a restart is the ONLY way to arm the sample-watcher responder or to pick up a
sample pack dropped since boot. Today that meant typing a systemctl line into a
terminal, mid-compose, which is exactly the friction the tray exists to remove.

The catch is that it sits two pixels under 'Launch Gig' and it is the only
entry in this menu that can STOP THE SOUND. So while the rig is up it is not a
one-click action: the first click arms it and says what it costs, and a second
click — which means opening the menu a second time, deliberately — fires. The
arming lapses after ten seconds. That is #116's rule ('the hot path cannot
start the sound by accident') applied to the opposite accident, and it is why
there is no confirmation DIALOG: a modal stealing focus mid-set is its own
hazard (#136). When the unit is DOWN there is nothing to interrupt, so it stays
a single click.

The label carries the state rather than the intent, because the two 'up's are
not the same up: systemd reports active the instant sclang execs, but the banks
register alphabetically after that and the rig is not playable until the end.
Measured today, active -> responder armed took 27 s, so under 30 s of uptime
the entry reads 'booting - Ns of ~30s' instead of claiming the sound is back.
A silent rig should never be a mystery.

Read on menu-open only, never on the 2 s thermal tick: it shells out to
systemctl, and paying that 30x a minute for a label nobody is looking at is the
per-tick cost this rig keeps getting bitten by. The restart runs through
QProcess so a two-second systemctl call cannot freeze the tray.

Verified: module imports clean, sc_state() reads (True, 145s) against the live
unit, fmt_dur spans 5s->2h13m, tray restarted and came back with no traceback.
parent 82d7a533
......@@ -22,6 +22,7 @@ import math
import os
import subprocess
import sys
import time
from pathlib import Path
from PyQt5.QtWidgets import (
......@@ -41,6 +42,43 @@ SERVICE = "perf-tray" # systemd --user unit controlling autostart
REFRESH_MS = 2000
BRIDGE_URL = "http://127.0.0.1:8773/"
# The unit that IS the sound. Restarting it is the one destructive thing this
# menu can do, so it gets its own confirm step (see PerfTray.on_sc_clicked).
SC_UNIT = "parvagues-sc"
SC_CONFIRM_MS = 10_000 # how long an armed restart stays armed
SC_READY_S = 30 # measured 2026-08-14: active→responder armed in 27 s
def sc_state():
"""(active, seconds_up) for the SuperDirt unit. Never raises — the tray is
non-essential and a status label must not be able to take the menu down."""
try:
r = subprocess.run(
["systemctl", "--user", "show", SC_UNIT,
"-p", "ActiveState", "-p", "ActiveEnterTimestampMonotonic"],
capture_output=True, text=True, timeout=3,
)
kv = dict(ln.split("=", 1) for ln in r.stdout.splitlines() if "=" in ln)
active = kv.get("ActiveState") == "active"
# Monotonic, so it survives a wall-clock jump; same epoch as
# time.monotonic() (both CLOCK_MONOTONIC) on Linux.
us = int(kv.get("ActiveEnterTimestampMonotonic") or 0)
up = time.monotonic() - us / 1e6 if (active and us) else None
return active, up
except (OSError, subprocess.SubprocessError, ValueError):
return None, None
def fmt_dur(s):
if s is None:
return "?"
s = int(s)
if s < 90:
return f"{s}s"
if s < 5400:
return f"{s // 60}m"
return f"{s // 3600}h{(s % 3600) // 60:02d}m"
def autostart_enabled():
"""True if the perf-tray systemd --user service is enabled (starts at login)."""
......@@ -178,6 +216,31 @@ class PerfTray:
self.gig_act.triggered.connect(lambda: self._launch("pulsar"))
self.menu.addAction(self.gig_act)
# PLN, 2026-08-14: "add a restart SC command in parvagues tray gui menu".
#
# This is the only entry in the menu that can STOP THE SOUND, and it sits
# two pixels under "Launch Gig" — so it is deliberately not a one-click
# action while the rig is up. First click ARMS it and says what it costs;
# a second click, which means opening the menu again, actually restarts.
# The arming lapses after ten seconds. That is the mirror of #116 ("the
# hot path cannot start the sound by accident") applied to the opposite
# accident, and it is why there is no confirmation DIALOG: a modal box
# stealing focus mid-set is its own hazard (#136).
#
# When the unit is DOWN there is nothing to interrupt, so starting it is
# a single click. Only a restart-while-playing needs the guard.
#
# Why it is wanted at all: the SuperCollider boot is the only way to arm
# the sample-watcher responder, and to pick up sample packs dropped since
# boot — a rig running headless under systemd has no post window and no
# eval-over-OSC endpoint, so a restart is the ONLY way in.
self.sc_act = QAction("↻ Restart SuperDirt", self.menu)
self.sc_act.triggered.connect(self.on_sc_clicked)
self.menu.addAction(self.sc_act)
self._sc_armed = False
self._sc_restart_at = None # monotonic time of the last restart we fired
self.menu.aboutToShow.connect(self.refresh_sc)
# #69 — the menu offered to LAUNCH things without ever saying what was
# already up. The state was there all along (launchers.is_running), but
# _build_menu ran once at startup and read only `available`, so a running
......@@ -288,6 +351,82 @@ class PerfTray:
else "🌊 Launch Gig — open ParVagues")
self.gig_act.setEnabled(bool(pulsar and pulsar["available"]))
# ------------------------------------------------------------ SuperDirt
#
# Read on menu-open only, never on the 2 s timer: this shells out to
# systemctl, and doing that 30x a minute for a label nobody is looking at is
# the per-tick cost this rig keeps getting bitten by.
def refresh_sc(self):
try:
active, up = sc_state()
if self._sc_armed:
self.sc_act.setText("⚠ Restart SuperDirt — click again to CUT THE SOUND")
return
if active is None:
self.sc_act.setText("↻ Restart SuperDirt — state unknown")
elif not active:
self.sc_act.setText("○ Start SuperDirt — down")
elif up is not None and up < SC_READY_S:
# `active` fires at exec; the banks then register alphabetically
# and the rig is not playable until the end of that. Say which of
# the two "up"s this is, so a silent rig is not a mystery.
self.sc_act.setText(
f"◐ SuperDirt booting — {fmt_dur(up)} of ~{SC_READY_S}s")
else:
self.sc_act.setText(f"● Restart SuperDirt — up {fmt_dur(up)}")
except Exception:
pass
def on_sc_clicked(self):
active, _ = sc_state()
if active and not self._sc_armed:
self._sc_armed = True
QTimer.singleShot(SC_CONFIRM_MS, self._disarm_sc)
self.tray.showMessage(
"perf-tray: restart armed",
"SuperDirt is UP. Restarting stops the sound for ~30 s.\n"
"Open the menu and click again within 10 s to confirm.",
QSystemTrayIcon.Warning, SC_CONFIRM_MS,
)
self.refresh_sc()
return
self._disarm_sc()
self._sc_restart(was_up=bool(active))
def _disarm_sc(self):
self._sc_armed = False
self.refresh_sc()
def _sc_restart(self, was_up):
# QProcess, not subprocess.run: a restart takes a couple of seconds to
# return and the tray must not freeze while it does.
proc = QProcess(self.app)
proc.setProgram("systemctl")
proc.setArguments(["--user", "restart", SC_UNIT])
def done(_code, _status):
err = bytes(proc.readAllStandardError()).decode(errors="replace")
if proc.exitCode() != 0:
self.tray.showMessage(
"perf-tray: SuperDirt restart failed",
err.strip() or f"systemctl --user restart {SC_UNIT} failed",
QSystemTrayIcon.Critical, 8000,
)
else:
self._sc_restart_at = time.monotonic()
self.tray.showMessage(
"perf-tray: SuperDirt restarting",
f"{'Sound stopped. ' if was_up else ''}Banks register "
f"alphabetically — playable in ~{SC_READY_S}s.\n"
"Any sample packs added since the last boot come up with it.",
QSystemTrayIcon.Information, 6000,
)
self.refresh_sc()
proc.finished.connect(done)
proc.start()
def refresh_gear(self):
try:
self._apply_gear(LA.snapshot())
......
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