mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-29 08:51:27 -03:00
7a36659a20
A failed aria2 transfer deleted the partial payload while keeping its .aria2 control file, and "No URI available" (expired CivitAI signed URL) was treated as a permanent failure, wasting nearly-complete downloads. - Re-schedule the transfer with a freshly resolved signed URL and continue=true when aria2 reports "No URI available", bounded by MAX_TRANSFER_RECOVERY_ATTEMPTS - Keep payload and .aria2 control file together as a resumable pair after a failed transfer instead of deleting the payload - Report and remove orphaned .aria2 control files that have no payload, both after failures and when restoring persisted downloads Fixes #1088
1075 lines
36 KiB
Python
1075 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from py.services.aria2_downloader import (
|
|
Aria2Downloader,
|
|
Aria2Error,
|
|
Aria2Transfer,
|
|
MAX_TRANSFER_RECOVERY_ATTEMPTS,
|
|
)
|
|
from py.services.aria2_transfer_state import Aria2TransferStateStore
|
|
from py.services import aria2_transfer_state
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolate_aria2_state(monkeypatch, tmp_path):
|
|
state_path = tmp_path / "cache" / "aria2" / "downloads.json"
|
|
monkeypatch.setattr(
|
|
aria2_transfer_state,
|
|
"get_aria2_state_path",
|
|
lambda: str(state_path),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_file_polls_until_complete(tmp_path, monkeypatch):
|
|
downloader = Aria2Downloader()
|
|
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
|
downloader._rpc_secret = "secret"
|
|
|
|
save_path = tmp_path / "downloads" / "model.safetensors"
|
|
progress_events = []
|
|
rpc_calls = []
|
|
statuses = iter(
|
|
[
|
|
{
|
|
"gid": "gid-1",
|
|
"status": "active",
|
|
"completedLength": "5",
|
|
"totalLength": "10",
|
|
"downloadSpeed": "25",
|
|
},
|
|
{
|
|
"gid": "gid-1",
|
|
"status": "complete",
|
|
"completedLength": "10",
|
|
"totalLength": "10",
|
|
"downloadSpeed": "0",
|
|
"files": [{"path": str(save_path)}],
|
|
},
|
|
]
|
|
)
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
rpc_calls.append((method, params))
|
|
if method == "aria2.addUri":
|
|
return "gid-1"
|
|
if method == "aria2.tellStatus":
|
|
return next(statuses)
|
|
raise AssertionError(f"Unexpected RPC method: {method}")
|
|
|
|
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
|
monkeypatch.setattr(
|
|
downloader,
|
|
"_resolve_authenticated_redirect_url",
|
|
AsyncMock(
|
|
return_value="https://signed.example.com/model.safetensors?token=abc"
|
|
),
|
|
)
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
async def progress_callback(progress, snapshot=None):
|
|
progress_events.append(snapshot.percent_complete if snapshot else progress)
|
|
|
|
success, result = await downloader.download_file(
|
|
"https://civitai.com/api/download/models/123",
|
|
str(save_path),
|
|
download_id="download-1",
|
|
progress_callback=progress_callback,
|
|
headers={"Authorization": "Bearer token"},
|
|
)
|
|
|
|
assert success is True
|
|
assert result == str(save_path)
|
|
assert progress_events == [50.0, 100.0]
|
|
assert downloader._transfers == {}
|
|
assert rpc_calls[0][0] == "aria2.addUri"
|
|
assert rpc_calls[0][1][0] == [
|
|
"https://signed.example.com/model.safetensors?token=abc"
|
|
]
|
|
assert rpc_calls[0][1][1]["out"] == "model.safetensors"
|
|
assert "header" not in rpc_calls[0][1][1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transfer_state_store_shares_lock_and_preserves_concurrent_updates(tmp_path):
|
|
state_path = tmp_path / "cache" / "aria2" / "downloads.json"
|
|
store_a = Aria2TransferStateStore(str(state_path))
|
|
store_b = Aria2TransferStateStore(str(state_path))
|
|
|
|
assert store_a._lock is store_b._lock
|
|
|
|
await asyncio.gather(
|
|
store_a.upsert("download-1", {"status": "downloading", "gid": "gid-1"}),
|
|
store_b.upsert("download-2", {"status": "paused", "gid": "gid-2"}),
|
|
)
|
|
|
|
assert await store_a.get("download-1") == {"status": "downloading", "gid": "gid-1"}
|
|
assert await store_b.get("download-2") == {"status": "paused", "gid": "gid-2"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_file_keeps_auth_headers_when_civitai_does_not_redirect(
|
|
tmp_path, monkeypatch
|
|
):
|
|
downloader = Aria2Downloader()
|
|
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
|
downloader._rpc_secret = "secret"
|
|
|
|
save_path = tmp_path / "downloads" / "model.safetensors"
|
|
rpc_calls = []
|
|
statuses = iter(
|
|
[
|
|
{
|
|
"gid": "gid-1",
|
|
"status": "complete",
|
|
"completedLength": "10",
|
|
"totalLength": "10",
|
|
"downloadSpeed": "0",
|
|
"files": [{"path": str(save_path)}],
|
|
},
|
|
]
|
|
)
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
rpc_calls.append((method, params))
|
|
if method == "aria2.addUri":
|
|
return "gid-1"
|
|
if method == "aria2.tellStatus":
|
|
return next(statuses)
|
|
raise AssertionError(f"Unexpected RPC method: {method}")
|
|
|
|
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
|
monkeypatch.setattr(
|
|
downloader,
|
|
"_resolve_authenticated_redirect_url",
|
|
AsyncMock(return_value="https://civitai.com/api/download/models/123"),
|
|
)
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
success, result = await downloader.download_file(
|
|
"https://civitai.com/api/download/models/123",
|
|
str(save_path),
|
|
download_id="download-1",
|
|
headers={"Authorization": "Bearer token"},
|
|
)
|
|
|
|
assert success is True
|
|
assert result == str(save_path)
|
|
assert rpc_calls[0][1][0] == ["https://civitai.com/api/download/models/123"]
|
|
assert rpc_calls[0][1][1]["header"] == ["Authorization: Bearer token"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_resume_cancel_forward_to_rpc(monkeypatch):
|
|
downloader = Aria2Downloader()
|
|
downloader._transfers["download-1"] = Aria2Transfer(
|
|
gid="gid-1", save_path="/tmp/model.safetensors"
|
|
)
|
|
|
|
calls = []
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
calls.append((method, params))
|
|
return "gid-1"
|
|
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
|
|
pause_result = await downloader.pause_download("download-1")
|
|
resume_result = await downloader.resume_download("download-1")
|
|
cancel_result = await downloader.cancel_download("download-1")
|
|
|
|
assert pause_result["success"] is True
|
|
assert resume_result["success"] is True
|
|
assert cancel_result["success"] is True
|
|
assert calls == [
|
|
("aria2.forcePause", ["gid-1"]),
|
|
("aria2.unpause", ["gid-1"]),
|
|
("aria2.forceRemove", ["gid-1"]),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_file_reuses_existing_transfer_without_add_uri(
|
|
tmp_path, monkeypatch
|
|
):
|
|
downloader = Aria2Downloader()
|
|
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
|
downloader._rpc_secret = "secret"
|
|
|
|
save_path = tmp_path / "downloads" / "model.safetensors"
|
|
downloader._transfers["download-1"] = Aria2Transfer(
|
|
gid="gid-1", save_path=str(save_path)
|
|
)
|
|
|
|
rpc_calls = []
|
|
statuses = iter(
|
|
[
|
|
{
|
|
"gid": "gid-1",
|
|
"status": "active",
|
|
"completedLength": "5",
|
|
"totalLength": "10",
|
|
"downloadSpeed": "25",
|
|
},
|
|
{
|
|
"gid": "gid-1",
|
|
"status": "complete",
|
|
"completedLength": "10",
|
|
"totalLength": "10",
|
|
"downloadSpeed": "0",
|
|
"files": [{"path": str(save_path)}],
|
|
},
|
|
]
|
|
)
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
rpc_calls.append((method, params))
|
|
if method == "aria2.tellStatus":
|
|
return next(statuses)
|
|
raise AssertionError(f"Unexpected RPC method: {method}")
|
|
|
|
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
success, result = await downloader.download_file(
|
|
"https://example.com/model.safetensors",
|
|
str(save_path),
|
|
download_id="download-1",
|
|
)
|
|
|
|
assert success is True
|
|
assert result == str(save_path)
|
|
assert [call[0] for call in rpc_calls] == ["aria2.tellStatus", "aria2.tellStatus"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_file_recovers_when_transfer_lost_mid_poll(
|
|
tmp_path, monkeypatch
|
|
):
|
|
downloader = Aria2Downloader()
|
|
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
|
downloader._rpc_secret = "secret"
|
|
|
|
save_path = tmp_path / "downloads" / "model.safetensors"
|
|
add_uri_count = {"n": 0}
|
|
poll_count = {"n": 0}
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
if method == "aria2.addUri":
|
|
add_uri_count["n"] += 1
|
|
return "gid-1" if add_uri_count["n"] == 1 else "gid-2"
|
|
if method == "aria2.tellStatus":
|
|
poll_count["n"] += 1
|
|
if poll_count["n"] == 1:
|
|
# Simulate a concurrent close() wiping the transfer mid-poll.
|
|
downloader._transfers.pop("download-1", None)
|
|
return {
|
|
"gid": "gid-1",
|
|
"status": "active",
|
|
"completedLength": "5",
|
|
"totalLength": "10",
|
|
"downloadSpeed": "25",
|
|
}
|
|
return {
|
|
"gid": "gid-2",
|
|
"status": "complete",
|
|
"completedLength": "10",
|
|
"totalLength": "10",
|
|
"downloadSpeed": "0",
|
|
"files": [{"path": str(save_path)}],
|
|
}
|
|
raise AssertionError(f"Unexpected RPC method: {method}")
|
|
|
|
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
success, result = await downloader.download_file(
|
|
"https://example.com/model.safetensors",
|
|
str(save_path),
|
|
download_id="download-1",
|
|
)
|
|
|
|
assert success is True
|
|
assert result == str(save_path)
|
|
assert add_uri_count["n"] == 2
|
|
assert downloader._transfers == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_file_recovers_when_rpc_fails_mid_poll(tmp_path, monkeypatch):
|
|
downloader = Aria2Downloader()
|
|
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
|
downloader._rpc_secret = "secret"
|
|
|
|
save_path = tmp_path / "downloads" / "model.safetensors"
|
|
add_uri_count = {"n": 0}
|
|
poll_count = {"n": 0}
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
if method == "aria2.addUri":
|
|
add_uri_count["n"] += 1
|
|
return "gid-1" if add_uri_count["n"] == 1 else "gid-2"
|
|
raise AssertionError(f"Unexpected RPC method: {method}")
|
|
|
|
async def fake_get_status_with_retry(download_id):
|
|
poll_count["n"] += 1
|
|
if poll_count["n"] == 1:
|
|
raise Aria2Error(
|
|
"Failed to query aria2 download status after 4 attempts: boom"
|
|
)
|
|
return {
|
|
"gid": "gid-2",
|
|
"status": "complete",
|
|
"completedLength": "10",
|
|
"totalLength": "10",
|
|
"downloadSpeed": "0",
|
|
"files": [{"path": str(save_path)}],
|
|
}
|
|
|
|
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
monkeypatch.setattr(downloader, "_get_status_with_retry", fake_get_status_with_retry)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
success, result = await downloader.download_file(
|
|
"https://example.com/model.safetensors",
|
|
str(save_path),
|
|
download_id="download-1",
|
|
)
|
|
|
|
assert success is True
|
|
assert result == str(save_path)
|
|
assert add_uri_count["n"] == 2
|
|
assert downloader._transfers == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_file_fails_after_recovery_attempts_exhausted(
|
|
tmp_path, monkeypatch
|
|
):
|
|
downloader = Aria2Downloader()
|
|
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
|
downloader._rpc_secret = "secret"
|
|
|
|
save_path = tmp_path / "downloads" / "model.safetensors"
|
|
add_uri_count = {"n": 0}
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
if method == "aria2.addUri":
|
|
add_uri_count["n"] += 1
|
|
return f"gid-{add_uri_count['n']}"
|
|
raise AssertionError(f"Unexpected RPC method: {method}")
|
|
|
|
async def fake_get_status(download_id):
|
|
return None # transfer never tracked / always lost
|
|
|
|
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
monkeypatch.setattr(downloader, "get_status", fake_get_status)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
success, result = await downloader.download_file(
|
|
"https://example.com/model.safetensors",
|
|
str(save_path),
|
|
download_id="download-1",
|
|
)
|
|
|
|
assert success is False
|
|
assert result == "aria2 download not found"
|
|
assert add_uri_count["n"] == 1 + MAX_TRANSFER_RECOVERY_ATTEMPTS
|
|
assert downloader._transfers == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_file_concurrent_same_id_schedules_once(tmp_path, monkeypatch):
|
|
downloader = Aria2Downloader()
|
|
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
|
downloader._rpc_secret = "secret"
|
|
|
|
save_path = tmp_path / "downloads" / "model.safetensors"
|
|
add_uri_count = {"n": 0}
|
|
poll_count = {"n": 0}
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
if method == "aria2.addUri":
|
|
add_uri_count["n"] += 1
|
|
return "gid-1"
|
|
if method == "aria2.tellStatus":
|
|
poll_count["n"] += 1
|
|
if poll_count["n"] < 4:
|
|
return {
|
|
"gid": "gid-1",
|
|
"status": "active",
|
|
"completedLength": "5",
|
|
"totalLength": "10",
|
|
"downloadSpeed": "25",
|
|
}
|
|
return {
|
|
"gid": "gid-1",
|
|
"status": "complete",
|
|
"completedLength": "10",
|
|
"totalLength": "10",
|
|
"downloadSpeed": "0",
|
|
"files": [{"path": str(save_path)}],
|
|
}
|
|
raise AssertionError(f"Unexpected RPC method: {method}")
|
|
|
|
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
results = await asyncio.gather(
|
|
downloader.download_file(
|
|
"https://example.com/model.safetensors",
|
|
str(save_path),
|
|
download_id="download-1",
|
|
),
|
|
downloader.download_file(
|
|
"https://example.com/model.safetensors",
|
|
str(save_path),
|
|
download_id="download-1",
|
|
),
|
|
)
|
|
|
|
assert all(success for success, _ in results)
|
|
assert all(result == str(save_path) for _, result in results)
|
|
assert add_uri_count["n"] <= 2
|
|
assert downloader._transfers == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_file_cleanup_preserves_newer_registration(tmp_path, monkeypatch):
|
|
downloader = Aria2Downloader()
|
|
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
|
downloader._rpc_secret = "secret"
|
|
|
|
save_path = tmp_path / "downloads" / "model.safetensors"
|
|
poll_count = {"n": 0}
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
if method == "aria2.addUri":
|
|
return "gid-1"
|
|
if method == "aria2.tellStatus":
|
|
poll_count["n"] += 1
|
|
if poll_count["n"] == 1:
|
|
# Simulate another invocation registering its own transfer.
|
|
downloader._transfers["download-1"] = Aria2Transfer(
|
|
gid="gid-new", save_path=str(save_path)
|
|
)
|
|
return {
|
|
"gid": "gid-1",
|
|
"status": "active",
|
|
"completedLength": "5",
|
|
"totalLength": "10",
|
|
"downloadSpeed": "25",
|
|
}
|
|
return {
|
|
"gid": "gid-new",
|
|
"status": "complete",
|
|
"completedLength": "10",
|
|
"totalLength": "10",
|
|
"downloadSpeed": "0",
|
|
"files": [{"path": str(save_path)}],
|
|
}
|
|
raise AssertionError(f"Unexpected RPC method: {method}")
|
|
|
|
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
success, result = await downloader.download_file(
|
|
"https://example.com/model.safetensors",
|
|
str(save_path),
|
|
download_id="download-1",
|
|
)
|
|
|
|
assert success is True
|
|
assert result == str(save_path)
|
|
assert downloader._transfers["download-1"].gid == "gid-new"
|
|
|
|
|
|
def test_build_progress_snapshot_normalizes_numeric_fields():
|
|
downloader = Aria2Downloader()
|
|
|
|
snapshot = downloader._build_progress_snapshot(
|
|
{
|
|
"completedLength": "75",
|
|
"totalLength": "100",
|
|
"downloadSpeed": "512",
|
|
}
|
|
)
|
|
|
|
assert snapshot.percent_complete == 75.0
|
|
assert snapshot.bytes_downloaded == 75
|
|
assert snapshot.total_bytes == 100
|
|
assert snapshot.bytes_per_second == 512.0
|
|
|
|
|
|
def test_resolve_executable_raises_when_binary_missing(monkeypatch):
|
|
downloader = Aria2Downloader()
|
|
settings = type("Settings", (), {"get": lambda self, key, default=None: ""})()
|
|
|
|
monkeypatch.setattr("py.services.aria2_downloader.get_settings_manager", lambda: settings)
|
|
monkeypatch.setattr("py.services.aria2_downloader.shutil.which", lambda _: None)
|
|
|
|
with pytest.raises(Aria2Error):
|
|
downloader._resolve_executable()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rpc_call_surfaces_json_error_on_non_200(monkeypatch):
|
|
downloader = Aria2Downloader()
|
|
downloader._rpc_url = "http://127.0.0.1:6800/jsonrpc"
|
|
downloader._rpc_secret = "secret"
|
|
|
|
class FakeResponse:
|
|
status = 400
|
|
|
|
async def text(self):
|
|
return (
|
|
'{"jsonrpc":"2.0","id":"x","error":{"code":1,"message":"Unauthorized"}}'
|
|
)
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
class FakeSession:
|
|
def post(self, _url, json=None):
|
|
return FakeResponse()
|
|
|
|
monkeypatch.setattr(downloader, "_get_rpc_session", AsyncMock(return_value=FakeSession()))
|
|
|
|
with pytest.raises(Aria2Error) as exc_info:
|
|
await downloader._rpc_call("aria2.addUri", [["https://example.com/file"]])
|
|
|
|
assert "Unauthorized" in str(exc_info.value)
|
|
assert "aria2.addUri" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_authenticated_redirect_url_returns_location(monkeypatch):
|
|
downloader = Aria2Downloader()
|
|
|
|
class FakeResponse:
|
|
status = 307
|
|
headers = {"Location": "https://signed.example.com/file.safetensors"}
|
|
|
|
async def text(self):
|
|
return ""
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
class FakeSession:
|
|
def get(self, _url, headers=None, allow_redirects=False, proxy=None):
|
|
return FakeResponse()
|
|
|
|
class FakeDownloader:
|
|
default_headers = {"User-Agent": "ComfyUI-LoRA-Manager/1.0"}
|
|
proxy_url = None
|
|
|
|
@property
|
|
def session(self):
|
|
async def _session():
|
|
return FakeSession()
|
|
|
|
return _session()
|
|
|
|
fake_downloader = FakeDownloader()
|
|
|
|
monkeypatch.setattr(
|
|
"py.services.aria2_downloader.get_downloader",
|
|
AsyncMock(return_value=fake_downloader),
|
|
)
|
|
|
|
result = await downloader._resolve_authenticated_redirect_url(
|
|
"https://civitai.com/api/download/models/123",
|
|
{"Authorization": "Bearer token"},
|
|
)
|
|
|
|
assert result == "https://signed.example.com/file.safetensors"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_with_retry_passes_through_success(monkeypatch):
|
|
"""A successful first call returns immediately, no retries."""
|
|
downloader = Aria2Downloader()
|
|
call_count = 0
|
|
|
|
async def fake_get_status(_id):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return {"status": "active", "completedLength": "50", "totalLength": "100"}
|
|
|
|
monkeypatch.setattr(downloader, "get_status", fake_get_status)
|
|
|
|
result = await downloader._get_status_with_retry("dummy")
|
|
assert result is not None
|
|
assert result["status"] == "active"
|
|
assert call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_with_retry_succeeds_after_transient_failure(monkeypatch):
|
|
"""A transient Aria2Error on the first call is retried and succeeds."""
|
|
downloader = Aria2Downloader()
|
|
call_count = 0
|
|
|
|
async def fake_get_status(_id):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
raise Aria2Error("timeout")
|
|
return {"status": "complete", "completedLength": "100", "totalLength": "100"}
|
|
|
|
monkeypatch.setattr(downloader, "get_status", fake_get_status)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
result = await downloader._get_status_with_retry("dummy")
|
|
assert result is not None
|
|
assert result["status"] == "complete"
|
|
assert call_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_with_retry_raises_after_all_retries_exhausted(monkeypatch):
|
|
"""All retry attempts fail → Aria2Error with a descriptive message."""
|
|
downloader = Aria2Downloader()
|
|
|
|
async def fake_get_status(_id):
|
|
raise Aria2Error("connection reset")
|
|
|
|
monkeypatch.setattr(downloader, "get_status", fake_get_status)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
with pytest.raises(Aria2Error) as exc_info:
|
|
await downloader._get_status_with_retry("dummy")
|
|
|
|
msg = str(exc_info.value)
|
|
assert "after 4 attempts" in msg
|
|
assert "connection reset" in msg
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_with_retry_returns_none_when_not_tracked(monkeypatch):
|
|
"""No transfer in _transfers → get_status returns None → no retry needed."""
|
|
downloader = Aria2Downloader()
|
|
|
|
# get_status returns None when the download_id has no transfer;
|
|
# _get_status_with_retry should propagate that without raising.
|
|
result = await downloader._get_status_with_retry("nonexistent")
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_until_ready_includes_stderr_in_error():
|
|
"""When the subprocess exits early, its stderr output must be in Aria2Error."""
|
|
import sys
|
|
|
|
downloader = Aria2Downloader()
|
|
|
|
# Start a subprocess that writes a message to stderr and exits with code 28.
|
|
proc = await asyncio.create_subprocess_exec(
|
|
sys.executable, "-c",
|
|
"import sys; print('ERROR: unknown option --fsync', file=sys.stderr); sys.exit(28)",
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
|
|
# Let the process exit
|
|
await asyncio.sleep(0.2)
|
|
|
|
# Point the downloader at this dead process and let _wait_until_ready
|
|
# discover the exit and read stderr.
|
|
downloader._process = proc
|
|
|
|
with pytest.raises(Aria2Error) as exc_info:
|
|
await downloader._wait_until_ready()
|
|
|
|
msg = str(exc_info.value)
|
|
assert "code 28" in msg
|
|
assert "ERROR: unknown option --fsync" in msg
|
|
|
|
|
|
def test_is_disk_write_error_matches_wrapper_and_cause_lines():
|
|
downloader = Aria2Downloader()
|
|
|
|
assert downloader._is_disk_write_error(
|
|
"[ERROR] [DownloadCommand.cc:127] errorCode=9 Write disk cache flush failure index=18798"
|
|
)
|
|
assert downloader._is_disk_write_error(
|
|
"Exception: [AbstractDiskWriter.cc:454] errNum=28 errorCode=9 "
|
|
"Failed to write into the file D:\\models\\model.safetensors, "
|
|
"cause: No space left on device"
|
|
)
|
|
# Windows: "used by another process" = the file is locked by antivirus etc.
|
|
assert downloader._is_disk_write_error(
|
|
"Exception: ... The process cannot access the file because it is "
|
|
"being used by another process."
|
|
)
|
|
assert not downloader._is_disk_write_error(
|
|
"Download aborted. URI=https://example.com/model.safetensors"
|
|
)
|
|
assert not downloader._is_disk_write_error("")
|
|
assert not downloader._is_disk_write_error("bad option: --fsync")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_stderr_promotes_disk_write_failure_to_info(caplog):
|
|
downloader = Aria2Downloader()
|
|
|
|
class FakeStderr:
|
|
def __init__(self, lines):
|
|
self._lines = list(lines)
|
|
|
|
def __aiter__(self):
|
|
return self
|
|
|
|
async def __anext__(self):
|
|
if not self._lines:
|
|
raise StopAsyncIteration
|
|
return self._lines.pop(0)
|
|
|
|
proc = type(
|
|
"Proc",
|
|
(),
|
|
{
|
|
"stderr": FakeStderr(
|
|
[
|
|
b"",
|
|
b"[ERROR] [WrDiskCacheEntry.cc:83] Error when trying to flush write cache",
|
|
b"Exception: [AbstractDiskWriter.cc:454] errNum=28 errorCode=9 "
|
|
b"Failed to write into the file D:\\models\\model.safetensors, "
|
|
b"cause: No space left on device",
|
|
b"Download aborted. URI=https://example.com/model.safetensors",
|
|
]
|
|
)
|
|
},
|
|
)()
|
|
downloader._process = proc
|
|
|
|
with caplog.at_level(logging.DEBUG, logger="py.services.aria2_downloader"):
|
|
await downloader._drain_stderr()
|
|
|
|
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
|
|
debug_records = [r for r in caplog.records if r.levelno == logging.DEBUG]
|
|
|
|
assert any(
|
|
"Error when trying to flush write cache" in r.message for r in info_records
|
|
)
|
|
assert any("No space left on device" in r.message for r in info_records)
|
|
assert any("Download aborted" in r.message for r in debug_records)
|
|
assert not any("Download aborted" in r.message for r in info_records)
|
|
|
|
|
|
def test_stderr_error_rate_limited_to_one_info_per_window(caplog, monkeypatch):
|
|
downloader = Aria2Downloader()
|
|
line = "Exception: ... cause: No space left on device"
|
|
|
|
with caplog.at_level(logging.DEBUG, logger="py.services.aria2_downloader"):
|
|
downloader._report_stderr_error(line)
|
|
downloader._report_stderr_error(line)
|
|
monkeypatch.setattr(downloader, "_stderr_error_report", {})
|
|
downloader._report_stderr_error(line)
|
|
|
|
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
|
|
debug_records = [r for r in caplog.records if r.levelno == logging.DEBUG]
|
|
|
|
assert len(info_records) == 2
|
|
assert len(debug_records) == 1
|
|
assert "repeated disk write error" in debug_records[0].message
|
|
|
|
|
|
def test_stderr_error_report_prunes_expired_entries():
|
|
downloader = Aria2Downloader()
|
|
old_line = "Exception: ... cause: No space left on device"
|
|
new_line = "Exception: ... cause: Input/output error"
|
|
downloader._stderr_error_report[old_line] = time.monotonic() - 120.0
|
|
|
|
downloader._report_stderr_error(new_line)
|
|
|
|
assert old_line not in downloader._stderr_error_report
|
|
assert new_line in downloader._stderr_error_report
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_returns_none_without_retry_when_gid_not_found(monkeypatch):
|
|
"""A forgotten GID is permanent: no retry attempts on a dead GID."""
|
|
downloader = Aria2Downloader()
|
|
downloader._transfers["download-1"] = Aria2Transfer(
|
|
gid="gone-gid", save_path="/tmp/model.safetensors"
|
|
)
|
|
|
|
calls = []
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
calls.append(method)
|
|
raise Aria2Error("GID gone-gid is not found")
|
|
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
assert await downloader._get_status_with_retry("download-1") is None
|
|
assert calls == ["aria2.tellStatus"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_still_raises_on_transient_rpc_error(monkeypatch):
|
|
downloader = Aria2Downloader()
|
|
downloader._transfers["download-1"] = Aria2Transfer(
|
|
gid="gid-1", save_path="/tmp/model.safetensors"
|
|
)
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
raise Aria2Error("connection reset")
|
|
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
with pytest.raises(Aria2Error, match="Failed to query aria2 download status"):
|
|
await downloader._get_status_with_retry("download-1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_download_pops_transfer_on_success(monkeypatch):
|
|
downloader = Aria2Downloader()
|
|
downloader._transfers["download-1"] = Aria2Transfer(
|
|
gid="gid-1", save_path="/tmp/model.safetensors"
|
|
)
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
return "gid-1"
|
|
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
|
|
result = await downloader.cancel_download("download-1")
|
|
|
|
assert result["success"] is True
|
|
assert "download-1" not in downloader._transfers
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_download_tolerates_missing_gid(monkeypatch):
|
|
"""Cancelling a transfer the daemon already forgot still succeeds."""
|
|
downloader = Aria2Downloader()
|
|
downloader._transfers["download-1"] = Aria2Transfer(
|
|
gid="gone-gid", save_path="/tmp/model.safetensors"
|
|
)
|
|
await downloader._state_store.upsert(
|
|
"download-1", {"gid": "gone-gid", "status": "downloading"}
|
|
)
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
raise Aria2Error("GID gone-gid is not found")
|
|
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
|
|
result = await downloader.cancel_download("download-1")
|
|
|
|
assert result["success"] is True
|
|
assert "download-1" not in downloader._transfers
|
|
assert await downloader._state_store.get("download-1") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rpc_call_suppresses_error_log_when_log_errors_false(
|
|
monkeypatch, caplog
|
|
):
|
|
"""Probing calls must not spam ERROR for an expected failure."""
|
|
|
|
class FakeResponse:
|
|
status = 400
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
async def text(self):
|
|
return '{"jsonrpc": "2.0", "error": {"code": 1, "message": "GID x is not found"}}'
|
|
|
|
class FakeSession:
|
|
closed = False
|
|
|
|
def post(self, *args, **kwargs):
|
|
return FakeResponse()
|
|
|
|
downloader = Aria2Downloader()
|
|
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
|
downloader._rpc_secret = "secret"
|
|
monkeypatch.setattr(downloader, "_get_rpc_session", AsyncMock(return_value=FakeSession()))
|
|
|
|
with caplog.at_level(logging.DEBUG, logger="py.services.aria2_downloader"):
|
|
with pytest.raises(Aria2Error, match="not found"):
|
|
await downloader._rpc_call("aria2.tellStatus", ["x"], log_errors=False)
|
|
|
|
error_records = [r for r in caplog.records if r.levelno == logging.ERROR]
|
|
debug_records = [r for r in caplog.records if r.levelno == logging.DEBUG]
|
|
assert error_records == []
|
|
assert any("GID x is not found" in r.message for r in debug_records)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_file_refreshes_signed_url_on_no_uri_available(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""An expired CivitAI signed URL ("No URI available") is recovered by
|
|
resolving a fresh URL and re-scheduling with continue=true."""
|
|
downloader = Aria2Downloader()
|
|
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
|
downloader._rpc_secret = "secret"
|
|
|
|
save_path = tmp_path / "downloads" / "model.safetensors"
|
|
add_uri_urls = []
|
|
gids = iter(["gid-1", "gid-2"])
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
if method == "aria2.addUri":
|
|
add_uri_urls.append(params[0][0])
|
|
return next(gids)
|
|
if method == "aria2.tellStatus":
|
|
gid = params[0]
|
|
if gid == "gid-1":
|
|
return {
|
|
"gid": "gid-1",
|
|
"status": "error",
|
|
"errorMessage": "No URI available.",
|
|
}
|
|
return {
|
|
"gid": "gid-2",
|
|
"status": "complete",
|
|
"completedLength": "10",
|
|
"totalLength": "10",
|
|
"downloadSpeed": "0",
|
|
"files": [{"path": str(save_path)}],
|
|
}
|
|
raise AssertionError(f"Unexpected RPC method: {method}")
|
|
|
|
resolve = AsyncMock(
|
|
side_effect=[
|
|
"https://signed.example.com/model.safetensors?token=old",
|
|
"https://signed.example.com/model.safetensors?token=fresh",
|
|
]
|
|
)
|
|
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
|
monkeypatch.setattr(downloader, "_resolve_authenticated_redirect_url", resolve)
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
success, result = await downloader.download_file(
|
|
"https://civitai.com/api/download/models/123",
|
|
str(save_path),
|
|
download_id="download-1",
|
|
headers={"Authorization": "Bearer token"},
|
|
)
|
|
|
|
assert success is True
|
|
assert result == str(save_path)
|
|
# The second addUri used a freshly resolved signed URL, and both
|
|
# re-schedules keep continue=true so the partial payload is resumed.
|
|
assert add_uri_urls == [
|
|
"https://signed.example.com/model.safetensors?token=old",
|
|
"https://signed.example.com/model.safetensors?token=fresh",
|
|
]
|
|
assert resolve.await_count == 2
|
|
assert downloader._transfers == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_file_fails_when_no_uri_available_exceeds_recovery_limit(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""URL refresh is bounded by MAX_TRANSFER_RECOVERY_ATTEMPTS."""
|
|
downloader = Aria2Downloader()
|
|
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
|
downloader._rpc_secret = "secret"
|
|
|
|
save_path = tmp_path / "downloads" / "model.safetensors"
|
|
add_uri_count = {"n": 0}
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
if method == "aria2.addUri":
|
|
add_uri_count["n"] += 1
|
|
return f"gid-{add_uri_count['n']}"
|
|
if method == "aria2.tellStatus":
|
|
return {
|
|
"gid": params[0],
|
|
"status": "error",
|
|
"errorMessage": "No URI available.",
|
|
}
|
|
raise AssertionError(f"Unexpected RPC method: {method}")
|
|
|
|
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
success, result = await downloader.download_file(
|
|
"https://example.com/model.safetensors",
|
|
str(save_path),
|
|
download_id="download-1",
|
|
)
|
|
|
|
assert success is False
|
|
assert result == "No URI available."
|
|
assert add_uri_count["n"] == 1 + MAX_TRANSFER_RECOVERY_ATTEMPTS
|
|
assert downloader._transfers == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_file_does_not_refresh_url_for_other_errors(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""Errors other than "No URI available" remain permanent failures."""
|
|
downloader = Aria2Downloader()
|
|
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
|
downloader._rpc_secret = "secret"
|
|
|
|
save_path = tmp_path / "downloads" / "model.safetensors"
|
|
add_uri_count = {"n": 0}
|
|
|
|
async def fake_rpc_call(method, params, **_kwargs):
|
|
if method == "aria2.addUri":
|
|
add_uri_count["n"] += 1
|
|
return "gid-1"
|
|
if method == "aria2.tellStatus":
|
|
return {
|
|
"gid": "gid-1",
|
|
"status": "error",
|
|
"errorMessage": "Download aborted. URI=https://example.com/model.safetensors",
|
|
}
|
|
raise AssertionError(f"Unexpected RPC method: {method}")
|
|
|
|
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
|
|
|
success, result = await downloader.download_file(
|
|
"https://example.com/model.safetensors",
|
|
str(save_path),
|
|
download_id="download-1",
|
|
)
|
|
|
|
assert success is False
|
|
assert "Download aborted" in result
|
|
assert add_uri_count["n"] == 1
|
|
assert downloader._transfers == {}
|