@@ -28,8 +28,87 @@ public async Task DownloadAsync_AutomaticallyUsesRangesAndVerifiesFile()
2828 var result = await completion ;
2929 Assert . IsTrue ( result . Success , result . Error ? . ToString ( ) ) ;
3030 CollectionAssert . AreEqual ( data , await File . ReadAllBytesAsync ( Path . Combine ( directory , file . FileName ) ) ) ;
31- Assert . IsTrue ( handler . RequestedRanges . Count ( range => range . End > range . Start ) >= 2 ,
32- "An 8 MiB response should be split automatically when the server supports ranges." ) ;
31+ Assert . IsTrue ( handler . RequestedRanges . Count ( range => range . End > range . Start ) >= 8 ,
32+ "An 8 MiB response should use the configured parallelism without creating sub-megabyte parts." ) ;
33+ }
34+ finally
35+ {
36+ Directory . Delete ( directory , true ) ;
37+ }
38+ }
39+
40+ [ TestMethod ]
41+ public async Task DownloadAsync_ProbeLatencyDoesNotReduceTransferParallelism ( )
42+ {
43+ var data = CreateData ( 16 * 1024 * 1024 ) ;
44+ var handler = new RangeHandler ( data ) { ProbeDelay = TimeSpan . FromMilliseconds ( 900 ) } ;
45+ var directory = CreateTestDirectory ( ) ;
46+
47+ try
48+ {
49+ var file = CreateFile ( directory , "delayed-probe.bin" , "https://primary.test/delayed-probe.bin" , data ) ;
50+ var completion = CaptureCompletion ( file ) ;
51+
52+ await DownloadHelper . DownloadAsync ( file , CreateSettings ( handler ) ) ;
53+
54+ Assert . IsTrue ( ( await completion ) . Success ) ;
55+ Assert . IsTrue ( handler . RequestedRanges . Count ( range => range . End > range . Start ) >= 8 ,
56+ "Connection setup latency must not be treated as low sustained bandwidth." ) ;
57+ CollectionAssert . AreEqual ( data , await File . ReadAllBytesAsync ( Path . Combine ( directory , file . FileName ) ) ) ;
58+ }
59+ finally
60+ {
61+ Directory . Delete ( directory , true ) ;
62+ }
63+ }
64+
65+ [ TestMethod ]
66+ public async Task DownloadAsync_AcceptsRangesWithoutOptionalLengthHeaders ( )
67+ {
68+ var data = CreateData ( 8 * 1024 * 1024 ) ;
69+ var handler = new RangeHandler ( data ) { OmitTransferLengths = true } ;
70+ var directory = CreateTestDirectory ( ) ;
71+
72+ try
73+ {
74+ var file = CreateFile ( directory , "unknown-length.bin" , "https://primary.test/unknown-length.bin" , data ) ;
75+ var completion = CaptureCompletion ( file ) ;
76+
77+ await DownloadHelper . DownloadAsync ( file , CreateSettings ( handler ) ) ;
78+
79+ Assert . IsTrue ( ( await completion ) . Success ) ;
80+ Assert . AreEqual ( 0 , handler . WholeFileRequests ,
81+ "A valid 206 response must not make the downloader fall back to a full-file request." ) ;
82+ CollectionAssert . AreEqual ( data , await File . ReadAllBytesAsync ( Path . Combine ( directory , file . FileName ) ) ) ;
83+ }
84+ finally
85+ {
86+ Directory . Delete ( directory , true ) ;
87+ }
88+ }
89+
90+ [ TestMethod ]
91+ public async Task DownloadAsync_SmallKnownFilesUseTheConfiguredConnectionBudgetWithoutProbes ( )
92+ {
93+ var data = CreateData ( 32 * 1024 ) ;
94+ var handler = new ConcurrentContentHandler ( data , TimeSpan . FromMilliseconds ( 100 ) ) ;
95+ var directory = CreateTestDirectory ( ) ;
96+ var files = Enumerable . Range ( 0 , 32 )
97+ . Select ( index => CreateFile ( directory , $ "asset-{ index } .bin", $ "https://assets.test/{ index } ", data ) )
98+ . Cast < AbstractDownloadBase > ( )
99+ . ToArray ( ) ;
100+
101+ try
102+ {
103+ var settings = CreateSettings ( handler , downloadThread : 32 ) ;
104+
105+ await DownloadHelper . DownloadAsync ( files , settings ) ;
106+
107+ Assert . AreEqual ( 0 , handler . RangeRequests ,
108+ "Known single-part files should not pay for a separate range probe." ) ;
109+ Assert . IsTrue ( handler . MaximumConcurrency > 16 ,
110+ $ "Expected the configured connection budget above the old 16-file cap; saw { handler . MaximumConcurrency } .") ;
111+ Assert . IsTrue ( files . All ( file => File . Exists ( Path . Combine ( directory , file . FileName ) ) ) ) ;
33112 }
34113 finally
35114 {
@@ -72,6 +151,88 @@ public async Task DownloadAsync_SwitchesToTheNextMirrorImmediately()
72151 }
73152 }
74153
154+ [ TestMethod ]
155+ public async Task DownloadAsync_WholeFileTriesEverySourceBeforeFailing ( )
156+ {
157+ var data = CreateData ( 256 * 1024 ) ;
158+ var handler = new FailoverHandler ( data , "source-5.test" ) ;
159+ var directory = CreateTestDirectory ( ) ;
160+
161+ try
162+ {
163+ var file = CreateMultiSourceFile ( directory , "whole-failover.bin" , data , 6 ) ;
164+ var completion = CaptureCompletion ( file ) ;
165+
166+ await DownloadHelper . DownloadAsync ( file , CreateSettings ( handler , retryCount : 2 ) ) ;
167+
168+ Assert . IsTrue ( ( await completion ) . Success ) ;
169+ Assert . IsTrue ( handler . Hosts . Contains ( "source-5.test" ) , "The last configured source was never attempted." ) ;
170+ CollectionAssert . AreEqual ( data , await File . ReadAllBytesAsync ( Path . Combine ( directory , file . FileName ) ) ) ;
171+ }
172+ finally
173+ {
174+ Directory . Delete ( directory , true ) ;
175+ }
176+ }
177+
178+ [ TestMethod ]
179+ public async Task DownloadAsync_RangeTransferTriesEverySourceBeforeFallingBack ( )
180+ {
181+ var data = CreateData ( 8 * 1024 * 1024 ) ;
182+ var handler = new FailoverHandler ( data , "source-5.test" , "source-0.test" ) ;
183+ var directory = CreateTestDirectory ( ) ;
184+
185+ try
186+ {
187+ var file = CreateMultiSourceFile ( directory , "range-failover.bin" , data , 6 ) ;
188+ var completion = CaptureCompletion ( file ) ;
189+
190+ await DownloadHelper . DownloadAsync ( file , CreateSettings ( handler , retryCount : 2 ) ) ;
191+
192+ Assert . IsTrue ( ( await completion ) . Success ) ;
193+ Assert . IsTrue ( handler . Hosts . Contains ( "source-5.test" ) , "The last configured source was never attempted." ) ;
194+ Assert . AreEqual ( 0 , handler . WholeFileRequests ,
195+ "Range failover should succeed before downgrading to a complete-file transfer." ) ;
196+ CollectionAssert . AreEqual ( data , await File . ReadAllBytesAsync ( Path . Combine ( directory , file . FileName ) ) ) ;
197+ }
198+ finally
199+ {
200+ Directory . Delete ( directory , true ) ;
201+ }
202+ }
203+
204+ [ TestMethod ]
205+ public async Task DownloadAsync_HashMismatchRotatesThroughEverySource ( )
206+ {
207+ var expected = CreateData ( 256 * 1024 ) ;
208+ var corrupt = expected . ToArray ( ) ;
209+ corrupt [ 0 ] ^= 0xff ;
210+ var handler = new PerHostContentHandler ( new Dictionary < string , byte [ ] >
211+ {
212+ [ "source-0.test" ] = corrupt ,
213+ [ "source-1.test" ] = corrupt ,
214+ [ "source-2.test" ] = expected
215+ } ) ;
216+ var directory = CreateTestDirectory ( ) ;
217+
218+ try
219+ {
220+ var file = CreateMultiSourceFile ( directory , "hash-failover.bin" , expected , 3 ) ;
221+ var completion = CaptureCompletion ( file ) ;
222+
223+ await DownloadHelper . DownloadAsync ( file , CreateSettings ( handler , checkFile : true , retryCount : 1 ) ) ;
224+
225+ Assert . IsTrue ( ( await completion ) . Success ) ;
226+ Assert . IsTrue ( handler . Hosts . Contains ( "source-2.test" ) ,
227+ "Hash validation stopped before reaching the valid backup source." ) ;
228+ CollectionAssert . AreEqual ( expected , await File . ReadAllBytesAsync ( Path . Combine ( directory , file . FileName ) ) ) ;
229+ }
230+ finally
231+ {
232+ Directory . Delete ( directory , true ) ;
233+ }
234+ }
235+
75236 [ TestMethod ]
76237 public async Task DownloadAsync_RetriesATransientProbeFailure ( )
77238 {
@@ -171,16 +332,38 @@ static SimpleDownloadFile CreateFile(string directory, string name, string url,
171332 } ;
172333 }
173334
174- static DownloadSettings CreateSettings ( HttpMessageHandler handler , bool checkFile = false )
335+ static MultiSourceDownloadFile CreateMultiSourceFile (
336+ string directory ,
337+ string name ,
338+ byte [ ] data ,
339+ int sourceCount )
340+ {
341+ return new MultiSourceDownloadFile
342+ {
343+ DownloadPath = directory ,
344+ FileName = name ,
345+ FileSize = data . Length ,
346+ CheckSum = Convert . ToHexString ( SHA256 . HashData ( data ) ) ,
347+ DownloadUris = Enumerable . Range ( 0 , sourceCount )
348+ . Select ( index => new DownloadUriInfo ( $ "https://source-{ index } .test/{ name } ", sourceCount - index ) )
349+ . ToArray ( )
350+ } ;
351+ }
352+
353+ static DownloadSettings CreateSettings (
354+ HttpMessageHandler handler ,
355+ bool checkFile = false ,
356+ int downloadThread = 16 ,
357+ int retryCount = 4 )
175358 {
176359 return new DownloadSettings
177360 {
178361 HttpClientFactory = new TestHttpClientFactory ( handler ) ,
179362 CheckFile = checkFile ,
180363 HashType = HashType . SHA256 ,
181- RetryCount = 4 ,
364+ RetryCount = retryCount ,
182365 DownloadParts = 16 ,
183- DownloadThread = 16 ,
366+ DownloadThread = downloadThread ,
184367 ConnectionTimeout = TimeSpan . FromSeconds ( 1 ) ,
185368 StallTimeout = TimeSpan . FromSeconds ( 1 ) ,
186369 ProgressInterval = TimeSpan . FromMilliseconds ( 50 )
@@ -221,13 +404,17 @@ sealed class RangeHandler(byte[] data, string? failingHost = null) : HttpMessage
221404 {
222405 readonly object _sync = new ( ) ;
223406 bool _truncated ;
407+ int _wholeFileRequests ;
224408 ( long Start , long End ) ? _truncatedRange ;
225409
226410 public ConcurrentQueue < ( long Start , long End ) > RequestedRanges { get ; } = new ( ) ;
227411 public ConcurrentBag < string > Hosts { get ; } = [ ] ;
228412 public bool TruncateFirstTransfer { get ; init ; }
413+ public bool OmitTransferLengths { get ; init ; }
414+ public TimeSpan ProbeDelay { get ; init ; }
229415 public int TransientFailures { get ; set ; }
230416 public bool SawResumedRange { get ; private set ; }
417+ public int WholeFileRequests => Volatile . Read ( ref this . _wholeFileRequests ) ;
231418
232419 protected override Task < HttpResponseMessage > SendAsync (
233420 HttpRequestMessage request ,
@@ -249,6 +436,7 @@ protected override Task<HttpResponseMessage> SendAsync(
249436 var requested = request . Headers . Range ? . Ranges . SingleOrDefault ( ) ;
250437 if ( requested == null )
251438 {
439+ Interlocked . Increment ( ref this . _wholeFileRequests ) ;
252440 var complete = new HttpResponseMessage ( HttpStatusCode . OK ) { Content = new ByteArrayContent ( data ) } ;
253441 complete . Content . Headers . ContentLength = data . Length ;
254442 return Task . FromResult ( complete ) ;
@@ -259,7 +447,9 @@ protected override Task<HttpResponseMessage> SendAsync(
259447 this . RequestedRanges . Enqueue ( ( start , end ) ) ;
260448 var length = checked ( ( int ) ( end - start + 1 ) ) ;
261449 var bytes = data . AsSpan ( checked ( ( int ) start ) , length ) . ToArray ( ) ;
262- HttpContent content = new ByteArrayContent ( bytes ) ;
450+ HttpContent content = this . OmitTransferLengths && end > start
451+ ? new UnknownLengthContent ( bytes )
452+ : new ByteArrayContent ( bytes ) ;
263453
264454 lock ( this . _sync )
265455 {
@@ -275,12 +465,140 @@ protected override Task<HttpResponseMessage> SendAsync(
275465 }
276466 }
277467
468+ if ( ! this . OmitTransferLengths || end == start )
469+ content . Headers . ContentLength = length ;
470+ content . Headers . ContentRange = this . OmitTransferLengths && end > start
471+ ? new ContentRangeHeaderValue ( start , end )
472+ : new ContentRangeHeaderValue ( start , end , data . Length ) ;
473+ var response = new HttpResponseMessage ( HttpStatusCode . PartialContent ) { Content = content } ;
474+ return this . ProbeDelay > TimeSpan . Zero && start == 0 && end == 0
475+ ? DelayResponseAsync ( response , this . ProbeDelay , cancellationToken )
476+ : Task . FromResult ( response ) ;
477+ }
478+
479+ static async Task < HttpResponseMessage > DelayResponseAsync (
480+ HttpResponseMessage response ,
481+ TimeSpan delay ,
482+ CancellationToken cancellationToken )
483+ {
484+ await Task . Delay ( delay , cancellationToken ) ;
485+ return response ;
486+ }
487+ }
488+
489+ sealed class ConcurrentContentHandler ( byte [ ] data , TimeSpan delay ) : HttpMessageHandler
490+ {
491+ int _active ;
492+ int _maximumConcurrency ;
493+ int _rangeRequests ;
494+
495+ public int MaximumConcurrency => Volatile . Read ( ref this . _maximumConcurrency ) ;
496+ public int RangeRequests => Volatile . Read ( ref this . _rangeRequests ) ;
497+
498+ protected override async Task < HttpResponseMessage > SendAsync (
499+ HttpRequestMessage request ,
500+ CancellationToken cancellationToken )
501+ {
502+ if ( request . Headers . Range != null ) Interlocked . Increment ( ref this . _rangeRequests ) ;
503+ var active = Interlocked . Increment ( ref this . _active ) ;
504+ UpdateMaximum ( ref this . _maximumConcurrency , active ) ;
505+
506+ try
507+ {
508+ await Task . Delay ( delay , cancellationToken ) ;
509+ var response = new HttpResponseMessage ( HttpStatusCode . OK ) { Content = new ByteArrayContent ( data ) } ;
510+ response . Content . Headers . ContentLength = data . Length ;
511+ return response ;
512+ }
513+ finally
514+ {
515+ Interlocked . Decrement ( ref this . _active ) ;
516+ }
517+ }
518+
519+ static void UpdateMaximum ( ref int maximum , int value )
520+ {
521+ var current = Volatile . Read ( ref maximum ) ;
522+ while ( value > current )
523+ {
524+ var observed = Interlocked . CompareExchange ( ref maximum , value , current ) ;
525+ if ( observed == current ) return ;
526+ current = observed ;
527+ }
528+ }
529+ }
530+
531+ sealed class FailoverHandler ( byte [ ] data , string successfulHost , string ? probeHost = null ) : HttpMessageHandler
532+ {
533+ int _wholeFileRequests ;
534+
535+ public ConcurrentBag < string > Hosts { get ; } = [ ] ;
536+ public int WholeFileRequests => Volatile . Read ( ref this . _wholeFileRequests ) ;
537+
538+ protected override Task < HttpResponseMessage > SendAsync (
539+ HttpRequestMessage request ,
540+ CancellationToken cancellationToken )
541+ {
542+ var host = request . RequestUri ! . Host ;
543+ this . Hosts . Add ( host ) ;
544+ var requested = request . Headers . Range ? . Ranges . SingleOrDefault ( ) ;
545+ if ( requested == null ) Interlocked . Increment ( ref this . _wholeFileRequests ) ;
546+
547+ var isProbe = requested is { From : 0 , To : 0 } &&
548+ string . Equals ( host , probeHost , StringComparison . OrdinalIgnoreCase ) ;
549+ if ( ! isProbe && ! string . Equals ( host , successfulHost , StringComparison . OrdinalIgnoreCase ) )
550+ return Task . FromResult ( new HttpResponseMessage ( HttpStatusCode . ServiceUnavailable ) ) ;
551+
552+ if ( requested == null )
553+ {
554+ var complete = new HttpResponseMessage ( HttpStatusCode . OK ) { Content = new ByteArrayContent ( data ) } ;
555+ complete . Content . Headers . ContentLength = data . Length ;
556+ return Task . FromResult ( complete ) ;
557+ }
558+
559+ var start = requested . From ?? 0 ;
560+ var end = requested . To ?? data . Length - 1 ;
561+ var length = checked ( ( int ) ( end - start + 1 ) ) ;
562+ var content = new ByteArrayContent ( data . AsSpan ( checked ( ( int ) start ) , length ) . ToArray ( ) ) ;
278563 content . Headers . ContentLength = length ;
279564 content . Headers . ContentRange = new ContentRangeHeaderValue ( start , end , data . Length ) ;
280565 return Task . FromResult ( new HttpResponseMessage ( HttpStatusCode . PartialContent ) { Content = content } ) ;
281566 }
282567 }
283568
569+ sealed class PerHostContentHandler ( IReadOnlyDictionary < string , byte [ ] > contentByHost ) : HttpMessageHandler
570+ {
571+ public ConcurrentBag < string > Hosts { get ; } = [ ] ;
572+
573+ protected override Task < HttpResponseMessage > SendAsync (
574+ HttpRequestMessage request ,
575+ CancellationToken cancellationToken )
576+ {
577+ var host = request . RequestUri ! . Host ;
578+ this . Hosts . Add ( host ) ;
579+ if ( ! contentByHost . TryGetValue ( host , out var data ) )
580+ return Task . FromResult ( new HttpResponseMessage ( HttpStatusCode . NotFound ) ) ;
581+
582+ var response = new HttpResponseMessage ( HttpStatusCode . OK ) { Content = new ByteArrayContent ( data ) } ;
583+ response . Content . Headers . ContentLength = data . Length ;
584+ return Task . FromResult ( response ) ;
585+ }
586+ }
587+
588+ sealed class UnknownLengthContent ( byte [ ] bytes ) : HttpContent
589+ {
590+ protected override Task SerializeToStreamAsync ( Stream stream , TransportContext ? context )
591+ {
592+ return stream . WriteAsync ( bytes ) . AsTask ( ) ;
593+ }
594+
595+ protected override bool TryComputeLength ( out long length )
596+ {
597+ length = 0 ;
598+ return false ;
599+ }
600+ }
601+
284602 sealed class ShortContent ( byte [ ] bytes , long advertisedLength ) : HttpContent
285603 {
286604 protected override Task SerializeToStreamAsync ( Stream stream , TransportContext ? context )
0 commit comments