OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed / test_remux_stall.py

139 lines · python ref:
"""A remux that stops producing bytes is killed promptly, not at the wall clock.

2026-09-03, No Other Choice (tmdb 639988): ffmpeg started at 21:07:50 and wrote
2,848,981,040 bytes by 21:16 -- then produced NOTHING for 36 minutes, until
`subprocess.run(timeout=MP4_REMUX_TIMEOUT)` killed it at 21:52:51. The job
reported "timed out after 2700 seconds", which reads as "too slow" and is wrong:
the copy ran at ~5.3 MB/s while it ran at all. It stalled.

The cost is not the wasted 36 minutes. It is that the wall-clock timeout is the
ONLY thing that ever ends a stall, so the failure always arrives 45 minutes
late, and `.part` -- the evidence and the resume point -- looks the same either
way. Watching the output grow distinguishes them at the only place the
difference shows.

ffmpeg's own -rw_timeout/-reconnect flags are set too, but they cannot be tested
here without a CDN that stalls on demand, and they only cover the stalls ffmpeg
itself notices. This watchdog is outside ffmpeg and covers all of them.

Run:  .venv/bin/python3 -m pytest test_remux_stall.py -q
"""

import os
import time

import pytest

import addon


@pytest.fixture
def out_file(tmp_path):
    return str(tmp_path / "out.part")


def test_a_command_that_never_writes_is_killed_at_the_stall_deadline(out_file):
    started = time.time()
    with pytest.raises(addon.RemuxStalled):
        addon._run_remux(["sh", "-c", "sleep 30"], out_file,
                         stall_timeout=2, hard_timeout=30)
    elapsed = time.time() - started
    # The point of the whole change: it ends at the STALL deadline, nowhere near
    # the hard one. Generous upper bound so a loaded machine cannot flake it.
    assert elapsed < 10, f"took {elapsed:.1f}s — did not end at the stall deadline"


def test_a_command_that_keeps_writing_is_left_alone(out_file):
    # Appends a byte every 0.2s for ~3s, i.e. across several stall windows.
    cmd = ["sh", "-c", f"for i in $(seq 15); do printf x >> {out_file}; sleep 0.2; done"]
    proc = addon._run_remux(cmd, out_file, stall_timeout=1, hard_timeout=30)
    assert proc.returncode == 0
    assert os.path.getsize(out_file) == 15


def test_the_hard_timeout_still_applies_to_a_command_that_writes_for_ever(out_file):
    cmd = ["sh", "-c", f"while :; do printf x >> {out_file}; sleep 0.1; done"]
    started = time.time()
    with pytest.raises(addon.RemuxTimedOut):
        addon._run_remux(cmd, out_file, stall_timeout=30, hard_timeout=2)
    assert time.time() - started < 10


def test_a_stall_is_measured_from_the_last_byte_not_from_the_start(out_file):
    # Writes for ~1s, then goes quiet. The stall deadline must be counted from
    # the last write -- otherwise a copy that starts slowly is killed for it.
    cmd = ["sh", "-c", f"for i in $(seq 5); do printf x >> {out_file}; sleep 0.2; done; sleep 30"]
    started = time.time()
    with pytest.raises(addon.RemuxStalled):
        addon._run_remux(cmd, out_file, stall_timeout=2, hard_timeout=30)
    elapsed = time.time() - started
    assert elapsed > 2, f"ended in {elapsed:.1f}s — did not wait out the writing phase"
    assert elapsed < 12, f"took {elapsed:.1f}s — did not end at the stall deadline"


def test_a_missing_output_file_counts_as_no_progress(out_file):
    # ffmpeg has not created the .part yet. That is still no progress, and must
    # not raise its own error on os.path.getsize.
    assert not os.path.exists(out_file)
    with pytest.raises(addon.RemuxStalled):
        addon._run_remux(["sh", "-c", "sleep 30"], out_file,
                         stall_timeout=2, hard_timeout=30)


def test_the_reconnect_flags_are_actually_passed_to_ffmpeg(monkeypatch, tmp_path):
    """The flags are the half of the fix this file cannot exercise directly, so
    at minimum assert they reach the command line."""
    seen = {}

    def fake_run(cmd, out_path, stall_timeout, hard_timeout):
        seen["cmd"] = cmd
        with open(out_path, "wb") as f:
            f.write(b"x")

        class P:
            returncode = 0
            stderr = ""
        return P()

    monkeypatch.setattr(addon, "_run_remux", fake_run)
    monkeypatch.setattr(addon, "_playlist_duration", lambda url: None)
    dest = str(tmp_path / "movie.mp4")
    addon._remux_to_mp4("http://example.invalid/v.m3u8", dest)
    cmd = seen["cmd"]
    assert "-rw_timeout" in cmd
    assert "-reconnect" in cmd
    assert "-reconnect_streamed" in cmd
    assert "-reconnect_on_network_error" in cmd
    # The hls demuxer defaults seg_max_retry to 0 -- one bad segment is fatal.
    assert "-seg_max_retry" in cmd


def test_stderr_is_captured_on_success(out_file):
    cmd = ["sh", "-c", f"printf x >> {out_file}; echo 'a warning' >&2"]
    proc = addon._run_remux(cmd, out_file, stall_timeout=10, hard_timeout=30)
    assert proc.returncode == 0
    assert "a warning" in proc.stderr


def test_a_chatty_stderr_does_not_look_like_a_stall(out_file):
    """The reason stderr is a temp file and not a pipe.

    A pipe holds 64K. Past that ffmpeg blocks on the write, therefore stops
    writing the OUTPUT, and the watchdog sees a stall -- killing a process that
    was only waiting for us to read, in exactly the failing case whose stderr is
    worth having. 400K is comfortably past any pipe buffer.
    """
    cmd = ["sh", "-c",
           f"yes 'ffmpeg noise on stderr, at length' | head -c 400000 >&2; "
           f"printf x >> {out_file}"]
    proc = addon._run_remux(cmd, out_file, stall_timeout=5, hard_timeout=30)
    assert proc.returncode == 0
    assert len(proc.stderr) > 100000, f"only captured {len(proc.stderr)} bytes"


def test_ffmpeg_s_last_words_survive_a_stall(out_file):
    cmd = ["sh", "-c", "echo 'Server returned 403 Forbidden' >&2; sleep 30"]
    with pytest.raises(addon.RemuxStalled) as e:
        addon._run_remux(cmd, out_file, stall_timeout=2, hard_timeout=30)
    assert "403 Forbidden" in str(e.value)