@@ -338,51 +338,14 @@ internal static async Task TaskOrCancellation(Task task, CancellationToken cance
338
338
}
339
339
}
340
340
341
- public class DownloadProgressEvent
342
- {
343
- // TODO: speed calculation would be nice
344
- public ulong BytesWritten { get ; init ; }
345
- public ulong ? BytesTotal { get ; init ; } // null if unknown
346
-
347
- public double ? Progress => BytesTotal == null ? null : ( double ) BytesWritten / BytesTotal . Value ;
348
-
349
- public override string ToString ( )
350
- {
351
- var s = FriendlyBytes ( BytesWritten ) ;
352
- if ( BytesTotal != null )
353
- s += $ " of { FriendlyBytes ( BytesTotal . Value ) } ";
354
- else
355
- s += " of unknown" ;
356
- if ( Progress != null )
357
- s += $ " ({ Progress : 0%} )";
358
- return s ;
359
- }
360
-
361
- private static readonly string [ ] ByteSuffixes = [ "B" , "KB" , "MB" , "GB" , "TB" , "PB" , "EB" ] ;
362
-
363
- // Unfortunately this is copied from FriendlyByteConverter in App. Ideally
364
- // it should go into some shared utilities project, but it's overkill to do
365
- // that for a single tiny function until we have more shared code.
366
- private static string FriendlyBytes ( ulong bytes )
367
- {
368
- if ( bytes == 0 )
369
- return $ "0 { ByteSuffixes [ 0 ] } ";
370
-
371
- var place = Convert . ToInt32 ( Math . Floor ( Math . Log ( bytes , 1024 ) ) ) ;
372
- var num = Math . Round ( bytes / Math . Pow ( 1024 , place ) , 1 ) ;
373
- return $ "{ num } { ByteSuffixes [ place ] } ";
374
- }
375
- }
376
-
377
341
/// <summary>
378
342
/// Downloads a Url to a file on disk. The download will be written to a temporary file first, then moved to the final
379
343
/// destination. The SHA1 of any existing file will be calculated and used as an ETag to avoid downloading the file if
380
344
/// it hasn't changed.
381
345
/// </summary>
382
346
public class DownloadTask
383
347
{
384
- private const int BufferSize = 4096 ;
385
- private const int ProgressUpdateDelayMs = 50 ;
348
+ private const int BufferSize = 64 * 1024 ;
386
349
private const string XOriginalContentLengthHeader = "X-Original-Content-Length" ; // overrides Content-Length if available
387
350
388
351
private static readonly HttpClient HttpClient = new ( new HttpClientHandler
@@ -398,22 +361,13 @@ public class DownloadTask
398
361
private readonly string _destinationPath ;
399
362
private readonly string _tempDestinationPath ;
400
363
401
- // ProgressChanged events are always delayed by up to 50ms to avoid
402
- // flooding.
403
- //
404
- // This will be called:
405
- // - once after the request succeeds but before the read/write routine
406
- // begins
407
- // - occasionally while the file is being downloaded (at least 50ms apart)
408
- // - once when the download is complete
409
- public EventHandler < DownloadProgressEvent > ? ProgressChanged ;
410
-
411
364
public readonly HttpRequestMessage Request ;
412
365
413
366
public Task Task { get ; private set ; } = null ! ; // Set in EnsureStartedAsync
367
+ public bool DownloadStarted { get ; private set ; } // Whether we've received headers yet and started the actual download
414
368
public ulong BytesWritten { get ; private set ; }
415
- public ulong ? TotalBytes { get ; private set ; }
416
- public double ? Progress => TotalBytes == null ? null : ( double ) BytesWritten / TotalBytes . Value ;
369
+ public ulong ? BytesTotal { get ; private set ; }
370
+ public double ? Progress => BytesTotal == null ? null : ( double ) BytesWritten / BytesTotal . Value ;
417
371
public bool IsCompleted => Task . IsCompleted ;
418
372
419
373
internal DownloadTask ( ILogger logger , HttpRequestMessage req , string destinationPath , IDownloadValidator validator )
@@ -496,32 +450,27 @@ private async Task Start(CancellationToken ct = default)
496
450
}
497
451
498
452
if ( res . Content . Headers . ContentLength >= 0 )
499
- TotalBytes = ( ulong ) res . Content . Headers . ContentLength ;
453
+ BytesTotal = ( ulong ) res . Content . Headers . ContentLength ;
500
454
501
455
// X-Original-Content-Length overrules Content-Length if set.
502
456
if ( res . Headers . TryGetValues ( XOriginalContentLengthHeader , out var headerValues ) )
503
457
{
504
458
// If there are multiple we only look at the first one.
505
459
var headerValue = headerValues . ToList ( ) . FirstOrDefault ( ) ;
506
460
if ( ! string . IsNullOrEmpty ( headerValue ) && ulong . TryParse ( headerValue , out var originalContentLength ) )
507
- TotalBytes = originalContentLength ;
461
+ BytesTotal = originalContentLength ;
508
462
else
509
463
_logger . LogWarning (
510
464
"Failed to parse {XOriginalContentLengthHeader} header value '{HeaderValue}'" ,
511
465
XOriginalContentLengthHeader , headerValue ) ;
512
466
}
513
467
514
- SendProgressUpdate ( new DownloadProgressEvent
515
- {
516
- BytesWritten = 0 ,
517
- BytesTotal = TotalBytes ,
518
- } ) ;
519
-
520
468
await Download ( res , ct ) ;
521
469
}
522
470
523
471
private async Task Download ( HttpResponseMessage res , CancellationToken ct )
524
472
{
473
+ DownloadStarted = true ;
525
474
try
526
475
{
527
476
var sha1 = res . Headers . Contains ( "ETag" ) ? SHA1 . Create ( ) : null ;
@@ -546,28 +495,13 @@ private async Task Download(HttpResponseMessage res, CancellationToken ct)
546
495
await tempFile . WriteAsync ( buffer . AsMemory ( 0 , n ) , ct ) ;
547
496
sha1 ? . TransformBlock ( buffer , 0 , n , null , 0 ) ;
548
497
BytesWritten += ( ulong ) n ;
549
- await QueueProgressUpdate ( new DownloadProgressEvent
550
- {
551
- BytesWritten = BytesWritten ,
552
- BytesTotal = TotalBytes ,
553
- } , ct ) ;
554
498
}
555
499
}
556
500
557
- // Clear any pending progress updates to ensure they won't be sent
558
- // after the final update.
559
- await ClearQueuedProgressUpdate ( ct ) ;
560
- // Then write the final status update.
561
- TotalBytes = BytesWritten ;
562
- SendProgressUpdate ( new DownloadProgressEvent
563
- {
564
- BytesWritten = BytesWritten ,
565
- BytesTotal = BytesWritten ,
566
- } ) ;
567
-
568
- if ( TotalBytes != null && BytesWritten != TotalBytes )
501
+ BytesTotal ??= BytesWritten ;
502
+ if ( BytesWritten != BytesTotal )
569
503
throw new IOException (
570
- $ "Downloaded file size does not match response Content-Length: Content-Length= { TotalBytes } , BytesRead ={ BytesWritten } ") ;
504
+ $ "Downloaded file size does not match expected response content length: Expected= { BytesTotal } , BytesWritten ={ BytesWritten } ") ;
571
505
572
506
// Verify the ETag if it was sent by the server.
573
507
if ( res . Headers . Contains ( "ETag" ) && sha1 != null )
@@ -612,69 +546,4 @@ await QueueProgressUpdate(new DownloadProgressEvent
612
546
throw ;
613
547
}
614
548
}
615
-
616
- // _progressEventLock protects _progressUpdateTask and _pendingProgressEvent.
617
- private readonly RaiiSemaphoreSlim _progressEventLock = new ( 1 , 1 ) ;
618
- private readonly CancellationTokenSource _progressUpdateCts = new ( ) ;
619
- private Task ? _progressUpdateTask ;
620
- private DownloadProgressEvent ? _pendingProgressEvent ;
621
-
622
- // Can be called multiple times, but must not be called or in progress while
623
- // SendQueuedProgressUpdateNow is called.
624
- private async Task QueueProgressUpdate ( DownloadProgressEvent e , CancellationToken ct )
625
- {
626
- using var _1 = await _progressEventLock . LockAsync ( ct ) ;
627
- _pendingProgressEvent = e ;
628
-
629
- if ( _progressUpdateCts . IsCancellationRequested )
630
- throw new InvalidOperationException ( "Progress update task was cancelled, cannot queue new progress update" ) ;
631
-
632
- // Start a task with a 50ms delay unless one is already running.
633
- var cts = CancellationTokenSource . CreateLinkedTokenSource ( ct , _progressUpdateCts . Token ) ;
634
- cts . CancelAfter ( TimeSpan . FromSeconds ( 5 ) ) ;
635
- _progressUpdateTask ??= Task . Delay ( ProgressUpdateDelayMs , cts . Token )
636
- . ContinueWith ( t =>
637
- {
638
- cts . Cancel ( ) ;
639
- using var _2 = _progressEventLock . Lock ( ) ;
640
- _progressUpdateTask = null ;
641
- if ( t . IsFaulted || t . IsCanceled ) return ;
642
-
643
- var ev = _pendingProgressEvent ;
644
- if ( ev != null ) SendProgressUpdate ( ev ) ;
645
- } , cts . Token ) ;
646
- }
647
-
648
- // Must only be called after all QueueProgressUpdate calls have completed.
649
- private async Task ClearQueuedProgressUpdate ( CancellationToken ct )
650
- {
651
- Task ? t ;
652
- using ( var _ = _progressEventLock . LockAsync ( ct ) )
653
- {
654
- await _progressUpdateCts . CancelAsync ( ) ;
655
- t = _progressUpdateTask ;
656
- }
657
-
658
- // We can't continue to hold the lock here because the continuation
659
- // grabs a lock. We don't need to worry about a new task spawning after
660
- // this because the token is cancelled.
661
- if ( t == null ) return ;
662
- try
663
- {
664
- await t . WaitAsync ( ct ) ;
665
- }
666
- catch ( TaskCanceledException )
667
- {
668
- // Ignore
669
- }
670
- }
671
-
672
- private void SendProgressUpdate ( DownloadProgressEvent e )
673
- {
674
- var handler = ProgressChanged ;
675
- if ( handler == null )
676
- return ;
677
- // Start a new task in the background to invoke the event.
678
- _ = Task . Run ( ( ) => handler . Invoke ( this , e ) ) ;
679
- }
680
549
}
0 commit comments