Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -33,9 +34,20 @@ public abstract partial class PausableProgressItemViewModelBase : ProgressItemVi
public virtual bool SupportsPauseResume => true;
public virtual bool SupportsCancel => true;

/// <summary>
/// Override to true in subclasses that support manual retry after failure.
/// Defaults to false so unrelated progress item types are never affected.
/// </summary>
public virtual bool SupportsRetry => false;

public bool CanPauseResume => SupportsPauseResume && !IsCompleted && !IsPending;
public bool CanCancel => SupportsCancel && !IsCompleted;

/// <summary>
/// True only when this item supports retry AND is in the Failed state.
/// </summary>
public bool CanRetry => SupportsRetry && State == ProgressState.Failed;

private AsyncRelayCommand? pauseCommand;
public IAsyncRelayCommand PauseCommand => pauseCommand ??= new AsyncRelayCommand(Pause);

Expand All @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ private void OnProgressStateChanged(ProgressState state)
}
}

/// <summary>
/// Downloads support manual retry when they reach the Failed state.
/// </summary>
public override bool SupportsRetry => true;

/// <inheritdoc />
public override Task Cancel()
{
Expand All @@ -91,4 +96,14 @@ public override Task Resume()
{
return downloadService.TryResumeDownload(download);
}

/// <inheritdoc />
/// 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);
}
}
9 changes: 9 additions & 0 deletions StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@
IsVisible="{Binding CanCancel}">
<ui:SymbolIcon Symbol="Cancel" />
</Button>

<!-- Retry button: only visible when download is in Failed state -->
<Button
Classes="transparent-full"
Command="{Binding RetryCommand}"
IsVisible="{Binding CanRetry}"
ToolTip.Tip="Retry download">
<ui:SymbolIcon Symbol="Refresh" />
</Button>
</StackPanel>

<ProgressBar
Expand Down
78 changes: 76 additions & 2 deletions StabilityMatrix.Core/Models/TrackedDownload.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Authentication;
using System.Text.Json.Serialization;
using AsyncAwaitBestPractices;
using NLog;
Expand Down Expand Up @@ -77,7 +78,9 @@ public class TrackedDownload
[JsonIgnore]
public Exception? Exception { get; private set; }

private const int MaxRetryAttempts = 3;
private int attempts;
private CancellationTokenSource? retryDelayCancellationTokenSource;

#region Events
public event EventHandler<ProgressReport>? ProgressUpdate;
Expand Down Expand Up @@ -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<ProgressReport>(OnProgressUpdate);
Expand Down Expand Up @@ -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();
Expand All @@ -201,13 +214,17 @@ 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(
"Attempted to resume download {Download} but it is not paused ({State})",
FileName,
ProgressState
);
return;
}
Logger.Debug("Resuming download {Download}", FileName);

Expand Down Expand Up @@ -235,6 +252,9 @@ internal void Resume()

public void Pause()
{
// Cancel any pending auto-retry delay.
CancelRetryDelay();

if (ProgressState != ProgressState.Working)
{
Logger.Warn(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -316,6 +339,16 @@ private void DoCleanup()
}
}

/// <summary>
/// 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).
/// </summary>
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);

/// <summary>
/// Invoked by the task's completion callback
/// </summary>
Expand Down Expand Up @@ -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(
Expand All @@ -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;
}

Expand Down Expand Up @@ -392,6 +455,17 @@ private void OnDownloadTaskCompleted(Task task)
downloadPauseTokenSource = null;
}

/// <summary>
/// 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.
/// </summary>
public void ResetAttempts()
{
attempts = 0;
ProgressState = ProgressState.Inactive;
}

public void SetDownloadService(IDownloadService service)
{
downloadService = service;
Expand Down
2 changes: 2 additions & 0 deletions StabilityMatrix.Core/Services/ITrackedDownloadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
40 changes: 40 additions & 0 deletions StabilityMatrix.Core/Services/TrackedDownloadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading