@@ -54,6 +54,17 @@ internal class MultipartDownloadManager : IDownloadManager
5454 private string _savedETag ;
5555 private int _discoveredPartCount ;
5656
57+ // Progress tracking fields for multipart download aggregation
58+ private long _totalTransferredBytes = 0 ;
59+ private long _totalObjectSize = 0 ;
60+ private EventHandler < WriteObjectProgressArgs > _userProgressCallback ;
61+
62+ // Atomic flag to ensure completion event fires exactly once
63+ // Without this, concurrent parts completing simultaneously can both see
64+ // transferredBytes >= _totalObjectSize and fire duplicate completion events
65+ // Uses int instead of bool because Interlocked.CompareExchange requires reference types
66+ private int _completionEventFired = 0 ; // 0 = false, 1 = true
67+
5768 private Logger Logger
5869 {
5970 get { return Logger . GetLogger ( typeof ( TransferUtility ) ) ; }
@@ -133,13 +144,17 @@ public async Task<DownloadDiscoveryResult> DiscoverDownloadStrategyAsync(Cancell
133144 }
134145
135146 /// <inheritdoc/>
136- public async Task StartDownloadsAsync ( DownloadDiscoveryResult discoveryResult , CancellationToken cancellationToken )
147+ public async Task StartDownloadsAsync ( DownloadDiscoveryResult discoveryResult , CancellationToken cancellationToken , EventHandler < WriteObjectProgressArgs > progressCallback = null )
137148 {
138149 ThrowIfDisposed ( ) ;
139150
140151 if ( discoveryResult == null )
141152 throw new ArgumentNullException ( nameof ( discoveryResult ) ) ;
142153
154+ // Store for progress aggregation
155+ _userProgressCallback = progressCallback ;
156+ _totalObjectSize = discoveryResult . ObjectSize ;
157+
143158 Logger . DebugFormat ( "MultipartDownloadManager: Starting downloads - TotalParts={0}, IsSinglePart={1}" ,
144159 discoveryResult . TotalParts , discoveryResult . IsSinglePart ) ;
145160
@@ -151,10 +166,27 @@ public async Task StartDownloadsAsync(DownloadDiscoveryResult discoveryResult, C
151166 // Prepare the data handler (e.g., create temp files for file-based downloads)
152167 await _dataHandler . PrepareAsync ( discoveryResult , cancellationToken ) . ConfigureAwait ( false ) ;
153168
169+ // Create delegate once and reuse for all parts
170+ var wrappedCallback = progressCallback != null
171+ ? new EventHandler < WriteObjectProgressArgs > ( DownloadPartProgressEventCallback )
172+ : null ;
173+
174+ // Attach progress callback to Part 1's response if provided
175+ if ( wrappedCallback != null )
176+ {
177+ discoveryResult . InitialResponse . WriteObjectProgressEvent += wrappedCallback ;
178+ }
179+
154180 // Process Part 1 from InitialResponse (applies to both single-part and multipart)
155181 Logger . DebugFormat ( "MultipartDownloadManager: Buffering Part 1 from discovery response" ) ;
156182 await _dataHandler . ProcessPartAsync ( 1 , discoveryResult . InitialResponse , cancellationToken ) . ConfigureAwait ( false ) ;
157183
184+ // Detach the event handler after processing to prevent memory leak
185+ if ( wrappedCallback != null )
186+ {
187+ discoveryResult . InitialResponse . WriteObjectProgressEvent -= wrappedCallback ;
188+ }
189+
158190 if ( discoveryResult . IsSinglePart )
159191 {
160192 // Single-part: Part 1 is the entire object
@@ -169,7 +201,7 @@ public async Task StartDownloadsAsync(DownloadDiscoveryResult discoveryResult, C
169201
170202 for ( int partNum = 2 ; partNum <= discoveryResult . TotalParts ; partNum ++ )
171203 {
172- var task = CreateDownloadTaskAsync ( partNum , discoveryResult . ObjectSize , internalCts . Token ) ;
204+ var task = CreateDownloadTaskAsync ( partNum , discoveryResult . ObjectSize , wrappedCallback , internalCts . Token ) ;
173205 downloadTasks . Add ( task ) ;
174206 }
175207
@@ -245,7 +277,7 @@ public async Task StartDownloadsAsync(DownloadDiscoveryResult discoveryResult, C
245277
246278
247279
248- private async Task CreateDownloadTaskAsync ( int partNumber , long objectSize , CancellationToken cancellationToken )
280+ private async Task CreateDownloadTaskAsync ( int partNumber , long objectSize , EventHandler < WriteObjectProgressArgs > progressCallback , CancellationToken cancellationToken )
249281 {
250282 Logger . DebugFormat ( "MultipartDownloadManager: [Part {0}] Waiting for buffer space" , partNumber ) ;
251283
@@ -301,6 +333,12 @@ private async Task CreateDownloadTaskAsync(int partNumber, long objectSize, Canc
301333 }
302334
303335 response = await _s3Client . GetObjectAsync ( getObjectRequest , cancellationToken ) . ConfigureAwait ( false ) ;
336+
337+ // Attach progress callback to response if provided
338+ if ( progressCallback != null )
339+ {
340+ response . WriteObjectProgressEvent += progressCallback ;
341+ }
304342
305343 Logger . DebugFormat ( "MultipartDownloadManager: [Part {0}] GetObject response received - ContentLength={1}" ,
306344 partNumber , response . ContentLength ) ;
@@ -553,6 +591,53 @@ internal void ValidateContentRange(GetObjectResponse response, int partNumber, l
553591 }
554592 }
555593
594+ /// <summary>
595+ /// Creates progress args with aggregated values for multipart downloads.
596+ /// </summary>
597+ private WriteObjectProgressArgs CreateProgressArgs ( long incrementTransferred , long transferredBytes , bool completed = false )
598+ {
599+ string filePath = ( _request as TransferUtilityDownloadRequest ) ? . FilePath ;
600+
601+ return new WriteObjectProgressArgs (
602+ _request . BucketName ,
603+ _request . Key ,
604+ filePath ,
605+ _request . VersionId ,
606+ incrementTransferred ,
607+ transferredBytes ,
608+ _totalObjectSize ,
609+ completed
610+ ) ;
611+ }
612+
613+ /// <summary>
614+ /// Progress aggregation callback that combines progress across all concurrent part downloads.
615+ /// Uses thread-safe counter increment to handle concurrent updates.
616+ /// Detects completion naturally when transferred bytes reaches total size.
617+ /// Uses atomic flag to ensure completion event fires exactly once.
618+ /// </summary>
619+ private void DownloadPartProgressEventCallback ( object sender , WriteObjectProgressArgs e )
620+ {
621+ long transferredBytes = Interlocked . Add ( ref _totalTransferredBytes , e . IncrementTransferred ) ;
622+
623+ // Use atomic CompareExchange to ensure only first thread fires completion
624+ bool isComplete = false ;
625+ if ( transferredBytes >= _totalObjectSize )
626+ {
627+ // CompareExchange returns the original value before the exchange
628+ // If original value was 0 (false), we're the first thread and should fire completion
629+ int originalValue = Interlocked . CompareExchange ( ref _completionEventFired , 1 , 0 ) ;
630+ if ( originalValue == 0 ) // Was false, now set to true
631+ {
632+ isComplete = true ;
633+ }
634+ }
635+
636+ // Create and fire aggregated progress event
637+ var aggregatedArgs = CreateProgressArgs ( e . IncrementTransferred , transferredBytes , isComplete ) ;
638+ _userProgressCallback ? . Invoke ( this , aggregatedArgs ) ;
639+ }
640+
556641 private void ThrowIfDisposed ( )
557642 {
558643 if ( _disposed )
0 commit comments