|
@@ -392,6 +392,92 @@ class MpDerivedStateTests(unittest.TestCase):
|
|
|
engine_mod.time.time = real
|
|
engine_mod.time.time = real
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# --------------------------------------------------------------------------
|
|
|
|
|
+# mpv socket death: a raw BrokenPipeError from sendall used to escape as a
|
|
|
|
|
+# plain OSError and crash the player mid-playback on a remote machine. It
|
|
|
|
|
+# must surface as EngineError and the player must recover, not die.
|
|
|
|
|
+# --------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+def _dead_mpv_engine(poll_rc=None):
|
|
|
|
|
+ """MpEngine whose mpv has died: alive() passes, socket writes fail.
|
|
|
|
|
+ poll_rc=None models the race that matters: poll() still reports the
|
|
|
|
|
+ process alive while its IPC server has already closed the socket."""
|
|
|
|
|
+ eng = MpEngine.__new__(MpEngine) # skip __init__: no mpv spawn
|
|
|
|
|
+ eng.mpv_bin = "mpv"
|
|
|
|
|
+ eng.mpv_log = "/tmp/.asot-ipc-99.log"
|
|
|
|
|
+ eng.proc = Mock()
|
|
|
|
|
+ eng.proc.poll.return_value = poll_rc
|
|
|
|
|
+ eng._cid = 0
|
|
|
|
|
+ eng._prop_names = {}
|
|
|
|
|
+ eng._adv = None
|
|
|
|
|
+ eng._sock = Mock()
|
|
|
|
|
+ eng._sock.sendall.side_effect = BrokenPipeError(32, "Broken pipe")
|
|
|
|
|
+ return eng
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class MpSocketDeathTests(unittest.TestCase):
|
|
|
|
|
+ def test_cmd_surfaces_engine_error(self):
|
|
|
|
|
+ eng = _dead_mpv_engine()
|
|
|
|
|
+ with self.assertRaises(engine_mod.EngineError) as cm:
|
|
|
|
|
+ eng._cmd(["get_property", "pause"])
|
|
|
|
|
+ msg = str(cm.exception)
|
|
|
|
|
+ self.assertIn("socket closed", msg)
|
|
|
|
|
+ self.assertIn("asot-ipc-99.log", msg) # and where to look
|
|
|
|
|
+
|
|
|
|
|
+ def test_dead_process_is_reported_without_socket_write(self):
|
|
|
|
|
+ eng = _dead_mpv_engine(poll_rc=1)
|
|
|
|
|
+ with self.assertRaises(engine_mod.EngineError) as cm:
|
|
|
|
|
+ eng._cmd(["get_property", "pause"])
|
|
|
|
|
+ self.assertIn("mpv process is dead", str(cm.exception))
|
|
|
|
|
+
|
|
|
|
|
+ def test_status_surfaces_engine_error(self):
|
|
|
|
|
+ # alive() passes (poll → None), the write itself fails — the exact
|
|
|
|
|
+ # shape of the reported crash traceback
|
|
|
|
|
+ eng = _dead_mpv_engine()
|
|
|
|
|
+ with self.assertRaises(engine_mod.EngineError):
|
|
|
|
|
+ eng.status()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class PlayerRecoverTests(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, ep, position, length):
|
|
|
|
|
+ engine = Mock()
|
|
|
|
|
+ engine.alive.return_value = False # _load: restore saved vol
|
|
|
|
|
+ p = Player(self.eps, engine, Mock(), State())
|
|
|
|
|
+ p.ep = ep
|
|
|
|
|
+ p.state.ep, p.state.url = ep.number, ep.url
|
|
|
|
|
+ p.state.position, p.state.length = position, length
|
|
|
|
|
+ return p
|
|
|
|
|
+
|
|
|
|
|
+ def test_recover_restarts_same_episode_at_saved_position(self):
|
|
|
|
|
+ p = self._player(self.eps[1], 3_000.0, 5_400.0) # mid-episode
|
|
|
|
|
+ self.assertTrue(p.recover())
|
|
|
|
|
+ self.assertEqual(p.ep.number, 1300) # same episode
|
|
|
|
|
+ self.assertEqual(p.engine.play.call_args.kwargs["seek_to"], 3_000.0)
|
|
|
|
|
+
|
|
|
|
|
+ def test_recover_advances_only_at_true_end(self):
|
|
|
|
|
+ p = self._player(self.eps[1], 5_380.0, 5_400.0) # within 30 s of end
|
|
|
|
|
+ self.assertTrue(p.recover())
|
|
|
|
|
+ self.assertEqual(p.ep.number, 1301) # advanced
|
|
|
|
|
+ self.assertEqual(p.engine.play.call_args.kwargs["seek_to"], 0.0)
|
|
|
|
|
+
|
|
|
|
|
+ def test_recover_reports_failure_when_restart_fails(self):
|
|
|
|
|
+ p = self._player(self.eps[1], 1_000.0, 5_400.0)
|
|
|
|
|
+ p.engine.play.side_effect = engine_mod.EngineError("mpv gone")
|
|
|
|
|
+ self.assertFalse(p.recover())
|
|
|
|
|
+
|
|
|
|
|
+ def test_recover_without_episode_is_noop(self):
|
|
|
|
|
+ p = Player(self.eps, Mock(), Mock(), State())
|
|
|
|
|
+ self.assertFalse(p.recover())
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
# --------------------------------------------------------------------------
|
|
# --------------------------------------------------------------------------
|
|
|
# Keyboard restore: Linux termios has no TCSU — using it silently skipped the
|
|
# Keyboard restore: Linux termios has no TCSU — using it silently skipped the
|
|
|
# restore (AttributeError swallowed), leaving the shell in cbreak mode.
|
|
# restore (AttributeError swallowed), leaving the shell in cbreak mode.
|