Files
Classroom_Desktop/tests/test-classroom-audio-daemon.sh
ekklesiaandClaude Opus 5 27cd64a668 fix: the room asks for its output again, not only once at boot
River Kids came up silent on stable 0.2.2. The decision this policy makes
was right and was never asked for.

classroom-audio applied its policy once at startup and then only when a
PipeWire event arrived. pactl talks to PipeWire's PulseAudio server, and
the unit was ordered after pipewire and wireplumber but not after that.
At boot it therefore raced the server and lost: the single startup apply
failed with "Connection refused", the subscription attached cleanly a few
seconds later, and from that moment the daemon waited for an event that
had already happened. systemd said active, the subscription was live, and
the policy had never run at all.

That is why the card was found on output:analog-stereo+input:analog-stereo
with its HDMI port available. That profile is WirePlumber's own pick, not
one this policy would ever produce, which is the evidence it never ran.

Reproduced in the lab against real PipeWire 1.4.2 and WirePlumber 0.5.8 by
starting the daemon with pipewire-pulse stopped and then starting it: the
old daemon sits idle forever, the new one converges.

The shape is now: wait for the server, apply, subscribe, apply again the
moment the subscription attaches, and coalesce each burst of events into
one decision. Applying on attach is the fix for the race. While the room
has not got the output it would prefer it also looks again on a backing
off timer capped at a minute, so a television switched on hours later is
picked up whether or not ALSA's jack change ever reaches us as an event.
Once the room has what it wants it stops looking entirely, and a machine
with no digital output never looks at all.

Also here, all of it found by measuring the real server rather than
reading the code:

  * sink lookup matched on fields a sink does not have. Two of its three
    conditions could never be true; the one that worked returned whichever
    sink of the card came first rather than the one for the chosen port.
  * volume and mute are no longer touched. The old code unmuted and forced
    70% on every output change, so every television power cycle reset a
    volume the teacher had set and could unmute an output muted on
    purpose. WirePlumber already remembers volume per device. A machine
    this policy has never configured still gets 70% once.
  * the profile chosen keeps the card's input, which is the difference
    between output:hdmi-stereo and the output:hdmi-stereo+input:analog-stereo
    an administrator reaches for by hand.
  * a card profile is only set when it is not already in place, and only
    so many times, so a sink that never appears cannot become a switching
    loop.
  * a machine with no sound server at all says so once and backs off,
    rather than filling the journal. pactl subscribe's own stderr is
    dropped because saying it is this daemon's job, and a subscription is
    only treated as healthy once it has lasted, since pactl starts
    successfully and then fails to connect.
  * the fixtures described a machine nobody has. Real pactl puts no
    "direction" on a port at all and says "availability unknown" rather
    than nothing, so a test could not have caught this.

Existing streams are moved explicitly. Measured both ways on the real
stack: changing the default sink does move a stream that never asked for
a particular sink, but a stream pinned to a device stays where it is with
no policy running, and this moves it back without the application being
restarted.

tests/test-classroom-audio-daemon.sh drives the real daemon through the
boot race, a television switched on with and without an event announcing
it, a power cycle, a set that never comes on, and a room already correct.
It uses a pactl mock because no loopback or dummy sound driver has an HDMI
port, so the one thing a lab cannot prove is that the real jack change
arrives; docs/testing.md carries the physical procedure for that and says
not to take a passing build as evidence of it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-20 14:46:46 -05:00

517 lines
18 KiB
Bash
Executable File

