python-mpv-jsonipc 1.2.3__py3-none-any.whl → 1.4.0__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mpv-jsonipc
3
- Version: 1.2.3
3
+ Version: 1.4.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
@@ -0,0 +1,6 @@
1
+ python_mpv_jsonipc.py,sha256=g5BxL4Bzljm4QnsXG5kOKGGfHuiKlpqQqKmd5atQ-nM,50502
2
+ python_mpv_jsonipc-1.4.0.dist-info/licenses/LICENSE.md,sha256=7GQBUOA3dNG1Jk-yaPOCncNnRVURCRMev23QpZ1ew-s,10495
3
+ python_mpv_jsonipc-1.4.0.dist-info/METADATA,sha256=-gKDmmYj96EQjMvcTwgj_1AnBouKSD9ENAr1r77sVXU,2277
4
+ python_mpv_jsonipc-1.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ python_mpv_jsonipc-1.4.0.dist-info/top_level.txt,sha256=nGk4yjpVHKmIrL39XdGD2PhCeWF0oMjwey0nsNOd2TM,19
6
+ python_mpv_jsonipc-1.4.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (83.0.0)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
python_mpv_jsonipc.py CHANGED
@@ -2,6 +2,7 @@ import threading
2
2
  import socket
3
3
  import json
4
4
  import os
5
+ import re
5
6
  import time
6
7
  import subprocess
7
8
  import random
@@ -11,8 +12,100 @@ import logging
11
12
  log = logging.getLogger('mpv-jsonipc')
12
13
 
13
14
  if os.name == "nt":
14
- import _winapi
15
- from multiprocessing.connection import PipeConnection
15
+ import ctypes
16
+ import ctypes.wintypes as _w
17
+
18
+ # The Windows transport calls kernel32 directly rather than going through
19
+ # ``_winapi`` and ``multiprocessing.connection.PipeConnection``. The second
20
+ # of those is a live bug, not tidiness:
21
+ #
22
+ # PipeConnection says outright that "a connection should only be used by
23
+ # a single thread", and this library used it from two -- a reader parked
24
+ # in recv_bytes, and whoever called stop(). Its _close() cancels a
25
+ # pending *send* before closing the handle, but has no equivalent for a
26
+ # pending *read*, because _recv_bytes never stores its OVERLAPPED
27
+ # anywhere reachable. So stop() closed the handle with an overlapped
28
+ # read outstanding and a kernel-owned buffer still live, which is
29
+ # undefined: the kernel may complete the I/O into memory Python is
30
+ # tearing down. CPython 3.13+ allocates its thread primitives from the
31
+ # parking lot, so that corruption now lands as an abort --
32
+ # ``Fatal Python error: _PySemaphore_Wakeup: ReleaseSemaphore failed
33
+ # (error: 6)`` -- seen in CI on both 3.13 and 3.14.
34
+ #
35
+ # Neither ``_winapi`` (private C extension) nor ``PipeConnection`` (absent
36
+ # from __all__, defined conditionally, an implementation detail of Pipe())
37
+ # carries a compatibility contract, so this also removes a dependency that
38
+ # breaks along the *Python* axis rather than ours. ctypes is stdlib and
39
+ # needs no PyInstaller hooks or bundled DLLs, which is the property the
40
+ # original _winapi choice was buying and that downstream projects such as
41
+ # SyncPlay and jellyfin-mpv-shim adopted this library for.
42
+ _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
43
+
44
+ _GENERIC_READ = 0x80000000
45
+ _GENERIC_WRITE = 0x40000000
46
+ _OPEN_EXISTING = 3
47
+ _FILE_FLAG_OVERLAPPED = 0x40000000
48
+ _INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
49
+ _INFINITE = 0xFFFFFFFF
50
+ _WAIT_OBJECT_0 = 0
51
+ _ERROR_FILE_NOT_FOUND = 2
52
+ _ERROR_PATH_NOT_FOUND = 3
53
+ _ERROR_BROKEN_PIPE = 109
54
+ # Also "the other end is gone", and reached when the peer disconnects
55
+ # rather than exits. Without them a normal disconnect logs an ERROR and a
56
+ # traceback. Measured on real Windows, including against the old
57
+ # PipeConnection build, which logged it too -- it only raised EOFError for
58
+ # 109 -- so this is an improvement on the previous behaviour rather than a
59
+ # regression being repaired. Real MPV exiting gives 109, which is why no
60
+ # CI leg ever showed it.
61
+ _ERROR_NO_DATA = 232
62
+ _ERROR_PIPE_NOT_CONNECTED = 233
63
+ _ERROR_MORE_DATA = 234
64
+ _ERROR_OPERATION_ABORTED = 995
65
+ _ERROR_IO_PENDING = 997
66
+
67
+ #: Every way the far end can be gone. All of them are a clean end of
68
+ #: stream, not an error to log.
69
+ _PEER_GONE = (_ERROR_BROKEN_PIPE, _ERROR_NO_DATA, _ERROR_PIPE_NOT_CONNECTED)
70
+
71
+ class _OVERLAPPED(ctypes.Structure):
72
+ _fields_ = [("Internal", ctypes.c_void_p),
73
+ ("InternalHigh", ctypes.c_void_p),
74
+ ("Offset", _w.DWORD),
75
+ ("OffsetHigh", _w.DWORD),
76
+ ("hEvent", _w.HANDLE)]
77
+
78
+ # Declared explicitly: without argtypes ctypes truncates pointers to int
79
+ # on 64-bit, which fails in ways that look like random handle errors.
80
+ _kernel32.CreateFileW.argtypes = [_w.LPCWSTR, _w.DWORD, _w.DWORD,
81
+ ctypes.c_void_p, _w.DWORD, _w.DWORD,
82
+ _w.HANDLE]
83
+ _kernel32.CreateFileW.restype = _w.HANDLE
84
+ _kernel32.CreateEventW.argtypes = [ctypes.c_void_p, _w.BOOL, _w.BOOL,
85
+ _w.LPCWSTR]
86
+ _kernel32.CreateEventW.restype = _w.HANDLE
87
+ _kernel32.SetEvent.argtypes = [_w.HANDLE]
88
+ _kernel32.SetEvent.restype = _w.BOOL
89
+ _kernel32.ResetEvent.argtypes = [_w.HANDLE]
90
+ _kernel32.ResetEvent.restype = _w.BOOL
91
+ _kernel32.ReadFile.argtypes = [_w.HANDLE, ctypes.c_void_p, _w.DWORD,
92
+ _w.LPDWORD, ctypes.c_void_p]
93
+ _kernel32.ReadFile.restype = _w.BOOL
94
+ _kernel32.WriteFile.argtypes = [_w.HANDLE, ctypes.c_void_p, _w.DWORD,
95
+ _w.LPDWORD, ctypes.c_void_p]
96
+ _kernel32.WriteFile.restype = _w.BOOL
97
+ _kernel32.GetOverlappedResult.argtypes = [_w.HANDLE, ctypes.c_void_p,
98
+ _w.LPDWORD, _w.BOOL]
99
+ _kernel32.GetOverlappedResult.restype = _w.BOOL
100
+ _kernel32.CancelIoEx.argtypes = [_w.HANDLE, ctypes.c_void_p]
101
+ _kernel32.CancelIoEx.restype = _w.BOOL
102
+ _kernel32.WaitForMultipleObjects.argtypes = [_w.DWORD, ctypes.c_void_p,
103
+ _w.BOOL, _w.DWORD]
104
+ _kernel32.WaitForMultipleObjects.restype = _w.DWORD
105
+ _kernel32.CloseHandle.argtypes = [_w.HANDLE]
106
+ _kernel32.CloseHandle.restype = _w.BOOL
107
+ _kernel32.WaitNamedPipeW.argtypes = [_w.LPCWSTR, _w.DWORD]
108
+ _kernel32.WaitNamedPipeW.restype = _w.BOOL
16
109
 
