soulsync/tests/test_torrent_cleanup_orphan.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

67 lines
2.3 KiB
Python

"""noldevin: a dead torrent (metaDL stuck / errored / timed out) was left ORPHANED
in qbit — cleared from SoulSync but still active in the client, then re-grabbed as
a duplicate. The monitor's terminal exits now call _cleanup_torrent, which removes
(abandon) or pauses it in the client."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from core.download_plugins.torrent import TorrentDownloadPlugin
class _FakeAdapter:
def __init__(self):
self.removed = []
self.paused = []
async def remove(self, h, delete_files=False):
self.removed.append((h, delete_files))
async def pause(self, h):
self.paused.append(h)
@pytest.fixture
def plugin():
with patch('core.download_plugins.torrent.ProwlarrClient'):
yield TorrentDownloadPlugin()
def test_abandon_removes_torrent_and_deletes_files(plugin):
fake = _FakeAdapter()
with patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=fake):
plugin._cleanup_torrent('abc123', 'abandon')
assert fake.removed == [('abc123', True)] # removed + partial data deleted
assert fake.paused == []
def test_pause_action_pauses_not_removes(plugin):
fake = _FakeAdapter()
with patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=fake):
plugin._cleanup_torrent('abc123', 'pause')
assert fake.paused == ['abc123']
assert fake.removed == []
def test_no_hash_is_a_noop(plugin):
fake = _FakeAdapter()
with patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=fake):
plugin._cleanup_torrent('', 'abandon')
plugin._cleanup_torrent(None, 'abandon')
assert fake.removed == [] and fake.paused == []
def test_no_adapter_is_a_noop(plugin):
with patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=None):
plugin._cleanup_torrent('abc123', 'abandon') # must not raise
def test_client_error_is_swallowed(plugin):
class _Boom:
async def remove(self, h, delete_files=False):
raise RuntimeError("qbit down")
with patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=_Boom()):
plugin._cleanup_torrent('abc123', 'abandon') # best-effort: logged, not raised