diff --git a/worker/lyra_worker/adapters/_slskd.py b/worker/lyra_worker/adapters/_slskd.py index 2548c79..8766db4 100644 --- a/worker/lyra_worker/adapters/_slskd.py +++ b/worker/lyra_worker/adapters/_slskd.py @@ -12,9 +12,25 @@ _DEFAULT_DOWNLOADS_ROOT = "/slskd-downloads" _AUDIO_EXT = {"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac"} _LOSSLESS_EXT = {"flac", "wav"} _SEARCH_POLLS = 40 # * 3s ≈ 2 min -_MAX_XFER_POLLS = 1200 # * 3s ≈ 60 min absolute backstop for a slow-but-progressing peer _STALL_POLLS = 30 # * 3s ≈ 90s of no byte progress → abandon a queued/dead peer and fall through _POLL_SECONDS = 3 +# Absolute backstop for a slow-but-progressing peer. Scaled to the album's total bytes (a flat +# cap starved big albums: a 500MB set from a ~400KB/s serial peer needs ~60-70min and was cut at +# a flat 60min, then re-downloaded from scratch — never finishing). Budget at a conservative floor +# throughput, clamped. The 90s stall timeout still abandons a truly dead/queued peer quickly. +_MIN_XFER_POLLS = 400 # * 3s ≈ 20 min floor (small albums) +_MAX_XFER_POLLS = 2400 # * 3s ≈ 2 h ceiling (bounds worst-case slot hold) +_BACKSTOP_FLOOR_BPS = 64_000 # budget the backstop assuming >= ~64 KB/s sustained + + +def _max_polls(total_bytes: int) -> int: + """Poll budget (each _POLL_SECONDS) for the absolute download backstop, scaled to album size at + a conservative floor throughput so a large slow-but-healthy transfer isn't abandoned mid-flight. + Clamped to [_MIN_XFER_POLLS, _MAX_XFER_POLLS]. Pure — unit-tested.""" + if total_bytes <= 0: + return _MIN_XFER_POLLS + budget = int(total_bytes / (_BACKSTOP_FLOOR_BPS * _POLL_SECONDS)) + return max(_MIN_XFER_POLLS, min(budget, _MAX_XFER_POLLS)) def _basename(path: str) -> str: @@ -242,7 +258,7 @@ class SlskdClient: speed = 0.0 # bytes/sec, EWMA-smoothed so the ETA doesn't jitter poll to poll prev_bytes = 0 prev_t = time.monotonic() - for _ in range(_MAX_XFER_POLLS): + for _ in range(_max_polls(total_bytes)): time.sleep(_POLL_SECONDS) data = self._get(f"/api/v0/transfers/downloads/{username}") states: list[str] = [] diff --git a/worker/tests/test_slskd_cancel.py b/worker/tests/test_slskd_cancel.py index 29157ed..60255c1 100644 --- a/worker/tests/test_slskd_cancel.py +++ b/worker/tests/test_slskd_cancel.py @@ -8,8 +8,12 @@ import pytest import lyra_worker.adapters._slskd as slskd_mod from lyra_worker.adapters._slskd import ( _ETA_CAP_SECONDS, + _MAX_XFER_POLLS, + _MIN_XFER_POLLS, + _POLL_SECONDS, SlskdClient, _eta_seconds, + _max_polls, _parse_search_responses, ) @@ -162,6 +166,25 @@ def test_eta_seconds_unknown_cases(): assert _eta_seconds(1000, 1000, 100.0) is None # already complete +def test_max_polls_scales_backstop_to_album_size(): + # A 521MB album (Drake "Scorpion", 25 FLACs) from a slow serial peer must get far more than + # the old flat 1200-poll/60min budget, or it gets abandoned mid-download and restarts from zero. + scorpion_bytes = 521_000_000 + polls = _max_polls(scorpion_bytes) + assert polls > 1200 # more headroom than the old flat cap + assert polls * _POLL_SECONDS >= 90 * 60 # at least ~90 min of wall-clock budget + + # A tiny single stays on the floor, not zero. + assert _max_polls(3_000_000) == _MIN_XFER_POLLS + assert _max_polls(0) == _MIN_XFER_POLLS + + # An enormous set is clamped to the ceiling (bounds worst-case slot hold). + assert _max_polls(50_000_000_000) == _MAX_XFER_POLLS + + # Monotonic: bigger album never gets a smaller budget. + assert _max_polls(200_000_000) <= _max_polls(400_000_000) + + def test_eta_seconds_capped_when_peer_nearly_stalls(): # A near-stalled peer (tiny speed) would yield an ETA far past int4, overflowing the # downloadEtaSeconds column write. The estimate must be clamped to a safe ceiling.