soulsync/core/downloads/cancel.py
Broque Thomas a685f9ca4a diag: log every cancel_download caller with a trigger label
Diagnostic-only change for issue Technodude reported: Tidal sync-playlist
downloads getting mass-cancelled mid-flight with no clear cause in the
logs. App.log shows ~91 second gaps between Tidal download start and
cancel — matches the monitor's 90s queue-timeout exactly — but none of
the monitor's WARNING log lines fire, so the trigger is ambiguous
between five `_should_retry_task` paths, three web_server cancel paths,
and the API endpoints.

Added a single `[CancelTrigger:<label>]` INFO log line immediately
before every `download_orchestrator.cancel_download(...)` call so the
next log dump pins down which path is firing.

Labels (grep-able, prefix tells the file, suffix tells the trigger):

  monitor.not_in_live_transfers_90s
  monitor.errored_state_retry
  monitor.queued_state_timeout
  monitor.stuck_at_0pct_timeout
  monitor.unknown_state_no_progress_timeout
  candidates.worker_cancelled_during_download
  web.orphan_cleanup
  web.cancel_download_task
  web.atomic_cancel_v2
  api.manual_cancel_single
  api.public_cancel

The monitor's `deferred_ops` tuple grew from 3 elements to 4 (added
trigger label as last element). The dispatch loop unpacks both legacy
and new shapes so the change is backward-compatible for any in-flight
ops mid-deploy.

Zero behavior change. 367 download tests still green. WHATS_NEW left
untouched — diagnostic only, not user-facing.

After ship: ask Technodude to re-run the same sync playlist scenario,
attach the new app.log, grep `[CancelTrigger:` lines for the trigger
context, then write the actual fix.
2026-05-19 22:31:29 -07:00

106 lines
3.8 KiB
Python

"""Download cancellation + clear helpers.
Four discrete operations lifted from web_server.py:
- `cancel_single_download(client, run_async, download_id, username)` — cancel
one slskd transfer.
- `cancel_all_active(client, run_async, sweep_callback)` — cancel every
active slskd transfer, then clear the now-cancelled ones, then sweep
empty download directories.
- `clear_finished_active(client, run_async, sweep_callback)` — clear all
terminal transfers from slskd (no cancel step), sweep dirs.
- `clear_completed_local()` — prune terminal-status tasks from the
local `download_tasks` tracker, drop empty batches, drop their locks.
Pure local mutation, doesn't touch slskd.
The slskd-touching helpers take the soulseek client and run_async callback
explicitly; the local helper imports its globals directly from
`core.runtime_state` since those are module-level shared state and every
caller sees the same dict.
Out of scope for this PR (deferred to the batch-lifecycle lift):
- `cancel_download_task` (calls _on_download_completed)
- `cancel_task_v2` + `_atomic_cancel_task` (manipulate batch active_count)
"""
from __future__ import annotations
import logging
from typing import Callable
from core.runtime_state import (
batch_locks,
download_batches,
download_tasks,
tasks_lock,
)
logger = logging.getLogger(__name__)
_TERMINAL_STATUSES = {
'completed', 'failed', 'not_found', 'cancelled', 'skipped', 'already_owned',
}
def cancel_single_download(download_orchestrator, run_async: Callable,
download_id: str, username: str) -> bool:
"""Cancel one specific slskd download (with `remove=True`)."""
logger.info(
f"[CancelTrigger:api.manual_cancel_single] download_id={download_id} username={username}"
)
return run_async(download_orchestrator.cancel_download(download_id, username, remove=True))
def cancel_all_active(download_orchestrator, run_async: Callable,
sweep_callback: Callable[[], None]) -> tuple[bool, str]:
"""Cancel every active slskd download, clear the resulting ones, sweep dirs.
Returns `(success, message)` so the route can map to the right HTTP shape.
"""
cancel_success = run_async(download_orchestrator.cancel_all_downloads())
if not cancel_success:
return False, "Failed to cancel active downloads."
run_async(download_orchestrator.clear_all_completed_downloads())
sweep_callback()
return True, "All downloads cancelled and cleared."
def clear_finished_active(download_orchestrator, run_async: Callable,
sweep_callback: Callable[[], None]) -> bool:
"""Clear all terminal transfers from slskd, sweep dirs on success."""
success = run_async(download_orchestrator.clear_all_completed_downloads())
if success:
sweep_callback()
return success
def clear_completed_local() -> int:
"""Remove completed/failed/cancelled tasks from the local tracker.
Also prunes batches whose queues are now empty, and removes the matching
`batch_locks` entry. Returns the number of cleared tasks.
"""
cleared = 0
with tasks_lock:
task_ids_to_remove = [
tid for tid, task in download_tasks.items()
if task.get('status') in _TERMINAL_STATUSES
]
for tid in task_ids_to_remove:
del download_tasks[tid]
cleared += 1
empty_batches = []
for bid, batch in download_batches.items():
remaining = [t for t in batch.get('queue', []) if t in download_tasks]
if not remaining:
empty_batches.append(bid)
else:
batch['queue'] = remaining
for bid in empty_batches:
del download_batches[bid]
if bid in batch_locks:
del batch_locks[bid]
return cleared