python-mpv-jsonipc 1.3.0__tar.gz → 1.4.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 (17) hide show
  1. {python_mpv_jsonipc-1.3.0/python_mpv_jsonipc.egg-info → python_mpv_jsonipc-1.4.0}/PKG-INFO +1 -1
  2. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/pyproject.toml +1 -1
  3. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0/python_mpv_jsonipc.egg-info}/PKG-INFO +1 -1
  4. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/python_mpv_jsonipc.egg-info/SOURCES.txt +1 -0
  5. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/python_mpv_jsonipc.py +386 -59
  6. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/tests/test_arg_building.py +32 -0
  7. python_mpv_jsonipc-1.4.0/tests/test_live_teardown.py +300 -0
  8. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/LICENSE.md +0 -0
  9. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/README.md +0 -0
  10. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/python_mpv_jsonipc.egg-info/dependency_links.txt +0 -0
  11. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/python_mpv_jsonipc.egg-info/top_level.txt +0 -0
  12. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/setup.cfg +0 -0
  13. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/tests/test_api_surface.py +0 -0
  14. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/tests/test_live_basic.py +0 -0
  15. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/tests/test_live_events.py +0 -0
  16. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/tests/test_live_failure.py +0 -0
  17. {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.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.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-mpv-jsonipc"
7
- version = "1.3.0"
7
+ version = "1.4.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.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
@@ -11,4 +11,5 @@ tests/test_arg_building.py
11
11
  tests/test_live_basic.py
12
12
  tests/test_live_events.py
13
13
  tests/test_live_failure.py
14
+ tests/test_live_teardown.py
14
15
  tests/test_python_floor.py
@@ -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)
@@ -107,6 +107,38 @@ class SocketPathTest(unittest.TestCase):
107
107
  self.assertFalse(os.path.exists(path))
108
108
 
109
109
 
110
+ class OutputRedirectionTest(unittest.TestCase):
111
+ """Which stdio ``Popen`` is asked for, which is not visible in the argv."""
112
+
113
+ def popen_call(self, **kwargs):
114
+ with mock.patch.object(python_mpv_jsonipc.subprocess, "Popen") as popen, \
115
+ mock.patch.object(python_mpv_jsonipc, "_ipc_endpoint_ready",
116
+ return_value=True):
117
+ popen.return_value = _FakeProcess()
118
+ python_mpv_jsonipc.MPVProcess("/tmp/testsocket", "mpv", **kwargs)
119
+ return popen.call_args
120
+
121
+ def test_by_default_mpv_inherits_our_stdout_and_stderr(self):
122
+ # Characterization: None means inherit. See test_live_teardown for
123
+ # what that costs and why it is still the default.
124
+ kwargs = self.popen_call()[1]
125
+ self.assertIsNone(kwargs["stdout"])
126
+ self.assertIsNone(kwargs["stderr"])
127
+
128
+ def test_discard_output_sends_both_to_devnull(self):
129
+ kwargs = self.popen_call(discard_output=True)[1]
130
+ self.assertEqual(kwargs["stdout"], python_mpv_jsonipc.subprocess.DEVNULL)
131
+ self.assertEqual(kwargs["stderr"], python_mpv_jsonipc.subprocess.DEVNULL)
132
+
133
+ def test_discard_output_is_never_forwarded_to_mpv(self):
134
+ # It is a keyword of ours, not an mpv option -- unlike `quiet`, which
135
+ # mpv really has and which must keep being forwarded. That collision
136
+ # is why this keyword is not named `quiet`.
137
+ argv = self.popen_call(discard_output=True, quiet=True)[0][0]
138
+ self.assertIn("--quiet=yes", argv)
139
+ self.assertEqual([a for a in argv if "discard" in a], [])
140
+
141
+
110
142
  class MpvFmtTest(unittest.TestCase):
111
143
  """`_mpv_fmt` in isolation, since it is where the bool/int trap lives."""
112
144
 
