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.
131 lines
6 KiB
Python
131 lines
6 KiB
Python
"""Stalled-torrent detection + policy (noldevin's request).
|
|
|
|
A torrent can sit forever making zero progress — most commonly stuck
|
|
"downloading metadata" on a magnet with no peers, but also a dead swarm
|
|
mid-download. The torrent poll loop would just burn the full 6-hour album
|
|
timeout on it. This module decides, from the live status stream, when a
|
|
torrent has been stalled too long, and what to do about it.
|
|
|
|
Design split, kept testable:
|
|
- ``StallTracker`` is the pure decision core — feed it each poll's
|
|
``(downloaded, state, now)`` and it answers "stalled too long?" using a
|
|
monotonic clock passed in (no time import, no I/O). Progress = bytes
|
|
moved since the last poll; any forward movement resets the stall clock.
|
|
Terminal/healthy-but-idle states (seeding, completed, paused) never count
|
|
as stalled — only states where the torrent is *supposed* to be working.
|
|
- ``get_stall_timeout`` / ``get_stall_action`` read the two settings.
|
|
|
|
A timeout of 0 disables stall handling entirely (back to the old behavior:
|
|
ride the full poll deadline).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from config.settings import config_manager
|
|
|
|
# 0 = disabled. 10 minutes is long enough to ride out a slow metadata fetch
|
|
# or a brief peer drought, short enough to give up on a truly dead magnet
|
|
# instead of holding a worker for 6 hours.
|
|
DEFAULT_STALL_TIMEOUT_SECONDS = 10 * 60
|
|
|
|
# What to do when a torrent stalls past the timeout:
|
|
# 'abandon' — remove it from the client (and its partial data) + fail the
|
|
# download so the worker is freed and the next source can try.
|
|
# 'pause' — pause it in the client + fail the download, leaving the
|
|
# torrent for the user to inspect/resume manually.
|
|
_VALID_ACTIONS = ("abandon", "pause")
|
|
DEFAULT_STALL_ACTION = "abandon"
|
|
|
|
# States where the torrent is meant to be making download progress, so a
|
|
# lack of it counts toward the stall clock. Mirrors the adapter-uniform set
|
|
# in core/torrent_clients/base.py. Notably EXCLUDES seeding/completed (done)
|
|
# and paused (the user's own choice) — neither is a stall.
|
|
STALLABLE_STATES = frozenset(("queued", "downloading", "stalled", "error"))
|
|
|
|
|
|
def get_stall_timeout() -> float:
|
|
"""Seconds of zero progress before a torrent is considered stalled.
|
|
0 (or invalid/negative) disables stall handling."""
|
|
raw = config_manager.get("download_source.torrent_stall_timeout_seconds",
|
|
DEFAULT_STALL_TIMEOUT_SECONDS)
|
|
try:
|
|
value = float(raw)
|
|
if value >= 0:
|
|
return value
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return DEFAULT_STALL_TIMEOUT_SECONDS
|
|
|
|
|
|
def get_stall_action() -> str:
|
|
"""What to do with a stalled torrent: 'abandon' (default) or 'pause'."""
|
|
raw = config_manager.get("download_source.torrent_stall_action",
|
|
DEFAULT_STALL_ACTION)
|
|
action = str(raw or "").strip().lower()
|
|
return action if action in _VALID_ACTIONS else DEFAULT_STALL_ACTION
|
|
|
|
|
|
class StallTracker:
|
|
"""Tracks one torrent's forward progress across polls.
|
|
|
|
Pure + clock-injected so it tests without sleeping. ``timeout`` <= 0
|
|
disables it (``is_stalled`` always returns False)."""
|
|
|
|
def __init__(self, timeout_seconds: float):
|
|
self.timeout = float(timeout_seconds or 0)
|
|
self._last_downloaded = -1 # -1 = first observation
|
|
self._had_metadata = None # None = first observation; else size>0?
|
|
self._progress_since = None # monotonic time of last forward movement
|
|
|
|
def is_stalled(self, downloaded: int, state: str, now: float,
|
|
size: int = None) -> bool:
|
|
"""Record this poll's observation; return True iff the torrent has gone
|
|
``timeout`` seconds with no real forward progress while in a working state.
|
|
|
|
``downloaded`` is cumulative payload bytes; ``state`` is the adapter-uniform
|
|
state; ``now`` is a monotonic timestamp; ``size`` is the torrent's total
|
|
size in bytes (0/None while still fetching metadata).
|
|
|
|
Metadata-phase fix (#852-adjacent torrent report): a magnet stuck
|
|
"downloading metadata" reports ``size==0`` and a ``downloaded`` byte
|
|
counter that still ticks up from DHT/peer-protocol overhead even though it
|
|
makes no actual progress. Treating those bumps as progress reset the stall
|
|
clock forever, so a dead magnet never timed out. Now the byte counter only
|
|
counts once metadata is in (``size>0``); during the metadata phase the only
|
|
thing that counts as progress is *obtaining* the metadata, so a torrent
|
|
that can't even do that within the timeout is correctly flagged stalled.
|
|
"""
|
|
if self.timeout <= 0:
|
|
return False
|
|
|
|
downloaded = int(downloaded or 0)
|
|
# size is None when the caller doesn't track it (assume metadata present —
|
|
# the old byte-progress behavior); an explicit size==0 is the metadata
|
|
# phase (metaDL), where the byte counter is unreliable noise.
|
|
has_metadata = size is None or int(size) > 0
|
|
|
|
# Real forward progress: first sighting, metadata just arrived, or (only
|
|
# once we have metadata) more payload bytes. Byte bumps during the
|
|
# metadata phase are protocol noise and do NOT count.
|
|
progressed = (
|
|
self._had_metadata is None # first poll
|
|
or (has_metadata and not self._had_metadata) # got metadata
|
|
or (has_metadata and downloaded > self._last_downloaded) # more payload
|
|
)
|
|
self._had_metadata = has_metadata
|
|
self._last_downloaded = downloaded
|
|
|
|
if progressed:
|
|
self._progress_since = now
|
|
return False
|
|
|
|
# Not in a working state → not a stall (seeding/paused/completed).
|
|
if state not in STALLABLE_STATES:
|
|
self._progress_since = now # don't accrue stall time while idle-by-design
|
|
return False
|
|
|
|
if self._progress_since is None:
|
|
self._progress_since = now
|
|
return False
|
|
|
|
return (now - self._progress_since) >= self.timeout
|