17
110
  TIMEOUT = 120
18
111
 
@@ -35,6 +128,79 @@ class MPVError(Exception):
35
128
  def __init__(self, *args, **kwargs):
36
129
  super(MPVError, self).__init__(*args, **kwargs)
37
130
 
131
+ class MPVProcessError(MPVError):
132
+ """MPV would not start, with whatever MPV said about why.
133
+
134
+ A subclass so that every existing ``except MPVError`` keeps working
135
+ unchanged -- this library is depended on by projects that will never be
136
+ updated, and several of them catch only that.
137
+
138
+ *returncode* is MPV's exit status, if it got far enough to have one.
139
+ *bad_option* is the option MPV refused, when that could be determined.
140
+ *log_output* is what MPV wrote about the failure.
141
+ *retryable* is False when starting again cannot possibly work.
142
+ *argv* is the command line that was tried.
143
+ """
144
+ def __init__(self, message, returncode=None, bad_option=None,
145
+ log_output=None, retryable=True, argv=None):
146
+ super(MPVProcessError, self).__init__(message)
147
+ self.returncode = returncode
148
+ self.bad_option = bad_option
149
+ self.log_output = log_output
150
+ self.retryable = retryable
151
+ self.argv = argv
152
+
153
+ #: MPV's own wording when it is handed an option it does not have. Matching
154
+ #: it is what turns "MPV exited" into "MPV has no --input-gamepad".
155
+ _OPTION_ERROR = re.compile(r"Error parsing option ([^\s]+) \(option not found\)")
156
+
157
+ def _diagnose_start_failure(argv, timeout=15):
158
+ """Ask MPV why it refused *argv*. Returns ``(bad_option, output)``.
159
+
160
+ Three details make this reliable, and each was measured rather than
161
+ reasoned about:
162
+
163
+ * **The message comes back on stdout, not stderr.** MPV writes its
164
+ terminal output to stdout; stderr is empty. Capturing the wrong stream
165
+ gets a confident, permanent "no idea why".
166
+ * **``--terminal=yes`` has to come first.** Options are parsed in order
167
+ and this library sets ``--terminal=no`` by default, so with the flags
168
+ left as they are MPV has already silenced itself by the time it reaches
169
+ the option it objects to. The library's own terminal flags are dropped
170
+ rather than fought with.
171
+ * **``--version`` goes last**, so a configuration that turns out to be
172
+ fine parses everything and then exits (~15ms) instead of starting up
173
+ and having to be killed. It exits before binding the IPC socket, so
174
+ this cannot disturb the real MPV that is about to start.
175
+
176
+ An earlier version of this read ``--log-file`` instead, on the theory
177
+ that Windows has no stderr to capture. That log is written
178
+ **unreliably**: MPV does not flush it before exiting, so it arrives
179
+ truncated at a random point -- measured at 6/8 and 7/8 runs containing
180
+ the error, cut off anywhere from 102 bytes in. A pipe has no such
181
+ problem, and is byte-identical across runs.
182
+
183
+ Every failure here is answered with ``(None, ...)``, which puts the
184
+ caller back on the path it took before this existed. Being unable to
185
+ explain a failed start must never turn into a second failure.
186
+ """
187
+ terminal_flags = ("--terminal=", "--input-terminal=")
188
+ argv = list(argv)
189
+ probe = ([argv[0], "--terminal=yes"]
190
+ + [a for a in argv[1:] if not a.startswith(terminal_flags)]
191
+ + ["--version"])
192
+ try:
193
+ completed = subprocess.run(probe,
194
+ stdout=subprocess.PIPE,
195
+ stderr=subprocess.STDOUT,
196
+ timeout=timeout)
197
+ except Exception:
198
+ log.debug("Could not run MPV to diagnose the failure.", exc_info=1)
199
+ return None, None
200
+ output = completed.stdout.decode("utf-8", "replace")
201
+ match = _OPTION_ERROR.search(output)
202
+ return (match.group(1) if match else None), output
203
+
38
204
  class WindowsSocket(threading.Thread):
