noldevin's first torrent was stuck "downloading metadata" — a dead magnet with no peers. The poll loop would ride the full album deadline (6h default) on it, holding the worker the whole time, with no built-in escape. New stall handling, off the existing poll loop: - core/download_plugins/torrent_stall.py — pure StallTracker (clock injected, no I/O): forward byte progress resets a stall clock; once a torrent spends the stall timeout in a working state (queued/downloading/stalled/error) with zero progress, it's stalled. seeding/completed/paused never count. Covers the metadata-stuck case (0 bytes, 0 progress) and a dead mid-download swarm with one rule. - _handle_stalled: 'abandon' (default) removes the torrent + its partial data (a metadata stub is junk) and fails the download so the next source can try; 'pause' parks it in the client for the user. Adapter errors are swallowed — the download still fails cleanly. - two settings (download_source.torrent_stall_timeout_seconds = 600, torrent_stall_action = 'abandon'); timeout 0 disables, restoring the old ride-the-deadline behavior. Config-key driven, matching the existing album_bundle_* tuning knobs (no UI form, same as those). Tests: 18 on the tracker + settings (timeout trip, progress reset, idle-state exemption, pause→resume clock restart, disable, parse tolerance) + 3 on the plugin action path (abandon removes w/ delete_files, pause pauses, adapter error survived). 158 torrent-family tests pass.
107 lines
4.5 KiB
Python
107 lines
4.5 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._progress_since = None # monotonic time of last forward movement
|
|
|
|
def is_stalled(self, downloaded: int, state: str, now: float) -> bool:
|
|
"""Record this poll's observation; return True iff the torrent has
|
|
gone ``timeout`` seconds with no byte progress while in a state
|
|
that's supposed to be downloading.
|
|
|
|
``downloaded`` is cumulative bytes; ``state`` is the adapter-uniform
|
|
state; ``now`` is a monotonic timestamp (seconds)."""
|
|
if self.timeout <= 0:
|
|
return False
|
|
|
|
downloaded = int(downloaded or 0)
|
|
|
|
# Forward progress (or first sighting) resets the stall clock.
|
|
if self._last_downloaded < 0 or downloaded > self._last_downloaded:
|
|
self._last_downloaded = downloaded
|
|
self._progress_since = now
|
|
return False
|
|
self._last_downloaded = downloaded
|
|
|
|
# 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
|