Просмотр исходного кода

Stop unattended sessions loudly: wall-clock limit, return rc, recover keeps pause

A player left paused for ~5 h closed with exit code 0 and no trace: the
main loop's 50 000-tick cap (measured 0.37 s/tick) expired, the loop
exited silently, and main() ended with 'return 0' ignoring the rc it
had set.

- run_player(): wall-clock safety limit (MAX_RUNTIME_SECONDS = 36 h)
  replaces the tick cap; hitting it prints 'runtime limit reached'
  above the bar and exits 1. The drop-budget break now also reports
  'playback failed — stream unavailable' and exits 1.
- main() returns rc (2 engine error, 130 Ctrl-C) instead of 0.
- recover() keeps a paused player paused: re-sends set_pause after the
  mpv restart (was: silently resumed playback after every stream drop).
- 4 regression tests (58 total) + README section on drops/exit codes.
Mindaugas Jarmolovičius 1 день назад
Родитель
Сommit
333c2f9a8e
3 измененных файлов с 179 добавлено и 66 удалено
  1. 6 0
      README.md
  2. 91 66
      asot_player/main.py
  3. 82 0
      tests/test_asot.py

+ 6 - 0
README.md

@@ -58,6 +58,12 @@ asot --state-dir /tmp/x       # custom state directory (also: $ASOT_STATE_DIR)
 | `n` / `p` | next / previous episode |
 | `q` / `Ctrl-C` | quit |
 
+## Stream drops & exit codes
+
+- **Stream drop / mpv death** — a dropped stream, lost network, or an mpv crash is detected on the next status poll; the bar reports `playback dropped … recovery attempt N` above itself and playback restarts at the saved position **keeping the paused/muted/volume state**. Up to 5 consecutive failed recoveries are retried (1 s apart); a healthy tick resets the budget.
+- **Unattended sessions** — a session that runs for longer than `MAX_RUNTIME_SECONDS` (36 h, see `asot_player/main.py`) stops with an explicit `runtime limit reached — stopping` report; it never closes silently. (An old 50 000-tick cap silently ended sessions after ~5 h with exit code 0 — the reason for a player that "just closed" when left paused.)
+- **Exit codes** — `0` normal (quit, end of feed) · `1` playback failed / runtime limit · `2` engine error (mpv missing, stream unplayable) · `130` interrupted.
+
 ## How it works
 
 - **Feed**: `miroppb.com/rss` is parsed for `<item>` titles matching "Episode NNN" and enclosure URLs.

+ 91 - 66
asot_player/main.py

@@ -25,6 +25,12 @@ except ImportError:
     tty = None
 
 
+# Wall-clock safety net for an unattended session (ticks are ~0.3–0.9 s each,
+# so the old 50 000-tick cap silently ended long/idle sessions after ~5 h with
+# exit code 0).  Reaching this limit is reported, and exits non-zero.
+MAX_RUNTIME_SECONDS = 36 * 3600
+
+
 # ---------------------------------------------------------------------------
 # key handling (non-full-screen; cbreak mode, restored on exit)
 # ---------------------------------------------------------------------------
@@ -244,7 +250,12 @@ class Player:
                 volume=max(0.0, min(1.0, float(st.volume))))
         except EngineError:
             return False
-        st.paused = False
+        if st.paused:
+            # a paused player stays paused across the restart
+            try:
+                self.engine.set_pause(True)
+            except EngineError:
+                st.paused = False
         self.msg_override = f"Recovered \u2192 ASOT {self.ep.number:04d}"
         self.msg_until = time.time() + 3.0
         return True
@@ -290,6 +301,83 @@ def _print_above(ui, text: str):
     sys.stdout.write(text.rstrip("\n") + "\n")
     sys.stdout.flush()
 