39
205
  """
40
206
  Wraps a Windows named pipe in a high-level interface. (Internal)
@@ -49,62 +215,215 @@ class WindowsSocket(threading.Thread):
49
215
  *callback(json_data)* is the function for recieving events.
50
216
  *quit_callback* is called when the socket connection dies.
51
217
  """
218
+ # First, so Thread's own attributes are in place before ours rather
219
+ # than landing on top of them. Thread grew a `self._handle` of its own
220
+ # in 3.13, so the pipe handle is `_pipe`: named `_handle` and assigned
221
+ # before this call, Thread silently replaced it with a
222
+ # _thread._ThreadHandle and every read died in ctypes argument
223
+ # conversion. Anything added below needs a name Thread does not use.
224
+ threading.Thread.__init__(self)
225
+ self.daemon = True
226
+
52
227
  ipc_socket = "\\\\.\\pipe\\" + ipc_socket
53
228
  self.callback = callback
54
229
  self.quit_callback = quit_callback
55
230
  self._stopping = False
56
-
57
- access = _winapi.GENERIC_READ | _winapi.GENERIC_WRITE
231
+ self._closed = False
232
+ # Every send is held under this lock, so holding it is proof that no
233
+ # write is in flight and the pipe is safe to close. The reader is the
234
+ # exception and must stay one -- see run().
235
+ self._io_lock = threading.RLock()
236
+ # Guards the handle *values* against being used after _close cleared
237
+ # them. Held only for the moment it takes to signal or close, never
238
+ # across a wait.
239
+ self._handle_lock = threading.RLock()
240
+
241
+ access = _GENERIC_READ | _GENERIC_WRITE
58
242
  limit = 5 # Connection may fail at first. Try 5 times.
59
243
  for _ in range(limit):
60
- try:
61
- pipe_handle = _winapi.CreateFile(
62
- ipc_socket, access, 0, _winapi.NULL, _winapi.OPEN_EXISTING,
63
- _winapi.FILE_FLAG_OVERLAPPED, _winapi.NULL
64
- )
244
+ # Still five attempts, and still load-bearing: _ipc_endpoint_ready
245
+ # does not run on the start_mpv=False attach path, and it answers
246
+ # True for a pipe whose instances are all busy, which CreateFile
247
+ # then refuses with ERROR_PIPE_BUSY.
248
+ handle = _kernel32.CreateFileW(
249
+ ipc_socket, access, 0, None, _OPEN_EXISTING,
250
+ _FILE_FLAG_OVERLAPPED, None)
251
+ if handle != _INVALID_HANDLE_VALUE:
65
252
  break
66
- except OSError:
67
- time.sleep(1)
253
+ time.sleep(1)
68
254
  else:
255
+ # Deliberately not an OSError: downstream and the failure tests
256
+ # both pin that this is what the Windows attach path raises.
69
257
  raise MPVError("Cannot connect to pipe.")
70
- self.socket = PipeConnection(pipe_handle)
258
+ self._pipe = handle
259
+
260
+ # Manual-reset, so a stop stays visible to the reader and to any send
261
+ # waiting alongside it rather than waking exactly one of them.
262
+ self._stop_event = _kernel32.CreateEventW(None, True, False, None)
263
+ self._read_event = _kernel32.CreateEventW(None, True, False, None)
264
+ self._write_event = _kernel32.CreateEventW(None, True, False, None)
265
+ if not all((self._stop_event, self._read_event, self._write_event)):
266
+ self._close()
267
+ raise MPVError("Cannot create pipe events.")
71
268
 
