Skip to content

Commit 9a3c20c

Browse files
stamateviorelclaude
andcommitted
Gate monitored-process restarts on ALSA output device readiness
When the monitored player exits because its loopback is still held (a not-yet-released previous instance, or the dmix state from #957), every respawn dies instantly with EINVAL and the stream stays silent - we saw a 5.5h outage from one stream re-assign. If the monitored command plays to an ALSA loopback (-o lb*), probe the device with a 1s silent aplay (the same open path the player uses) before each spawn and wait quietly until it opens. On the first failed probe, log the current /dev/snd holders via fuser so the journal explains the wedge; after four failed probes, kill a stale leftover player process that targets the same device (strict match on the binary name and exact -o argument, never the monitor's own child). Processes without an -o lb* argument are completely unaffected. Signed-off-by: Stamate Viorel <stamate.viorel@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6e84345 commit 9a3c20c

1 file changed

Lines changed: 108 additions & 3 deletions

File tree

streams/process_monitor.py

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,120 @@
11
#!/usr/bin/env python3
22

3-
import sys
4-
import subprocess
3+
import os
54
import signal
5+
import subprocess
6+
import sys
67
import time
78

89
args = sys.argv[1:]
910
print(f'Starting process monitor for process: {args}')
1011

1112
_START_TIME_THRESHOLD = 10 # seconds — consider a run "successful" if it lasted this long
13+
_DEVICE_PROBE_TIMEOUT = 5 # seconds — hard cap on a single aplay readiness probe
14+
_ORPHAN_KILL_AFTER = 4 # failed probes in a row before killing a stale holder
15+
16+
17+
def _alsa_out_device(argv):
18+
"""Return the ALSA loopback output device (-o lbXX) if the monitored
19+
process plays to one, else None. All of the device gating below is
20+
skipped for processes without such an argument."""
21+
for i, a in enumerate(argv[:-1]):
22+
if a == '-o' and argv[i + 1].startswith('lb'):
23+
return argv[i + 1]
24+
return None
25+
26+
27+
OUT_DEV = _alsa_out_device(args)
28+
29+
30+
def device_ready(dev):
31+
"""True if the ALSA device can be opened for playback right now.
32+
Mirrors squeezelite's open path (loopback dmix): fails with EINVAL
33+
while a stale holder keeps the device busy, succeeds once it is free.
34+
Writes 1s of silence to a loopback nobody is reading — harmless."""
35+
try:
36+
res = subprocess.run(
37+
['aplay', '-D', dev, '-r', '48000', '-f', 'S16_LE', '-c', '2',
38+
'-d', '1', '-q', '/dev/zero'],
39+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
40+
timeout=_DEVICE_PROBE_TIMEOUT)
41+
return res.returncode == 0
42+
except Exception:
43+
return False
44+
45+
46+
def print_device_diagnostics(dev):
47+
"""Dump who currently holds the sound devices so a wedge is explained
48+
in the journal rather than inferred afterwards."""
49+
print(f'Device {dev} not ready — current /dev/snd holders:', flush=True)
50+
try:
51+
out = subprocess.run('fuser -v /dev/snd/pcm* 2>&1', shell=True,
52+
capture_output=True, text=True, timeout=10)
53+
print((out.stdout + out.stderr).strip(), flush=True)
54+
except Exception as e:
55+
print(f'(fuser failed: {e})', flush=True)
56+
57+
58+
def find_stale_holders(dev, own_child_pid):
59+
"""PIDs of squeezelite processes (argv0 ends in 'squeezelite') playing
60+
to this exact device via -o <dev>, excluding ourselves and our own
61+
child. Strict match — cannot hit the monitor or unrelated processes."""
62+
pids = []
63+
for entry in os.listdir('/proc'):
64+
if not entry.isdigit():
65+
continue
66+
pid = int(entry)
67+
if pid in (own_child_pid, os.getpid()):
68+
continue
69+
try:
70+
with open(f'/proc/{pid}/cmdline', 'rb') as f:
71+
argv = f.read().decode(errors='replace').split('\0')
72+
except OSError:
73+
continue
74+
if not argv or not argv[0].endswith('squeezelite'):
75+
continue
76+
for i, a in enumerate(argv[:-1]):
77+
if a == '-o' and argv[i + 1] == dev:
78+
pids.append(pid)
79+
break
80+
return pids
81+
82+
83+
def wait_for_device(dev, own_child_pid):
84+
"""Block until the output device is openable. Converts the old
85+
crash-loop (spawn → EINVAL → die → respawn, silent for hours) into a
86+
quiet wait, and clears a stale squeezelite holder if one is wedging
87+
the loopback."""
88+
failures = 0
89+
while not device_ready(dev):
90+
failures += 1
91+
if failures == 1:
92+
print_device_diagnostics(dev)
93+
if failures == _ORPHAN_KILL_AFTER:
94+
for pid in find_stale_holders(dev, own_child_pid):
95+
print(f'Killing stale squeezelite holder pid={pid} on {dev}', flush=True)
96+
try:
97+
os.kill(pid, signal.SIGKILL)
98+
except OSError as e:
99+
print(f'(kill {pid} failed: {e})', flush=True)
100+
delay = min(2 ** failures, 30)
101+
print(f'Device {dev} busy (probe {failures} failed) — retrying in {delay}s...',
102+
flush=True)
103+
time.sleep(delay)
104+
if failures:
105+
print(f'Device {dev} ready again after {failures} failed probe(s).', flush=True)
106+
107+
108+
proc = None
109+
last_child_pid = -1
12110

13111

14112
def signal_handler(sig, frame):
15113
"""Signal handler function that sends the received signal to the
16114
subprocess and exits the script for certain signals."""
17115
print(f'Forwarding signal {sig} to subprocess.', flush=True)
18-
proc.send_signal(sig)
116+
if proc is not None and proc.poll() is None:
117+
proc.send_signal(sig)
19118
if sig in [signal.SIGINT, signal.SIGTERM, signal.SIGQUIT]:
20119
sys.exit(0)
21120
else:
@@ -35,8 +134,14 @@ def signal_handler(sig, frame):
35134
consecutive_failures = 0
36135

37136
while True:
137+
# Don't spawn into a busy loopback — the child would just exit with
138+
# EINVAL and we'd crash-loop while staying silent for hours.
139+
if OUT_DEV:
140+
wait_for_device(OUT_DEV, last_child_pid)
141+
38142
start_time = time.monotonic()
39143
proc = subprocess.Popen(args)
144+
last_child_pid = proc.pid
40145

41146
try:
42147
# Wait for the subprocess to complete

0 commit comments

Comments
 (0)