Live testing surfaced that slskd reports a peer-side abort as 'Completed, Aborted' at 0 bytes (peer accepts then drops every transfer). That string contains 'Completed', so the poll's completed-branch ran first and misread it as 'completed but file missing' — routing it into the #715 unresolved/download_path grace (gives up after 45s with a misleading 'download_path mismatch' log) instead of recognizing it as a failure. Add 'Aborted' and 'Cancelled' to the failure-token check (which runs before the completed branch), so these resolve immediately and correctly as failed. Test added for the all-aborted folder.
162 lines
6.7 KiB
Python
162 lines
6.7 KiB
Python
"""Regression for the Soulseek album-bundle poll hanging when the peer stalls.
|
|
|
|
`_poll_album_bundle_downloads` waits for every transfer in the selected folder
|
|
to reach a terminal state (completed or failed). A transfer the peer stalls on —
|
|
stuck InProgress/Queued, or dropped by slskd — is never failed, never completed,
|
|
and never marked "completed-but-unresolved", so it used to block both the
|
|
all-terminal finish check AND the #715 grace exit, and the poll spun to the full
|
|
~6h timeout (the Slipknot hang).
|
|
|
|
The fix adds a bundle-level stall guard: if NOTHING progresses (no transfer
|
|
reaches terminal AND no pending transfer's byte count moves) for `_stall_grace`
|
|
seconds, the stuck transfers are marked failed so the bundle resolves with what
|
|
completed. These tests drive the real poll with a fake clock + scripted slskd
|
|
states.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
from core.soulseek_client import SoulseekClient
|
|
|
|
|
|
class _Clock:
|
|
def __init__(self):
|
|
self.now = 0.0
|
|
|
|
def monotonic(self):
|
|
return self.now
|
|
|
|
def sleep(self, seconds):
|
|
self.now += seconds
|
|
|
|
|
|
def _dl(username, filename, state, *, size=100, transferred=100):
|
|
return SimpleNamespace(
|
|
username=username, filename=filename, state=state,
|
|
size=size, transferred=transferred,
|
|
)
|
|
|
|
|
|
class _StubClient:
|
|
"""Minimal stand-in for SoulseekClient with just what the poll touches."""
|
|
|
|
def __init__(self, states, resolvable):
|
|
self._states = states # list of download-lists, one per poll; last repeats
|
|
self._call = 0
|
|
self._resolvable = set(resolvable)
|
|
|
|
def get_all_downloads(self):
|
|
i = min(self._call, len(self._states) - 1)
|
|
self._call += 1
|
|
return self._states[i]
|
|
|
|
def _resolve_downloaded_album_file(self, filename):
|
|
base = os.path.basename((filename or "").replace("\\", "/"))
|
|
if base in self._resolvable or filename in self._resolvable:
|
|
return Path(f"/staged/{base}")
|
|
return None
|
|
|
|
|
|
def _run_poll(stub, transfer_keys, *, timeout=7200.0, interval=2.0):
|
|
clock = _Clock()
|
|
emits = []
|
|
with patch("core.soulseek_client.time", clock), \
|
|
patch("core.soulseek_client.run_async", lambda x: x), \
|
|
patch("core.soulseek_client.get_poll_timeout", lambda: timeout), \
|
|
patch("core.soulseek_client.get_poll_interval", lambda: interval):
|
|
result = SoulseekClient._poll_album_bundle_downloads(
|
|
stub, transfer_keys, lambda phase, **kw: emits.append((phase, kw))
|
|
)
|
|
return result, clock, emits
|
|
|
|
|
|
def _keys(*names, user="peer"):
|
|
"""Build {(user, name): TrackResult-ish} preserving order."""
|
|
return {(user, n): SimpleNamespace(filename=n) for n in names}
|
|
|
|
|
|
def test_stalled_peer_gives_up_and_returns_completed_subset():
|
|
tk = _keys("01.flac", "02.flac", "03.flac")
|
|
# 01 completes (resolvable); 02/03 stuck InProgress with FROZEN byte counts.
|
|
frozen = [
|
|
_dl("peer", "01.flac", "Completed", transferred=100, size=100),
|
|
_dl("peer", "02.flac", "InProgress", transferred=50, size=100),
|
|
_dl("peer", "03.flac", "InProgress", transferred=30, size=100),
|
|
]
|
|
stub = _StubClient([frozen], resolvable={"01.flac"})
|
|
result, clock, _ = _run_poll(stub, tk, timeout=7200.0, interval=2.0)
|
|
|
|
# Resolved with the one completed track instead of hanging to the deadline.
|
|
assert result == [Path("/staged/01.flac")]
|
|
# Gave up around the stall window (~180s), nowhere near the 7200s timeout.
|
|
assert clock.now < 600.0
|
|
|
|
|
|
def test_progressing_bundle_is_not_falsely_stalled():
|
|
tk = _keys("01.flac", "02.flac")
|
|
# 02 keeps downloading more bytes each poll, then completes — must NOT trip
|
|
# the stall guard even though it takes a while.
|
|
states = [
|
|
[_dl("peer", "01.flac", "Completed"), _dl("peer", "02.flac", "InProgress", transferred=10, size=100)],
|
|
[_dl("peer", "01.flac", "Completed"), _dl("peer", "02.flac", "InProgress", transferred=40, size=100)],
|
|
[_dl("peer", "01.flac", "Completed"), _dl("peer", "02.flac", "InProgress", transferred=80, size=100)],
|
|
[_dl("peer", "01.flac", "Completed"), _dl("peer", "02.flac", "Completed", transferred=100, size=100)],
|
|
]
|
|
stub = _StubClient(states, resolvable={"01.flac", "02.flac"})
|
|
result, _clock, _ = _run_poll(stub, tk, timeout=7200.0, interval=2.0)
|
|
|
|
assert set(result) == {Path("/staged/01.flac"), Path("/staged/02.flac")}
|
|
|
|
|
|
def test_all_transfers_stalled_returns_empty():
|
|
tk = _keys("01.flac", "02.flac")
|
|
frozen = [
|
|
_dl("peer", "01.flac", "InProgress", transferred=10, size=100),
|
|
_dl("peer", "02.flac", "Queued", transferred=0, size=100),
|
|
]
|
|
stub = _StubClient([frozen], resolvable=set())
|
|
result, clock, _ = _run_poll(stub, tk, timeout=7200.0, interval=2.0)
|
|
|
|
assert result == [] # nothing completed → empty (caller falls back)
|
|
assert clock.now < 600.0 # didn't spin to the deadline
|
|
|
|
|
|
def test_dropped_transfers_also_stall_out():
|
|
"""slskd dropping the transfers entirely (dl=None) must also trip the guard,
|
|
not hang — there's no byte progress and nothing terminal."""
|
|
tk = _keys("01.flac", "02.flac")
|
|
stub = _StubClient([[]], resolvable=set()) # get_all_downloads returns nothing
|
|
result, clock, _ = _run_poll(stub, tk, timeout=7200.0, interval=2.0)
|
|
|
|
assert result == []
|
|
assert clock.now < 600.0
|
|
|
|
|
|
def test_completed_aborted_classified_as_failed_not_unresolved():
|
|
"""slskd reports a peer-side abort as 'Completed, Aborted' at 0 bytes. Because
|
|
that string contains 'Completed', it was misread as 'completed but file
|
|
missing' (#715 path). It must be classified as FAILED — so an all-aborted
|
|
folder resolves immediately, not after the unresolved/stall grace."""
|
|
tk = _keys("01.flac", "02.flac")
|
|
aborted = [
|
|
_dl("peer", "01.flac", "Completed, Aborted", transferred=0, size=3_600_000),
|
|
_dl("peer", "02.flac", "Completed, Aborted", transferred=0, size=24_700_000),
|
|
]
|
|
stub = _StubClient([aborted], resolvable=set())
|
|
result, clock, _ = _run_poll(stub, tk, timeout=7200.0, interval=2.0)
|
|
assert result == [] # all failed → empty (caller falls back)
|
|
assert clock.now < 30.0 # resolved on the first poll, no 45s/180s wait
|
|
|
|
|
|
def test_clean_finish_unaffected():
|
|
tk = _keys("01.flac", "02.flac")
|
|
done = [_dl("peer", "01.flac", "Completed"), _dl("peer", "02.flac", "Succeeded")]
|
|
stub = _StubClient([done], resolvable={"01.flac", "02.flac"})
|
|
result, clock, _ = _run_poll(stub, tk)
|
|
assert set(result) == {Path("/staged/01.flac"), Path("/staged/02.flac")}
|
|
assert clock.now < 10.0 # resolves on the first couple polls
|