72
269
  if self.callback is None:
73
270
  self.callback = lambda data: None
74
271
 
75
- threading.Thread.__init__(self)
76
- self.daemon = True
272
+ def _transfer(self, func, event, buf, size):
273
+ """Run one overlapped operation, waiting for it or for *stop*.
274
+
275
+ Returns the bytes transferred, or None if the pipe ended or *stop* cut
276
+ the operation short.
277
+
278
+ The GetOverlappedResult(wait=True) below is the point of this whole
279
+ class. It does not return until the kernel has finished with *buf* and
280
+ the OVERLAPPED -- including when we cancelled it -- so neither is ever
281
+ released while an I/O could still write into it.
282
+ """
283
+ ov = _OVERLAPPED()
284
+ ov.hEvent = event
285
+ _kernel32.ResetEvent(event)
286
+ moved = _w.DWORD(0)
287
+
288
+ # The guarded region starts *before* the call, and assumes the
289
+ # operation may be outstanding until proven otherwise. An
290
+ # asynchronous exception -- a KeyboardInterrupt, which lands on the
291
+ # main thread, which is where send() runs -- can arrive between the
292
+ # call returning ERROR_IO_PENDING and any flag recording that, so a
293
+ # flag set after the call is wrong in exactly the direction that
294
+ # matters: it would let the frame, and its buffer, go while the kernel
295
+ # still owns them. Assuming the worst costs nothing when nothing was
296
+ # started, because a fresh OVERLAPPED has Internal = STATUS_SUCCESS
297
+ # and GetOverlappedResult then returns immediately (measured).
298
+ outstanding = True
299
+ try:
300
+ if func(self._pipe, buf, size, ctypes.byref(moved),
301
+ ctypes.byref(ov)):
302
+ outstanding = False
303
+ return moved.value
304
+
305
+ err = ctypes.get_last_error()
306
+ if err != _ERROR_IO_PENDING:
307
+ outstanding = False
308
+ if err in _PEER_GONE:
309
+ return None
310
+ if err == _ERROR_MORE_DATA:
311
+ # A completed read that filled the buffer, not a failure.
312
+ # The completion path below already treats it that way;
313
+ # raising here would have desynchronised the two.
314
+ return moved.value
315
+ raise ctypes.WinError(err)
316
+
317
+ handles = (_w.HANDLE * 2)(event, self._stop_event)
318
+ waitres = _kernel32.WaitForMultipleObjects(2, handles, False,
319
+ _INFINITE)
320
+ if waitres != _WAIT_OBJECT_0:
321
+ # stop() wants us gone, or the wait itself failed. Either way
322
+ # we will not be consuming this operation. Read the wait's
323
+ # error *first*: CancelIoEx overwrites the thread's last error,
324
+ # so reading it afterwards reports the cancel's success and
325
+ # loses the real cause on the path hardest to reproduce.
326
+ waitfail = ctypes.get_last_error()
327
+ _kernel32.CancelIoEx(self._pipe, ctypes.byref(ov))
328
+ if waitres != _WAIT_OBJECT_0 + 1:
329
+ raise ctypes.WinError(waitfail)
330
+ except BaseException:
331
+ if outstanding:
332
+ _kernel32.CancelIoEx(self._pipe, ctypes.byref(ov))
333
+ raise
334
+ finally:
335
+ # Cancelling only *asks*. This is the wait that makes it true.
336
+ if outstanding:
337
+ completed = _kernel32.GetOverlappedResult(
338
+ self._pipe, ctypes.byref(ov), ctypes.byref(moved), True)
339
+ failure = 0 if completed else ctypes.get_last_error()
340
+
341
+ if not completed:
342
+ if failure in _PEER_GONE or failure == _ERROR_OPERATION_ABORTED:
343
+ return None
344
+ if failure != _ERROR_MORE_DATA:
345
+ raise ctypes.WinError(failure)
346
+ return moved.value
347
+
348
+ def _close(self):
349
+ """Close the handles, once, with no I/O outstanding."""
350
+ # _io_lock first: holding it means no send is mid-write. _handle_lock
351
+ # second, to exclude a concurrent stop() signalling an event we are
352
+ # about to close. Nothing ever takes these in the other order, and
353
+ # _handle_lock is never held across a wait, so stop() cannot block.
354
+ with self._io_lock, self._handle_lock:
355
+ if self._closed:
356
+ return
357
+ self._closed = True
358
+ for name in ("_pipe", "_stop_event", "_read_event",
359
+ "_write_event"):
360
+ handle = getattr(self, name, None)
361
+ if handle:
362
+ _kernel32.CloseHandle(handle)
363
+ # Cleared, not just closed: terminate() is routinely called
364
+ # twice, and a closed handle value can be reused by the OS.
365
+ setattr(self, name, None)
77
366
 
