Skip to content

Commit 1e25322

Browse files
fix: make model integrity downloads reliable (#49)
1 parent f4baec5 commit 1e25322

6 files changed

Lines changed: 530 additions & 70 deletions

File tree

README.md

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -454,11 +454,10 @@ model can execute code in those runtimes.
454454

455455
### Coverage
456456

457-
40 of 58 catalog models are pinned, covering every Hugging Face source. The
458-
remainder cannot be pinned from published metadata:
457+
Every catalog source that publishes usable integrity metadata is pinned,
458+
including all Hugging Face sources and the Moonshine asset manifests. The
459+
current exceptions cannot be pinned from published metadata:
459460

460-
- 13 Moonshine models are downloaded by the `moonshine_voice` library rather
461-
than by the gateway, so their transfer is outside our control.
462461
- 3 Handy-mirrored models on `blob.handy.computer` return a multipart S3 ETag,
463462
which is not a digest of the file content.
464463
- 2 sherpa-onnx release tarballs on GitHub publish no checksum.
@@ -476,17 +475,23 @@ is silently downgraded.
476475

477476
### Refreshing pins
478477

479-
When upstream legitimately re-uploads a model, its pinned download starts
480-
failing until the pin is updated. That is intentional: the change becomes a
481-
reviewable diff instead of a silent swap.
478+
The harvester is incremental by default: after adding a catalog model, it pins
479+
only entries missing from `app/model_pins.json`. Existing records remain byte
480+
for byte unchanged, so adding one model cannot silently refresh every model in
481+
the catalog. Use `--refresh` only when intentionally reviewing every upstream
482+
change; `--only` explicitly refreshes the matching model or family.
482483

483484
```sh
484-
uv run scripts/harvest-model-pins.py # all free sources
485-
uv run scripts/harvest-model-pins.py --only whisperkit: # one family
485+
uv run scripts/harvest-model-pins.py # newly added models
486+
uv run scripts/harvest-model-pins.py --only whisperkit: # refresh one family
487+
uv run scripts/harvest-model-pins.py --refresh # refresh everything
486488
```
487489

488-
Review the resulting diff as carefully as code. A changed digest means the
489-
upstream bytes changed, and the commit message should say why.
490+
Each revision and its digests are written as one snapshot. If the complete
491+
snapshot cannot be collected, the command fails and preserves the previous
492+
record rather than combining a new revision with stale hashes. Review the
493+
resulting diff as carefully as code. A changed digest means the upstream bytes
494+
changed, and the commit message should say why.
490495

491496
## Engine selection
492497

app/model_manager.py

Lines changed: 98 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import tarfile
99
import threading
1010
import time
11-
from collections.abc import Callable
11+
from collections.abc import Awaitable, Callable
1212
from dataclasses import dataclass, field
1313
from functools import partial
1414
from http import client as http_client
@@ -51,10 +51,7 @@
5151

5252
_MAX_NETWORK_ATTEMPTS = 3
5353
_RETRY_BACKOFF_SECONDS = 2.0
54-
# Transport-level failures only. A hash mismatch is deliberately excluded: it
55-
# is retried by the user, not automatically, since a mismatch can mean the
56-
# host is compromised and silently re-trying could mask that rather than
57-
# surface it.
54+
MAX_PARALLEL_FILE_DOWNLOADS = 4
5855
_RETRYABLE_NETWORK_ERRORS = (
5956
TimeoutError,
6057
ConnectionError,
@@ -69,6 +66,8 @@ def _call_with_retries[ActionResult](
6966
*,
7067
on_retry: Callable[[], None] | None = None,
7168
attempts: int = _MAX_NETWORK_ATTEMPTS,
69+
retryable_errors: tuple[type[BaseException], ...] = _RETRYABLE_NETWORK_ERRORS,
70+
retry_delay: Callable[[BaseException, int], float] | None = None,
7271
) -> ActionResult:
7372
"""Call *action* up to *attempts* times, retrying transport failures.
7473
@@ -80,12 +79,15 @@ def _call_with_retries[ActionResult](
8079
for attempt in range(1, attempts + 1):
8180
try:
8281
return action()
83-
except _RETRYABLE_NETWORK_ERRORS:
82+
except retryable_errors as error:
8483
if on_retry is not None:
8584
on_retry()
8685
if attempt == attempts:
8786
raise
88-
time.sleep(_RETRY_BACKOFF_SECONDS * attempt)
87+
delay = _RETRY_BACKOFF_SECONDS * attempt
88+
if retry_delay is not None:
89+
delay = retry_delay(error, attempt)
90+
time.sleep(delay)
8991
raise AssertionError("unreachable") # pragma: no cover
9092

9193

@@ -149,6 +151,50 @@ class _DownloadHandle:
149151
task: asyncio.Task[None] | None = field(default=None)
150152

151153

154+
@dataclass(slots=True)
155+
class _DownloadBatch:
156+
downloads: tuple[Callable[[], Awaitable[None]], ...]
157+
download_handle: _DownloadHandle
158+
semaphore: asyncio.Semaphore = field(
159+
default_factory=lambda: asyncio.Semaphore(MAX_PARALLEL_FILE_DOWNLOADS)
160+
)
161+
162+
async def run(self) -> None:
163+
"""Wait for every worker to stop before reporting a folder failure."""
164+
outcomes = await asyncio.gather(
165+
*(self._run_one(download) for download in self.downloads),
166+
return_exceptions=True,
167+
)
168+
failure = self._first_failure(outcomes)
169+
if failure is not None:
170+
raise failure
171+
if any(isinstance(outcome, DownloadCancelled) for outcome in outcomes):
172+
raise DownloadCancelled
173+
174+
async def _run_one(self, download: Callable[[], Awaitable[None]]) -> None:
175+
try:
176+
await self._run_bounded(download)
177+
except Exception:
178+
self.download_handle.cancel.set()
179+
raise
180+
181+
async def _run_bounded(self, download: Callable[[], Awaitable[None]]) -> None:
182+
async with self.semaphore:
183+
if self.download_handle.cancel.is_set():
184+
raise DownloadCancelled
185+
await download()
186+
187+
def _first_failure(self, outcomes: list[None | BaseException]) -> Exception | None:
188+
return next(
189+
(
190+
outcome
191+
for outcome in outcomes
192+
if isinstance(outcome, Exception) and not isinstance(outcome, DownloadCancelled)
193+
),
194+
None,
195+
)
196+
197+
152198
class ModelManager:
153199
"""Downloads, lists, and deletes local speech models."""
154200

@@ -383,7 +429,7 @@ async def _run_archive_download(
383429
partial_dir = final_dir.with_name(f"{final_dir.name}{PARTIAL_DIRECTORY_SUFFIX}")
384430
extraction_dir = final_dir.with_name(f"{final_dir.name}.extracting")
385431
archive_path = final_dir.with_name(f"{final_dir.name}.download")
386-
_clear_staging_directory(partial_dir)
432+
shutil.rmtree(partial_dir, ignore_errors=True)
387433
_remove_tree(extraction_dir)
388434
archive_path.unlink(missing_ok=True)
389435
try:
@@ -465,14 +511,18 @@ async def _run_sherpa_huggingface_download(
465511
for name in model.required_files
466512
if (entry := available.get(name)) is not None
467513
)
468-
await asyncio.gather(
469-
*(
470-
self._download_required_file(
471-
model, partial_dir, download_handle, name, available
472-
)
473-
for name in model.required_files
514+
downloads = tuple(
515+
partial(
516+
self._download_required_file,
517+
model,
518+
partial_dir,
519+
download_handle,
520+
name,
521+
available,
474522
)
523+
for name in model.required_files
475524
)
525+
await _DownloadBatch(downloads, download_handle).run()
476526
missing = [name for name in model.required_files if not (partial_dir / name).is_file()]
477527
if missing:
478528
raise RuntimeError(_missing_model_files_message(missing))
@@ -590,12 +640,11 @@ async def _run_huggingface_download(
590640
_remove_tree(partial_dir)
591641
partial_dir.mkdir(parents=True, exist_ok=True)
592642
try:
593-
await asyncio.gather(
594-
*(
595-
self._download_repo_file(model, partial_dir, download_handle, entry)
596-
for entry in files
597-
)
643+
downloads = tuple(
644+
partial(self._download_repo_file, model, partial_dir, download_handle, entry)
645+
for entry in files
598646
)
647+
await _DownloadBatch(downloads, download_handle).run()
599648
if download_handle.cancel.is_set():
600649
raise DownloadCancelled
601650
final_dir.parent.mkdir(parents=True, exist_ok=True)
@@ -856,13 +905,17 @@ def _download_file(
856905
before raising, so a rejected download cannot be left behind for an engine
857906
to load later.
858907
859-
A dropped connection or read timeout mid-stream is retried a few times
860-
(see `_call_with_retries`) rather than surfaced immediately, since it can
861-
otherwise produce a truncated file that fails SHA-256 verification for a
862-
reason that has nothing to do with the source's integrity.
908+
A dropped connection, short response, or checksum mismatch is retried a
909+
few times against the exact same expected digest. A persistent mismatch is
910+
still rejected; retrying can only accept bytes that satisfy the pin.
863911
"""
864912
download = _DownloadAttempt(url, destination, state, cancel, display_name, expected_sha256)
865-
return _call_with_retries(download.run, on_retry=download.rollback)
913+
return _call_with_retries(
914+
download.run,
915+
on_retry=download.rollback,
916+
retryable_errors=(*_RETRYABLE_NETWORK_ERRORS, ModelIntegrityError),
917+
retry_delay=download.retry_delay,
918+
)
866919

867920

868921
@dataclass
@@ -874,9 +927,11 @@ class _DownloadAttempt:
874927
display_name: str
875928
expected_sha256: str | None
876929
bytes_this_attempt: int = 0
930+
response_size: int | None = None
877931

878932
def run(self) -> str:
879933
self.bytes_this_attempt = 0
934+
self.response_size = None
880935
request = urllib_request.Request(self.url, headers={"User-Agent": USER_AGENT})
881936
digest = hashlib.sha256()
882937
with urllib_request.urlopen(request, timeout=60) as response:
@@ -885,17 +940,27 @@ def run(self) -> str:
885940
self.state.current_file = self.display_name or self.destination.name
886941
with self.destination.open("wb") as output:
887942
self._write_response(response, output, digest)
943+
self._verify_response_size()
888944
actual = digest.hexdigest()
889945
self._verify_digest(actual)
890946
return actual
891947

892948
def rollback(self) -> None:
893-
self.state.downloaded_bytes -= self.bytes_this_attempt
949+
self.destination.unlink(missing_ok=True)
950+
self.state.downloaded_bytes = max(0, self.state.downloaded_bytes - self.bytes_this_attempt)
951+
952+
def retry_delay(self, error: BaseException, attempt: int) -> float:
953+
"""Retry a checksum mismatch immediately; back off for network failures."""
954+
if isinstance(error, ModelIntegrityError):
955+
return 0
956+
return _RETRY_BACKOFF_SECONDS * attempt
894957

895958
def _set_total_bytes(self, response: Any) -> None:
896959
length = response.headers.get("Content-Length")
897-
if self.state.total_bytes is None and length and length.isdigit():
898-
self.state.total_bytes = int(length)
960+
if length and length.isdigit():
961+
self.response_size = int(length)
962+
if self.state.total_bytes is None:
963+
self.state.total_bytes = self.response_size
899964

900965
def _write_response(self, response: Any, output: Any, digest: Any) -> None:
901966
chunk = response.read(CHUNK_SIZE)
@@ -908,6 +973,12 @@ def _write_response(self, response: Any, output: Any, digest: Any) -> None:
908973
self.state.downloaded_bytes += len(chunk)
909974
chunk = response.read(CHUNK_SIZE)
910975

976+
def _verify_response_size(self) -> None:
977+
if self.response_size is None or self.bytes_this_attempt == self.response_size:
978+
return
979+
missing = max(0, self.response_size - self.bytes_this_attempt)
980+
raise http_client.IncompleteRead(b"", missing)
981+
911982
def _verify_digest(self, actual: str) -> None:
912983
if self.expected_sha256 is None or actual == self.expected_sha256:
913984
return
@@ -926,10 +997,6 @@ def _remove_tree(path: Path) -> None:
926997
shutil.rmtree(path, ignore_errors=True)
927998

928999

929-
def _clear_staging_directory(path: Path) -> None:
930-
_remove_tree(path)
931-
932-
9331000
def _missing_model_files_message(missing: list[str]) -> str:
9341001
names = ", ".join(missing)
9351002
return f"Downloaded model is missing: {names}."

docs/troubleshooting.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -320,9 +320,11 @@ includes the bearer token, recordings, transcripts, or session identifiers.
320320
## A model download fails SHA-256 verification
321321

322322
The gateway pins the expected digest of every catalog model it can
323-
(`app/model_pins.json`) and discards a download that does not match, so the
324-
failure means the bytes served differ from the bytes this release was built
325-
against. The partial file is already deleted; nothing unverified is kept.
323+
(`app/model_pins.json`) and discards a download that does not match. It retries
324+
short responses and checksum mismatches three times against that same pin; it
325+
never changes or bypasses the expected digest. A reported failure therefore
326+
means the mismatch persisted. The partial file is already deleted; nothing
327+
unverified is kept.
326328

327329
Two very different causes look identical here, so check which one it is before
328330
retrying:
@@ -337,10 +339,9 @@ retrying:
337339
```
338340

339341
2. **The bytes were altered in transit or at the source.** A corrupted proxy or
340-
mirror, or a compromised upstream. Retry once — a corrupted transfer usually
341-
will not reproduce, while an altered source will fail identically every
342-
time. Do not "fix" a reproducible mismatch by refreshing the pin unless the
343-
upstream commit genuinely changed.
342+
mirror, or a compromised upstream. Because the gateway already retried the
343+
transfer, do not keep clicking Retry. Do not "fix" a reproducible mismatch
344+
by refreshing the pin unless the upstream commit genuinely changed.
344345

345346
Never work around this by deleting the pin. The check is the only thing
346347
standing between a swapped model file and an ONNX/GGUF/Core ML runtime that

0 commit comments

Comments
 (0)