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.
- {python_mpv_jsonipc-1.3.0/python_mpv_jsonipc.egg-info → python_mpv_jsonipc-1.4.0}/PKG-INFO +1 -1
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/pyproject.toml +1 -1
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0/python_mpv_jsonipc.egg-info}/PKG-INFO +1 -1
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/python_mpv_jsonipc.egg-info/SOURCES.txt +1 -0
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/python_mpv_jsonipc.py +386 -59
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/tests/test_arg_building.py +32 -0
- python_mpv_jsonipc-1.4.0/tests/test_live_teardown.py +300 -0
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/LICENSE.md +0 -0
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/README.md +0 -0
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/python_mpv_jsonipc.egg-info/dependency_links.txt +0 -0
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/python_mpv_jsonipc.egg-info/top_level.txt +0 -0
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/setup.cfg +0 -0
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/tests/test_api_surface.py +0 -0
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/tests/test_live_basic.py +0 -0
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/tests/test_live_events.py +0 -0
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/tests/test_live_failure.py +0 -0
- {python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/tests/test_python_floor.py +0 -0
|
@@ -12,8 +12,100 @@ import logging
|
|
|
12
12
|
log = logging.getLogger('mpv-jsonipc')
|
|
13
13
|
|
|
14
14
|
if os.name == "nt":
|
|
15
|
-
import
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
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 +=
|
|
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
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
499
|
+
sock = self.socket # bind once; see stop()
|
|
500
|
+
if sock is None:
|
|
245
501
|
raise BrokenPipeError("socket is closed")
|
|
246
|
-
|
|
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 =
|
|
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
|
-
|
|
286
|
-
_winapi.WaitNamedPipe(ipc_socket, 0)
|
|
542
|
+
if _kernel32.WaitNamedPipeW(ipc_socket, 0):
|
|
287
543
|
return True
|
|
288
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
383
|
-
"""
|
|
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
|
-
|
|
386
|
-
|
|
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,
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_mpv_jsonipc-1.3.0 → python_mpv_jsonipc-1.4.0}/python_mpv_jsonipc.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|