@@ -0,0 +1,300 @@
1
+ """What ``terminate()`` guarantees about the mpv process it started.
2
+
3
+ Reported by jellyfin-mpv-shim, 2026-08-31: an integration leg printed its
4
+ results, exited cleanly, and then its parent hung forever reading the leg's
5
+ stdout. The orphan was an mpv with PPID 1 still holding the pipe the parent
6
+ was blocked on -- ``/proc/<mpv>/fd/1`` named the exact inode.
7
+
8
+ Two separate facts produced that, and they are tested separately here because
9
+ only one of them was fixed:
10
+
11
+ * ``stop()`` sent SIGTERM and returned without waiting, so ``terminate()``
12
+ could return while mpv was still running. That is now a wait with a kill
13
+ escalation, and it is what these tests pin.
14
+ * mpv inherits the caller's stdout and stderr, because ``close_fds`` only
15
+ covers fds above 2. That is *unchanged by default* -- callers have had
16
+ mpv's output on their terminal for years -- and opt-out via
17
+ ``discard_output``. The default is pinned below as characterization, not
18
+ as approval.
19
+
20
+ The intermittency is the tell: it is a race between mpv's SIGTERM handling
21
+ and the caller exiting, so a run where the orphan does not appear proves
22
+ nothing. These assert the postcondition instead of trying to lose the race.
23
+
24
+ A third fact arrived with the fix rather than before it, which is why it is
25
+ tested here too: making ``stop()`` wait turned a long-latent check-then-act
26
+ race on the socket unlink into a reliable one, because the wait ends on the
27
+ same MPV exit that wakes the reader into a second ``terminate()``. It was
28
+ found by ``stress_teardown.py`` at ~50 cycles, not by this file -- a reminder
29
+ that the cheap tests here cover postconditions, not interleavings.
30
+ """
31
+
32
+ import os
33
+ import tempfile
34
+ import threading
35
+ import time
36
+ import unittest
37
+ from unittest import mock
38
+
39
+ from _harness import MPV_BINARY, LIVE_OPTIONS, python_mpv_jsonipc, requires_mpv
40
+
41
+ #: The fd-inheritance assertions read /proc, so they are Linux-only. The
42
+ #: waiting assertions above them are not, and run everywhere.
43
+ has_proc = unittest.skipUnless(os.path.exists("/proc/self/fd"),
44
+ "needs /proc to see the child's fds")
45
+
46
+
47
+ def _skip_only_the_readiness_poll():
48
+ """Make MPVProcess's 0.1s poll free without touching any other sleep.
49
+
50
+ Patching ``time.sleep`` wholesale would also hit ``Popen.wait(timeout=)``,
51
+ which stop() calls from inside the same patched block -- turning a bounded
52
+ wait into a busy spin, and spinning every other sleeping thread with it.
53
+ """
54
+ real_sleep = python_mpv_jsonipc.time.sleep
55
+ return lambda seconds: None if seconds == 0.1 else real_sleep(seconds)
56
+
57
+
58
+ def start_mpv(**extra):
59
+ options = dict(LIVE_OPTIONS)
60
+ options.update(extra)
61
+ return python_mpv_jsonipc.MPV(mpv_location=MPV_BINARY, **options)
62
+
63
+
64
+ @requires_mpv
65
+ class TerminateWaitsTest(unittest.TestCase):
66
+ def test_terminate_does_not_return_while_mpv_may_still_be_alive(self):
67
+ mpv = start_mpv()
68
+ process = mpv.mpv_process.process
69
+
70
+ mpv.terminate()
71
+
72
+ # returncode is only set by a wait(). Before the fix nothing ever
73
+ # called one, so this was None however long mpv had been dead --
74
+ # which is the whole complaint: the caller had no way to know.
75
+ self.assertIsNotNone(
76
+ process.returncode,
77
+ "terminate() returned without waiting for mpv; a caller that "
78
+ "exits here orphans an mpv still holding its stdout")
79
+
80
+ @unittest.skipIf(os.name == "nt", "POSIX socket files only")
81
+ def test_the_socket_removal_survives_losing_the_race(self):
82
+ # Two threads reach MPVProcess.stop(): the caller's terminate(), and
83
+ # the reader's quit_callback -> terminate(join=False), woken by the
84
+ # very MPV exit that stop() now waits for. So they overlap by
85
+ # construction, and the loser finds the socket already unlinked.
86
+ #
87
+ # The window itself cannot be hit on demand, but its outcome can:
88
+ # exists() answering True for a file that is gone is exactly what the
89
+ # loser sees. Check-then-act raises FileNotFoundError here.
90
+ mpv = start_mpv()
91
+ process = mpv.mpv_process
92
+ mpv.terminate()
93
+ self.assertFalse(os.path.exists(process.ipc_socket))
94
+
95
+ with mock.patch("os.path.exists", return_value=True):
96
+ process.stop()
97
+
98
+ @unittest.skipIf(os.name == "nt", "UnixSocket only")
99
+ def test_stopping_the_socket_survives_the_other_thread_finishing_first(self):
100
+ # Two threads reach UnixSocket.stop(): the caller's terminate() and
101
+ # the reader's quit_callback -> terminate(join=False). Making stop()
102
+ # wait for MPV widened that overlap, because the wait ends on the same
103
+ # MPV exit that wakes the reader.
104
+ #
105
+ # Racing it is unreliable -- 400 live cycles produced none -- so model
106
+ # the losing thread instead: the winner clears self.socket midway
107
+ # through ours. Re-reading the attribute then calls close() on None,
108
+ # an AttributeError, which `except OSError` does not catch.
109
+ class Winner:
110
+ def __init__(self, real, owner):
111
+ self._real, self._owner = real, owner
112
+
113
+ def shutdown(self, how):
114
+ self._owner.socket = None # the other thread finishes here
115
+ return self._real.shutdown(how)
116
+
117
+ def close(self):
118
+ return self._real.close()
119
+
120
+ mpv = start_mpv()
121
+ self.addCleanup(mpv.terminate)
122
+ transport = mpv.mpv_inter.socket
123
+ transport.socket = Winner(transport.socket, transport)
124
+
125
+ transport.stop(join=False)
126
+
127
+ def test_a_failed_start_does_not_leave_mpv_running(self):
128
+ # The failure path orphaned MPV exactly as the success path did, and
129
+ # worse: MPV.__init__ retries construction up to start_retries times,
130
+ # so a machine where MPV wedges without opening its pipe left five
131
+ # live processes, each holding the caller's stdout.
132
+ #
133
+ # The endpoint poll is answered False so the start "times out" while
134
+ # MPV is genuinely running, and sleep is a no-op so that costs
135
+ # microseconds instead of the real ten seconds.
136
+ spawned = []
137
+ real_popen = python_mpv_jsonipc.subprocess.Popen
138
+
139
+ def record(*args, **kwargs):
140
+ process = real_popen(*args, **kwargs)
141
+ spawned.append(process)
142
+ return process
143
+
144
+ with mock.patch.object(python_mpv_jsonipc.subprocess, "Popen", record), \
145
+ mock.patch.object(python_mpv_jsonipc, "_ipc_endpoint_ready",
146
+ return_value=False), \
147
+ mock.patch.object(python_mpv_jsonipc.time, "sleep",
148
+ _skip_only_the_readiness_poll()):
149
+ with self.assertRaises(python_mpv_jsonipc.MPVProcessError):
150
+ python_mpv_jsonipc.MPVProcess(
151
+ os.path.join(tempfile.mkdtemp(), "failed-start.sock"),
152
+ MPV_BINARY, **LIVE_OPTIONS)
153
+
154
+ self.assertEqual(len(spawned), 1)
155
+ self.assertIsNotNone(
156
+ spawned[0].returncode,
157
+ "a start that failed left MPV running and unreaped")
158
+
159
+ def test_a_quit_callback_that_terminates_does_not_join_its_own_thread(self):
160
+ # quit_callback runs on the reader thread, and terminate() defaults to
161
+ # join=True -- so the reader was asked to join itself. RuntimeError
162
+ # escaped run(), which skipped the internal terminate(join=False)
163
+ # after it and leaked the event handler.
164
+ mpv = start_mpv()
165
+ escaped = []
166
+ original_hook = threading.excepthook
167
+ threading.excepthook = lambda args: escaped.append(
168
+ "{0}: {1}".format(args.exc_type.__name__, args.exc_value))
169
+ self.addCleanup(setattr, threading, "excepthook", original_hook)
170
+
171
+ mpv.quit_callback = mpv.terminate
172
+ mpv.mpv_process.process.kill()
173
+
174
+ deadline = time.time() + 5
175
+ while mpv.mpv_inter.socket.is_alive() and time.time() < deadline:
176
+ time.sleep(0.02)
177
+ time.sleep(0.2)
178
+
179
+ self.assertEqual(escaped, [])
180
+
181
+ def test_the_process_is_reaped_not_merely_signalled(self):
182
+ mpv = start_mpv()
183
+ process = mpv.mpv_process.process
184
+
185
+ mpv.terminate()
186
+
187
+ self.assertIsNotNone(process.poll())
188
+
189
+
190
+ class StopWaitingTest(unittest.TestCase):
191
+ """No mpv here: a stub is the only way to hold SIGTERM open on demand."""
192
+
193
+ class _Stubborn:
194
+ """Ignores terminate(); only kill() gets through."""
195
+
196
+ def __init__(self):
197
+ self.killed = False
198
+ self.waits = []
199
+
200
+ def terminate(self):
201
+ pass
202
+
203
+ def kill(self):
204
+ self.killed = True
205
+
206
+ def wait(self, timeout=None):
207
+ self.waits.append(timeout)
208
+ if self.killed:
209
+ return -9
210
+ raise python_mpv_jsonipc.subprocess.TimeoutExpired("mpv", timeout)
211
+
212
+ def _process(self, socket_name="absent.sock"):
213
+ process = python_mpv_jsonipc.MPVProcess.__new__(
214
+ python_mpv_jsonipc.MPVProcess)
215
+ process.process = self._Stubborn()
216
+ process.ipc_socket = os.path.join(tempfile.mkdtemp(), socket_name)
217
+ return process
218
+
219
+ def test_a_timeout_waits_then_escalates_to_kill(self):
220
+ process = self._process()
221
+
222
+ process.stop(timeout=0)
223
+
224
+ self.assertTrue(process.process.killed)
225
+ self.assertEqual(process.process.waits, [0, 0])
226
+
227
+ @unittest.skipIf(os.name == "nt", "POSIX socket files only")
228
+ def test_an_unlink_failure_that_is_not_the_race_is_not_swallowed(self):
229
+ # Only FileNotFoundError means "the other thread got there first".
230
+ # Swallowing every OSError would hide a permission problem or a path
231
+ # that is not the socket we think it is.
232
+ process = self._process()
233
+ process.process.killed = True # so the wait returns instead of escalating
234
+ with mock.patch("os.remove", side_effect=PermissionError("nope")):
235
+ with self.assertRaises(PermissionError):
236
+ process.stop(timeout=0)
237
+
238
+
239
+ @requires_mpv
240
+ class TerminateAlwaysTerminatesTest(unittest.TestCase):
241
+ def test_it_reaps_mpv_even_when_asked_not_to_join(self):
242
+ # `join` governs whether we join our *threads* -- _quit_callback
243
+ # passes False only because a thread cannot join itself. It is not a
244
+ # "do not block" flag: it is undocumented in both the docstring and
245
+ # docs.md, and every use of it reaches a Thread.join and nothing else.
246
+ # So it must not gate the process wait, or passing it hands back the
247
+ # orphaned MPV this file exists to prevent.
248
+ mpv = start_mpv()
249
+ process = mpv.mpv_process.process
250
+
251
+ mpv.terminate(join=False)
252
+
253
+ self.assertIsNotNone(
254
+ process.returncode,
255
+ "terminate(join=False) left MPV unreaped; join is about threads, "
256
+ "not about whether MPV actually dies")
257
+
258
+
259
+ @requires_mpv
260
+ @has_proc
261
+ class InheritedOutputTest(unittest.TestCase):
262
+ def _child_stdio(self, mpv):
263
+ pid = mpv.mpv_process.process.pid
264
+ return (os.readlink("/proc/{0}/fd/1".format(pid)),
265
+ os.readlink("/proc/{0}/fd/2".format(pid)))
266
+
267
+ def test_mpv_inherits_the_callers_stdout_and_stderr_by_default(self):
268
+ # Characterization. This is the behaviour that let an orphaned mpv
269
+ # hold a test runner's pipe open, and it is still the default because
270
+ # changing it would silently take mpv's output away from every caller
271
+ # who currently sees it.
272
+ mpv = start_mpv()
273
+ self.addCleanup(mpv.terminate)
274
+ ours = (os.readlink("/proc/self/fd/1"), os.readlink("/proc/self/fd/2"))
275
+
276
+ self.assertEqual(self._child_stdio(mpv), ours)
277
+
278
+ def test_discard_output_keeps_mpv_off_the_callers_pipes(self):
279
+ mpv = start_mpv(discard_output=True)
280
+ self.addCleanup(mpv.terminate)
281
+
282
+ stdout, stderr = self._child_stdio(mpv)
283
+
284
+ self.assertEqual(stdout, os.devnull)
285
+ self.assertEqual(stderr, os.devnull)
286
+
287
+ def test_discard_output_is_not_passed_on_to_mpv(self):
288
+ # mpv has a real --quiet; it must keep working as an mpv option, which
289
+ # is why this keyword is not called that. If discard_output ever leaks
290
+ # into the argv, mpv rejects it and nothing starts at all.
291
+ mpv = start_mpv(discard_output=True, quiet=True)
292
+ self.addCleanup(mpv.terminate)
293
+
294
+ self.assertIn("--quiet=yes", mpv.mpv_process.argv)
295
+ self.assertEqual(
296
+ [a for a in mpv.mpv_process.argv if "discard" in a], [])
297
+
298
+
299
+ if __name__ == "__main__":
300
+ unittest.main()