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.
67 lines
2.3 KiB
Python
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
|