diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs index 0881e65d4..6ecea5df4 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs @@ -14,7 +14,8 @@ public abstract partial class PausableProgressItemViewModelBase : ProgressItemVi nameof(IsPaused), nameof(IsCompleted), nameof(CanPauseResume), - nameof(CanCancel) + nameof(CanCancel), + nameof(CanRetry) )] private ProgressState state = ProgressState.Inactive; @@ -33,9 +34,20 @@ public abstract partial class PausableProgressItemViewModelBase : ProgressItemVi public virtual bool SupportsPauseResume => true; public virtual bool SupportsCancel => true; + /// + /// Override to true in subclasses that support manual retry after failure. + /// Defaults to false so unrelated progress item types are never affected. + /// + public virtual bool SupportsRetry => false; + public bool CanPauseResume => SupportsPauseResume && !IsCompleted && !IsPending; public bool CanCancel => SupportsCancel && !IsCompleted; + /// + /// True only when this item supports retry AND is in the Failed state. + /// + public bool CanRetry => SupportsRetry && State == ProgressState.Failed; + private AsyncRelayCommand? pauseCommand; public IAsyncRelayCommand PauseCommand => pauseCommand ??= new AsyncRelayCommand(Pause); @@ -51,6 +63,11 @@ public abstract partial class PausableProgressItemViewModelBase : ProgressItemVi public virtual Task Cancel() => Task.CompletedTask; + private AsyncRelayCommand? retryCommand; + public IAsyncRelayCommand RetryCommand => retryCommand ??= new AsyncRelayCommand(Retry); + + public virtual Task Retry() => Task.CompletedTask; + [RelayCommand] private Task TogglePauseResume() { diff --git a/StabilityMatrix.Avalonia/ViewModels/Progress/DownloadProgressItemViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Progress/DownloadProgressItemViewModel.cs index 04809ec28..ff6a2bc7f 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Progress/DownloadProgressItemViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Progress/DownloadProgressItemViewModel.cs @@ -71,6 +71,11 @@ private void OnProgressStateChanged(ProgressState state) } } + /// + /// Downloads support manual retry when they reach the Failed state. + /// + public override bool SupportsRetry => true; + /// public override Task Cancel() { @@ -91,4 +96,14 @@ public override Task Resume() { return downloadService.TryResumeDownload(download); } + + /// + /// Resets the internal retry counter so the user gets a fresh 3-attempt budget, + /// then re-registers the download in the service dictionary (it was removed on + /// failure) and resumes it through the normal concurrency queue. + public override Task Retry() + { + download.ResetAttempts(); + return downloadService.TryRestartDownload(download); + } } diff --git a/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml b/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml index c22862001..93cc353ae 100644 --- a/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml +++ b/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml @@ -113,6 +113,15 @@ IsVisible="{Binding CanCancel}"> + + + ? ProgressUpdate; @@ -119,6 +122,13 @@ private void EnsureDownloadService() } } + private void CancelRetryDelay() + { + retryDelayCancellationTokenSource?.Cancel(); + retryDelayCancellationTokenSource?.Dispose(); + retryDelayCancellationTokenSource = null; + } + private async Task StartDownloadTask(long resumeFromByte, CancellationToken cancellationToken) { var progress = new Progress(OnProgressUpdate); @@ -184,6 +194,9 @@ internal void Start() $"Download state must be inactive or pending to start, not {ProgressState}" ); } + // Cancel any pending auto-retry delay (defensive: Start() accepts Inactive state). + CancelRetryDelay(); + Logger.Debug("Starting download {Download}", FileName); EnsureDownloadService(); @@ -201,6 +214,9 @@ internal void Start() internal void Resume() { + // Cancel any pending auto-retry delay since we're resuming now. + CancelRetryDelay(); + if (ProgressState != ProgressState.Inactive && ProgressState != ProgressState.Paused) { Logger.Warn( @@ -208,6 +224,7 @@ internal void Resume() FileName, ProgressState ); + return; } Logger.Debug("Resuming download {Download}", FileName); @@ -235,6 +252,9 @@ internal void Resume() public void Pause() { + // Cancel any pending auto-retry delay. + CancelRetryDelay(); + if (ProgressState != ProgressState.Working) { Logger.Warn( @@ -264,6 +284,9 @@ public void Cancel() return; } + // Cancel any pending auto-retry delay. + CancelRetryDelay(); + Logger.Debug("Cancelling download {Download}", FileName); // Cancel token if it exists @@ -316,6 +339,16 @@ private void DoCleanup() } } + /// + /// Returns true for transient network/SSL exceptions that are safe to retry (ie: VPN tunnel resets or TLS re-key failures) + /// (IOException, AuthenticationException, or either wrapped in an AggregateException). + /// + private static bool IsTransientNetworkException(Exception? ex) => + ex is IOException or AuthenticationException + || ex?.InnerException is IOException or AuthenticationException + || ex is AggregateException ae + && ae.InnerExceptions.Any(e => e is IOException or AuthenticationException); + /// /// Invoked by the task's completion callback /// @@ -349,7 +382,7 @@ private void OnDownloadTaskCompleted(Task task) // Set the exception Exception = task.Exception; - if ((Exception is IOException || Exception?.InnerException is IOException) && attempts < 3) + if (IsTransientNetworkException(Exception) && attempts < MaxRetryAttempts) { attempts++; Logger.Warn( @@ -359,9 +392,39 @@ private void OnDownloadTaskCompleted(Task task) attempts ); + // Exponential backoff: 2 s → 4 s → 8 s, capped at 30 s, ±500 ms jitter. + // Gives the VPN tunnel time to re-key/re-route before reconnecting, + // which prevents the retry from hitting the same torn connection. + var delayMs = + (int)Math.Min(2000 * Math.Pow(2, attempts - 1), 30_000) + Random.Shared.Next(-500, 500); + Logger.Debug( + "Download {Download} retrying in {Delay}ms (attempt {Attempt}/{MaxAttempts})", + FileName, + delayMs, + attempts, + MaxRetryAttempts + ); + + // Persist Inactive to disk before the delay so a restart during backoff loads it as resumable. OnProgressStateChanging(ProgressState.Inactive); ProgressState = ProgressState.Inactive; - Resume(); + OnProgressStateChanged(ProgressState.Inactive); + + // Clean up the completed task resources; Resume() will create new ones. + downloadTask = null; + downloadCancellationTokenSource = null; + downloadPauseTokenSource = null; + + // Schedule the retry with a cancellation token so Cancel/Pause can abort the delay. + retryDelayCancellationTokenSource?.Dispose(); + retryDelayCancellationTokenSource = new CancellationTokenSource(); + Task.Delay(Math.Max(delayMs, 0), retryDelayCancellationTokenSource.Token) + .ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + Resume(); + }) + .SafeFireAndForget(); return; } @@ -392,6 +455,17 @@ private void OnDownloadTaskCompleted(Task task) downloadPauseTokenSource = null; } + /// + /// Resets the retry counter and silently sets state to Inactive without firing events. + /// Must be called before re-adding to TrackedDownloadService to avoid events + /// firing while the download is absent from the dictionary. + /// + public void ResetAttempts() + { + attempts = 0; + ProgressState = ProgressState.Inactive; + } + public void SetDownloadService(IDownloadService service) { downloadService = service; diff --git a/StabilityMatrix.Core/Services/ITrackedDownloadService.cs b/StabilityMatrix.Core/Services/ITrackedDownloadService.cs index ee1e2ba85..86c00da32 100644 --- a/StabilityMatrix.Core/Services/ITrackedDownloadService.cs +++ b/StabilityMatrix.Core/Services/ITrackedDownloadService.cs @@ -15,5 +15,7 @@ TrackedDownload NewDownload(string downloadUrl, FilePath downloadPath) => NewDownload(new Uri(downloadUrl), downloadPath); Task TryStartDownload(TrackedDownload download); Task TryResumeDownload(TrackedDownload download); + Task TryRestartDownload(TrackedDownload download); + void UpdateMaxConcurrentDownloads(int newMax); } diff --git a/StabilityMatrix.Core/Services/TrackedDownloadService.cs b/StabilityMatrix.Core/Services/TrackedDownloadService.cs index 12cf3ca7d..69bb23637 100644 --- a/StabilityMatrix.Core/Services/TrackedDownloadService.cs +++ b/StabilityMatrix.Core/Services/TrackedDownloadService.cs @@ -129,6 +129,46 @@ public async Task TryStartDownload(TrackedDownload download) } } + public async Task TryRestartDownload(TrackedDownload download) + { + // Re-create the backing JSON file and re-add to the dictionary. + // Downloads are removed on failure, so this restores the tracking entry + // so that subsequent state-change events can persist normally. + var downloadsDir = new DirectoryPath(settingsManager.DownloadsDirectory); + downloadsDir.Create(); + var jsonFile = downloadsDir.JoinFile($"{download.Id}.json"); + + var jsonFileStream = new FileStream( + jsonFile.Info.FullName, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.Read, + bufferSize: 4096, + useAsync: true + ); + var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(download); + + try + { + await jsonFileStream.WriteAsync(jsonBytes).ConfigureAwait(false); + await jsonFileStream.FlushAsync().ConfigureAwait(false); + + // Handlers are already attached from the original AddDownload call. + if (!downloads.TryAdd(download.Id, (download, jsonFileStream))) + { + // Already tracked; discard the newly opened stream. + await jsonFileStream.DisposeAsync().ConfigureAwait(false); + } + } + catch + { + await jsonFileStream.DisposeAsync().ConfigureAwait(false); + throw; + } + + await TryResumeDownload(download).ConfigureAwait(false); + } + public async Task TryResumeDownload(TrackedDownload download) { if (IsQueueEnabled && ActiveDownloads >= MaxConcurrentDownloads)