+
+def run_player(p, kb) -> int:
+    """The main playback loop.  Returns the exit code: 0 when playback ended
+    normally (quit key / end of feed); 1 when it was stopped by the drop
+    budget or the wall-clock runtime limit — both are failures, and both
+    print a reason above the bar, so a closed player is never silent."""
+    deadline = time.time() + MAX_RUNTIME_SECONDS
+    drops = 0                       # consecutive mpv deaths recovered from
+    last_save = time.time()
+    stop_msg = ""                   # why the loop ended when not finished
+    while not p.finished and time.time() < deadline:
+        t0 = time.time()
+        try:
+            # keys (applied immediately)
+            keys = kb.drain() if kb is not None else []
+            for k in keys:
+                if p.finished:
+                    break
+                p.handle_key(k)
+
+            try:
+                st = p._status()
+                drops = 0              # healthy tick resets the budget
+            except EngineError as e:
+                # mpv died (stream drop / EOF race / network loss): clear
+                # the bar, say so above it, then resume from the saved
+                # position — end only when the episode has truly finished.
+                drops += 1
+                _print_above(p.ui, f"[asot] playback dropped "
+                                   f"({e.__class__.__name__}); recovery "
+                                   f"attempt {drops}…")
+                if drops > 5 or not p.recover():
+                    stop_msg = "[asot] playback failed — stream unavailable"
+                    break
+                time.sleep(1.0)            # don't hammer a dead stream
+                continue
+
+            # eof → next episode (auto-advance)
+            if st.state == "eof":
+                if p.advance(1):
+                    continue
+                p.msg_override = f"Finished — end of feed at ASOT {p.ep.number:04d}"
+                p.msg_until = time.time() + 6.0
+                p.render_once(st)
+                p.finished = True
+                break
+
+            p.render_once(st)
+
+            changed = (abs(st.position - p.state.position) > 1.0
+                       or st.muted != p.state.muted
+                       or st.paused != p.state.paused)
+            if changed or time.time() - last_save >= 3.0:
+                p.record(st)
+                last_save = time.time()
+
+            elapsed = time.time() - t0
+            sleep_for = 0.25 if keys else 0.9
+            rem = sleep_for - elapsed
+            if rem > 0.1:
+                time.sleep(min(rem, 0.3))
+        except Exception:
+            # anything unexpected: erase the bar, print the traceback at
+            # the line above it, then keep drawing the bar at the bottom
+            _print_above(p.ui, traceback.format_exc().rstrip())
+            time.sleep(1.0)
+            continue
+
+    if p.finished:
+        return 0
+    # the loop ended on the drop budget or the runtime limit: a failure, not
+    # a clean exit — report it above the bar and exit non-zero.  (The old
+    # 50 000-tick cap ended here silently with code 0: a player left paused
+    # for ~5 h "just closed" with no trace.)
+    _print_above(p.ui, stop_msg or "[asot] runtime limit reached — stopping")
+    return 1
+
 def main():
     ap = argparse.ArgumentParser(
         prog="asot",
@@ -360,70 +448,7 @@ def main():
         p._load(start_ep, reason="Starting",
                 seek_to=resume_pos if resume else 0.0)
 
-        tick = 0
-        drops = 0                       # consecutive mpv deaths recovered from
-        last_save = time.time()
-        while not p.finished and tick < 50_000:
-            t0 = time.time()
-            try:
-                # keys (applied immediately)
-                keys = kb.drain() if kb is not None else []
-                for k in keys:
-                    if p.finished:
-                        break
-                    p.handle_key(k)
-
-                try:
-                    st = p._status()
-                    drops = 0              # healthy tick resets the budget
-                except EngineError as e:
-                    # mpv died (stream drop / EOF race / network loss): clear
-                    # the bar, say so above it, then resume from the saved
-                    # position — end only when the episode has truly finished.
-                    drops += 1
-                    _print_above(p.ui, f"[asot] playback dropped "
-                                       f"({e.__class__.__name__}); recovery "
-                                       f"attempt {drops}…")
-                    if drops > 5 or not p.recover():
-                        p.msg_override = "Playback failed — stream unavailable"
-                        p.msg_until = time.time() + 6.0
-                        break
-                    tick += 1                  # keep the global tick cap binding
-                    time.sleep(1.0)            # don't hammer a dead stream
-                    continue
-
-                # eof → next episode (auto-advance)
-                if st.state == "eof":
-                    if p.advance(1):
-                        continue
-                    p.msg_override = f"Finished — end of feed at ASOT {p.ep.number:04d}"
-                    p.msg_until = time.time() + 6.0
-                    p.render_once(st)
-                    p.finished = True
-                    break
-
-                p.render_once(st)
-
-                changed = (abs(st.position - p.state.position) > 1.0
-                           or st.muted != p.state.muted
-                           or st.paused != p.state.paused)
-                if changed or time.time() - last_save >= 3.0:
-                    p.record(st)
-                    last_save = time.time()
-
-                elapsed = time.time() - t0
-                sleep_for = 0.25 if keys else 0.9
-                rem = sleep_for - elapsed
-                if rem > 0.1:
-                    time.sleep(min(rem, 0.3))
-                tick += 1
-            except Exception:
-                # anything unexpected: erase the bar, print the traceback at
-                # the line above it, then keep drawing the bar at the bottom
-                _print_above(p.ui, traceback.format_exc().rstrip())
-                tick += 1
-                time.sleep(1.0)
-                continue
+        rc = run_player(p, kb)
     except EngineError as e:
         _print_above(p.ui, f"[asot] playback error: {e}")
         rc = 2
@@ -449,7 +474,7 @@ def main():
             sys.stdout.flush()
         except Exception:
             pass
-    return 0
+    return rc
 
 
 if __name__ == "__main__":

+ 82 - 0
tests/test_asot.py

@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import contextlib
 import io
 import os
 import re
@@ -479,6 +480,87 @@ class PlayerRecoverTests(unittest.TestCase):
         p = Player(self.eps, Mock(), Mock(), State())
         self.assertFalse(p.recover())
 
+    def test_recover_preserves_paused_state(self):
+        # regression: a player left paused used to resume *playing* after a
+        # stream drop recovered — the restart must keep it paused
+        p = self._player(self.eps[1], 3_000.0, 5_400.0)
+        p.state.paused = True
+        self.assertTrue(p.recover())
+        self.assertTrue(p.state.paused)
+        p.engine.set_pause.assert_called_once_with(True)
+
+
+# --------------------------------------------------------------------------
+# Runtime limit: a session left running (e.g. paused) for hours must not end
+# silently with exit code 0 (the old 50 000-tick cap did exactly that, at
+# ~0.37 s per tick ≈ 5 h)
+# --------------------------------------------------------------------------
+
+class RuntimeLimitTests(unittest.TestCase):
+    def setUp(self):
+        self._fetch = main_mod.fetch_meta
+        main_mod.fetch_meta = lambda url: Meta()   # no network in tests
+        self.eps = fake_eps()
+
+    def tearDown(self):
+        main_mod.fetch_meta = self._fetch
+
+    def _player(self):
+        engine = FakeEngine(self.eps[1].url, length=7_200.0, speed=0.0)
+        p = Player(self.eps, engine, UI(color=False, width=72, bar_width=40),
+                   State())
+        p.ep = self.eps[1]
+        p.state.ep, p.state.url = self.eps[1].number, self.eps[1].url
+        p.state.paused = True
+        return p
+
+    def test_wallclock_limit_reports_and_exits_nonzero(self):
+        p = self._player()
+        limit = main_mod.MAX_RUNTIME_SECONDS
+        main_mod.MAX_RUNTIME_SECONDS = 0.2
+        try:
+            buf = io.StringIO()
+            with contextlib.redirect_stdout(buf):
+                rc = main_mod.run_player(p, None)
+        finally:
+            main_mod.MAX_RUNTIME_SECONDS = limit
+        self.assertEqual(rc, 1)
+        self.assertIn("runtime limit", buf.getvalue())
+
+    def test_normal_finish_exits_zero_without_limit_message(self):
+        p = self._player()
+        p.finished = True                      # as if the user quit
+        buf = io.StringIO()
+        with contextlib.redirect_stdout(buf):
+            rc = main_mod.run_player(p, None)
+        self.assertEqual(rc, 0)
+        self.assertNotIn("limit", buf.getvalue())
+
+    def test_main_returns_rc_when_engine_fails(self):
+        # rc=2 is set on EngineError — main() must return it, not 0
+        eps = fake_eps()
+        engine = Mock()
+        engine.alive.return_value = False
+        engine.play.side_effect = engine_mod.EngineError("mpv gone")
+        with tempfile.TemporaryDirectory() as tmp:
+            old_argv = list(sys.argv)
+            sys.argv = ["asot", "--fake", "--fresh", "--width", "60",
+                        "--state-dir", tmp]
+            _fetch, _fake = main_mod.fetch_episodes, main_mod.FakeEngine
+            main_mod.fetch_episodes = lambda: eps
+            main_mod.FakeEngine = lambda *a, **k: engine
+            try:
+                buf = io.StringIO()
+                with contextlib.redirect_stdout(buf), \
+                     contextlib.redirect_stderr(io.StringIO()), \
+                     unittest.mock.patch("sys.stdin", io.StringIO()):
+                    rc = main_mod.main()
+            finally:
+                sys.argv = old_argv
+                main_mod.fetch_episodes = _fetch
+                main_mod.FakeEngine = _fake
+        self.assertEqual(rc, 2)
+
 
 # --------------------------------------------------------------------------
 # Bottom anchoring: the block erases itself cleanly (never two bars), reports