@@ -319,6 +319,10 @@ private void DoCleanup()
319319
320320 /// <summary>
321321 /// Returns true for transient network/SSL exceptions that are safe to retry.
322+ /// Catches direct IOException/AuthenticationException, the same types wrapped as
323+ /// InnerException (common AggregateException shape from HttpClient), and any leg
324+ /// of a multi-inner AggregateException — covering VPN tunnel resets
325+ /// ("Connection reset by peer") and TLS re-key failures (OpenSSL SSL_ERROR_SSL).
322326 /// </summary>
323327 private static bool IsTransientNetworkException ( Exception ? ex ) =>
324328 ex is IOException or AuthenticationException
@@ -369,9 +373,26 @@ private void OnDownloadTaskCompleted(Task task)
369373 attempts
370374 ) ;
371375
376+ // Exponential backoff: 2 s → 4 s → 8 s, capped at 30 s, ±500 ms jitter.
377+ // Gives the VPN tunnel time to re-key/re-route before reconnecting,
378+ // which prevents the retry from hitting the same torn connection.
379+ var delayMs =
380+ ( int ) Math . Min ( 2000 * Math . Pow ( 2 , attempts - 1 ) , 30_000 ) + Random . Shared . Next ( - 500 , 500 ) ;
381+ Logger . Debug (
382+ "Download {Download} retrying in {Delay}ms (attempt {Attempt}/3)" ,
383+ FileName ,
384+ delayMs ,
385+ attempts
386+ ) ;
387+
388+ // Persist Inactive state to disk before the delay so that a restart
389+ // during the backoff window loads the download as a resumable entry.
372390 OnProgressStateChanging ( ProgressState . Inactive ) ;
373391 ProgressState = ProgressState . Inactive ;
374- Resume ( ) ;
392+ OnProgressStateChanged ( ProgressState . Inactive ) ;
393+
394+ // Fire-and-forget the delayed resume to avoid blocking the task continuation thread.
395+ Task . Delay ( Math . Max ( delayMs , 0 ) ) . ContinueWith ( _ => Resume ( ) ) . SafeFireAndForget ( ) ;
375396 return ;
376397 }
377398
0 commit comments