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)