#!/bin/bash
# The audio policy daemon, driven through the situations a classroom is
# actually in, with the real script and a pactl that answers the way
# PipeWire really does.
#
# These are the properties a snapshot cannot show: that the room converges
# when the sound server was not ready at boot, that a television switched
# on later is picked up whether or not an event announces it, and that
# nothing is switched over and over when everything is already right.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHONPATH="$ROOT/lib" CLASSROOM_ROOT="$ROOT" python3 - <<'PY'
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
ROOT = os.environ["CLASSROOM_ROOT"]
DAEMON = os.path.join(ROOT, "scripts", "classroom-audio")
MOCK = os.path.join(ROOT, "tests", "mock-pactl")
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
ANALOG_PROFILES = [
"output:analog-stereo",
"output:analog-stereo+input:analog-stereo",
"output:analog-surround-40+input:analog-stereo",
]
HDMI_PROFILES = [
"output:hdmi-stereo",
"output:hdmi-stereo+input:analog-stereo",
"output:hdmi-surround+input:analog-stereo",
]
ALL_PROFILES = ["off"] + ANALOG_PROFILES + HDMI_PROFILES
ANALOG_SINK = "alsa_output.pci-0000_00_1f.3.analog-stereo"
HDMI_SINK = "alsa_output.pci-0000_00_1f.3.hdmi-stereo"
def room(hdmi="not available", jack="not available",
active="output:analog-stereo+input:analog-stereo",
server_up=True):
"""The River Kids machine, as pactl described it over SSH.
`jack` is what the onboard analog output reports. River Kids has
nothing plugged into it, which is why it says "not available" and why
that room prefers HDMI even with the television off: there is nowhere
better for the sound to go. A room with speakers in the jack is a
different room, and it is the one that can fall back.
"""
return {
"server_up": server_up,
"default_sink": ANALOG_SINK,
"levels": {},
"streams": [],
"cards": [{
"name": "alsa_card.pci-0000_00_1f.3",
"index": 47,
"description": "Built-in Audio",
"active_profile": active,
"profiles": ALL_PROFILES,
"ports": {
"analog-output-lineout": {
"availability": jack,
"priority": 9900,
"profiles": ANALOG_PROFILES,
},
"hdmi-output-0": {
"availability": hdmi,
"priority": 5900,
"profiles": HDMI_PROFILES,
},
},
}],
}
class Lab:
def __init__(self, state):
self.dir = tempfile.mkdtemp(prefix="classroom-audio-")
self.state_path = os.path.join(self.dir, "state.json")
self.events = os.path.join(self.dir, "events")
self.journal = os.path.join(self.dir, "journal")
self.log = os.path.join(self.dir, "daemon.log")
self.write(state)
open(self.events, "w").close()
open(self.journal, "w").close()
# A pactl that is this mock and nothing else.
self.bin = os.path.join(self.dir, "bin")
os.makedirs(self.bin)
os.symlink(MOCK, os.path.join(self.bin, "pactl"))
self.process = None
def write(self, state):
with open(self.state_path, "w") as handle:
json.dump(state, handle, indent=2)
def read(self):
with open(self.state_path) as handle:
return json.load(handle)
def edit(self, change):
state = self.read()
change(state)
self.write(state)
def start(self):
environment = dict(os.environ)
environment["PATH"] = self.bin + os.pathsep + environment["PATH"]
environment["MOCK_AUDIO_STATE"] = self.state_path
environment["MOCK_AUDIO_EVENTS"] = self.events
environment["MOCK_AUDIO_JOURNAL"] = self.journal
environment["XDG_STATE_HOME"] = os.path.join(self.dir, "state-home")
# device.conf is not readable here, so the daemon falls back to
# auto, which is what the classroom machines are set to.
environment["PYTHONPATH"] = os.path.join(ROOT, "lib")
self.process = subprocess.Popen(
[sys.executable, DAEMON],
stdout=open(self.log, "w"), stderr=subprocess.STDOUT,
env=environment,
)
def stop(self):
if self.process:
self.process.terminate()
try:
self.process.wait(timeout=10)
except subprocess.SubprocessError:
self.process.kill()
def journal_lines(self):
with open(self.journal) as handle:
return [line.strip() for line in handle if line.strip()]
def daemon_log(self):
with open(self.log) as handle:
return handle.read()
def await_log(self, text, timeout=40):
"""Wait for the daemon to SAY it has done something.
Waiting on the sink cannot distinguish "the daemon chose this"
from "the fixture already said this", and a test that cannot tell
those apart races the daemon's first decision.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if text in self.daemon_log():
return True
time.sleep(0.25)
return False
def await_sink(self, name, timeout=40):
"""Wait for the room to be playing through a particular sink."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self.read().get("default_sink") == name:
return True
time.sleep(0.25)
return False
def clean(self):
self.stop()
shutil.rmtree(self.dir, ignore_errors=True)
def profile_of(state):
return state["cards"][0]["active_profile"]
def switches(lab):
return [line for line in lab.journal_lines()
if line.startswith("set-card-profile")]
# ---------------------------------------------------------------- 1
print("1. the computer starts with the television already on")
lab = Lab(room(hdmi="available"))
try:
lab.start()
check("HDMI is selected without anyone touching anything",
lab.await_sink(HDMI_SINK), lab.daemon_log())
check("and on a profile that keeps the card's input",
profile_of(lab.read()) == "output:hdmi-stereo+input:analog-stereo",
profile_of(lab.read()))
finally:
lab.clean()
# ---------------------------------------------------------------- 2
print("2. the television is off at boot, then switched on (with an event)")
lab = Lab(room(hdmi="not available", jack="available"))
try:
lab.start()
check("the room falls back to the jack rather than going silent",
lab.await_log("output is analog-output-lineout"), lab.daemon_log())
def switch_on(state):
state["cards"][0]["ports"]["hdmi-output-0"]["availability"] = "available"
lab.edit(switch_on)
with open(lab.events, "a") as handle:
handle.write("Event 'change' on card #47\n")
check("the television is picked up when it announces itself",
lab.await_sink(HDMI_SINK), lab.daemon_log())
finally:
lab.clean()
# ---------------------------------------------------------------- 3
print("3. the television is switched on and NOTHING announces it")
# ALSA's jack change does not always surface as a pactl event. The old
# policy could only ever act on an event, so this was a room that stayed
# silent all morning.
lab = Lab(room(hdmi="not available", jack="available"))
try:
lab.start()
check("the room starts on the jack",
lab.await_log("output is analog-output-lineout"), lab.daemon_log())
def switch_on(state):
state["cards"][0]["ports"]["hdmi-output-0"]["availability"] = "available"
lab.edit(switch_on)
# Deliberately no event written.
check("the television is still picked up, by looking again",
lab.await_sink(HDMI_SINK), lab.daemon_log())
finally:
lab.clean()
# ---------------------------------------------------------------- 4
print("4. the boot race that silenced the rooms on 0.2.2")
# The daemon starts before PipeWire's PulseAudio server will answer. The
# old code failed its single startup apply, subscribed successfully a
# moment later, and then waited forever for an event that had already
# happened — leaving the card on WirePlumber's own profile, which is
# exactly what was found in River Kids.
lab = Lab(room(hdmi="available", server_up=False))
try:
lab.start()
time.sleep(3)
check("nothing is applied while the server refuses connections",
lab.read().get("default_sink") == ANALOG_SINK)
def server_up(state):
state["server_up"] = True
lab.edit(server_up)
# No event is written: at a real boot the card's events all happen
# while the daemon cannot connect, and none arrive afterwards.
check("the room converges on HDMI once the server answers",
lab.await_sink(HDMI_SINK), lab.daemon_log())
check("and the card is left on a profile the policy chose",
profile_of(lab.read()) == "output:hdmi-stereo+input:analog-stereo",
profile_of(lab.read()))
finally:
lab.clean()
# ---------------------------------------------------------------- 5
print("5. the television is switched off and on again while a lesson plays")
# Speakers in the jack, so there is somewhere to fall back TO. The room
# that has nothing in the jack is scenario 5b below.
lab = Lab(room(hdmi="available", jack="available"))
try:
lab.start()
check("the lesson starts out on the television", lab.await_sink(HDMI_SINK))
def add_stream(state):
state["streams"] = [{
"index": 700,
"sink": 41,
"sink_name": HDMI_SINK,
"properties": {"application.name": "Chromium"},
}]
lab.edit(add_stream)
def switch_off(state):
state["cards"][0]["ports"]["hdmi-output-0"]["availability"] = \
"not available"
lab.edit(switch_off)
with open(lab.events, "a") as handle:
handle.write("Event 'change' on card #47\n")
check("it falls back to the jack while the set is off",
lab.await_sink(ANALOG_SINK), lab.daemon_log())
def switch_on(state):
state["cards"][0]["ports"]["hdmi-output-0"]["availability"] = "available"
lab.edit(switch_on)
with open(lab.events, "a") as handle:
handle.write("Event 'change' on card #47\n")
check("and comes back to the television when it returns",
lab.await_sink(HDMI_SINK), lab.daemon_log())
time.sleep(2)
stream = lab.read()["streams"][0]
check("the lesson that never stopped playing is on the television too",
stream["sink_name"] == HDMI_SINK,
f"stream left on {stream['sink_name']}")
finally:
lab.clean()
# ---------------------------------------------------------------- 5b
print("5b. River Kids: the same power cycle with nothing in the jack")
# This room has no speakers in the analog socket, so when the television
# goes off there is nowhere better for the sound to go and the policy is
# right to stay pointed at the set. What it must not do is thrash between
# two outputs that are both unusable, or lose the television when it comes
# back.
lab = Lab(room(hdmi="available", jack="not available"))
try:
lab.start()
check("the lesson starts on the television", lab.await_sink(HDMI_SINK))
def switch_off(state):
state["cards"][0]["ports"]["hdmi-output-0"]["availability"] = \
"not available"
lab.edit(switch_off)
with open(lab.events, "a") as handle:
handle.write("Event 'change' on card #47\n")
time.sleep(8)
check("it stays on the set rather than moving to an empty socket",
lab.read()["default_sink"] == HDMI_SINK,
lab.read()["default_sink"])
check("and does not switch the card back and forth while it is off",
len(switches(lab)) <= 2, f"profile switches: {switches(lab)}")
def switch_on(state):
state["cards"][0]["ports"]["hdmi-output-0"]["availability"] = "available"
lab.edit(switch_on)
check("and the television still works when it comes back",
lab.await_sink(HDMI_SINK), lab.daemon_log())
finally:
lab.clean()
# ---------------------------------------------------------------- 6
print("6. a television that is never switched on")
lab = Lab(room(hdmi="not available", jack="available"))
try:
lab.start()
check("the room settles on the jack",
lab.await_log("output is analog-output-lineout"), lab.daemon_log())
time.sleep(25)
moves = switches(lab)
check("the card is not switched over and over while it waits",
len(moves) <= 2, f"{len(moves)} profile switches: {moves}")
check("and the daemon is still alive rather than having given up",
lab.process.poll() is None)
check("and it never went silent",
lab.read()["default_sink"] == ANALOG_SINK)
finally:
lab.clean()
# ---------------------------------------------------------------- 7
print("7. a room that is already right is left alone")
lab = Lab(room(hdmi="available"))
try:
lab.start()
check("it selects the television once", lab.await_sink(HDMI_SINK))
time.sleep(20)
check("and then changes nothing at all, however long it runs",
len(switches(lab)) == 1,
f"profile switches: {switches(lab)}")
check("no repeated default-sink writes either",
len([line for line in lab.journal_lines()
if line.startswith("set-default-sink")]) == 1,
str(lab.journal_lines()))
finally:
lab.clean()
# ---------------------------------------------------------------- 8
print("8. the teacher's volume and mute are left alone")
lab = Lab(room(hdmi="not available", jack="available"))
try:
lab.start()
check("the room starts on the jack",
lab.await_log("output is analog-output-lineout"), lab.daemon_log())
# The teacher turns it down and mutes it on purpose.
def teacher(state):
state["levels"] = {
ANALOG_SINK: {"volume": 35, "mute": True},
HDMI_SINK: {"volume": 55, "mute": True},
}
state["cards"][0]["ports"]["hdmi-output-0"]["availability"] = "available"
lab.edit(teacher)
check("the television is picked up", lab.await_sink(HDMI_SINK),
lab.daemon_log())
time.sleep(2)
levels = lab.read()["levels"]
check("the television's own volume is not overwritten",
levels[HDMI_SINK]["volume"] == 55,
f"volume became {levels[HDMI_SINK]['volume']}")
check("an output muted on purpose is not unmuted",
levels[HDMI_SINK]["mute"] is True)
volume_commands = [line for line in lab.journal_lines()
if line.startswith("set-sink-volume")]
check("the only volume ever set is the one first run on the jack",
len(volume_commands) == 1
and ANALOG_SINK in volume_commands[0]
and "70%" in volume_commands[0],
str(volume_commands))
check("and the television's volume was never written",
not [line for line in volume_commands if HDMI_SINK in line],
str(volume_commands))
check("and mute was never touched",
not [line for line in lab.journal_lines()
if line.startswith("set-sink-mute")],
str(lab.journal_lines()))
finally:
lab.clean()
# ---------------------------------------------------------------- 9
print("9. sound that is already playing follows the television")
lab = Lab(room(hdmi="not available", jack="available"))
try:
lab.start()
check("the room starts on the jack",
lab.await_log("output is analog-output-lineout"), lab.daemon_log())
def lesson_and_tv(state):
state["streams"] = [
{"index": 701, "sink": 41, "sink_name": ANALOG_SINK,
"properties": {"application.name": "Wonder Ink"}},
{"index": 702, "sink": 41, "sink_name": ANALOG_SINK,
"properties": {"application.name": "Chromium"}},
]
state["cards"][0]["ports"]["hdmi-output-0"]["availability"] = "available"
lab.edit(lesson_and_tv)
check("the television is picked up", lab.await_sink(HDMI_SINK),
lab.daemon_log())
time.sleep(2)
streams = lab.read()["streams"]
check("Wonder Ink follows without being restarted",
streams[0]["sink_name"] == HDMI_SINK,
f"left on {streams[0]['sink_name']}")
check("and so does Chrome",
streams[1]["sink_name"] == HDMI_SINK,
f"left on {streams[1]['sink_name']}")
check("a stream that starts afterwards lands on the television",
lab.read()["default_sink"] == HDMI_SINK)
finally:
lab.clean()
# ---------------------------------------------------------------- 10
print("10. a machine with no digital output never waits for one")
laptop = {
"server_up": True,
"default_sink": "alsa_output.pci-0000_00_1f.3.analog-stereo",
"levels": {},
"streams": [],
"cards": [{
"name": "alsa_card.pci-0000_00_1f.3",
"index": 47,
"description": "Built-in Audio",
"active_profile": "output:analog-stereo+input:analog-stereo",
"profiles": ["off"] + ANALOG_PROFILES,
"ports": {
"analog-output-speaker": {
"availability": "available",
"priority": 10000,
"profiles": ANALOG_PROFILES,
},
},
}],
}
lab = Lab(laptop)
try:
lab.start()
check("it uses the speakers it has",
lab.await_sink("alsa_output.pci-0000_00_1f.3.analog-stereo"))
time.sleep(15)
check("and settles without hunting for a television that cannot exist",
len(switches(lab)) <= 1, f"profile switches: {switches(lab)}")
finally:
lab.clean()
print()
print(f"{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)
PY