7c4a3f2f20
The pulse_socket named volume survives container recreation, so after a
compose recreate the previous container's /run/pulse/pid and native
socket were still present. PulseAudio read the stale pid file, decided a
daemon was already running, and refused to start:
E: [pulseaudio] pid.c: Daemon already running.
The entrypoint still reported "PulseAudio socket ready" because it only
checked that the socket file existed - and a stale one did. Capture then
failed in a restart loop against a dead daemon.
Readiness in both the op25 entrypoint and drb-edge-node now means a
pactl probe actually succeeds. Stale pid/socket are removed only when
that probe fails, so a live daemon's socket is never deleted.
pulseaudio-utils was missing from the edge-node image (only libpulse0
was installed), so no pactl binary existed there at all - added.
Capture exits are now classified: a missing source logs at ERROR and
names the configured PULSE_SOURCE, rather than looking identical to
"daemon not up yet". Retrying forever against a wrong source name is how
the April PulseAudio failure stayed hidden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
5.4 KiB
Python
136 lines
5.4 KiB
Python
"""
|
|
Unit tests for PulseAudio readiness helpers (app.internal.pulse).
|
|
|
|
The whole point of this module is that readiness means "a PulseAudio
|
|
connection actually succeeds," never "a socket file exists at this path" —
|
|
that was the exact bug reproduced on live hardware: a killed daemon left its
|
|
pid file and native socket behind in the shared `pulse_socket` volume, the
|
|
old file-existence check reported "ready", and FFmpeg launched against a
|
|
dead daemon.
|
|
|
|
No real PulseAudio daemon or `pactl` binary is required for these tests:
|
|
`_daemon_responds` (the one function that actually shells out) is monkeypatched
|
|
everywhere except the dedicated subprocess-layer tests, which fake out
|
|
`shutil.which`/`subprocess.run` directly so the "stale file, dead daemon" case
|
|
is proven at the layer that matters.
|
|
"""
|
|
import subprocess
|
|
from unittest.mock import Mock
|
|
|
|
from app.internal import pulse
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# socket_path()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_socket_path_defaults_when_pulse_server_unset(monkeypatch):
|
|
monkeypatch.delenv("PULSE_SERVER", raising=False)
|
|
assert pulse.socket_path() == pulse.DEFAULT_SOCKET_PATH
|
|
|
|
|
|
def test_socket_path_parses_unix_prefixed_pulse_server(monkeypatch):
|
|
monkeypatch.setenv("PULSE_SERVER", "unix:/tmp/somewhere/native")
|
|
assert pulse.socket_path() == "/tmp/somewhere/native"
|
|
|
|
|
|
def test_socket_path_falls_back_on_malformed_pulse_server(monkeypatch):
|
|
monkeypatch.setenv("PULSE_SERVER", "not-a-unix-uri")
|
|
assert pulse.socket_path() == pulse.DEFAULT_SOCKET_PATH
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_ready() / wait_until_ready() against a monkeypatched probe
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_is_ready_true_when_daemon_responds(monkeypatch):
|
|
monkeypatch.setattr(pulse, "_daemon_responds", lambda: True)
|
|
assert pulse.is_ready() is True
|
|
|
|
|
|
def test_is_ready_false_when_daemon_does_not_respond(monkeypatch):
|
|
monkeypatch.setattr(pulse, "_daemon_responds", lambda: False)
|
|
assert pulse.is_ready() is False
|
|
|
|
|
|
async def test_wait_until_ready_short_circuits_when_already_live(monkeypatch):
|
|
calls = Mock(return_value=True)
|
|
monkeypatch.setattr(pulse, "_daemon_responds", calls)
|
|
assert await pulse.wait_until_ready(timeout=5) is True
|
|
assert calls.call_count == 1
|
|
|
|
|
|
async def test_wait_until_ready_polls_until_daemon_comes_up(monkeypatch):
|
|
monkeypatch.setattr(pulse, "POLL_INTERVAL", 0.01)
|
|
responses = iter([False, False, True])
|
|
monkeypatch.setattr(pulse, "_daemon_responds", lambda: next(responses))
|
|
assert await pulse.wait_until_ready(timeout=5) is True
|
|
|
|
|
|
async def test_wait_until_ready_times_out_when_daemon_never_responds(monkeypatch):
|
|
monkeypatch.setattr(pulse, "POLL_INTERVAL", 0.01)
|
|
monkeypatch.setattr(pulse, "_daemon_responds", lambda: False)
|
|
assert await pulse.wait_until_ready(timeout=0.05) is False
|
|
|
|
|
|
async def test_wait_until_ready_uses_settings_default_when_timeout_omitted(monkeypatch):
|
|
monkeypatch.setattr(pulse.settings, "pulse_wait_timeout", 0.05)
|
|
monkeypatch.setattr(pulse, "POLL_INTERVAL", 0.01)
|
|
monkeypatch.setattr(pulse, "_daemon_responds", lambda: False)
|
|
assert await pulse.wait_until_ready() is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _daemon_responds() at the subprocess layer — proves a stale FILE is not
|
|
# enough, which is the actual regression this module fixes.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_daemon_responds_false_when_pactl_missing(monkeypatch):
|
|
monkeypatch.setattr(pulse.shutil, "which", lambda name: None)
|
|
assert pulse._daemon_responds() is False
|
|
|
|
|
|
def test_daemon_responds_false_on_probe_timeout(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(pulse.shutil, "which", lambda name: "/usr/bin/pactl")
|
|
|
|
def fake_run(*args, **kwargs):
|
|
raise subprocess.TimeoutExpired(cmd="pactl", timeout=pulse.PROBE_TIMEOUT_SECONDS)
|
|
|
|
monkeypatch.setattr(pulse.subprocess, "run", fake_run)
|
|
assert pulse._daemon_responds() is False
|
|
|
|
|
|
def test_daemon_responds_false_when_stale_socket_file_exists_but_daemon_dead(monkeypatch, tmp_path):
|
|
"""
|
|
The regression, reproduced at the layer that matters: a plain FILE sits
|
|
at the socket path (exactly what a killed daemon leaves behind), but
|
|
`pactl info` against it fails (nonzero exit — connection refused). This
|
|
must NOT be treated as ready.
|
|
"""
|
|
stale_socket = tmp_path / "native"
|
|
stale_socket.write_bytes(b"") # a stale file, not a live socket
|
|
monkeypatch.setenv("PULSE_SERVER", f"unix:{stale_socket}")
|
|
|
|
monkeypatch.setattr(pulse.shutil, "which", lambda name: "/usr/bin/pactl")
|
|
monkeypatch.setattr(
|
|
pulse.subprocess, "run",
|
|
lambda *a, **k: subprocess.CompletedProcess(args=a, returncode=1),
|
|
)
|
|
|
|
assert stale_socket.exists() # sanity: the old file-existence check would pass
|
|
assert pulse._daemon_responds() is False
|
|
|
|
|
|
def test_daemon_responds_true_when_pactl_succeeds(monkeypatch, tmp_path):
|
|
live_socket = tmp_path / "native"
|
|
live_socket.write_bytes(b"")
|
|
monkeypatch.setenv("PULSE_SERVER", f"unix:{live_socket}")
|
|
|
|
monkeypatch.setattr(pulse.shutil, "which", lambda name: "/usr/bin/pactl")
|
|
monkeypatch.setattr(
|
|
pulse.subprocess, "run",
|
|
lambda *a, **k: subprocess.CompletedProcess(args=a, returncode=0),
|
|
)
|
|
|
|
assert pulse._daemon_responds() is True
|