python-mpv-jsonipc 1.4.0__tar.gz → 1.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {python_mpv_jsonipc-1.4.0/python_mpv_jsonipc.egg-info → python_mpv_jsonipc-1.5.0}/PKG-INFO +5 -1
  2. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/README.md +4 -0
  3. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/pyproject.toml +1 -1
  4. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0/python_mpv_jsonipc.egg-info}/PKG-INFO +5 -1
  5. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/python_mpv_jsonipc.egg-info/SOURCES.txt +1 -0
  6. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/python_mpv_jsonipc.py +254 -27
  7. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/tests/test_arg_building.py +4 -2
  8. python_mpv_jsonipc-1.5.0/tests/test_live_async.py +550 -0
  9. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/tests/test_live_teardown.py +26 -2
  10. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/LICENSE.md +0 -0
  11. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/python_mpv_jsonipc.egg-info/dependency_links.txt +0 -0
  12. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/python_mpv_jsonipc.egg-info/top_level.txt +0 -0
  13. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/setup.cfg +0 -0
  14. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/tests/test_api_surface.py +0 -0
  15. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/tests/test_live_basic.py +0 -0
  16. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/tests/test_live_events.py +0 -0
  17. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/tests/test_live_failure.py +0 -0
  18. {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/tests/test_python_floor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mpv-jsonipc
3
- Version: 1.4.0
3
+ Version: 1.5.0
4
4
  Summary: Python API to MPV using JSON IPC
5
5
  Author-email: Izzie Walton <izzie@iwalton.com>
6
6
  License-Expression: Apache-2.0
@@ -55,6 +55,10 @@ mpv.volume = 20
55
55
  # You can also send commands.
56
56
  mpv.command_name(arg1, arg2)
57
57
 
58
+ # Or send one without waiting for it. Returns a concurrent.futures.Future.
59
+ mpv.command_async("sub-add", "https://example.com/slow.srt",
60
+ callback=lambda error, result: print(error or "loaded"))
61
+
58
62
  # Bind to key press events with a decorator
59
63
  @mpv.on_key_press("space")
60
64
  def space_handler():
@@ -41,6 +41,10 @@ mpv.volume = 20
41
41
  # You can also send commands.
42
42
  mpv.command_name(arg1, arg2)
43
43
 
44
+ # Or send one without waiting for it. Returns a concurrent.futures.Future.
45
+ mpv.command_async("sub-add", "https://example.com/slow.srt",
46
+ callback=lambda error, result: print(error or "loaded"))
47
+
44
48
  # Bind to key press events with a decorator
45
49
  @mpv.on_key_press("space")
46
50
  def space_handler():
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-mpv-jsonipc"
7
- version = "1.4.0"
7
+ version = "1.5.0"
8
8
  description = "Python API to MPV using JSON IPC"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mpv-jsonipc
3
- Version: 1.4.0
3
+ Version: 1.5.0
4
4
  Summary: Python API to MPV using JSON IPC
5
5
  Author-email: Izzie Walton <izzie@iwalton.com>
6
6
  License-Expression: Apache-2.0
@@ -55,6 +55,10 @@ mpv.volume = 20
55
55
  # You can also send commands.
56
56
  mpv.command_name(arg1, arg2)
57
57
 
58
+ # Or send one without waiting for it. Returns a concurrent.futures.Future.
59
+ mpv.command_async("sub-add", "https://example.com/slow.srt",
60
+ callback=lambda error, result: print(error or "loaded"))
61
+
58
62
  # Bind to key press events with a decorator
59
63
  @mpv.on_key_press("space")
60
64
  def space_handler():
@@ -8,6 +8,7 @@ python_mpv_jsonipc.egg-info/dependency_links.txt
8
8
  python_mpv_jsonipc.egg-info/top_level.txt
9
9
  tests/test_api_surface.py
10
10
  tests/test_arg_building.py
11
+ tests/test_live_async.py
11
12
  tests/test_live_basic.py
12
13
  tests/test_live_events.py
13
14
  tests/test_live_failure.py
@@ -8,6 +8,7 @@ import subprocess
8
8
  import random
9
9
  import queue
10
10
  import logging
11
+ from concurrent.futures import Future
11
12
 
12
13
  log = logging.getLogger('mpv-jsonipc')
13
14
 
@@ -420,8 +421,13 @@ class WindowsSocket(threading.Thread):
420
421
  # finally below, only once its own read has returned.
421
422
  nread = self._transfer(_kernel32.ReadFile, self._read_event,
422
423
  buf, len(buf))
423
- if not nread:
424
+ if nread is None: # the peer is gone, or stop()
424
425
  break
426
+ if nread == 0:
427
+ # A zero-length message, not the end: mpv's pipe is
428
+ # message-mode and it answers an async command with an
429
+ # empty write. Treating it as EOF closed the connection.
430
+ continue
425
431
 
426
432
  data += buf.raw[:nread]
427
433
  if data[-1] != 10:
@@ -699,6 +705,27 @@ class MPVProcess:
699
705
  except FileNotFoundError:
700
706
  pass # The other thread got there first, which is all we wanted.
701
707
 
708
+ #: Stands in a sync command's result slot when the connection closed first.
709
+ _CLOSED = object()
710
+
711
+ def _call_user(what, func, *args):
712
+ """Call user code from a thread this library owns, logging anything it
713
+ raises -- SystemExit included: a caller's bug must not end the reader,
714
+ the close sweep or the drain. Not for EventHandler.run, which a
715
+ BaseException still ends (see there)."""
716
+ try:
717
+ func(*args)
718
+ except BaseException:
719
+ log.error("%s raised.", what, exc_info=1)
720
+
721
+ def _reply_outcome(data):
722
+ """``(error, result)`` for one IPC reply."""
723
+ if data["error"] == "success":
724
+ return None, data.get("data")
725
+ if data["error"] == "property unavailable":
726
+ return None, None
727
+ return MPVError(data["error"]), None
728
+
702
729
  class MPVInter:
703
730
  """
704
731
  Low-level interface to MPV. Does NOT manage an mpv process. (Internal)
@@ -708,6 +735,7 @@ class MPVInter:
708
735
 
709
736
  *ipc_socket* is the path to the Unix/Linux socket or name of the Windows pipe.
710
737
  *callback(event_name, data)* is the function for recieving events.
738
+ It runs on the reader thread; an exception from it is logged.
711
739
  *quit_callback* is called when the socket connection to MPV dies.
712
740
  """
713
741
  Socket = UnixSocket
@@ -719,25 +747,64 @@ class MPVInter:
719
747
  if self.callback is None:
720
748
  self.callback = lambda event, data: None
721
749
 
722
- self.socket = Socket(ipc_socket, self.event_callback, self.quit_callback)
723
- self.socket.start()
724
750
  self.command_id = 1
725
751
  self.rid_lock = threading.Lock()
726
752
  self.socket_lock = threading.Lock()
727
753
  self.cid_result = {}
728
754
  self.cid_wait = {}
755
+ # Before the reader starts: it can deliver a reply, or die, at once.
756
+ self.cid_async = {}
757
+ self.async_lock = threading.Lock()
758
+ self.closed = False
759
+
760
+ self.socket = Socket(ipc_socket, self.event_callback, self._on_quit)
761
+ self.socket.start()
729
762
 
730
763
  def stop(self, join=True):
731
764
  """Terminate the underlying connection."""
732
765
  self.socket.stop(join)
766
+ # The reader's _on_quit usually gets here first. This one does not
767
+ # depend on it, so nothing is left pending once stop() returns.
768
+ self._fail_async()
769
+
770
+ def _on_quit(self):
771
+ self._fail_async()
772
+ if self.quit_callback:
773
+ self.quit_callback()
774
+
775
+ def _fail_async(self):
776
+ """Answer every pending command, async or not: nothing else ever will."""
777
+ with self.async_lock:
778
+ self.closed = True
779
+ pending, self.cid_async = self.cid_async, {}
780
+ for command_id, event in list(self.cid_wait.items()):
781
+ # setdefault: a reply that landed just before still wins.
782
+ self.cid_result.setdefault(command_id, _CLOSED)
783
+ event.set()
784
+ for on_reply in pending.values():
785
+ _call_user("command_async reply handler", on_reply,
786
+ BrokenPipeError("MPV connection closed."), None)
733
787
 
734
788
  def event_callback(self, data):
735
789
  """Internal callback for recieving events from MPV."""
736
790
  if "request_id" in data:
737
- self.cid_result[data["request_id"]] = data
738
- self.cid_wait[data["request_id"]].set()
791
+ request_id = data["request_id"]
792
+ with self.async_lock:
793
+ on_reply = self.cid_async.pop(request_id, None)
794
+ if on_reply is not None:
795
+ _call_user("command_async reply handler", on_reply,
796
+ *_reply_outcome(data))
797
+ return
798
+ # Not indexed: a reply nobody waits for (mpv answers id 0 to a
799
+ # request without one) would raise here, on the reader thread,
800
+ # and end the read loop as if the connection had died.
801
+ event = self.cid_wait.get(request_id)
802
+ if event is not None:
803
+ self.cid_result[request_id] = data
804
+ event.set()
739
805
  elif "event" in data:
740
- self.callback(data["event"], data)
806
+ _call_user("MPVInter event callback", self.callback,
807
+ data["event"], data)
741
808
 
742
809
  def command(self, command, *args):
743
810
  """
@@ -746,7 +813,8 @@ class MPVInter:
746
813
  *command* is the name of the MPV command
747
814
 
748
815
  All further arguments are forwarded to the MPV command.
749
- Throws TimeoutError if timeout of 120 seconds is reached.
816
+ Throws TimeoutError if timeout of 120 seconds is reached, and
817
+ BrokenPipeError as soon as the connection closes before the reply.
750
818
  """
751
819
  self.rid_lock.acquire()
752
820
  command_id = self.command_id
@@ -754,7 +822,12 @@ class MPVInter:
754
822
  self.rid_lock.release()
755
823
 
756
824
  event = threading.Event()
757
- self.cid_wait[command_id] = event
825
+ # Under the lock _fail_async takes, so a close either finds this
826
+ # waiter or is seen here first; neither way waits out TIMEOUT.
827
+ with self.async_lock:
828
+ if self.closed:
829
+ raise BrokenPipeError("MPV connection closed.")
830
+ self.cid_wait[command_id] = event
758
831
 
759
832
  command_list = [command]
760
833
  command_list.extend(args)
@@ -766,23 +839,59 @@ class MPVInter:
766
839
 
767
840
  has_event = event.wait(timeout=TIMEOUT)
768
841
  if has_event:
769
- data = self.cid_result[command_id]
770
- del self.cid_result[command_id]
842
+ data = self.cid_result.pop(command_id)
771
843
  del self.cid_wait[command_id]
772
- if data["error"] != "success":
773
- if data["error"] == "property unavailable":
774
- return None
775
- raise MPVError(data["error"])
776
- else:
777
- return data.get("data")
844
+ if data is _CLOSED:
845
+ raise BrokenPipeError("MPV connection closed.")
846
+ error, result = _reply_outcome(data)
847
+ if error is not None:
848
+ raise error
849
+ return result
778
850
  else:
779
851
  raise TimeoutError("No response from MPV.")
780
852
 
853
+ def command_async(self, on_reply, command, *args):
854
+ """
855
+ Issue a command to MPV without waiting for it to finish.
856
+
857
+ *on_reply(error, result)* is called exactly once: with MPV's answer on
858
+ the transport's reader thread; with BrokenPipeError on the thread
859
+ that closes the connection, or on the calling thread if it is already
860
+ closed; or with the send's own error on the calling thread. An
861
+ exception from it is logged when the reader or the close calls it,
862
+ and raised to the caller otherwise. There is no timeout.
863
+ """
864
+ with self.rid_lock:
865
+ command_id = self.command_id
866
+ self.command_id += 1
867
+
868
+ with self.async_lock:
869
+ closed = self.closed
870
+ if not closed:
871
+ self.cid_async[command_id] = on_reply
872
+ if closed:
873
+ on_reply(BrokenPipeError("MPV connection closed."), None)
874
+ return
875
+
876
+ command_list = [command]
877
+ command_list.extend(args)
878
+ try:
879
+ with self.socket_lock:
880
+ self.socket.send({"command": command_list,
881
+ "request_id": command_id, "async": True})
882
+ except Exception as error:
883
+ with self.async_lock:
884
+ owned = self.cid_async.pop(command_id, None)
885
+ if owned is not None: # else _fail_async has already answered
886
+ on_reply(error, None)
887
+
781
888
  class EventHandler(threading.Thread):
782
889
  """Event handling thread. (Internal)"""
783
890
  def __init__(self):
784
891
  """Create an instance of the thread."""
785
892
  self.queue = queue.Queue()
893
+ self.stopped = False
894
+ self.stop_lock = threading.Lock()
786
895
  threading.Thread.__init__(self)
787
896
  self.daemon = True
788
897
 
@@ -793,24 +902,62 @@ class EventHandler(threading.Thread):
793
902
  *func* is the function to call
794
903
 
795
904
  All further arguments are forwarded to *func*.
905
+
906
+ Returns False, without queueing it, once the thread has stopped --
907
+ by stop(), or because a task raised SystemExit or the like.
796
908
  """
797
- self.queue.put((func, args))
909
+ with self.stop_lock:
910
+ if self.stopped:
911
+ return False
912
+ self.queue.put((func, args))
913
+ return True
798
914
 
799
915
  def stop(self, join=True):
800
916
  """Terminate the thread."""
801
- self.queue.put("quit")
802
- self.join(join)
917
+ with self.stop_lock:
918
+ self.stopped = True
919
+ self.queue.put("quit")
920
+ # A callback that calls terminate() is on this thread. The guard is
921
+ # the transports' (see UnixSocket.stop); `join` stays the timeout.
922
+ if self.ident is not None and self is not threading.current_thread():
923
+ self.join(join)
803
924
 
804
925
  def run(self):
805
926
  """Process socket events. Do not run this directly. Use *start*."""
927
+ try:
928
+ while True:
929
+ event = self.queue.get()
930
+ if event == "quit":
931
+ break
932
+ try:
933
+ event[0](*event[1])
934
+ except Exception:
935
+ log.error("EventHandler caught exception from %s.", event, exc_info=1)
936
+ except BaseException as ex:
937
+ log.warning("A callback raised %r, ending the event thread: "
938
+ "property observers, key bindings and events will no "
939
+ "longer be delivered; command_async callbacks now run "
940
+ "on whichever thread delivers their outcome.", ex)
941
+ # Not _call_user, deliberately: a callback's SystemExit ends this
942
+ # thread, as it has since 2020.
943
+ raise
944
+ finally:
945
+ self._drain()
946
+
947
+ def _drain(self):
948
+ """A callback's SystemExit (or similar) ends this thread, as it always
949
+ has. Refuse work from here on, so put_task says so, and run what was
950
+ already queued: a command_async future may be waiting in it."""
951
+ with self.stop_lock:
952
+ self.stopped = True
806
953
  while True:
807
- event = self.queue.get()
808
- if event == "quit":
809
- break
810
954
  try:
811
- event[0](*event[1])
812
- except Exception:
813
- log.error("EventHandler caught exception from {0}.".format(event), exc_info=1)
955
+ event = self.queue.get_nowait()
956
+ except queue.Empty:
957
+ return
958
+ if event == "quit":
959
+ continue
960
+ _call_user("EventHandler task", event[0], *event[1])
814
961
 
815
962
  class MPV:
816
963
  """
@@ -833,7 +980,8 @@ class MPV:
833
980
  *mpv_location* is the location of MPV for *start_mpv*. (Default: Use MPV in PATH)
834
981
  *log_handler(level, prefix, text)* is an optional handler for log events. (Default: Disabled)
835
982
  *loglevel* is the level for log messages. Levels are fatal, error, warn, info, v, debug, trace. (Default: Disabled)
836
- *quit_callback* is called when the socket connection to MPV dies.
983
+ *quit_callback* is called on the reader thread when the socket connection to MPV dies.
984
+ An exception from it is logged, and MPV is still terminated.
837
985
  *diagnose_start_failures* re-runs MPV once, on the first failed start, to find out
838
986
  why it refused to start. If MPV names an option it does not have, MPVProcessError is
839
987
  raised immediately with *bad_option* set instead of spending every retry on something
@@ -958,7 +1106,7 @@ class MPV:
958
1106
  Internal handler for quit events.
959
1107
  """
960
1108
  if self.quit_callback:
961
- self.quit_callback()
1109
+ _call_user("quit_callback", self.quit_callback)
962
1110
  self.terminate(join=False)
963
1111
 
964
1112
  def bind_event(self, name, callback):
@@ -1068,6 +1216,11 @@ class MPV:
1068
1216
  Waits for the value of a property to change.
1069
1217
 
1070
1218
  *name* is the name of the property.
1219
+
1220
+ Never call it from inside a callback here: the change is delivered on
1221
+ the event thread, which the wait would be blocking, so it never ends.
1222
+ It also never returns once a callback has ended the event thread,
1223
+ since the change is then never delivered.
1071
1224
  """
1072
1225
  event = threading.Event()
1073
1226
  first_event = True
@@ -1126,6 +1279,80 @@ class MPV:
1126
1279
  """
1127
1280
  return self.mpv_inter.command(command, *args)
1128
1281
 
1282
+ def command_async(self, command, *args, callback=None):
1283
+ """
1284
+ Send a command to MPV without waiting for it to finish, for one that
1285
+ can take a long time (``sub-add`` of a slow URL). Unlike *command*,
1286
+ it holds neither the caller nor the connection: other commands are
1287
+ answered while it runs, and it has no timeout.
1288
+
1289
+ Returns a ``concurrent.futures.Future``. With no *callback* it holds
1290
+ MPV's result or the error, as *command* would have returned or raised.
1291
+ *callback(error, result)* is called first, with *error* None on
1292
+ success, and the future then holds what it returns or raises.
1293
+
1294
+ The callback runs on the event thread, like every other callback
1295
+ here. If an earlier callback ended that thread by raising SystemExit
1296
+ or the like, a warning is logged, and property observers, key
1297
+ bindings and events are no longer delivered at all; command_async
1298
+ callbacks still run, on whichever thread delivers their outcome
1299
+ instead -- the connection's reader while MPV is connected, or the
1300
+ thread that closes the connection -- and a SystemExit from one there
1301
+ is logged, not raised. With the event thread gone, a call made once
1302
+ the connection has closed (by *terminate* or by MPV exiting), or
1303
+ whose send fails, runs its callback on the calling thread before
1304
+ command_async returns, and a SystemExit from it is raised to that
1305
+ caller. A logging handler that raises SystemExit is not caught
1306
+ anywhere: as in the standard library, it ends the thread that logged,
1307
+ and in the close that leaves the futures after it unresolved.
1308
+
1309
+ The future resolves when MPV answers or, if the connection closes
1310
+ first, with BrokenPipeError -- meaning no answer will come, not that
1311
+ the command did not run. Until then it is pending: a command MPV is
1312
+ still running has no timeout.
1313
+
1314
+ Never block on the future from inside a callback here (a property
1315
+ observer, a key binding, an event, another command_async callback).
1316
+ Those run on the event thread, the thread that would resolve it, so
1317
+ the wait never ends. python-mpv behaves the same.
1318
+
1319
+ Once the event thread is gone, a callback must not block at all. On
1320
+ the reader, a sync *command* waits out TIMEOUT with nothing else read
1321
+ meanwhile, and a wait on a future never ends -- nor does a
1322
+ *terminate* called from another thread. python-mpv fails here too:
1323
+ once a callback's SystemExit stops its event loop, no callback or
1324
+ future is delivered at all.
1325
+
1326
+ The future cannot be cancelled: MPV's IPC protocol cannot abort a
1327
+ command. Needs MPV 0.33 or later; an older MPV ignores the request to
1328
+ run in the background and runs the command as *command* does, blocking
1329
+ every other command on the connection until it finishes.
1330
+ """
1331
+ future = Future()
1332
+ future.set_running_or_notify_cancel()
1333
+
1334
+ def finish(error, result):
1335
+ try:
1336
+ if callback is not None:
1337
+ future.set_result(callback(error, result))
1338
+ elif error is not None:
1339
+ future.set_exception(error)
1340
+ else:
1341
+ future.set_result(result)
1342
+ except BaseException as ex:
1343
+ future.set_exception(ex)
1344
+ if not isinstance(ex, Exception):
1345
+ raise # which thread it reaches, and what happens: see the docstring
1346
+ log.error("command_async callback for {0} raised.".format(
1347
+ command), exc_info=1)
1348
+
1349
+ def on_reply(error, result):
1350
+ if not self.event_handler.put_task(finish, error, result):
1351
+ finish(error, result)
1352
+
1353
+ self.mpv_inter.command_async(on_reply, command, *args)
1354
+ return future
1355
+
1129
1356
  def __getattr__(self, name):
1130
1357
  if name in self.properties:
1131
1358
  return self.command("get_property", name.replace("_", "-"))
@@ -98,8 +98,10 @@ class ArgumentTranslationTest(unittest.TestCase):
98
98
  class SocketPathTest(unittest.TestCase):
99
99
  @unittest.skipIf(os.name == "nt", "POSIX socket paths only")
100
100
  def test_a_stale_socket_file_is_removed_before_starting(self):
101
- import tempfile
102
- path = os.path.join(tempfile.mkdtemp(), "stale.sock")
101
+ import shutil, tempfile
102
+ directory = tempfile.mkdtemp()
103
+ self.addCleanup(shutil.rmtree, directory, True)
104
+ path = os.path.join(directory, "stale.sock")
103
105
  with open(path, "w") as handle:
104
106
  handle.write("")
105
107
  self.assertTrue(os.path.exists(path))
@@ -0,0 +1,550 @@
1
+ """``command_async`` against a real mpv.
2
+
3
+ The case it exists for is a command that takes a long time and must not hold
4
+ the caller or the pipe while it does: jellyfin-mpv-shim's ``sub-add`` of a
5
+ subtitle the server extracts before it answers, which held a synchronous
6
+ ``command()`` for the full 120s ``TIMEOUT`` and then raised. A local HTTP
7
+ server that answers late stands in for that server.
8
+
9
+ The pipe half is the one a fake could not show: whether mpv answers *other*
10
+ requests while one is running is mpv's behaviour, and it only does so for a
11
+ request that carries ``"async": true``.
12
+ """
13
+
14
+ import http.server
15
+ import json
16
+ import logging
17
+ import os
18
+ import shutil
19
+ import socket
20
+ import sys
21
+ import tempfile
22
+ import threading
23
+ import time
24
+ import unittest
25
+ from unittest import mock
26
+
27
+ from _harness import LiveMPVTest, python_mpv_jsonipc
28
+
29
+ SRT = b"1\n00:00:00,000 --> 00:10:00,000\nhello\n"
30
+
31
+
32
+ class _LateServer:
33
+ """``/<seconds>/ok.srt`` answers with a subtitle after that long;
34
+ ``/<seconds>/missing.srt`` with a 404."""
35
+
36
+ def __init__(self):
37
+ class Handler(http.server.BaseHTTPRequestHandler):
38
+ def do_GET(handler):
39
+ _, delay, name = handler.path.split("/")
40
+ time.sleep(float(delay))
41
+ if name == "ok.srt":
42
+ handler.send_response(200)
43
+ handler.send_header("Content-Length", str(len(SRT)))
44
+ handler.end_headers()
45
+ handler.wfile.write(SRT)
46
+ else:
47
+ handler.send_response(404)
48
+ handler.send_header("Content-Length", "0")
49
+ handler.end_headers()
50
+
51
+ def log_message(handler, *args):
52
+ pass
53
+
54
+ self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
55
+ self.server.daemon_threads = True
56
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
57
+
58
+ def url(self, delay, name="ok.srt"):
59
+ return "http://127.0.0.1:{0}/{1}/{2}".format(
60
+ self.server.server_address[1], delay, name)
61
+
62
+ def close(self):
63
+ self.server.shutdown()
64
+ self.server.server_close()
65
+
66
+
67
+ def _wait(predicate, timeout=10):
68
+ deadline = time.monotonic() + timeout
69
+ while time.monotonic() < deadline:
70
+ if predicate():
71
+ return True
72
+ time.sleep(0.05)
73
+ return False
74
+
75
+
76
+ class _AsyncTest(LiveMPVTest):
77
+ def setUp(self):
78
+ super().setUp()
79
+ self.http = _LateServer()
80
+ self.addCleanup(self.http.close)
81
+ self.mpv.play("av://lavfi:testsrc")
82
+ self.assertTrue(_wait(lambda: self.mpv.time_pos is not None),
83
+ "mpv never started playing")
84
+
85
+ def external_subs(self):
86
+ return [t for t in self.mpv.track_list or []
87
+ if t.get("type") == "sub" and t.get("external")]
88
+
89
+
90
+ class CommandAsyncTest(_AsyncTest):
91
+ def test_it_returns_before_the_command_finishes(self):
92
+ started = time.monotonic()
93
+ future = self.mpv.command_async("sub-add", self.http.url(2), "select")
94
+ self.assertLess(time.monotonic() - started, 0.5)
95
+ self.assertFalse(future.done())
96
+
97
+ self.assertIsNone(future.result(timeout=15))
98
+ self.assertGreaterEqual(time.monotonic() - started, 2)
99
+ subs = self.external_subs()
100
+ self.assertEqual(len(subs), 1, subs)
101
+ self.assertTrue(subs[0].get("selected"), subs)
102
+
103
+ def test_other_requests_are_answered_while_it_runs(self):
104
+ # The difference "async" makes on the wire: without it mpv's IPC
105
+ # handler executes the command inline and reads nothing else from
106
+ # this client until it finishes, so this read would wait out the
107
+ # server's delay.
108
+ future = self.mpv.command_async("sub-add", self.http.url(5))
109
+ started = time.monotonic()
110
+ self.assertIsNotNone(self.mpv.pause)
111
+ self.assertLess(time.monotonic() - started, 1)
112
+ self.assertFalse(future.done())
113
+ future.result(timeout=15)
114
+
115
+ def test_a_failure_arrives_as_the_future_exception(self):
116
+ future = self.mpv.command_async(
117
+ "sub-add", self.http.url(0, "missing.srt"))
118
+ self.assertIsInstance(future.exception(timeout=15),
119
+ python_mpv_jsonipc.MPVError)
120
+
121
+ def test_an_unavailable_property_is_none_as_it_is_for_command(self):
122
+ # testsrc has no audio, so mpv answers "property unavailable", which
123
+ # command() turns into None rather than raising.
124
+ self.assertIsNone(self.mpv.command("get_property", "audio-params"))
125
+ future = self.mpv.command_async("get_property", "audio-params")
126
+ self.assertIsNone(future.result(timeout=5))
127
+
128
+ def test_the_callback_sees_the_outcome_and_supplies_the_result(self):
129
+ seen = []
130
+
131
+ def callback(error, result):
132
+ seen.append((error, result, threading.current_thread()))
133
+ return "from the callback"
134
+
135
+ ok = self.mpv.command_async("get_property", "pause", callback=callback)
136
+ self.assertEqual(ok.result(timeout=5), "from the callback")
137
+ bad = self.mpv.command_async(
138
+ "sub-add", self.http.url(0, "missing.srt"), callback=callback)
139
+ self.assertEqual(bad.result(timeout=15), "from the callback")
140
+
141
+ (error, result, thread), (error2, result2, _) = seen
142
+ self.assertIsNone(error)
143
+ self.assertIs(result, False)
144
+ self.assertIsInstance(error2, python_mpv_jsonipc.MPVError)
145
+ self.assertIsNone(result2)
146
+ # Never the transport's reader: user code there stalls every reply.
147
+ self.assertIs(thread, self.mpv.event_handler)
148
+
149
+ def test_a_callback_that_raises_becomes_the_future_exception(self):
150
+ def callback(error, result):
151
+ raise ValueError("boom")
152
+
153
+ with self.assertLogs("mpv-jsonipc", "ERROR"):
154
+ future = self.mpv.command_async("get_property", "pause",
155
+ callback=callback)
156
+ self.assertIsInstance(future.exception(timeout=5), ValueError)
157
+
158
+ def test_it_cannot_be_cancelled(self):
159
+ # The IPC protocol has no way to abort a running command.
160
+ future = self.mpv.command_async("sub-add", self.http.url(2))
161
+ self.assertFalse(future.cancel())
162
+ future.result(timeout=15)
163
+
164
+ def test_loading_another_file_aborts_a_pending_sub_add(self):
165
+ # mpv's behaviour, pinned because the shim relies on it: a subtitle
166
+ # still loading when the next item starts must not land on that item.
167
+ future = self.mpv.command_async("sub-add", self.http.url(5), "select")
168
+ time.sleep(0.5)
169
+ self.mpv.play("av://lavfi:testsrc")
170
+ self.assertIsInstance(future.exception(timeout=15),
171
+ python_mpv_jsonipc.MPVError)
172
+ self.assertTrue(_wait(lambda: self.mpv.time_pos is not None))
173
+ self.assertEqual(self.external_subs(), [])
174
+
175
+
176
+ class TeardownTest(_AsyncTest):
177
+ """A closed connection resolves every future, and a callback that
178
+ raises, SystemExit included, strands none. One left pending forever is a
179
+ caller blocked on ``result()`` forever, which is worse than the timeout
180
+ this replaces."""
181
+
182
+ def test_terminate_fails_what_is_pending(self):
183
+ future = self.mpv.command_async("sub-add", self.http.url(30))
184
+ self.mpv.terminate()
185
+ self.assertIsInstance(future.exception(timeout=5), BrokenPipeError)
186
+
187
+ def test_mpv_dying_fails_what_is_pending(self):
188
+ future = self.mpv.command_async("sub-add", self.http.url(30))
189
+ self.mpv.mpv_process.process.kill()
190
+ self.assertIsInstance(future.exception(timeout=5), BrokenPipeError)
191
+
192
+ def test_after_terminate_it_fails_instead_of_hanging(self):
193
+ self.mpv.terminate()
194
+ future = self.mpv.command_async("get_property", "pause")
195
+ self.assertIsInstance(future.exception(timeout=5), BrokenPipeError)
196
+
197
+ def test_the_callback_still_runs_after_terminate(self):
198
+ seen = []
199
+ self.mpv.terminate()
200
+ self.mpv.command_async("get_property", "pause",
201
+ callback=lambda e, r: seen.append(e))
202
+ self.assertTrue(_wait(lambda: seen, 5))
203
+ self.assertIsInstance(seen[0], BrokenPipeError)
204
+
205
+ def test_a_callback_can_terminate(self):
206
+ # The callback runs on the event thread, and terminate() stops that
207
+ # thread: it must not try to join itself.
208
+ future = self.mpv.command_async(
209
+ "get_property", "pause",
210
+ callback=lambda e, r: self.mpv.terminate())
211
+ self.assertIsNone(future.exception(timeout=10))
212
+ self.assertTrue(_wait(lambda: self.mpv.mpv_process.process.poll()
213
+ is not None, 10), "mpv is still running")
214
+
215
+ def test_a_callback_that_exits_strands_nothing(self):
216
+ # A BaseException from a callback ends the event thread, as it always
217
+ # has; what was waiting behind it must still resolve, and so must
218
+ # what comes after.
219
+ def leave(error, result):
220
+ sys.exit(3)
221
+
222
+ first = self.mpv.command_async("get_property", "pause", callback=leave)
223
+ second = self.mpv.command_async("get_property", "pause")
224
+ self.assertIsInstance(first.exception(timeout=5), SystemExit)
225
+ self.assertIs(second.result(timeout=5), False)
226
+ self.assertTrue(_wait(lambda: not self.mpv.event_handler.is_alive(), 5))
227
+ self.assertIs(self.mpv.command_async("get_property", "pause")
228
+ .result(timeout=5), False)
229
+
230
+ def test_a_callback_that_exits_on_the_reader_ends_nothing(self):
231
+ # Once a callback has ended the event thread, later callbacks run on
232
+ # the reader. One that exits there must not end the read loop too:
233
+ # replies still arrive, and mpv dying is still noticed.
234
+ def leave(error, result):
235
+ ran_on.append(threading.current_thread())
236
+ sys.exit(3)
237
+
238
+ ran_on = []
239
+ with self.assertLogs("mpv-jsonipc", "WARNING") as logs:
240
+ self.mpv.command_async("get_property", "pause",
241
+ callback=leave).exception(timeout=5)
242
+ self.assertTrue(
243
+ _wait(lambda: not self.mpv.event_handler.is_alive(), 5))
244
+ self.assertIsInstance(self.mpv.command_async(
245
+ "get_property", "pause", callback=leave).exception(timeout=5),
246
+ SystemExit)
247
+ # Logged after the future resolves, on the reader.
248
+ self.assertTrue(_wait(lambda: any(
249
+ r.levelname == "ERROR" for r in logs.records), 5))
250
+ self.assertIs(ran_on[1], self.mpv.mpv_inter.socket)
251
+ self.assertIn("WARNING", [r.levelname for r in logs.records])
252
+ with mock.patch.object(python_mpv_jsonipc, "TIMEOUT", 3):
253
+ self.assertIs(self.mpv.command("get_property", "pause"), False)
254
+ pending = self.mpv.command_async("sub-add", self.http.url(30))
255
+ self.mpv.mpv_process.process.kill()
256
+ self.assertIsInstance(pending.exception(timeout=5), BrokenPipeError)
257
+
258
+ def test_mpv_dying_fails_a_waiting_command_at_once(self):
259
+ # command() used to wait out TIMEOUT (120 s) for a reply that could
260
+ # no longer come, holding whatever lock its caller held.
261
+ outcome = []
262
+
263
+ def wait():
264
+ try:
265
+ self.mpv.command("sub-add", self.http.url(30))
266
+ except BaseException as error:
267
+ outcome.append(error)
268
+
269
+ waiter = threading.Thread(target=wait, daemon=True)
270
+ waiter.start()
271
+ time.sleep(0.5)
272
+ self.mpv.mpv_process.process.kill()
273
+ waiter.join(10)
274
+ self.assertFalse(waiter.is_alive(), "command() is still waiting")
275
+ self.assertIsInstance(outcome[0], BrokenPipeError)
276
+
277
+
278
+ class ReplyRoutingTest(unittest.TestCase):
279
+ """The reader thread's side, without a pipe. An exception here escapes
280
+ the transport's read loop, which reports it as the connection dying."""
281
+
282
+ def setUp(self):
283
+ transport = mock.MagicMock()
284
+ patch = mock.patch.object(python_mpv_jsonipc, "UnixSocket", transport)
285
+ patch.start()
286
+ self.addCleanup(patch.stop)
287
+ mock.patch.object(python_mpv_jsonipc, "WindowsSocket",
288
+ transport).start()
289
+ self.addCleanup(mock.patch.stopall)
290
+ self.inter = python_mpv_jsonipc.MPVInter("unused")
291
+ self.quits = []
292
+ self.inter.quit_callback = lambda: self.quits.append(True)
293
+
294
+ def player_whose_event_thread_is_gone(self):
295
+ """An MPV over the stub, in the state a callback's SystemExit leaves
296
+ it: put_task refuses, so callbacks run on whichever thread delivers
297
+ the reply."""
298
+ player = python_mpv_jsonipc.MPV.__new__(python_mpv_jsonipc.MPV)
299
+ handler = python_mpv_jsonipc.EventHandler()
300
+ handler.stop(join=False)
301
+ for name, value in [("properties", set()), ("mpv_process", None),
302
+ ("mpv_inter", self.inter),
303
+ ("event_handler", handler)]:
304
+ object.__setattr__(player, name, value)
305
+ return player
306
+
307
+ def request_id(self, n):
308
+ return self.inter.socket.send.call_args_list[n][0][0]["request_id"]
309
+
310
+ def test_a_reply_nobody_is_waiting_for_is_dropped(self):
311
+ # mpv answers a request that carried no request_id with id 0.
312
+ self.inter.event_callback({"request_id": 0, "error": "success"})
313
+ self.inter.event_callback({"request_id": 12345, "error": "success"})
314
+
315
+ def test_an_async_reply_is_delivered_once(self):
316
+ seen = []
317
+ self.inter.command_async(lambda e, r: seen.append((e, r)),
318
+ "get_property", "pause")
319
+ sent = self.inter.socket.send.call_args[0][0]
320
+ self.assertIs(sent["async"], True)
321
+ reply = {"request_id": sent["request_id"], "error": "success",
322
+ "data": True}
323
+ self.inter.event_callback(reply)
324
+ self.inter.event_callback(reply)
325
+ self.inter.stop()
326
+ self.assertEqual(seen, [(None, True)])
327
+
328
+ def test_a_raising_on_reply_loses_nothing_else_on_close(self):
329
+ seen, quits = [], []
330
+ self.inter.quit_callback = lambda: quits.append(True)
331
+
332
+ def bad(error, result):
333
+ raise ValueError("a caller's bug")
334
+
335
+ self.inter.command_async(bad, "get_property", "pause")
336
+ self.inter.command_async(lambda e, r: seen.append(e),
337
+ "get_property", "pause")
338
+ with self.assertLogs("mpv-jsonipc", "ERROR"):
339
+ self.inter._on_quit()
340
+ self.assertEqual(len(seen), 1)
341
+ self.assertIsInstance(seen[0], BrokenPipeError)
342
+ self.assertEqual(quits, [True])
343
+
344
+ def test_a_raising_on_reply_does_not_end_the_read_loop(self):
345
+ def bad(error, result):
346
+ raise ValueError("a caller's bug")
347
+
348
+ self.inter.command_async(bad, "get_property", "pause")
349
+ sent = self.inter.socket.send.call_args[0][0]
350
+ with self.assertLogs("mpv-jsonipc", "ERROR"):
351
+ self.inter.event_callback({"request_id": sent["request_id"],
352
+ "error": "success", "data": True})
353
+
354
+ def test_an_exiting_callback_on_the_reader_is_logged(self):
355
+ player = self.player_whose_event_thread_is_gone()
356
+ first = player.command_async("get_property", "pause",
357
+ callback=lambda e, r: sys.exit(3))
358
+ second = player.command_async("get_property", "pause")
359
+ try:
360
+ with self.assertLogs("mpv-jsonipc", "ERROR"):
361
+ self.inter.event_callback({"request_id": self.request_id(0),
362
+ "error": "success", "data": False})
363
+ except SystemExit:
364
+ self.fail("SystemExit escaped event_callback, ending the reader")
365
+ self.assertIsInstance(first.exception(0), SystemExit)
366
+ self.inter._on_quit()
367
+ self.assertIsInstance(second.exception(0), BrokenPipeError)
368
+ self.assertEqual(self.quits, [True])
369
+
370
+ def test_an_exiting_callback_does_not_cut_the_close_short(self):
371
+ player = self.player_whose_event_thread_is_gone()
372
+ first = player.command_async("get_property", "pause",
373
+ callback=lambda e, r: sys.exit(3))
374
+ rest = [player.command_async("get_property", "pause")
375
+ for _ in range(2)]
376
+ try:
377
+ with self.assertLogs("mpv-jsonipc", "ERROR"):
378
+ self.inter._on_quit()
379
+ except SystemExit:
380
+ self.fail("SystemExit escaped the close sweep")
381
+ self.assertIsInstance(first.exception(0), SystemExit)
382
+ for future in rest:
383
+ self.assertIsInstance(future.exception(0), BrokenPipeError)
384
+ self.assertEqual(self.quits, [True])
385
+
386
+ def test_terminate_logs_an_exiting_callback_instead_of_raising_it(self):
387
+ # The callback runs on terminate()'s caller here, but it is the
388
+ # library's close doing the calling: the rest must still resolve.
389
+ player = self.player_whose_event_thread_is_gone()
390
+ first = player.command_async("get_property", "pause",
391
+ callback=lambda e, r: sys.exit(3))
392
+ second = player.command_async("get_property", "pause")
393
+ try:
394
+ with self.assertLogs("mpv-jsonipc", "ERROR"):
395
+ player.terminate()
396
+ except SystemExit:
397
+ self.fail("SystemExit raised out of terminate()")
398
+ self.assertIsInstance(first.exception(0), SystemExit)
399
+ self.assertIsInstance(second.exception(0), BrokenPipeError)
400
+
401
+ def test_a_raising_event_callback_is_logged(self):
402
+ def bad(event, data):
403
+ raise ValueError("a caller's bug")
404
+
405
+ self.inter.callback = bad
406
+ try:
407
+ with self.assertLogs("mpv-jsonipc", "ERROR"):
408
+ self.inter.event_callback({"event": "pause"})
409
+ except ValueError:
410
+ self.fail("the event callback's exception escaped event_callback")
411
+
412
+ def test_a_command_after_the_reader_died_fails_at_once(self):
413
+ # The reader's quit sweeps before anything stops the socket, so a
414
+ # send can still "succeed" into a dead connection in between.
415
+ self.inter._on_quit()
416
+ with mock.patch.object(python_mpv_jsonipc, "TIMEOUT", 2):
417
+ with self.assertRaises(BrokenPipeError):
418
+ self.inter.command("get_property", "pause")
419
+
420
+ def test_a_send_that_fails_is_reported_to_the_callback(self):
421
+ seen = []
422
+ self.inter.socket.send.side_effect = BrokenPipeError("gone")
423
+ self.inter.command_async(lambda e, r: seen.append(e), "stop")
424
+ self.assertEqual(len(seen), 1)
425
+ self.assertIsInstance(seen[0], BrokenPipeError)
426
+
427
+
428
+
429
+ @unittest.skipIf(os.name == "nt", "the reader there is a named pipe")
430
+ class RealReaderTest(unittest.TestCase):
431
+ """The stub above cannot show whether the read loop goes on afterwards;
432
+ a real UnixSocket reader against a fake mpv can."""
433
+
434
+ def test_an_exiting_event_callback_does_not_end_the_read_loop(self):
435
+ directory = tempfile.mkdtemp()
436
+ self.addCleanup(shutil.rmtree, directory, True)
437
+ path = os.path.join(directory, "mpv.sock")
438
+ server = socket.socket(socket.AF_UNIX)
439
+ server.bind(path)
440
+ server.listen(1)
441
+ self.addCleanup(server.close)
442
+ accepted = []
443
+ threading.Thread(target=lambda: accepted.append(server.accept()[0]),
444
+ daemon=True).start()
445
+
446
+ def leave(event, data):
447
+ raise SystemExit(3)
448
+
449
+ inter = python_mpv_jsonipc.MPVInter(path, callback=leave)
450
+ self.addCleanup(inter.stop)
451
+ self.assertTrue(_wait(lambda: accepted, 5))
452
+ peer = accepted[0]
453
+ self.addCleanup(peer.close)
454
+ seen = []
455
+ inter.command_async(lambda e, r: seen.append(r),
456
+ "get_property", "pause")
457
+ request = json.loads(peer.recv(4096).split(b"\n")[0])
458
+ with self.assertLogs("mpv-jsonipc", "ERROR"):
459
+ peer.sendall(b'{"event": "pause"}\n')
460
+ peer.sendall(json.dumps({"request_id": request["request_id"],
461
+ "error": "success", "data": 7})
462
+ .encode() + b"\n")
463
+ self.assertTrue(_wait(lambda: seen, 3),
464
+ "the reply after the event was never read")
465
+ self.assertEqual(seen, [7])
466
+ self.assertTrue(inter.socket.is_alive())
467
+
468
+
469
+ class _BadRepr:
470
+ """A task's object whose repr raises: logging it must not cost the
471
+ tasks queued after it."""
472
+
473
+ def __repr__(self):
474
+ raise ValueError("bad repr")
475
+
476
+ def method(self):
477
+ pass
478
+
479
+ def raises(self):
480
+ raise RuntimeError("a caller's bug")
481
+
482
+
483
+ class EventThreadTest(unittest.TestCase):
484
+ def records(self):
485
+ seen = []
486
+ handler = logging.Handler()
487
+ handler.emit = seen.append
488
+ logger = logging.getLogger("mpv-jsonipc")
489
+ logger.addHandler(handler)
490
+ self.addCleanup(logger.removeHandler, handler)
491
+ return seen
492
+
493
+ def test_a_callback_ending_the_thread_warns_once(self):
494
+ seen = self.records()
495
+ handler = python_mpv_jsonipc.EventHandler()
496
+ handler.start()
497
+ handler.put_task(sys.exit, 3)
498
+ handler.join(5)
499
+ self.assertFalse(handler.is_alive())
500
+ warnings = [r for r in seen if r.levelno == logging.WARNING]
501
+ self.assertEqual(len(warnings), 1, [r.getMessage() for r in seen])
502
+
503
+ def test_an_ordinary_stop_does_not_warn(self):
504
+ seen = self.records()
505
+ handler = python_mpv_jsonipc.EventHandler()
506
+ handler.start()
507
+ handler.stop(5)
508
+ self.assertEqual([r for r in seen if r.levelno >= logging.WARNING], [])
509
+
510
+ def test_a_task_whose_repr_raises_strands_nothing_behind_it(self):
511
+ seen = []
512
+ handler = python_mpv_jsonipc.EventHandler()
513
+ handler.put_task(sys.exit, 3)
514
+ handler.put_task(_BadRepr().method)
515
+ handler.put_task(seen.append, "after")
516
+ self.records()
517
+ handler.start()
518
+ handler.join(5)
519
+ self.assertEqual(seen, ["after"])
520
+
521
+ def test_a_raising_task_whose_repr_raises_does_not_end_the_thread(self):
522
+ seen = self.records()
523
+ handler = python_mpv_jsonipc.EventHandler()
524
+ handler.start()
525
+ self.addCleanup(handler.stop, 5)
526
+ handler.put_task(_BadRepr().raises)
527
+ self.assertTrue(_wait(lambda: seen, 5))
528
+ time.sleep(0.1)
529
+ self.assertTrue(handler.is_alive(), "the event thread ended")
530
+ self.assertEqual([r.levelno for r in seen], [logging.ERROR])
531
+
532
+ def test_what_was_queued_behind_an_exit_still_runs(self):
533
+ # The first exit ends run(); the rest goes to _drain, which must
534
+ # survive the second and still pass the third its arguments.
535
+ seen = []
536
+ handler = python_mpv_jsonipc.EventHandler()
537
+ handler.put_task(sys.exit, 3)
538
+ handler.put_task(sys.exit, 4)
539
+ handler.put_task(seen.append, "after")
540
+ with self.assertLogs("mpv-jsonipc", "ERROR") as logs:
541
+ handler.start()
542
+ handler.join(5)
543
+ self.assertFalse(handler.is_alive())
544
+ self.assertEqual(seen, ["after"])
545
+ self.assertTrue(any(r.exc_info and r.exc_info[0] is SystemExit
546
+ for r in logs.records))
547
+
548
+
549
+ if __name__ == "__main__":
550
+ unittest.main()
@@ -30,6 +30,7 @@ that the cheap tests here cover postconditions, not interleavings.
30
30
  """
31
31
 
32
32
  import os
33
+ import shutil
33
34
  import tempfile
34
35
  import threading
35
36
  import time
@@ -141,6 +142,8 @@ class TerminateWaitsTest(unittest.TestCase):
141
142
  spawned.append(process)
142
143
  return process
143
144
 
145
+ directory = tempfile.mkdtemp()
146
+ self.addCleanup(shutil.rmtree, directory, True)
144
147
  with mock.patch.object(python_mpv_jsonipc.subprocess, "Popen", record), \
145
148
  mock.patch.object(python_mpv_jsonipc, "_ipc_endpoint_ready",
146
149
  return_value=False), \
@@ -148,7 +151,7 @@ class TerminateWaitsTest(unittest.TestCase):
148
151
  _skip_only_the_readiness_poll()):
149
152
  with self.assertRaises(python_mpv_jsonipc.MPVProcessError):
150
153
  python_mpv_jsonipc.MPVProcess(
151
- os.path.join(tempfile.mkdtemp(), "failed-start.sock"),
154
+ os.path.join(directory, "failed-start.sock"),
152
155
  MPV_BINARY, **LIVE_OPTIONS)
153
156
 
154
157
  self.assertEqual(len(spawned), 1)
@@ -178,6 +181,25 @@ class TerminateWaitsTest(unittest.TestCase):
178
181
 
179
182
  self.assertEqual(escaped, [])
180
183
 
184
+ def test_a_raising_quit_callback_still_terminates(self):
185
+ # A raise there used to skip the terminate(join=False) after it,
186
+ # leaving the event thread running with MPV gone.
187
+ mpv = start_mpv()
188
+ self.addCleanup(mpv.terminate)
189
+
190
+ def bad():
191
+ raise ValueError("a caller's bug")
192
+
193
+ mpv.quit_callback = bad
194
+ with self.assertLogs("mpv-jsonipc", "ERROR"):
195
+ mpv.mpv_process.process.kill()
196
+ deadline = time.time() + 5
197
+ while mpv.event_handler.is_alive() and time.time() < deadline:
198
+ time.sleep(0.02)
199
+ self.assertFalse(
200
+ mpv.event_handler.is_alive(),
201
+ "terminate never ran: the event thread is still alive")
202
+
181
203
  def test_the_process_is_reaped_not_merely_signalled(self):
182
204
  mpv = start_mpv()
183
205
  process = mpv.mpv_process.process
@@ -213,7 +235,9 @@ class StopWaitingTest(unittest.TestCase):
213
235
  process = python_mpv_jsonipc.MPVProcess.__new__(
214
236
  python_mpv_jsonipc.MPVProcess)
215
237
  process.process = self._Stubborn()
216
- process.ipc_socket = os.path.join(tempfile.mkdtemp(), socket_name)
238
+ directory = tempfile.mkdtemp()
239
+ self.addCleanup(shutil.rmtree, directory, True)
240
+ process.ipc_socket = os.path.join(directory, socket_name)
217
241
  return process
218
242
 
219
243
  def test_a_timeout_waits_then_escalates_to_kill(self):