soulsync/tests/test_torrent_stall.py
BoulderBadgeDad 53c264ab50 Torrents: fix stall handling on "downloading metadata" + stop orphaning in qbit
noldevin: a magnet stuck "downloading metadata" ran 11h despite a 15-min stall
timeout, got cleared from SoulSync but left active in qbit, then re-grabbed as a
duplicate. Two bugs:

1. Stall never fired on metaDL. StallTracker reset its clock on any `downloaded`
   byte increase, but a metaDL torrent's byte counter still ticks up from DHT/peer
   protocol overhead while making no real progress — so the clock reset forever.
   Fix: the byte counter only counts once metadata is in (size>0). During the
   metadata phase (size==0) the only thing that counts as progress is *obtaining*
   metadata, so a magnet that can't even do that within the timeout is correctly
   flagged stalled. size=None preserves the old byte-only behavior (back-compat).

2. Orphaned in qbit. The monitor's stall exit removed the torrent, but the `error`
   exit and the 6h deadline exit only marked the download failed — leaving the
   torrent active in qbit, untracked here, so SoulSync re-grabbed the same dead
   torrent (qbit logs the duplicate-add). Fix: both terminal exits now run
   _cleanup_torrent (shared with the stall path), which removes+deletes (abandon)
   or pauses per the stall action — nothing is left orphaned.

Tests (10 new): metaDL byte-noise no longer resets the clock (stalls at timeout);
obtaining metadata resets it; real byte-progress still tracked after metadata;
_cleanup_torrent removes+delete_files on abandon / pauses on pause / no-ops on
empty hash or no adapter / swallows a client error. 151 torrent tests green.
2026-06-11 14:37:46 -07:00

139 lines
5.9 KiB
Python

"""Stalled-torrent detection + policy (noldevin: 'stuck on downloading metadata').
The pure StallTracker decides, from the per-poll status stream, when a
torrent has gone too long with no byte progress while it's supposed to be
downloading. Clock is injected so this tests without sleeping.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from core.download_plugins.torrent_stall import (
StallTracker,
get_stall_action,
get_stall_timeout,
)
def test_no_progress_trips_after_timeout():
t = StallTracker(timeout_seconds=600)
# First sighting at t=0 (metadata fetch: 0 bytes, 'downloading').
assert t.is_stalled(0, "downloading", now=0) is False
assert t.is_stalled(0, "downloading", now=300) is False # 5 min, under
assert t.is_stalled(0, "downloading", now=599) is False # just under
assert t.is_stalled(0, "downloading", now=600) is True # hit the timeout
def test_forward_progress_resets_the_clock():
t = StallTracker(timeout_seconds=600)
t.is_stalled(0, "downloading", now=0)
t.is_stalled(0, "downloading", now=500) # stalling...
assert t.is_stalled(1024, "downloading", now=550) is False # bytes moved → reset
assert t.is_stalled(1024, "downloading", now=1000) is False # 450s since reset
assert t.is_stalled(1024, "downloading", now=1150) is True # 600s since reset
def test_explicit_stalled_state_counts():
t = StallTracker(timeout_seconds=600)
t.is_stalled(2048, "stalled", now=0)
assert t.is_stalled(2048, "stalled", now=600) is True
def test_idle_by_design_states_never_stall():
# Seeding / paused / completed aren't stalls even with zero progress.
for state in ("seeding", "completed", "paused"):
t = StallTracker(timeout_seconds=600)
t.is_stalled(5000, state, now=0)
assert t.is_stalled(5000, state, now=10_000) is False, state
def test_state_flip_active_to_idle_to_active():
t = StallTracker(timeout_seconds=600)
t.is_stalled(0, "downloading", now=0)
t.is_stalled(0, "paused", now=500) # user paused → clock parked
# Resumed; no bytes yet. Clock restarts from the un-pause, not from t=0.
assert t.is_stalled(0, "downloading", now=900) is False
assert t.is_stalled(0, "downloading", now=1100) is True # 600s after un-pause
def test_timeout_zero_disables():
t = StallTracker(timeout_seconds=0)
assert t.is_stalled(0, "downloading", now=0) is False
assert t.is_stalled(0, "downloading", now=10_000_000) is False
# ── settings helpers ─────────────────────────────────────────────────────────
def _cfg(values):
class _C:
def get(self, key, default=None):
return values.get(key, default)
return _C()
@pytest.mark.parametrize("raw,expected", [
(300, 300.0),
("450", 450.0),
(0, 0.0), # explicit disable honored
(-5, 10 * 60), # negative → default
("bad", 10 * 60), # garbage → default
(None, 10 * 60),
])
def test_get_stall_timeout(raw, expected):
import core.download_plugins.torrent_stall as ts
with patch.object(ts, "config_manager",
_cfg({"download_source.torrent_stall_timeout_seconds": raw})):
assert get_stall_timeout() == expected
@pytest.mark.parametrize("raw,expected", [
("abandon", "abandon"),
("pause", "pause"),
("PAUSE", "pause"),
("nonsense", "abandon"),
("", "abandon"),
(None, "abandon"),
])
def test_get_stall_action(raw, expected):
import core.download_plugins.torrent_stall as ts
with patch.object(ts, "config_manager",
_cfg({"download_source.torrent_stall_action": raw})):
assert get_stall_action() == expected
# ── metadata-phase noise (noldevin #2: metaDL stuck 11h, stall never fired) ──
def test_metadata_phase_byte_noise_does_not_reset_clock():
"""A magnet stuck 'downloading metadata' reports size==0 and a downloaded
counter that still ticks up from DHT/peer overhead. Those bumps must NOT
reset the stall clock, or the dead magnet never times out (the bug)."""
t = StallTracker(timeout_seconds=600)
assert t.is_stalled(0, "downloading", now=0, size=0) is False # first
assert t.is_stalled(16384, "downloading", now=120, size=0) is False # noise bump
assert t.is_stalled(32768, "downloading", now=300, size=0) is False # more noise
assert t.is_stalled(40000, "downloading", now=480, size=0) is False # still under
# Despite the byte counter climbing the whole time, no metadata was obtained
# → stalled at the timeout.
assert t.is_stalled(50000, "downloading", now=600, size=0) is True
def test_obtaining_metadata_resets_the_clock():
"""size 0 -> >0 means metadata arrived — real progress, reset the clock."""
t = StallTracker(timeout_seconds=600)
assert t.is_stalled(0, "downloading", now=0, size=0) is False
assert t.is_stalled(0, "downloading", now=500, size=0) is False # accruing
# metadata arrives at t=550 (size now known) → progress → clock resets
assert t.is_stalled(0, "downloading", now=550, size=10_000_000) is False
assert t.is_stalled(0, "downloading", now=900, size=10_000_000) is False # <600 since reset
assert t.is_stalled(0, "downloading", now=1150, size=10_000_000) is True # 600 later, no bytes
def test_real_download_progress_tracked_after_metadata():
"""Once metadata is in, byte progress resets the clock as normal."""
t = StallTracker(timeout_seconds=600)
assert t.is_stalled(0, "downloading", now=0, size=10_000_000) is False
assert t.is_stalled(500000, "downloading", now=400, size=10_000_000) is False # progress
assert t.is_stalled(500000, "downloading", now=900, size=10_000_000) is False # <600 since
assert t.is_stalled(500000, "downloading", now=1001, size=10_000_000) is True # stalled