88import tarfile
99import threading
1010import time
11- from collections .abc import Callable
11+ from collections .abc import Awaitable , Callable
1212from dataclasses import dataclass , field
1313from functools import partial
1414from http import client as http_client
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+
152198class 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-
9331000def _missing_model_files_message (missing : list [str ]) -> str :
9341001 names = ", " .join (missing )
9351002 return f"Downloaded model is missing: { names } ."
0 commit comments