78
367
  def stop(self, join=True):
79
368
  """Terminate the thread."""
80
369
  self._stopping = True
81
- if self.socket is not None:
82
- try:
83
- self.socket.close()
84
- except OSError:
85
- pass # Ignore socket close failure.
86
- if join:
370
+ # Deliberately does NOT close the handle. Closing it from here with
371
+ # the reader's overlapped read still pending is the corruption this
372
+ # class was rewritten to remove; instead we wake the reader and let it
373
+ # close the handle it owns, once its read has provably finished.
374
+ with self._handle_lock:
375
+ if self._stop_event:
376
+ _kernel32.SetEvent(self._stop_event)
377
+ # Two ways join() raises, and both would skip the _close() below and
378
+ # leak all four handles. is_alive() is True for the calling thread, so
379
+ # it never prevented the self-join it looked like it prevented -- the
380
+ # reader reaches here whenever a user's quit_callback calls
381
+ # terminate(). And `ident is None` means start() was never called,
382
+ # where join() raises too.
383
+ if (join and self.ident is not None
384
+ and self is not threading.current_thread()):
87
385
  self.join()
386
+ if not self.is_alive():
387
+ # The reader has finished, or never ran. Either way nothing can
388
+ # have I/O outstanding, so the handle is ours to close.
389
+ self._close()
88
390
 
89
391
  def send(self, data):
90
392
  """Send *data* to the pipe, encoded as JSON."""
91
- try:
92
- self.socket.send_bytes(json.dumps(data).encode('utf-8') + b'\n')
93
- except OSError as ex:
94
- if len(ex.args) == 1 and ex.args[0] == "handle is closed":
393
+ blob = json.dumps(data).encode('utf-8') + b'\n'
394
+ with self._io_lock:
395
+ if self._closed:
95
396
  raise BrokenPipeError("handle is closed")
96
- raise ex
397
+ written = self._transfer(_kernel32.WriteFile, self._write_event,
398
+ blob, len(blob))
399
+ if written is None:
400
+ raise BrokenPipeError("handle is closed")
97
401
 
98
402
  def run(self):
99
403
  """Process pipe events. Do not run this directly. Use *start*."""
404
+ buf = ctypes.create_string_buffer(2048)
100
405
  data = b''
101
406
  try:
102
- while True:
103
- current_data = self.socket.recv_bytes(2048)
104
- if current_data == b'':
407
+ while not self._stopping:
408
+ # The stop event is only observed when a read *pends*, and a
409
+ # read that completes synchronously returns before ever
410
+ # reaching the wait. A peer with data always available could
411
+ # therefore keep stop() waiting in join(). Not reproducible --
412
+ # 124k messages of flooding still let the reader park -- but
413
+ # the flag costs nothing and closes the argument.
414
+ #
415
+ # Emphatically NOT under _io_lock: this read is parked
416
+ # whenever MPV has nothing to say, which is almost always, and
417
+ # holding the lock across it would block every send. The lock
418
+ # exists to keep a send and the close apart; the reader needs
419
+ # no such guard because it closes the handle itself, in the
420
+ # finally below, only once its own read has returned.
421
+ nread = self._transfer(_kernel32.ReadFile, self._read_event,
422
+ buf, len(buf))
423
+ if not nread:
105
424
  break
106
425
 
107
- data += current_data
426
+ data += buf.raw[:nread]
108
427
  if data[-1] != 10:
109
428
  continue
110
429
 
@@ -115,15 +434,14 @@ class WindowsSocket(threading.Thread):
115
434
  json_data = json.loads(item)
116
435
  self.callback(json_data)
117
436
  data = b''
118
- except EOFError:
119
- if self.quit_callback:
120
- self.quit_callback()
121
- except Exception as ex:
437
+ except Exception:
122
438
  # Only log if not intentionally stopping
123
439
  if not self._stopping:
124
440
  log.error("Pipe connection died.", exc_info=1)
125
- if self.quit_callback:
126
- self.quit_callback()
441
+ finally:
442
+ self._close()
443
+ if self.quit_callback:
444
+ self.quit_callback()
127
445
 
128
446
  class UnixSocket(threading.Thread):
