#!/usr/bin/env bash # The device list used to show only what is present, which meant that unplugging # a headset made the row that owned the sound vanish and left no trace of why # the laptop speakers had taken over. PipeWire remembers the choice; the page # did not show it. # # SoundDefaults is what makes the ghost row possible. Its whole job is the # difference between two keys that look the same in a log: # # default.audio.sink what is playing right now # default.configured.audio.sink what was chosen, present or not # # Reading the first one is the bug, and it is a quiet one: it always names a # device that exists, so the ghost row never appears and the feature does # nothing on every machine where the configured device happens to be plugged # in -- which is most machines, most of the time. This pins the second. # # It also pins the label, because an absent device has no description to borrow # and the stored name is all there is to work with. # # Runs against canned metadata through the service's own fixture seam. The # session's real defaults are never read and never written. set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" service="$repo_dir/config/dot/quickshell/services/SoundDefaults.qml" harness="$repo_dir/config/dot/quickshell/sound-services-harness.qml" fixture="$(mktemp -d /tmp/panama-sound-defaults.XXXXXX)" state_home="$fixture/state" shell_log="$fixture/quickshell.log" harness_pid="" # A Bluetooth address no adapter in this house has ever seen, so the "not # present" assertions cannot be flipped by what happens to be paired during the # sweep. The effective sink is a plausible built-in card, and the point of the # fixture is that the two disagree. configured_sink="bluez_output.AA_BB_CC_11_22_33.1" effective_sink="alsa_output.pci-0000_00_1f.3.analog-stereo" configured_source="alsa_input.usb-Blue_Microphones_Yeti-00.analog-stereo" fail() { printf 'sound defaults contract: %s\n' "$1" >&2 [[ -s "$shell_log" ]] && sed -n '1,80p' "$shell_log" >&2 exit 1 } [[ -f "$service" ]] || fail 'SoundDefaults.qml is missing' [[ -f "$harness" ]] || fail 'sound-services-harness.qml is missing' mkdir -p "$fixture/bin" "$state_home" # The headset is the configured sink and is not here; the built-in card is what # is actually playing. A service that read the effective key would report the # built-in card and be wrong in the one case this exists for. The configured # line is written *first* so that "last key wins" is not what makes it pass. cat >"$fixture/metadata-absent" <"$fixture/metadata-unconfigured" <"$fixture/metadata-empty" <<'JSON' Found "default" metadata 30 JSON # pw-metadata prints keys it does not own alongside the ones it does, and some # of them are bare strings rather than JSON. One of those must not take the # whole read down with it. cat >"$fixture/metadata-noise" <"$fixture/cards.json" # Nothing here should reach pipewire, but if the fixture seam ever regressed, # these stubs are what stands between the contract and the session's real # defaults. They answer nothing, which fails the assertions loudly rather than # passing them against real hardware. for tool in pw-metadata pw-dump pactl wpctl; do printf '%s\n' '#!/usr/bin/env bash' 'exit 1' >"$fixture/bin/$tool" chmod +x "$fixture/bin/$tool" done export PANAMA_SOUND_DEFAULTS_FIXTURE="$fixture/defaults" export PANAMA_SOUND_CARDS_FIXTURE="$fixture/cards.json" # The seam is a path read once at construction, so a case changes what the file # says rather than where it points. `absent` deletes it, which is what a read # that fails looks like from the service's side. defaults_fixture() { if [[ "$1" == "failing" ]]; then rm -f "$PANAMA_SOUND_DEFAULTS_FIXTURE" else cp "$fixture/metadata-$1" "$PANAMA_SOUND_DEFAULTS_FIXTURE" fi } defaults_fixture absent instances_for_harness() { qs list --all 2>/dev/null | awk -v expected="$harness" ' /^Instance / { pid = "" } /^[[:space:]]*Process ID:/ { pid = $3 } /^[[:space:]]*Config path:/ { path = $0 sub(/^[[:space:]]*Config path: /, "", path) if (path == expected && pid ~ /^[0-9]+$/) print pid } ' } cleanup() { if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then kill "$harness_pid" 2>/dev/null || true for _ in $(seq 1 40); do kill -0 "$harness_pid" 2>/dev/null || break sleep 0.05 done fi rm -rf "$fixture" } trap cleanup EXIT run() { PATH="$fixture/bin:$PATH" \ XDG_STATE_HOME="$state_home" \ PANAMA_SOUND_DEFAULTS_FIXTURE="$PANAMA_SOUND_DEFAULTS_FIXTURE" \ PANAMA_SOUND_CARDS_FIXTURE="$PANAMA_SOUND_CARDS_FIXTURE" \ qs -p "$harness" "$@" } ipc() { if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then run ipc --pid "$harness_pid" "$@" else run ipc "$@" fi } # Refresh, then wait for the parsed value to settle into the shape the filter # describes. Everything passed here goes to jq, so a case can bring its own # `--arg`. await_defaults() { local state="" ipc call sound-services-test refreshDefaults >/dev/null for _ in $(seq 1 60); do state="$(ipc call sound-services-test defaults)" jq -e "$@" >/dev/null <<<"$state" && { printf '%s' "$state"; return 0; } sleep 0.1 done printf '%s' "$state" return 1 } run --daemonize >"$shell_log" 2>&1 || fail 'sound services harness did not launch' for _ in $(seq 1 60); do harness_pid="$(instances_for_harness | head -1)" if [[ "$harness_pid" =~ ^[0-9]+$ ]] \ && ipc show 2>/dev/null | rg -q '^target sound-services-test$'; then break fi sleep 0.1 done [[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'sound services harness process did not start' # ── Configured, and not here ───────────────────────────────────────────────── state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \ || fail "the configured sink was not read from the configured key: $state" [[ "$(jq -r .sink <<<"$state")" != "$effective_sink" ]] \ || fail 'the effective sink was reported as the configured one, so no ghost row can ever appear' [[ "$(jq -r .source <<<"$state")" == "$configured_source" ]] \ || fail "the configured source was not read: $state" # ── The ghost row ──────────────────────────────────────────────────────────── # Configured, absent from the graph, and labelled from the stored name -- which # is all there is, because the node that carried the description is gone. ghost="$(ipc call sound-services-test absent output)" jq -e --arg sink "$configured_sink" '.present == false and .name == $sink' \ >/dev/null <<<"$ghost" || fail "a configured device that is not present did not become a ghost row: $ghost" [[ "$(jq -r .label <<<"$ghost")" == "Bluetooth device (AA:BB:CC:11:22:33)" ]] \ || fail "the ghost row's label is the raw node name: $(jq -r .label <<<"$ghost")" # The label is the only part of a ghost row anyone reads, and the names it has # to work with are structured differently per transport. [[ "$(ipc call sound-services-test labelFor raop_sink.Living-Room.local.192.168.1.162.7000)" \ == "Living Room" ]] \ || fail 'an AirPlay speaker is not named by its mDNS hostname' [[ "$(ipc call sound-services-test labelFor alsa_output.usb-Generic_USB_Audio-00.analog-stereo)" \ == "Generic USB Audio" ]] \ || fail 'a USB device is not named by its product string' [[ "$(ipc call sound-services-test labelFor "")" == "" ]] \ || fail 'an empty name produced a label out of nothing' # ── Keys that are not ours ─────────────────────────────────────────────────── # pw-metadata prints the whole store, and some of it is not JSON. One bad line # must not take the read down with it. defaults_fixture noise state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \ || fail "a non-JSON value on an unrelated key discarded the whole read: $state" # ── Nothing configured ─────────────────────────────────────────────────────── defaults_fixture unconfigured state="$(await_defaults '.sink == ""')" \ || fail "an unconfigured default did not read as empty: $state" [[ "$(jq -r .source <<<"$state")" == "" ]] \ || fail "an unconfigured source did not read as empty: $state" jq -e '.present == true' >/dev/null <<<"$(ipc call sound-services-test absent output)" \ || fail 'a session with no configured default still drew a ghost row' # ── An empty metadata store ────────────────────────────────────────────────── defaults_fixture empty state="$(await_defaults '.sink == "" and .source == ""')" \ || fail "an empty metadata store did not read as empty: $state" # ── The read failing ───────────────────────────────────────────────────────── # A session where this cannot be read is a session with no ghost rows, which is # the pre-redesign behaviour and perfectly usable. It is not a session where the # Sound page reports a device nobody configured. defaults_fixture failing state="$(await_defaults '.sink == "" and .source == ""')" \ || fail "a failed read left a stale or invented configured device: $state" # ── Recovery ───────────────────────────────────────────────────────────────── defaults_fixture absent state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \ || fail "the service never recovered after a failed read: $state" if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then fail 'sound services harness emitted a QML runtime warning' fi # ── Static ─────────────────────────────────────────────────────────────────── rg -Fq 'pragma Singleton' "$service" || fail 'SoundDefaults is not a singleton' # The two greps that say which keys are being read. A regression here is # invisible at runtime on any machine whose configured device is plugged in. rg -Fq 'default.configured.audio.sink' "$service" \ || fail 'SoundDefaults does not name the configured sink key' rg -Fq 'default.configured.audio.source' "$service" \ || fail 'SoundDefaults does not name the configured source key' # The live command, which the fixture seam means nothing above exercises. rg -Fq '"pw-metadata", "-n", "default", "0"' "$service" \ || fail 'the live read is not pw-metadata against the default metadata store' # `Pipewire.preferredDefaultAudioSink` is the same configured value typed as a # node pointer, so it reads null in exactly the case this service exists for. # Binding the ghost row to it would make the ghost row impossible. The file's # own comment is allowed to say so; the code is not allowed to do it. python3 - "$service" <<'PY' || fail 'the configured device was read as a node pointer, which is null precisely when the device is absent' import sys for line in open(sys.argv[1], encoding="utf-8"): if line.strip().startswith("//"): continue if "preferredDefaultAudio" in line: raise SystemExit(1) PY trap - EXIT cleanup printf 'sound defaults contract: PASS\n'