Skip to content

Commit 006e980

Browse files
authored
fix(models): reject false-complete downloads that wrote no data (#1548) (#1562)
* fix(download-manager): validate output before marking a download complete Both the torrent and HTTP paths could mark a download complete without checking that anything was actually written to dest. The torrent path trusted torrent.download() returning cleanly; the HTTP path only checked SHA256 when one was supplied, so an empty or truncated body still passed. Add a shared validation step (file exists, size > 0, size matches the known total when available, SHA256 matches when provided) and run it before marking either path complete. On failure the task is marked error and the bad file is removed, matching the existing SHA-mismatch behavior. Fixes the backend half of #1548. * fix(downloads): fold review findings on the false-complete validation - MAJOR: do not treat Content-Length as the expected on-disk size for content-encoded (gzip/br/deflate/zstd) responses. httpx auto-decompresses, so the written file is larger than Content-Length and the size check would delete a valid download. Leave total_bytes unknown and rely on the SHA. - normalize SHA256 comparison to lowercase so an uppercase expected hash is not a false mismatch - torrent path: torrent.download() already SHA-verifies internally, so skip the redundant full-file re-hash (avoids a multi-GB blocking read) * fix(downloads): offload the SHA fallback read to a thread so it can't block the loop The computed_sha256-is-None fallback re-reads the whole file to hash it; for a multi-GB model that would stall the event loop. Move _validate_download to async and run the read+hash via asyncio.to_thread.
1 parent 90cab1e commit 006e980

2 files changed

Lines changed: 137 additions & 3 deletions

File tree

tests/test_download_manager.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,23 @@ async def test_download_creates_parent_dirs(self, dm, tmp_path):
314314
assert dest.exists()
315315
assert dest.read_bytes() == data
316316

317+
@pytest.mark.asyncio
318+
async def test_download_empty_body_marks_error_not_complete(self, dm, tmp_path):
319+
"""A 0-byte response body (e.g. an error page served with a 200,
320+
or a stream that closes immediately) must not be reported as a
321+
complete download."""
322+
dest = tmp_path / "out.bin"
323+
task = DownloadTask(id="dl", url="http://example.com/f.bin", dest=dest)
324+
mock_resp = self._make_async_context_manager_mock(b"", None)
325+
mock_client = self._make_mock_client(mock_resp)
326+
327+
with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client):
328+
await dm._download(task, expected_sha256=None)
329+
330+
assert task.status == "error"
331+
assert task.error == "download produced no data"
332+
assert not dest.exists()
333+
317334
@pytest.mark.asyncio
318335
async def test_download_no_content_length(self, dm, tmp_path):
319336
data = b"no content length"
@@ -391,13 +408,15 @@ async def test_falls_through_when_license_disallows(self, dm, tmp_path):
391408
async def test_torrent_success_path(self, dm, tmp_path):
392409
dest = tmp_path / "model.bin"
393410
task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest)
411+
data = b"z" * 999
394412

395413
fake_torrent = AsyncMock()
396414
fake_progress_task = MagicMock()
397415
fake_progress_task.total_bytes = 999
398416
fake_progress_task.downloaded_bytes = 999
399417

400418
async def mock_download(task_id, magnet_or_torrent, dest, expected_sha256, progress_cb):
419+
dest.write_bytes(data)
401420
progress_cb(fake_progress_task)
402421

403422
fake_torrent.download = mock_download
@@ -414,6 +433,62 @@ async def mock_download(task_id, magnet_or_torrent, dest, expected_sha256, progr
414433
assert task.total_bytes == 999
415434
assert task.downloaded_bytes == 999
416435
assert task.completed_at > 0
436+
assert dest.read_bytes() == data
437+
438+
@pytest.mark.asyncio
439+
async def test_torrent_success_reported_but_no_file_marks_error(self, dm, tmp_path):
440+
"""Guards against the false-complete bug: the torrent swarm can
441+
report a clean finish via progress_cb while writing nothing (or
442+
an empty file) to dest. That must not be reported as complete."""
443+
dest = tmp_path / "model.bin"
444+
task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest)
445+
446+
fake_torrent = AsyncMock()
447+
fake_progress_task = MagicMock()
448+
fake_progress_task.total_bytes = 999
449+
fake_progress_task.downloaded_bytes = 999
450+
451+
async def mock_download(task_id, magnet_or_torrent, dest, expected_sha256, progress_cb):
452+
# never writes dest, just reports progress as if it finished
453+
progress_cb(fake_progress_task)
454+
455+
fake_torrent.download = mock_download
456+
457+
with patch.object(dm, "_get_torrent_downloader", return_value=fake_torrent):
458+
await dm._download_with_fallback(
459+
task,
460+
expected_sha256=None,
461+
magnet="magnet:?xt=urn:btih:abc",
462+
license_allows_redistribution=True,
463+
)
464+
465+
assert task.status == "error"
466+
assert task.error == "download produced no data"
467+
assert not dest.exists()
468+
469+
@pytest.mark.asyncio
470+
async def test_torrent_success_reported_but_empty_file_marks_error(self, dm, tmp_path):
471+
dest = tmp_path / "model.bin"
472+
task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest)
473+
474+
fake_torrent = AsyncMock()
475+
476+
async def mock_download(task_id, magnet_or_torrent, dest, expected_sha256, progress_cb):
477+
dest.write_bytes(b"")
478+
479+
fake_torrent.download = mock_download
480+
481+
with patch.object(dm, "_get_torrent_downloader", return_value=fake_torrent):
482+
await dm._download_with_fallback(
483+
task,
484+
expected_sha256=None,
485+
magnet="magnet:?xt=urn:btih:abc",
486+
license_allows_redistribution=True,
487+
)
488+
489+
assert task.status == "error"
490+
assert task.error == "download produced no data"
491+
assert not dest.exists()
417492