129
447
  """
@@ -155,28 +473,41 @@ class UnixSocket(threading.Thread):
155
473
  def stop(self, join=True):
156
474
  """Terminate the thread."""
157
475
  self._stopping = True
158
- if self.socket is not None:
476
+ # Bind once. Two threads reach here -- the caller's terminate() and
477
+ # the reader's quit_callback -> terminate(join=False) -- and making
478
+ # stop() wait for MPV widened that overlap, because the wait ends on
479
+ # the same MPV exit that wakes the reader. Re-reading self.socket
480
+ # after the None check let the loser call shutdown/close on None,
481
+ # which is an AttributeError and so is *not* caught below.
482
+ sock, self.socket = self.socket, None
483
+ if sock is not None:
159
484
  try:
160
- self.socket.shutdown(socket.SHUT_WR)
161
- self.socket.close()
162
- self.socket = None
485
+ sock.shutdown(socket.SHUT_WR)
486
+ sock.close()
163
487
  except OSError:
164
488
  pass # Ignore socket close failure.
165
- if join:
489
+ # Not a bare `if join`: the reader reaches here whenever a user's
490
+ # quit_callback calls terminate(), and joining yourself raises -- which
491
+ # escaped run() and leaked the event handler. `ident is None` is the
492
+ # never-started case, where join() raises as well.
493
+ if (join and self.ident is not None
494
+ and self is not threading.current_thread()):
166
495
  self.join()
167
496
 
168
497
  def send(self, data):
169
498
  """Send *data* to the socket, encoded as JSON."""
170
- if self.socket is None:
499
+ sock = self.socket # bind once; see stop()
500
+ if sock is None:
171
501
  raise BrokenPipeError("socket is closed")
172
- self.socket.send(json.dumps(data).encode('utf-8') + b'\n')
502
+ sock.send(json.dumps(data).encode('utf-8') + b'\n')
173
503
 
174
504
  def run(self):
175
505
  """Process socket events. Do not run this directly. Use *start*."""
176
506
  data = b''
507
+ sock = self.socket # bind once; see stop()
177
508
  try:
178
509
  while True:
179
- current_data = self.socket.recv(1024)
510
+ current_data = sock.recv(1024)
180
511
  if current_data == b'':
181
512
  break
182
513
 
@@ -198,16 +529,41 @@ class UnixSocket(threading.Thread):
198
529
  if self.quit_callback:
199
530
  self.quit_callback()
200
531
 
532
+ def _ipc_endpoint_ready(ipc_socket):
533
+ """Return True once MPV's IPC endpoint is available.
534
+
535
+ On POSIX the endpoint is a filesystem socket, so ``os.path.exists`` works. On
536
+ Windows it is a *named pipe*, for which ``os.path.exists`` is always False — so we
537
+ probe it with ``WaitNamedPipe`` instead. Using ``os.path.exists`` on Windows made
538
+ the startup poll never detect the pipe, raising a spurious "MPV start timed out".
539
+ """
540
+ if os.name != 'nt':
541
+ return os.path.exists(ipc_socket)
542
+ if _kernel32.WaitNamedPipeW(ipc_socket, 0):
543
+ return True
544
+ # Both codes, because the old _winapi version raised FileNotFoundError and
545
+ # CPython maps ERROR_PATH_NOT_FOUND to ENOENT as well. Treating 3 as
546
+ # "present but busy" would report a successful start and then burn
547
+ # WindowsSocket's five CreateFileW retries before failing with the wrong
548
+ # exception for the caller's retry loop.
549
+ if ctypes.get_last_error() in (_ERROR_FILE_NOT_FOUND, _ERROR_PATH_NOT_FOUND):
550
+ return False
551
+ # Pipe exists but all instances are momentarily busy — it is present.
552
+ return True
553
+
554
+
201
555
  class MPVProcess:
202
556
  """
203
557
  Manages an MPV process, ensuring the socket or pipe is available. (Internal)
204
558
  """
205
- def __init__(self, ipc_socket, mpv_location=None, **kwargs):
559
+ def __init__(self, ipc_socket, mpv_location=None, discard_output=False, **kwargs):
206
560
  """
207
561
  Create and start the MPV process. Will block until socket/pipe is available.
208
562
 
209
563
  *ipc_socket* is the path to the Unix/Linux socket or name of the Windows pipe.
210
564
  *mpv_location* is the path to mpv. If left unset it tries the one in the PATH.
565
+ *discard_output* sends MPV's stdout and stderr to devnull instead of inheriting
566
+ the caller's. (Default: False)
211
567
 
212
568
  All other arguments are forwarded to MPV as command-line arguments.
