11using System . Diagnostics . CodeAnalysis ;
2+ using System . Security . Authentication ;
23using System . Text . Json . Serialization ;
34using AsyncAwaitBestPractices ;
45using NLog ;
@@ -77,7 +78,9 @@ public class TrackedDownload
7778 [ JsonIgnore ]
7879 public Exception ? Exception { get ; private set ; }
7980
81+ private const int MaxRetryAttempts = 3 ;
8082 private int attempts ;
83+ private CancellationTokenSource ? retryDelayCancellationTokenSource ;
8184
8285 #region Events
8386 public event EventHandler < ProgressReport > ? ProgressUpdate ;
@@ -119,6 +122,13 @@ private void EnsureDownloadService()
119122 }
120123 }
121124
125+ private void CancelRetryDelay ( )
126+ {
127+ retryDelayCancellationTokenSource ? . Cancel ( ) ;
128+ retryDelayCancellationTokenSource ? . Dispose ( ) ;
129+ retryDelayCancellationTokenSource = null ;
130+ }
131+
122132 private async Task StartDownloadTask ( long resumeFromByte , CancellationToken cancellationToken )
123133 {
124134 var progress = new Progress < ProgressReport > ( OnProgressUpdate ) ;
@@ -184,6 +194,9 @@ internal void Start()
184194 $ "Download state must be inactive or pending to start, not { ProgressState } "
185195 ) ;
186196 }
197+ // Cancel any pending auto-retry delay (defensive: Start() accepts Inactive state).
198+ CancelRetryDelay ( ) ;
199+
187200 Logger . Debug ( "Starting download {Download}" , FileName ) ;
188201
189202 EnsureDownloadService ( ) ;
@@ -201,13 +214,21 @@ internal void Start()
201214
202215 internal void Resume ( )
203216 {
204- if ( ProgressState != ProgressState . Inactive && ProgressState != ProgressState . Paused )
217+ // Cancel any pending auto-retry delay since we're resuming now.
218+ CancelRetryDelay ( ) ;
219+
220+ if (
221+ ProgressState != ProgressState . Inactive
222+ && ProgressState != ProgressState . Paused
223+ && ProgressState != ProgressState . Pending
224+ )
205225 {
206226 Logger . Warn (
207227 "Attempted to resume download {Download} but it is not paused ({State})" ,
208228 FileName ,
209229 ProgressState
210230 ) ;
231+ return ;
211232 }
212233 Logger . Debug ( "Resuming download {Download}" , FileName ) ;
213234
@@ -235,6 +256,9 @@ internal void Resume()
235256
236257 public void Pause ( )
237258 {
259+ // Cancel any pending auto-retry delay.
260+ CancelRetryDelay ( ) ;
261+
238262 if ( ProgressState != ProgressState . Working )
239263 {
240264 Logger . Warn (
@@ -252,6 +276,33 @@ public void Pause()
252276 OnProgressStateChanged ( ProgressState ) ;
253277 }
254278
279+ /// <summary>
280+ /// Cleans up temp file and all sidecar files (e.g. .cm-info.json, preview image)
281+ /// for a download that has already failed and will not be retried.
282+ /// This transitions the state to <see cref="ProgressState.Cancelled"/> so the
283+ /// service removes the tracking entry.
284+ /// </summary>
285+ public void Dismiss ( )
286+ {
287+ if ( ProgressState != ProgressState . Failed )
288+ {
289+ Logger . Warn (
290+ "Attempted to dismiss download {Download} but it is not in a failed state ({State})" ,
291+ FileName ,
292+ ProgressState
293+ ) ;
294+ return ;
295+ }
296+
297+ Logger . Debug ( "Dismissing failed download {Download}" , FileName ) ;
298+
299+ DoCleanup ( ) ;
300+
301+ OnProgressStateChanging ( ProgressState . Cancelled ) ;
302+ ProgressState = ProgressState . Cancelled ;
303+ OnProgressStateChanged ( ProgressState ) ;
304+ }
305+
255306 public void Cancel ( )
256307 {
257308 if ( ProgressState is not ( ProgressState . Working or ProgressState . Inactive ) )
@@ -264,6 +315,9 @@ public void Cancel()
264315 return ;
265316 }
266317
318+ // Cancel any pending auto-retry delay.
319+ CancelRetryDelay ( ) ;
320+
267321 Logger . Debug ( "Cancelling download {Download}" , FileName ) ;
268322
269323 // Cancel token if it exists
@@ -290,9 +344,12 @@ public void SetPending()
290344 }
291345
292346 /// <summary>
293- /// Deletes the temp file and any extra cleanup files
347+ /// Deletes the temp file and, optionally, any extra cleanup files (e.g. sidecar metadata).
348+ /// Pass <paramref name="includeExtraCleanupFiles"/> as <c>false</c> when the download
349+ /// failed but may be retried — sidecar files (.cm-info.json, preview image) should survive
350+ /// so a manual retry doesn't need to recreate them.
294351 /// </summary>
295- private void DoCleanup ( )
352+ private void DoCleanup ( bool includeExtraCleanupFiles = true )
296353 {
297354 try
298355 {
@@ -303,6 +360,9 @@ private void DoCleanup()
303360 Logger . Warn ( "Failed to delete temp file {TempFile}" , TempFileName ) ;
304361 }
305362
363+ if ( ! includeExtraCleanupFiles )
364+ return ;
365+
306366 foreach ( var extraFile in ExtraCleanupFileNames )
307367 {
308368 try
@@ -316,6 +376,16 @@ private void DoCleanup()
316376 }
317377 }
318378
379+ /// <summary>
380+ /// Returns true for transient network/SSL exceptions that are safe to retry (ie: VPN tunnel resets or TLS re-key failures)
381+ /// (IOException, AuthenticationException, or either wrapped in an AggregateException).
382+ /// </summary>
383+ private static bool IsTransientNetworkException ( Exception ? ex ) =>
384+ ex is IOException or AuthenticationException
385+ || ex ? . InnerException is IOException or AuthenticationException
386+ || ex is AggregateException ae
387+ && ae . InnerExceptions . Any ( e => e is IOException or AuthenticationException ) ;
388+
319389 /// <summary>
320390 /// Invoked by the task's completion callback
321391 /// </summary>
@@ -349,7 +419,7 @@ private void OnDownloadTaskCompleted(Task task)
349419 // Set the exception
350420 Exception = task . Exception ;
351421
352- if ( ( Exception is IOException || Exception ? . InnerException is IOException ) && attempts < 3 )
422+ if ( IsTransientNetworkException ( Exception ) && attempts < MaxRetryAttempts )
353423 {
354424 attempts ++ ;
355425 Logger . Warn (
@@ -359,9 +429,39 @@ private void OnDownloadTaskCompleted(Task task)
359429 attempts
360430 ) ;
361431
432+ // Exponential backoff: 2 s → 4 s → 8 s, capped at 30 s, ±500 ms jitter.
433+ // Gives the VPN tunnel time to re-key/re-route before reconnecting,
434+ // which prevents the retry from hitting the same torn connection.
435+ var delayMs =
436+ ( int ) Math . Min ( 2000 * Math . Pow ( 2 , attempts - 1 ) , 30_000 ) + Random . Shared . Next ( - 500 , 500 ) ;
437+ Logger . Debug (
438+ "Download {Download} retrying in {Delay}ms (attempt {Attempt}/{MaxAttempts})" ,
439+ FileName ,
440+ delayMs ,
441+ attempts ,
442+ MaxRetryAttempts
443+ ) ;
444+
445+ // Persist Inactive to disk before the delay so a restart during backoff loads it as resumable.
362446 OnProgressStateChanging ( ProgressState . Inactive ) ;
363447 ProgressState = ProgressState . Inactive ;
364- Resume ( ) ;
448+ OnProgressStateChanged ( ProgressState . Inactive ) ;
449+
450+ // Clean up the completed task resources; Resume() will create new ones.
451+ downloadTask = null ;
452+ downloadCancellationTokenSource = null ;
453+ downloadPauseTokenSource = null ;
454+
455+ // Schedule the retry with a cancellation token so Cancel/Pause can abort the delay.
456+ retryDelayCancellationTokenSource ? . Dispose ( ) ;
457+ retryDelayCancellationTokenSource = new CancellationTokenSource ( ) ;
458+ Task . Delay ( Math . Max ( delayMs , 0 ) , retryDelayCancellationTokenSource . Token )
459+ . ContinueWith ( t =>
460+ {
461+ if ( t . IsCompletedSuccessfully )
462+ Resume ( ) ;
463+ } )
464+ . SafeFireAndForget ( ) ;
365465 return ;
366466 }
367467
@@ -377,11 +477,17 @@ private void OnDownloadTaskCompleted(Task task)
377477 ProgressState = ProgressState . Success ;
378478 }
379479
380- // For failed or cancelled, delete the temp files
381- if ( ProgressState is ProgressState . Failed or ProgressState . Cancelled )
480+ // For cancelled, delete the temp file and any sidecar metadata.
481+ // For failed, only delete the temp file — sidecar files (.cm-info.json, preview image)
482+ // are preserved so a manual retry doesn't need to recreate them.
483+ if ( ProgressState is ProgressState . Cancelled )
382484 {
383485 DoCleanup ( ) ;
384486 }
487+ else if ( ProgressState is ProgressState . Failed )
488+ {
489+ DoCleanup ( includeExtraCleanupFiles : false ) ;
490+ }
385491 // For pause, just do nothing
386492
387493 OnProgressStateChanged ( ProgressState ) ;
@@ -392,6 +498,17 @@ private void OnDownloadTaskCompleted(Task task)
392498 downloadPauseTokenSource = null ;
393499 }
394500
501+ /// <summary>
502+ /// Resets the retry counter and silently sets state to Inactive without firing events.
503+ /// Must be called before re-adding to TrackedDownloadService to avoid events
504+ /// firing while the download is absent from the dictionary.
505+ /// </summary>
506+ public void ResetAttempts ( )
507+ {
508+ attempts = 0 ;
509+ ProgressState = ProgressState . Inactive ;
510+ }
511+
395512 public void SetDownloadService ( IDownloadService service )
396513 {
397514 downloadService = service ;
0 commit comments