@@ -166,6 +166,16 @@ public final class DownloadSpeedTracker: @unchecked Sendable {
166166 }
167167}
168168
169+ private struct DownloadedFileSizeMismatchError : LocalizedError {
170+ let fileName : String
171+ let expectedSize : Int64
172+ let actualSize : Int64
173+
174+ var errorDescription : String ? {
175+ " Downloaded file \( fileName) is incomplete (expected \( expectedSize) bytes, got \( actualSize) ). "
176+ }
177+ }
178+
169179// MARK: — Downloader actor
170180
171181public actor ModelDownloader {
@@ -276,14 +286,20 @@ public actor ModelDownloader {
276286 guard let freshHttp = freshResponse as? HTTPURLResponse , ( 200 ..< 300 ) . contains ( freshHttp. statusCode) else {
277287 throw URLError ( . badServerResponse)
278288 }
279- try await streamToFile (
289+ let totalSize = freshHttp. expectedContentLength > 0 ? freshHttp. expectedContentLength : ( expectedSize ?? 0 )
290+ let writtenSize = try await streamToFile (
280291 asyncBytes: freshBytes,
281292 destURL: incompleteURL,
282293 resumeOffset: 0 ,
283- totalSize: freshHttp . expectedContentLength > 0 ? freshHttp . expectedContentLength : ( expectedSize ?? 0 ) ,
294+ totalSize: totalSize ,
284295 speedTracker: speedTracker,
285296 onProgress: onProgress
286297 )
298+ try validateCompletedDownloadSize (
299+ fileName: fileName,
300+ actualSize: writtenSize,
301+ expectedSize: totalSize > 0 ? totalSize : expectedSize
302+ )
287303 } else if ( 200 ..< 300 ) . contains ( http. statusCode) {
288304 // 200 = full content (server ignored Range), 206 = partial content (resume worked)
289305 let isResume = ( http. statusCode == 206 )
@@ -299,14 +315,19 @@ public actor ModelDownloader {
299315 } else {
300316 totalSize = http. expectedContentLength > 0 ? http. expectedContentLength : ( expectedSize ?? 0 )
301317 }
302- try await streamToFile (
318+ let writtenSize = try await streamToFile (
303319 asyncBytes: asyncBytes,
304320 destURL: incompleteURL,
305321 resumeOffset: isResume ? resumeOffset : 0 ,
306322 totalSize: totalSize,
307323 speedTracker: speedTracker,
308324 onProgress: onProgress
309325 )
326+ try validateCompletedDownloadSize (
327+ fileName: fileName,
328+ actualSize: writtenSize,
329+ expectedSize: totalSize > 0 ? totalSize : expectedSize
330+ )
310331 } else {
311332 throw URLError ( . badServerResponse)
312333 }
@@ -325,7 +346,7 @@ public actor ModelDownloader {
325346 totalSize: Int64 ,
326347 speedTracker: DownloadSpeedTracker ,
327348 onProgress: @Sendable ( Double , Double ? ) -> Void
328- ) async throws {
349+ ) async throws -> Int64 {
329350 let fileHandle : FileHandle
330351 if resumeOffset > 0 , FileManager . default. fileExists ( atPath: destURL. path) {
331352 fileHandle = try FileHandle ( forWritingTo: destURL)
@@ -336,43 +357,57 @@ public actor ModelDownloader {
336357 }
337358 defer { try ? fileHandle. close ( ) }
338359
339- var bytesWritten : Int64 = resumeOffset
340- var buffer = Data ( )
341360 let flushSize = 256 * 1024 // Flush every 256 KB
342361 var lastProgressUpdate = Date ( )
362+ var bytesWritten : Int64 = resumeOffset
363+ var iterator = asyncBytes. makeAsyncIterator ( )
364+ var chunkBuffer = [ UInt8] ( repeating: 0 , count: flushSize)
343365
344- for try await byte in asyncBytes {
366+ while true {
345367 try Task . checkCancellation ( )
346- buffer. append ( byte)
347-
348- if buffer. count >= flushSize {
349- fileHandle. write ( buffer)
350- bytesWritten += Int64 ( buffer. count)
351- buffer. removeAll ( keepingCapacity: true )
352368
353- speedTracker. record ( totalBytes: bytesWritten)
369+ var chunkCount = 0
370+ while chunkCount < chunkBuffer. count,
371+ let byte = try await iterator. next ( ) {
372+ chunkBuffer [ chunkCount] = byte
373+ chunkCount += 1
374+ }
354375
355- // Throttle progress updates to ~10/sec
356- let now = Date ( )
357- if now. timeIntervalSince ( lastProgressUpdate) >= 0.1 {
358- lastProgressUpdate = now
359- if totalSize > 0 {
360- onProgress ( Double ( bytesWritten) / Double( totalSize) , speedTracker. speedBytesPerSec)
361- }
362- }
376+ if chunkCount == 0 {
377+ break
363378 }
364- }
365379
366- // Flush remaining bytes
367- if !buffer. isEmpty {
368- fileHandle. write ( buffer)
369- bytesWritten += Int64 ( buffer. count)
380+ fileHandle. write ( Data ( chunkBuffer [ 0 ..< chunkCount] ) )
381+ bytesWritten += Int64 ( chunkCount)
382+
370383 speedTracker. record ( totalBytes: bytesWritten)
384+
385+ let now = Date ( )
386+ if now. timeIntervalSince ( lastProgressUpdate) >= 0.1 {
387+ lastProgressUpdate = now
388+ if totalSize > 0 {
389+ onProgress ( Double ( bytesWritten) / Double( totalSize) , speedTracker. speedBytesPerSec)
390+ }
391+ }
371392 }
372393
373394 if totalSize > 0 {
374395 onProgress ( Double ( bytesWritten) / Double( totalSize) , speedTracker. speedBytesPerSec)
375396 }
397+ return bytesWritten
398+ }
399+
400+ private func validateCompletedDownloadSize(
401+ fileName: String ,
402+ actualSize: Int64 ,
403+ expectedSize: Int64 ?
404+ ) throws {
405+ guard let expectedSize, expectedSize > 0 , actualSize != expectedSize else { return }
406+ throw DownloadedFileSizeMismatchError (
407+ fileName: fileName,
408+ expectedSize: expectedSize,
409+ actualSize: actualSize
410+ )
376411 }
377412
378413 /// Download all model files to `ModelStorage.cacheRoot` in the Hugging Face
0 commit comments