Files
Classroom_Desktop/tools/validate-trixie-release.sh
T
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

213 lines
7.9 KiB
Bash
Executable File

#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ $# -lt 1 || $# -gt 2 ]]; then
echo "Usage: $0 CANDIDATE.deb [PREVIOUS.deb]" >&2
exit 2
fi
candidate="$(realpath "$1")"
previous=""
[[ -f "$candidate" ]] || { echo "Package not found: $candidate" >&2; exit 1; }
if [[ $# -eq 2 ]]; then
previous="$(realpath "$2")"
[[ -f "$previous" ]] || { echo "Package not found: $previous" >&2; exit 1; }
fi
echo "Validating $(basename "$candidate") on Debian Trixie"
docker_args=(
run --rm -i
-v "$candidate:/packages/candidate.deb:ro"
)
if [[ -n "$previous" ]]; then
docker_args+=( -v "$previous:/packages/previous.deb:ro" )
fi
timeout --signal=TERM --kill-after=15s 720s \
docker "${docker_args[@]}" debian:trixie-slim \
bash -s -- "${previous:+/packages/previous.deb}" <<'CONTAINER'
set -Eeuo pipefail
validation_failure() {
status=$?
echo "Validation failed at line ${BASH_LINENO[0]}: ${BASH_COMMAND}" >&2
if command -v lpstat >/dev/null 2>&1; then
lpstat -t >&2 || true
lpoptions -p Elmer -l >&2 || true
fi
[[ ! -f /tmp/provision-first.log ]] || cat /tmp/provision-first.log >&2
[[ ! -f /tmp/provision-second.log ]] || cat /tmp/provision-second.log >&2
exit "$status"
}
trap validation_failure ERR
export DEBIAN_FRONTEND=noninteractive
# Package validation runs in a container with no init system. Do not let
# maintainer scripts try to start CUPS, Avahi or other irrelevant daemons;
# services are inspected and CUPS is started explicitly when the test needs it.
cat > /usr/sbin/policy-rc.d <<'POLICY'
#!/bin/sh
exit 101
POLICY
chmod 0755 /usr/sbin/policy-rc.d
run_logged() {
label="$1"
seconds="$2"
shift 2
if ! timeout --signal=TERM --kill-after=10s "${seconds}s" "$@" \
>"/tmp/${label}.log" 2>&1; then
cat "/tmp/${label}.log" >&2
echo "${label} exceeded ${seconds}s or failed" >&2
exit 1
fi
}
run_logged apt-update 120 apt-get update -qq
previous="${1:-}"
if [[ -n "$previous" ]]; then
run_logged previous-install 360 apt-get install -y -qq "$previous"
cat > /etc/ekklesia-classroom/device.conf <<'CONFIG'
# Configuration carried forward from 0.1.36
UPDATE_URL=https://updates.riverofglorymn.org
CHANNEL=canary
DVD_ENABLED=false
UI_SCALE=1.25
MQTT_ENABLED=true
MQTT_HOST=broker.example.test
MQTT_PORT=1884
MQTT_USERNAME=classroom
MQTT_PASSWORD=preserve-me
CUSTOM_SETTING=preserve-me-too
CONFIG
fi
run_logged candidate-install 360 apt-get install -y -qq /packages/candidate.deb
command -v nmtui >/dev/null
command -v lpadmin >/dev/null
command -v lpinfo >/dev/null
systemctl is-enabled NetworkManager.service >/dev/null
systemctl is-enabled cups.service >/dev/null
! systemctl is-enabled NetworkManager-wait-online.service >/dev/null 2>&1
# The audio policy reaches a session, and is ordered after the server it
# actually talks to. Ordering it only after pipewire and wireplumber is
# what let it race PipeWire's PulseAudio server at boot, fail its one
# startup attempt and then sit idle — the rooms were silent on 0.2.2 for
# exactly this reason, so the ordering is asserted rather than assumed.
audio_unit=/etc/systemd/user/ekklesia-classroom-audio.service
[[ -f "$audio_unit" ]]
grep -q 'pipewire-pulse' "$audio_unit"
[[ -L /etc/systemd/user/plasma-workspace.target.wants/ekklesia-classroom-audio.service ]]
command -v classroom-audio >/dev/null
command -v pactl >/dev/null
# The policy module and the daemon agree on what they import, on a machine
# rather than in a checkout.
PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 -c \
'import classroom_audio; classroom_audio.settled([], "auto", None)'
grep -q '^managed=false$' /etc/NetworkManager/NetworkManager.conf
# A normal Docker container has Ethernet and no Wi-Fi adapter. Reaching this
# point proves package configuration did not require Wi-Fi hardware.
! find /sys/class/net -mindepth 2 -maxdepth 2 -type d -name wireless | grep -q .
/usr/sbin/cupsd
sleep 1
driver='foomatic-db-compressed-ppds:0/ppd/foomatic-ppd/Generic-PCL_6_PCL_XL_Printer-pxlcolor.ppd'
lpinfo -m | grep -q "^${driver} "
# Simulate the queue an older release may have left behind, then run the real
# boot provisioning path. Chrome is stubbed as already installed and DVD is
# disabled so the test remains entirely local and deterministic.
lpadmin -p Classroom_Color -v socket://192.0.2.10:9100 -m "$driver"
install -m755 /bin/true /usr/local/bin/google-chrome
timeout --signal=TERM --kill-after=5s 45s classroom-provision \
>/tmp/provision-first.log
! lpstat -p Classroom_Color >/dev/null 2>&1
lpstat -p Elmer -d -v | grep -q 'socket://10.10.0.155:9100'
lpstat -l -p Elmer | grep -q 'Description: Elmer'
lpoptions -p Elmer -l | grep -Eq '^PrintoutMode/.*:.*\*Normal([[:space:]]|$)'
lpoptions -p Elmer -l | grep -Eq '^ColorModel/.*:.*\*Color([[:space:]]|$)'
[[ "$(lpstat -e)" == "Elmer" ]]
[[ "$(systemctl is-enabled avahi-daemon.service 2>/dev/null || true)" == "masked" ]]
[[ "$(systemctl is-enabled avahi-daemon.socket 2>/dev/null || true)" == "masked" ]]
timeout --signal=TERM --kill-after=5s 45s classroom-provision \
>/tmp/provision-second.log
grep -q 'Elmer is already correctly configured' /tmp/provision-second.log
if [[ -n "$previous" ]]; then
# The room's own values, untouched by the upgrade.
grep -q '^DVD_ENABLED=false$' /etc/ekklesia-classroom/device.conf
grep -q '^UI_SCALE=1.25$' /etc/ekklesia-classroom/device.conf
grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf
grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf
# The channel a room was put on deliberately survives the upgrade.
# canary is where the next development release goes, and the Plasma
# classroom is the stable line now, so nothing here has to move.
grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf
# And a room that was carried through the experiment on its own
# channel comes home, because that channel is the obsolete one now.
sed -i 's/^CHANNEL=.*/CHANNEL=classroom-v2/' /etc/ekklesia-classroom/device.conf
classroom-migrate >/tmp/migrate-from-v2.log 2>&1
grep -q '^CHANNEL=stable$' /etc/ekklesia-classroom/device.conf
grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf
grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf
sed -i 's/^CHANNEL=.*/CHANNEL=canary/' /etc/ekklesia-classroom/device.conf
# Every setting this release has that the room predates, appended
# exactly once. classroom-migrate hands device.conf to the shared
# helper after its own work for this, so that a machine converges
# whether it got here through this postinst or through somebody
# running the command by hand.
for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE; do
[[ "$(grep -c "^${setting}=" /etc/ekklesia-classroom/device.conf)" -eq 1 ]]
done
run_logged candidate-reinstall 360 \
apt-get install --reinstall -y -qq /packages/candidate.deb
# An install that changes nothing must change nothing.
for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE; do
[[ "$(grep -c "^${setting}=" /etc/ekklesia-classroom/device.conf)" -eq 1 ]]
done
grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf
grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf
grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf
# And by hand, on a machine that is already converged: the command a
# technician runs must be safe to run twice.
classroom-migrate >/tmp/migrate-by-hand.log 2>&1
for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE; do
[[ "$(grep -c "^${setting}=" /etc/ekklesia-classroom/device.conf)" -eq 1 ]]
done
grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf
! grep -q 'preserve-me' /tmp/migrate-by-hand.log
fi
echo "Trixie package, upgrade, bounded install, networking, Elmer printing and reinstall checks passed."
CONTAINER