Make media stop race-free and cover start failures

Stop always calls systemctl and treats exit 5 (unit already collected)
as done, so there is no is-active/stop race. Cleanup never masks the
copy error, the play ssh timeout covers the remote worst case, and
tests cover stop exit codes and systemd-run stderr reporting.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-29 09:30:03 +10:00
1 parent f81f70ed5d
commit 020e3386e1
3 files changed
+27 -12

No files matched your search

+20 -7
View File
@@ -83,15 +83,28 @@ class Media(unittest.TestCase):
ssh.assert_not_called()
def test_fake_frame_stop_only_owns_our_unit(self):
with patch.object(remote.subprocess, 'run') as run, patch.object(remote, 'status', return_value={}), \
patch.object(remote, 'active', return_value=True):
remote.run({'action': 'stop'})
for rc in (0, 5): # 5: already collected ("not loaded"), a no-op
with patch.object(remote.subprocess, 'run') as run, patch.object(remote, 'status', return_value={'state': 'ended'}):
run.return_value.returncode = rc
self.assertEqual(remote.run({'action': 'stop'})['state'], 'ended')
self.assertEqual(run.call_args.args[0], ['systemctl', '--user', 'stop', 'frame-control-media.service'])
# A finished player is already collected; Stop is then a no-op, not an error.
with patch.object(remote.subprocess, 'run') as run, patch.object(remote, 'status', return_value={}), \
patch.object(remote, 'active', return_value=False):
with patch.object(remote.subprocess, 'run') as run, patch.object(remote, 'status', return_value={}):
run.return_value.returncode, run.return_value.stderr = 1, 'Access denied'
with self.assertRaisesRegex(RuntimeError, 'Access denied'):
remote.run({'action': 'stop'})
run.assert_not_called()
def test_start_failure_reports_systemd_error(self):
with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)), \
patch.object(remote, 'STATUS', Path(d)/'status.json'), \
patch.object(remote, 'active', return_value=False), \
patch.object(remote.subprocess, 'run') as run:
identity = 'b'*32+'/still_SBS.png'
(Path(d)/identity).parent.mkdir()
(Path(d)/identity).write_bytes(b'x')
run.return_value.returncode, run.return_value.stderr = 1, 'Unit already exists'
with patch.object(remote, 'probe', return_value=({}, False)), \
self.assertRaisesRegex(RuntimeError, 'Unit already exists'):
remote.run({'action': 'play', 'id': identity})
def test_upload_rejects_unplayable_names_and_keeps_copy_error(self):
with patch.object(server, 'ssh') as ssh, patch.object(server, 'push_file') as push:
+5 -3
View File
@@ -63,9 +63,11 @@ def run(body):
if action == 'status':
return status()
if action == 'stop':
# --collect unloads the unit after it exits; stopping it then is a no-op, not an error.
if active():
subprocess.run(['systemctl', '--user', 'stop', UNIT], check=True, timeout=15)
# --collect unloads the unit after it exits; systemctl then exits 5
# ("not loaded", verified on the Frame). That's a finished player, not an error.
stopped = subprocess.run(['systemctl', '--user', 'stop', UNIT], capture_output=True, text=True, timeout=15)
if stopped.returncode not in (0, 5):
raise RuntimeError('Could not stop the media player: ' + (stopped.stderr.strip() or 'exit %s' % stopped.returncode))
return {'message': 'Media player stopped', **status()}
if action != 'play':
raise ValueError('Media action must be list, status, play or stop')
+2 -2
View File
@@ -1262,7 +1262,7 @@ for name, source in json.load(sys.stdin).items():
ssh("python3 -c " + shlex.quote(installer), stdin=json.dumps(sources))
try:
out = ssh("python3 ~/.local/share/frame-control/media/frame_media_remote.py",
stdin=json.dumps(body), timeout=60)
stdin=json.dumps(body), timeout=75) # remote worst case: ffprobe 30 + reset-failed 10 + systemd-run 15 s
except Failure as e:
for line in reversed(getattr(e, "stdout", "").splitlines()):
try:
@@ -1290,7 +1290,7 @@ def push_media(path):
except Exception:
try:
ssh("rm -rf ~/" + dest)
except Failure:
except Exception:
pass # keep the copy error; an empty folder isn't listed as media
raise
return {"message": "Media sent. Choose its layout and press Play.",