soulsync/tests/test_runtime_state.py
dev 05b36704c3 fix(post-processing): prevent double-claim race that fails imported tracks
Two subsystems post-process the same completed transfer: the browser-poll
status endpoint (web_server) and the background download monitor. Both watch
the same slskd/streaming transfers and each launches the verification
pipeline. When one path quarantines + requeues the next-best candidate
(clearing username/filename, status -> 'searching'), the monitor's
already-submitted run_post_processing_worker then runs, finds no source info,
and falsely marks the task 'failed' ("missing file or source information") —
clobbering the in-flight retry while a parallel attempt imports the song.

Fix: a single atomic claim (downloading/queued -> post_processing under
tasks_lock) so exactly one path processes each download.

- runtime_state: new claim_for_post_processing() helper
- post_processing: race guard — worker bails (no fail/notify) if the task is
  no longer 'post_processing' when it runs
- web_server: both poll paths (Soulseek + streaming) claim before launching;
  claim is released on thread-launch failure

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:03:33 +02:00

63 lines
2.7 KiB
Python

import pytest
import core.runtime_state as runtime_state
def test_mark_task_completed_requires_tasks_lock():
original_tasks = dict(runtime_state.download_tasks)
runtime_state.download_tasks.clear()
runtime_state.download_tasks["task-1"] = {"status": "running", "stream_processed": False}
try:
with pytest.raises(RuntimeError, match="tasks_lock"):
runtime_state.mark_task_completed("task-1")
finally:
runtime_state.download_tasks.clear()
runtime_state.download_tasks.update(original_tasks)
def test_mark_task_completed_succeeds_when_lock_held():
original_tasks = dict(runtime_state.download_tasks)
runtime_state.download_tasks.clear()
runtime_state.download_tasks["task-1"] = {"status": "running", "stream_processed": False}
try:
with runtime_state.tasks_lock:
assert runtime_state.mark_task_completed("task-1", {"name": "Song One"}) is True
assert runtime_state.download_tasks["task-1"]["status"] == "completed"
assert runtime_state.download_tasks["task-1"]["stream_processed"] is True
assert runtime_state.download_tasks["task-1"]["track_info"] == {"name": "Song One"}
finally:
runtime_state.download_tasks.clear()
runtime_state.download_tasks.update(original_tasks)
@pytest.fixture
def _isolated_tasks():
original_tasks = dict(runtime_state.download_tasks)
runtime_state.download_tasks.clear()
yield runtime_state.download_tasks
runtime_state.download_tasks.clear()
runtime_state.download_tasks.update(original_tasks)
@pytest.mark.parametrize("start_status", ["downloading", "queued"])
def test_claim_for_post_processing_wins_from_active_status(_isolated_tasks, start_status):
_isolated_tasks["t1"] = {"status": start_status}
assert runtime_state.claim_for_post_processing("t1") is True
assert _isolated_tasks["t1"]["status"] == "post_processing"
@pytest.mark.parametrize("start_status", ["post_processing", "searching", "completed", "failed", "cancelled"])
def test_claim_for_post_processing_loses_when_already_owned(_isolated_tasks, start_status):
"""A task already being post-processed by the monitor (post_processing),
requeued by a quarantine retry (searching), or already terminal must NOT be
re-claimed — that is the double-processing race that produced the bogus
'missing file or source information' failure."""
_isolated_tasks["t1"] = {"status": start_status}
assert runtime_state.claim_for_post_processing("t1") is False
assert _isolated_tasks["t1"]["status"] == start_status # untouched
def test_claim_for_post_processing_missing_task_returns_false(_isolated_tasks):
assert runtime_state.claim_for_post_processing("absent") is False