testsAndMisc/python_pkg/wsg_grabber/tests/test_player_errors.py
Krzysztof kuhy Rudnicki 4b2cd0c398 Clear the wsg_grabber test modules: python_pkg is now under the cap
The last six over-cap files split into eleven new modules, in two passes
since the first cut left five still over. python_pkg has no violations
left at all, down from 50 when this effort started.

Each split carries the whole preamble including the module's pytest
fixtures - started, toolkit and session are taken as parameters and
resolve lexically, so a hand-written import block would have missed
them. ruff then pruned 82 unused imports.

Pass count is 277 before and after, checked after both passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 18:19:26 +02:00

220 lines
7.0 KiB
Python

"""Tests for how the player reports IPC failures."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.wsg_grabber import logs, player
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
def _socket_mock(**kwargs: object) -> MagicMock:
"""Build a socket stand-in whose reader thread terminates immediately.
An unconfigured MagicMock returns a MagicMock from recv(), which is truthy
and instant, so the drain loop spins as fast as the CPU allows -- and
MagicMock records every call, so memory grows without bound. That once
reached 21 GB and took the machine down, hence this helper: every socket
mock yields EOF unless a test deliberately says otherwise.
Args:
**kwargs: Attributes to set on the mock.
Returns:
MagicMock: Socket double.
"""
sock = MagicMock()
sock.recv.return_value = b""
for name, value in kwargs.items():
setattr(sock, name, value)
return sock
@pytest.fixture(autouse=True)
def _safe_sockets() -> Iterator[None]:
"""Stop a stray socket mock from turning the drain loop into a busy loop.
``MpvPlayer`` runs a reader thread that calls ``recv`` until EOF. An
unconfigured MagicMock returns a MagicMock -- truthy, instant, and it
records every call -- so the loop spins and memory grows without bound.
That once reached 21 GB and OOM-killed the machine. Defaulting the module's
socket factory to something that reports EOF means a test has to opt into
the hazard deliberately.
Yields:
None: Control returns to the test.
"""
with patch(
"python_pkg.wsg_grabber.player.socket.socket",
side_effect=lambda *_a, **_k: _socket_mock(),
):
yield
@pytest.fixture
def started(tmp_path: Path) -> Iterator[tuple[player.MpvPlayer, MagicMock, MagicMock]]:
"""Construct an MpvPlayer with its process and socket mocked out.
Args:
tmp_path: Per-test temporary directory.
Yields:
tuple: The player, its process mock and its socket mock.
"""
ipc = tmp_path / "mpv.sock"
process = MagicMock()
process.poll.return_value = None
sock = _socket_mock()
def create_socket(_seconds: float) -> None:
"""Stand in for mpv finally creating its control socket."""
ipc.write_bytes(b"")
# __init__ unlinks any stale socket first, so it has to appear *during*
# the wait rather than being pre-created.
with (
patch("python_pkg.wsg_grabber.player.subprocess.Popen", return_value=process),
patch("python_pkg.wsg_grabber.player.socket.socket", return_value=sock),
patch("python_pkg.wsg_grabber.player.time.sleep", side_effect=create_socket),
):
yield player.MpvPlayer(1234, ipc), process, sock
def test_a_failed_send_is_logged_not_swallowed(
started: tuple[player.MpvPlayer, MagicMock, MagicMock],
tmp_path: Path,
) -> None:
"""A dead socket must not make a frozen reviewer look like a working one."""
mpv, _process, sock = started
sock.sendall.side_effect = BrokenPipeError("gone")
log = logs.start("debug")
try:
mpv.play(tmp_path / "clip.webm")
finally:
logs.stop()
recorded = [
json.loads(line)
for line in log.read_text(encoding="utf-8").splitlines()
if line
]
assert any(r["event"] == "player.send_failed" for r in recorded)
assert any(r["level"] == "error" for r in recorded)
def test_the_socket_is_drained_so_mpv_keeps_accepting_commands(
tmp_path: Path,
) -> None:
"""The regression that made the reviewer freeze on one video.
mpv pushes events down the same socket; a client that never reads lets the
buffer fill, at which point mpv stops reading commands while still looking
perfectly alive.
"""
ipc = tmp_path / "mpv.sock"
process = MagicMock()
process.poll.return_value = None
sock = _socket_mock()
seen: list[int] = []
def recv(size: int) -> bytes:
seen.append(size)
return b'{"event":"property-change"}\n' if len(seen) < 3 else b""
sock.recv.side_effect = recv
with (
patch("python_pkg.wsg_grabber.player.subprocess.Popen", return_value=process),
patch("python_pkg.wsg_grabber.player.socket.socket", return_value=sock),
patch(
"python_pkg.wsg_grabber.player.time.sleep",
side_effect=lambda _s: ipc.write_bytes(b""),
),
):
mpv = player.MpvPlayer(1, ipc)
mpv._reader.join(timeout=5)
mpv.close()
assert seen, "the reader never read from the socket"
assert not mpv._reader.is_alive()
def test_the_reader_stops_on_a_socket_error(tmp_path: Path) -> None:
ipc = tmp_path / "mpv.sock"
process = MagicMock()
process.poll.return_value = None
sock = _socket_mock()
sock.recv.side_effect = ConnectionResetError("gone")
with (
patch("python_pkg.wsg_grabber.player.subprocess.Popen", return_value=process),
patch("python_pkg.wsg_grabber.player.socket.socket", return_value=sock),
patch(
"python_pkg.wsg_grabber.player.time.sleep",
side_effect=lambda _s: ipc.write_bytes(b""),
),
):
mpv = player.MpvPlayer(1, ipc)
mpv._reader.join(timeout=5)
assert not mpv._reader.is_alive()
mpv.close()
def test_the_reader_keeps_going_through_read_timeouts(tmp_path: Path) -> None:
"""A quiet socket must not be mistaken for a closed one."""
ipc = tmp_path / "mpv.sock"
process = MagicMock()
process.poll.return_value = None
sock = _socket_mock()
calls = {"n": 0}
def recv(_size: int) -> bytes:
calls["n"] += 1
if calls["n"] < 3:
raise TimeoutError
return b""
sock.recv.side_effect = recv
with (
patch("python_pkg.wsg_grabber.player.subprocess.Popen", return_value=process),
patch("python_pkg.wsg_grabber.player.socket.socket", return_value=sock),
patch(
"python_pkg.wsg_grabber.player.time.sleep",
side_effect=lambda _s: ipc.write_bytes(b""),
),
):
mpv = player.MpvPlayer(1, ipc)
mpv._reader.join(timeout=5)
mpv.close()
assert calls["n"] >= 3
def test_mpv_errors_are_logged_rather_than_discarded(
started: tuple[player.MpvPlayer, MagicMock, MagicMock],
) -> None:
"""A file mpv cannot decode should be visible, not just look stuck."""
mpv, _process, _sock = started
log = logs.start("debug")
try:
mpv._note_errors(
b'{"error":"success"}\n'
b"\n"
b"garbage\n"
b'{"request_id":1,"error":"loadfile failed"}\n',
)
finally:
logs.stop()
recorded = [
json.loads(line)
for line in log.read_text(encoding="utf-8").splitlines()
if line
]
failures = [r for r in recorded if r["event"] == "player.reply_error"]
assert len(failures) == 1
assert failures[0]["detail"] == "loadfile failed"