Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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