213
569
  """
@@ -243,12 +599,20 @@ class MPVProcess:
243
599
 
244
600
  args.extend("--{0}={1}".format(v[0].replace("_", "-"), self._mpv_fmt(v[1]))
245
601
  for v in arg_pairs)
246
- self.process = subprocess.Popen(args)
602
+ self.argv = args
603
+ # close_fds only covers fds above 2, so MPV inherits our stdout and
604
+ # stderr. It never writes to them under --terminal=no, which is why
605
+ # this is invisible until something waits for EOF on the other end of
606
+ # that pipe: an MPV that outlives us holds it open and that reader
607
+ # blocks forever. Opt-in rather than default because callers who do
608
+ # want MPV's output on their terminal have had it for years.
609
+ stdio = subprocess.DEVNULL if discard_output else None
610
+ self.process = subprocess.Popen(args, stdout=stdio, stderr=stdio)
247
611
  ipc_exists = False
248
612
  for _ in range(100): # Give MPV 10 seconds to start.
249
613
  time.sleep(0.1)
250
614
  self.process.poll()
251
- if os.path.exists(ipc_socket):
615
+ if _ipc_endpoint_ready(ipc_socket):
252
616
  ipc_exists = True
253
617
  log.debug("Found MPV socket.")
254
618
  break
@@ -256,12 +620,22 @@ class MPVProcess:
256
620
  log.error("MPV failed with returncode {0}.".format(self.process.returncode))
257
621
  break
258
622
  else:
259
- self.process.terminate()
260
- raise MPVError("MPV start timed out.")
623
+ # stop(), not a bare terminate(): a failed start orphans MPV
624
+ # exactly like a failed teardown did, and MPV.__init__ retries
625
+ # this up to start_retries times -- so a machine where MPV wedges
626
+ # without opening its pipe used to leave five live processes,
627
+ # each still holding the caller's stdout.
628
+ self._stop_quietly()
629
+ raise MPVProcessError("MPV start timed out.", argv=args)
261
630
 
262
631
  if not ipc_exists or self.process.returncode is not None:
263
- self.process.terminate()
264
- raise MPVError("MPV not started.")
632
+ self._stop_quietly()
633
+ # Message unchanged: it has been this string for years and is not
634
+ # ours to break. What is new is the argv riding along, which is
635
+ # what lets the caller ask MPV why.
636
+ raise MPVProcessError("MPV not started.",
637
+ returncode=self.process.returncode,
638
+ argv=args)
265
639
 
266
640
  def _set_default(self, prop_dict, key, value):
267
641
  if key not in prop_dict:
@@ -279,11 +653,51 @@ class MPVProcess:
279
653
  else:
280
654
  return data
281
655
 
282
- def stop(self):
283
- """Terminate the process."""
656
+ def _stop_quietly(self):
657
+ """stop(), for the paths that are already raising something better.
658
+
659
+ A failed unlink here would replace MPVProcessError -- and an OSError
660
+ escapes MPV.__init__'s ``except MPVError`` retry loop, so the caller
661
+ would lose both the retries and the argv and returncode that the
662
+ error carries.
663
+ """
664
+ try:
665
+ self.stop()
666
+ except OSError:
667
+ log.debug("Cleanup after a failed MPV start did not complete.",
668
+ exc_info=1)
669
+
670
+ def stop(self, timeout=5):
671
+ """Terminate the process, and do not return while it may still be alive.
672
+
673
+ *timeout* is the seconds to wait for a polite exit before killing, and
674
+ again for the kill to land.
675
+ """
676
+ # terminate() only *asks*. Returning here while MPV is still running
677
+ # left the caller no supported way to wait for it -- and a caller that
678
+ # exits at that moment orphans an MPV still holding its stdout. Both
679
+ # waits are bounded so a wedged MPV cannot hang teardown.
284
680
  self.process.terminate()
285
- if os.name != 'nt' and os.path.exists(self.ipc_socket):
286
- os.remove(self.ipc_socket)
681
+ try:
682
+ self.process.wait(timeout=timeout)
683
+ except subprocess.TimeoutExpired:
684
+ log.warning("MPV ignored terminate for {0}s; killing it.".format(timeout))
685
+ self.process.kill()
686
+ try:
687
+ self.process.wait(timeout=timeout)
688
+ except subprocess.TimeoutExpired:
689
+ log.error("MPV survived kill; giving up on reaping it.")
690
+ if os.name != 'nt':
691
+ # try/except rather than exists()-then-remove, because two threads
692
+ # reach here: the caller's terminate(), and the reader's
693
+ # quit_callback -> terminate(join=False) when MPV's exit closes
694
+ # the socket. Waiting above made them *overlap by construction* --
695
+ # the wait ends when MPV dies, which is the same event that wakes
696
+ # the reader -- so the check-then-act window is reliably lost.
697
+ try:
698
+ os.remove(self.ipc_socket)
699
+ except FileNotFoundError:
700
+ pass # The other thread got there first, which is all we wanted.
287
701
 
288
702
  class MPVInter:
289
703
  """
@@ -409,7 +823,8 @@ class MPV:
409
823
  list is used. Not all commands may actually work when this fallback is used.
410
824
  """
411
825
  def __init__(self, start_mpv=True, ipc_socket=None, mpv_location=None,
412
- log_handler=None, loglevel=None, quit_callback=None, start_retries=5, start_retry_delay_ms=1000, **kwargs):
826
+ log_handler=None, loglevel=None, quit_callback=None, start_retries=5, start_retry_delay_ms=1000,
827
+ diagnose_start_failures=True, discard_output=False, **kwargs):
413
828
  """
414
829
  Create the interface to MPV and process instance.
415
830
 
@@ -419,6 +834,14 @@ class MPV:
419
834
  *log_handler(level, prefix, text)* is an optional handler for log events. (Default: Disabled)
420
835
  *loglevel* is the level for log messages. Levels are fatal, error, warn, info, v, debug, trace. (Default: Disabled)
421
836
  *quit_callback* is called when the socket connection to MPV dies.
837
+ *diagnose_start_failures* re-runs MPV once, on the first failed start, to find out
838
+ why it refused to start. If MPV names an option it does not have, MPVProcessError is
839
+ raised immediately with *bad_option* set instead of spending every retry on something
840
+ that cannot succeed. Set it to False for the pre-1.3 behaviour. (Default: True)
841
+ *discard_output* sends MPV's stdout and stderr to devnull instead of letting it
842
+ inherit the caller's. Set it if anything waits for EOF on your stdout, since an
843
+ MPV that outlives *terminate* would otherwise hold that pipe open. Note this is
844
+ not MPV's own --quiet, which is still forwarded as an MPV option. (Default: False)
422
845
 
