python-mpv-jsonipc 1.3.0__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.3.0
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,,
python_mpv_jsonipc.py CHANGED
@@ -12,8 +12,100 @@ import logging
12
12
  log = logging.getLogger('mpv-jsonipc')
13
13
 
14
14
  if os.name == "nt":
15
- import _winapi
16
- 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
17
109
 
18
110
  TIMEOUT = 120
19
111
 
@@ -123,62 +215,215 @@ class WindowsSocket(threading.Thread):
123
215
  *callback(json_data)* is the function for recieving events.
124
216
  *quit_callback* is called when the socket connection dies.
125
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
+
126
227
  ipc_socket = "\\\\.\\pipe\\" + ipc_socket
127
228
  self.callback = callback
128
229
  self.quit_callback = quit_callback
129
230
  self._stopping = False
130
-
131
- 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
132
242
  limit = 5 # Connection may fail at first. Try 5 times.
133
243
  for _ in range(limit):
134
- try:
135
- pipe_handle = _winapi.CreateFile(
136
- ipc_socket, access, 0, _winapi.NULL, _winapi.OPEN_EXISTING,
137
- _winapi.FILE_FLAG_OVERLAPPED, _winapi.NULL
138
- )
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:
139
252
  break
140
- except OSError:
141
- time.sleep(1)
253
+ time.sleep(1)
142
254
  else:
255
+ # Deliberately not an OSError: downstream and the failure tests
256
+ # both pin that this is what the Windows attach path raises.
143
257
  raise MPVError("Cannot connect to pipe.")
144
- 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.")
145
268
 
146
269
  if self.callback is None:
147
270
  self.callback = lambda data: None
148
271
 
149
- threading.Thread.__init__(self)
150
- 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)
151
366
 
152
367
  def stop(self, join=True):
153
368
  """Terminate the thread."""
154
369
  self._stopping = True
155
- if self.socket is not None:
156
- try:
157
- self.socket.close()
158
- except OSError:
159
- pass # Ignore socket close failure.
160
- 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()):
161
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()
162
390
 
163
391
  def send(self, data):
164
392
  """Send *data* to the pipe, encoded as JSON."""
165
- try:
166
- self.socket.send_bytes(json.dumps(data).encode('utf-8') + b'\n')
167
- except OSError as ex:
168
- 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:
169
396
  raise BrokenPipeError("handle is closed")
170
- 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")
171
401
 
172
402
  def run(self):
173
403
  """Process pipe events. Do not run this directly. Use *start*."""
404
+ buf = ctypes.create_string_buffer(2048)
174
405
  data = b''
175
406
  try:
176
- while True:
177
- current_data = self.socket.recv_bytes(2048)
178
- 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:
179
424
  break
180
425
 
181
- data += current_data
426
+ data += buf.raw[:nread]
182
427
  if data[-1] != 10:
183
428
  continue
184
429
 
@@ -189,15 +434,14 @@ class WindowsSocket(threading.Thread):
189
434
  json_data = json.loads(item)
190
435
  self.callback(json_data)
191
436
  data = b''
192
- except EOFError:
193
- if self.quit_callback:
194
- self.quit_callback()
195
- except Exception as ex:
437
+ except Exception:
196
438
  # Only log if not intentionally stopping
197
439
  if not self._stopping:
198
440
  log.error("Pipe connection died.", exc_info=1)
199
- if self.quit_callback:
200
- self.quit_callback()
441
+ finally:
442
+ self._close()
443
+ if self.quit_callback:
444
+ self.quit_callback()
201
445
 
202
446
  class UnixSocket(threading.Thread):
203
447
  """
@@ -229,28 +473,41 @@ class UnixSocket(threading.Thread):
229
473
  def stop(self, join=True):
230
474
  """Terminate the thread."""
231
475
  self._stopping = True
232
- 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:
233
484
  try:
234
- self.socket.shutdown(socket.SHUT_WR)
235
- self.socket.close()
236
- self.socket = None
485
+ sock.shutdown(socket.SHUT_WR)
486
+ sock.close()
237
487
  except OSError:
238
488
  pass # Ignore socket close failure.
239
- 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()):
240
495
  self.join()
241
496
 
242
497
  def send(self, data):
243
498
  """Send *data* to the socket, encoded as JSON."""
244
- if self.socket is None:
499
+ sock = self.socket # bind once; see stop()
500
+ if sock is None:
245
501
  raise BrokenPipeError("socket is closed")
246
- self.socket.send(json.dumps(data).encode('utf-8') + b'\n')
502
+ sock.send(json.dumps(data).encode('utf-8') + b'\n')
247
503
 
248
504
  def run(self):
249
505
  """Process socket events. Do not run this directly. Use *start*."""
250
506
  data = b''
507
+ sock = self.socket # bind once; see stop()
251
508
  try:
252
509
  while True:
253
- current_data = self.socket.recv(1024)
510
+ current_data = sock.recv(1024)
254
511
  if current_data == b'':
255
512
  break
256
513
 
@@ -282,26 +539,31 @@ def _ipc_endpoint_ready(ipc_socket):
282
539
  """
283
540
  if os.name != 'nt':
284
541
  return os.path.exists(ipc_socket)
