soulsync/tests/downloads
Broque Thomas 5c69b853b4 Bound slskd HTTP timeout — fixes worker thread deadlock
GitHub issue #499 (@bafoed). Big initial sync of Spotify playlists
worked for 2-3 hours then downloads silently stopped:

- 3 active tasks stuck in "Searching" state, replaced every ~10 min
  by different ones
- slskd UI showed no actual searches happening
- Debug log: orphaned-task count grew over time, no jobs executed
- Container restart was the only fix (bought another 2-3 hours)
- Not a rate limit (rates showed 0/min)

Root cause: ``core/soulseek_client.py`` constructed
``aiohttp.ClientSession()`` with no timeout at four sites. When slskd
hung on a request (overloaded, transient network blip, internal
stall), the HTTP call blocked indefinitely — and the worker thread
blocked with it. The download executor only has
``ThreadPoolExecutor(max_workers=3)``, so once 3 worker threads were
wedged on hung calls, no further downloads could start.

Batch-level "stuck detection" (10-minute timer in
``check_batch_completion_v2``) was correctly marking tasks
``not_found`` and trying to start replacements, but the executor pool
was exhausted — replacements queued forever inside the executor with
no thread to run them. Symptom: tasks rotating every ~10 min at the
batch level while the underlying executor stayed wedged.

Fix: bounded ``aiohttp.ClientTimeout`` (total 120s, connect 15s,
sock_read 60s) on every slskd ``ClientSession`` construction. Module-
level constant ``_SLSKD_DEFAULT_TIMEOUT`` so the four sites stay in
lockstep — future sites get the same protection by reusing the
constant.

Why these timeouts are safe:

- Every slskd API call is metadata-level (search submission, status
  polls, download enqueue, transfer state queries). None stream
  files — slskd handles file transfer via its own peer-to-peer
  infrastructure entirely outside our HTTP requests.
- Legitimate metadata calls finish in seconds. 120s ceiling is
  ~50× the normal latency.

Timeout handling:

- ``asyncio.TimeoutError`` caught explicitly BEFORE the generic
  ``except Exception`` — surfaces "slskd timed out" specifically in
  logs (debuggable instead of buried as "Error making API request").
- Returns None to the caller (same code path as a 5xx response or
  any other failure). No new error path; callers already handle
  None as "request failed".
- Worker thread unblocks immediately → executor pool stays healthy
  → downloads keep flowing.

Sites updated:

- ``_make_request`` (general /api/v0/ helper, line 152) — used for
  every slskd API operation
- ``_make_direct_request`` (non-/api/v0/ helper, line 235)
- ``_explore_api_endpoints`` Swagger fetch (line 1566) — diagnostic
- ``_explore_api_endpoints`` per-endpoint probe (line 1617) —
  diagnostic

Tests: 3 new tests in ``tests/downloads/test_soulseek_pinning.py``
pin:

- ``_SLSKD_DEFAULT_TIMEOUT`` is bounded (total set, ≤300s ceiling,
  connect ≤60s) — guards against future regressions that drop or
  unbound the timeout
- ``_make_request`` returns None on ``asyncio.TimeoutError`` rather
  than raising — pins the caller contract
- ``_make_direct_request`` returns None on ``asyncio.TimeoutError``

2185/2185 full suite green.

Closes #499.
2026-05-06 22:02:25 -07:00
..
__init__.py PR4a: lift sync history recording to core/downloads/history.py 2026-04-27 20:37:16 -07:00
test_background_download_worker.py Add 5 test additions JohnBaumb suggested 2026-05-05 13:04:18 -07:00
test_deezer_pinning.py Surface engine-not-wired errors + exclude soulseek from monitor aggregation 2026-05-05 12:20:51 -07:00
test_download_engine.py Add 5 test additions JohnBaumb suggested 2026-05-05 13:04:18 -07:00
test_download_orchestrator.py Add 5 test additions JohnBaumb suggested 2026-05-05 13:04:18 -07:00
test_downloads_cancel.py PR4b: lift cancel + clear download routes to core/downloads/cancel.py 2026-04-27 21:41:35 -07:00
test_downloads_candidates.py Cin-6: Rename soulseek_client global → download_orchestrator 2026-05-04 23:23:32 -07:00
test_downloads_cleanup.py PR4c: lift _automatic_wishlist_cleanup_after_db_update to core/downloads/cleanup.py 2026-04-27 22:23:41 -07:00
test_downloads_history.py PR4a: lift sync history recording to core/downloads/history.py 2026-04-27 20:37:16 -07:00
test_downloads_lifecycle.py Rename wishlist lifecycle helper 2026-04-30 08:06:02 +03:00
test_downloads_master.py Cin-6: Rename soulseek_client global → download_orchestrator 2026-05-04 23:23:32 -07:00
test_downloads_post_processing.py Cin-6: Rename soulseek_client global → download_orchestrator 2026-05-04 23:23:32 -07:00
test_downloads_staging.py Lift _try_staging_match to core/downloads/staging.py 2026-04-28 23:26:15 -07:00
test_downloads_status.py PR4e: lift status helpers + 3 routes to core/downloads/status.py 2026-04-27 23:26:26 -07:00
test_downloads_task_worker.py Filter SoundCloud previews at every entry point + fix hybrid fallback regression 2026-05-05 10:26:25 -07:00
test_downloads_validation.py Filter SoundCloud previews at every entry point + fix hybrid fallback regression 2026-05-05 10:26:25 -07:00
test_hifi_pinning.py Surface engine-not-wired errors + exclude soulseek from monitor aggregation 2026-05-05 12:20:51 -07:00
test_lidarr_pinning.py A8: Pin LidarrDownloadClient download lifecycle behavior 2026-05-04 12:10:49 -07:00
test_qobuz_pinning.py Surface engine-not-wired errors + exclude soulseek from monitor aggregation 2026-05-05 12:20:51 -07:00
test_rate_limit_policy.py E1: Add RateLimitPolicy declaration mechanism 2026-05-04 14:38:20 -07:00
test_soulseek_pinning.py Bound slskd HTTP timeout — fixes worker thread deadlock 2026-05-06 22:02:25 -07:00
test_soundcloud_pinning.py Surface engine-not-wired errors + exclude soulseek from monitor aggregation 2026-05-05 12:20:51 -07:00
test_tidal_pinning.py Surface engine-not-wired errors + exclude soulseek from monitor aggregation 2026-05-05 12:20:51 -07:00
test_youtube_pinning.py Surface engine-not-wired errors + exclude soulseek from monitor aggregation 2026-05-05 12:20:51 -07:00