423
846
  All other arguments are forwarded to MPV as command-line arguments if *start_mpv* is used.
424
847
  """
@@ -440,16 +863,57 @@ class MPV:
440
863
 
441
864
  if start_mpv:
442
865
  # Attempt to start MPV multiple times.
866
+ last_error = None
867
+ diagnosed = False
868
+ log_output = None
443
869
  for i in range(start_retries):
444
870
  try:
445
- self.mpv_process = MPVProcess(ipc_socket, mpv_location, **kwargs)
871
+ self.mpv_process = MPVProcess(ipc_socket, mpv_location,
872
+ discard_output=discard_output, **kwargs)
446
873
  break
447
- except MPVError:
874
+ except MPVError as error:
875
+ last_error = error
448
876
  log.warning("MPV start failed.", exc_info=1)
877
+
878
+ # Ask MPV why, once, on the first failure. An option MPV
879
+ # does not have cannot start on the fifth attempt either,
880
+ # and spending the whole retry budget on it produces the
881
+ # least useful error this library has ever raised: a
882
+ # timeout that names nothing. The probe costs one ~20ms
883
+ # MPV run and only ever happens on a start that has
884
+ # already failed.
885
+ argv = getattr(error, "argv", None)
886
+ if diagnose_start_failures and argv and not diagnosed:
887
+ diagnosed = True
888
+ bad_option, diagnosis = _diagnose_start_failure(argv)
889
+ # Keep what MPV said even when it does not name an
890
+ # option. Refusing to start over a missing DLL or an
891
+ # unusable VO is the same predicament from the user's
892
+ # side -- something they changed, and nothing to go
893
+ # on -- and on Windows there is no console for them to
894
+ # have seen it in. It rides out on .log_output below.
895
+ log_output = diagnosis
896
+ if bad_option is not None:
897
+ raise MPVProcessError(
898
+ "MPV rejected the option --{0}.".format(
899
+ bad_option),
900
+ returncode=getattr(error, "returncode", None),
901
+ bad_option=bad_option,
902
+ log_output=log_output,
903
+ retryable=False,
904
+ argv=argv)
905
+
449
906
  time.sleep(start_retry_delay_ms / 1000)
450
907
  continue
451
908
  else:
452
- raise MPVError("MPV process retry limit reached.")
909
+ # The message is unchanged on purpose. Downstream projects
910
+ # that will never be updated may be matching on it, and this
911
+ # is still the same situation it has always described.
912
+ raise MPVProcessError(
913
+ "MPV process retry limit reached.",
914
+ returncode=getattr(last_error, "returncode", None),
915
+ log_output=log_output,
916
+ argv=getattr(last_error, "argv", None))
453
917
 
454
918
  self.mpv_inter = MPVInter(ipc_socket, self._callback, self._quit_callback)
455
919
  self.properties = set(x.replace("-", "_") for x in self.command("get_property", "property-list"))
@@ -637,6 +1101,14 @@ class MPV:
637
1101
  def terminate(self, join=True):
638
1102
  """Terminate the connection to MPV and process (if *start_mpv* is used)."""
639
1103
  if self.mpv_process:
1104
+ # Unconditionally, regardless of *join*. That flag only ever
1105
+ # reaches our own threads -- and imperfectly: the transports join
1106
+ # properly, while EventHandler.stop passes it as Thread.join's
1107
+ # *timeout*, so join=True waits at most a second there and does
1108
+ # not confirm the thread exited. Either way it says nothing about
1109
+ # MPV, and _quit_callback passes False only because a thread
1110
+ # cannot join itself. Letting it skip this wait would hand the
1111
+ # orphaned-MPV bug back to anyone who passed it.
640
1112
  self.mpv_process.stop()
641
1113
  if self.mpv_inter:
642
1114
  self.mpv_inter.stop(join)
@@ -1,6 +0,0 @@
1
- python_mpv_jsonipc.py,sha256=-UvwnvcI2x1JyVue-4xGLipkzzGFlCrT6PqGe_FVAkY,24302
2
- python_mpv_jsonipc-1.2.3.dist-info/licenses/LICENSE.md,sha256=7GQBUOA3dNG1Jk-yaPOCncNnRVURCRMev23QpZ1ew-s,10495
3
- python_mpv_jsonipc-1.2.3.dist-info/METADATA,sha256=YF-8QYjJ5EtyYcPfaVC2_8dywv_pkKujnsgl2-qAU1g,2277
4
- python_mpv_jsonipc-1.2.3.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
5
- python_mpv_jsonipc-1.2.3.dist-info/top_level.txt,sha256=nGk4yjpVHKmIrL39XdGD2PhCeWF0oMjwey0nsNOd2TM,19
6
- python_mpv_jsonipc-1.2.3.dist-info/RECORD,,