285
- try:
286
- _winapi.WaitNamedPipe(ipc_socket, 0)
542
+ if _kernel32.WaitNamedPipeW(ipc_socket, 0):
287
543
  return True
288
- except FileNotFoundError:
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):
289
550
  return False
290
- except OSError:
291
- # Pipe exists but all instances are momentarily busy — it is present.
292
- return True
551
+ # Pipe exists but all instances are momentarily busy — it is present.
552
+ return True
293
553
 
294
554
 
295
555
  class MPVProcess:
296
556
  """
297
557
  Manages an MPV process, ensuring the socket or pipe is available. (Internal)
298
558
  """
299
- def __init__(self, ipc_socket, mpv_location=None, **kwargs):
559
+ def __init__(self, ipc_socket, mpv_location=None, discard_output=False, **kwargs):
300
560
  """
301
561
  Create and start the MPV process. Will block until socket/pipe is available.
302
562
 
303
563
  *ipc_socket* is the path to the Unix/Linux socket or name of the Windows pipe.
304
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)
305
567
 
306
568
  All other arguments are forwarded to MPV as command-line arguments.
307
569
  """
@@ -338,7 +600,14 @@ class MPVProcess:
338
600
  args.extend("--{0}={1}".format(v[0].replace("_", "-"), self._mpv_fmt(v[1]))
339
601
  for v in arg_pairs)
340
602
  self.argv = args
341
- self.process = subprocess.Popen(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)
342
611
  ipc_exists = False
343
612
  for _ in range(100): # Give MPV 10 seconds to start.
344
613
  time.sleep(0.1)
@@ -351,11 +620,16 @@ class MPVProcess:
351
620
  log.error("MPV failed with returncode {0}.".format(self.process.returncode))
352
621
  break
353
622
  else:
354
- self.process.terminate()
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()
355
629
  raise MPVProcessError("MPV start timed out.", argv=args)
356
630
 
357
631
  if not ipc_exists or self.process.returncode is not None:
358
- self.process.terminate()
632
+ self._stop_quietly()
359
633
  # Message unchanged: it has been this string for years and is not
360
634
  # ours to break. What is new is the argv riding along, which is
361
635
  # what lets the caller ask MPV why.
@@ -379,11 +653,51 @@ class MPVProcess:
379
653
  else:
380
654
  return data
381
655
 
382
- def stop(self):
383
- """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.
384
680
  self.process.terminate()
385
- if os.name != 'nt' and os.path.exists(self.ipc_socket):
386
- 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.
387
701
 
388
702
  class MPVInter:
389
703
  """
@@ -510,7 +824,7 @@ class MPV:
510
824
  """
511
825
  def __init__(self, start_mpv=True, ipc_socket=None, mpv_location=None,
512
826
  log_handler=None, loglevel=None, quit_callback=None, start_retries=5, start_retry_delay_ms=1000,
513
- diagnose_start_failures=True, **kwargs):
827
+ diagnose_start_failures=True, discard_output=False, **kwargs):
514
828
  """
515
829
  Create the interface to MPV and process instance.
516
830
 
@@ -524,6 +838,10 @@ class MPV:
524
838
  why it refused to start. If MPV names an option it does not have, MPVProcessError is
525
839
  raised immediately with *bad_option* set instead of spending every retry on something
526
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)
527
845
 
528
846
  All other arguments are forwarded to MPV as command-line arguments if *start_mpv* is used.
529
847
  """
@@ -550,7 +868,8 @@ class MPV:
550
868
  log_output = None
551
869
  for i in range(start_retries):
552
870
  try:
553
- self.mpv_process = MPVProcess(ipc_socket, mpv_location, **kwargs)
871
+ self.mpv_process = MPVProcess(ipc_socket, mpv_location,
872
+ discard_output=discard_output, **kwargs)
554
873
  break
555
874
  except MPVError as error:
556
875
  last_error = error
@@ -782,6 +1101,14 @@ class MPV:
782
1101
  def terminate(self, join=True):
783
1102
  """Terminate the connection to MPV and process (if *start_mpv* is used)."""
784
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.
785
1112
  self.mpv_process.stop()
786
1113
  if self.mpv_inter:
787
1114
  self.mpv_inter.stop(join)
@@ -1,6 +0,0 @@
1
- python_mpv_jsonipc.py,sha256=vsGhKYoGeAl4uw652aZZsMF2weee_i8o7sJV9Ssp80g,31762
2
- python_mpv_jsonipc-1.3.0.dist-info/licenses/LICENSE.md,sha256=7GQBUOA3dNG1Jk-yaPOCncNnRVURCRMev23QpZ1ew-s,10495
3
- python_mpv_jsonipc-1.3.0.dist-info/METADATA,sha256=1_LpKl1fCquLmZBb0qYczUSbHynCP6FLGdt92Z5jV4U,2277
4
- python_mpv_jsonipc-1.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
- python_mpv_jsonipc-1.3.0.dist-info/top_level.txt,sha256=nGk4yjpVHKmIrL39XdGD2PhCeWF0oMjwey0nsNOd2TM,19
6
- python_mpv_jsonipc-1.3.0.dist-info/RECORD,,