418493
@pytest.mark.asyncio
419494
async def test_torrent_failure_falls_back_to_http(self, dm, tmp_path):

tinyagentos/download_manager.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,38 @@ def list_active(self) -> list[DownloadTask]:
9797
def list_all(self) -> list[DownloadTask]:
9898
return list(self._tasks.values())
9999

100+
async def _validate_download(
101+
self,
102+
task: DownloadTask,
103+
expected_sha256: str | None = None,
104+
computed_sha256: str | None = None,
105+
) -> str | None:
106+
"""Check a finished download before it is marked complete.
107+
108+
Returns None if the download is valid, or an error message
109+
describing why it isn't. Applies to both the torrent and HTTP
110+
paths so neither can mark a task complete when nothing (or the
111+
wrong thing) was actually written to disk.
112+
"""
113+
if not task.dest.exists() or task.dest.stat().st_size == 0:
114+
return "download produced no data"
115+
if task.total_bytes and task.dest.stat().st_size != task.total_bytes:
116+
return "size mismatch"
117+
if expected_sha256:
118+
digest = computed_sha256
119+
if digest is None:
120+
# Fallback for a caller that did not stream the hash. Reading a
121+
# potentially multi-GB model is offloaded to a thread so it
122+
# never blocks the event loop.
123+
digest = await asyncio.to_thread(
124+
lambda: hashlib.sha256(task.dest.read_bytes()).hexdigest()
125+
)
126+
# Hex digests are case-insensitive; a caller passing an uppercase
127+
# expected value must not be treated as a mismatch.
128+
if digest.lower() != expected_sha256.lower():
129+
return "SHA256 mismatch"
130+
return None
131+
100132
async def _download_with_fallback(
101133
self,
102134
task: DownloadTask,
@@ -133,6 +165,21 @@ def _progress(t):
133165
expected_sha256=expected_sha256,
134166
progress_cb=_progress,
135167
)
168+
# torrent.download() already SHA-verified the file internally
169+
# (and raised on mismatch), so re-hashing here would just re-read
170+
# a multi-GB file to no benefit. Only the cheap non-empty / size
171+
# floor is needed on this path.
172+
error = await self._validate_download(task)
173+
if error:
174+
task.dest.unlink(missing_ok=True)
175+
task.status = "error"
176+
task.error = error
177+
logger.error(
178+
"Torrent download for %s produced an invalid result (%s)",
179+
task.id,
180+
error,
181+
)
182+
return
136183
task.status = "complete"
137184
task.completed_at = time.time()
138185
logger.info("Downloaded %s via torrent swarm", task.id)
@@ -160,17 +207,29 @@ async def _download(self, task: DownloadTask, expected_sha256: str | None = None
160207
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
161208
async with client.stream("GET", task.url) as resp:
162209
resp.raise_for_status()
210+
# Content-Length is the size of the ON-THE-WIRE body. When
211+
# the response is content-encoded (gzip/br/deflate/zstd),
212+
# httpx's aiter_bytes() transparently decompresses, so the
213+
# bytes we write to disk are LARGER than Content-Length.
214+
# Treating that as the expected on-disk size would make
215+
# _validate_download flag a perfectly good download as a
216+
# "size mismatch" and delete it, so leave total_bytes at 0
217+
# (unknown) for encoded responses and rely on the SHA check.
163218
total = resp.headers.get("content-length")
164-
task.total_bytes = int(total) if total else 0
219+
if total and not resp.headers.get("content-encoding"):
220+
task.total_bytes = int(total)
221+
else:
222+
task.total_bytes = 0
165223
with open(task.dest, "wb") as f:
166224
async for chunk in resp.aiter_bytes(chunk_size=65536):
167225
f.write(chunk)
168226
sha.update(chunk)
169227
task.downloaded_bytes += len(chunk)
170-
if expected_sha256 and sha.hexdigest() != expected_sha256:
228+
error = await self._validate_download(task, expected_sha256, computed_sha256=sha.hexdigest())
229+
if error:
171230
task.dest.unlink(missing_ok=True)
172231
task.status = "error"
173-
task.error = "SHA256 mismatch"
232+
task.error = error
174233
else:
175234
task.status = "complete"
176235
task.completed_at = time.time()

0 commit comments

Comments
 (0)