Skip to content

Commit 2feb162

Browse files
authored
Merge pull request LykosAI#1574 from NeuralFault/vpn-ssl-downloadfix
Fix: Model downloads failing on VPN connections (SSL/TLS decrypt error). QoL: Retry button
2 parents 90da7a8 + e393c9e commit 2feb162

7 files changed

Lines changed: 433 additions & 9 deletions

File tree

StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ public abstract partial class PausableProgressItemViewModelBase : ProgressItemVi
1414
nameof(IsPaused),
1515
nameof(IsCompleted),
1616
nameof(CanPauseResume),
17-
nameof(CanCancel)
17+
nameof(CanCancel),
18+
nameof(CanRetry),
19+
nameof(CanDismiss)
1820
)]
1921
private ProgressState state = ProgressState.Inactive;
2022

@@ -33,9 +35,31 @@ public abstract partial class PausableProgressItemViewModelBase : ProgressItemVi
3335
public virtual bool SupportsPauseResume => true;
3436
public virtual bool SupportsCancel => true;
3537

38+
/// <summary>
39+
/// Override to true in subclasses that support manual retry after failure.
40+
/// Defaults to false so unrelated progress item types are never affected.
41+
/// </summary>
42+
public virtual bool SupportsRetry => false;
43+
44+
/// <summary>
45+
/// Override to true in subclasses that support dismissing a failed item,
46+
/// which runs full sidecar cleanup before removing the entry.
47+
/// </summary>
48+
public virtual bool SupportsDismiss => false;
49+
3650
public bool CanPauseResume => SupportsPauseResume && !IsCompleted && !IsPending;
3751
public bool CanCancel => SupportsCancel && !IsCompleted;
3852

53+
/// <summary>
54+
/// True only when this item supports retry AND is in the Failed state.
55+
/// </summary>
56+
public bool CanRetry => SupportsRetry && State == ProgressState.Failed;
57+
58+
/// <summary>
59+
/// True only when this item supports dismiss AND is in the Failed state.
60+
/// </summary>
61+
public bool CanDismiss => SupportsDismiss && State == ProgressState.Failed;
62+
3963
private AsyncRelayCommand? pauseCommand;
4064
public IAsyncRelayCommand PauseCommand => pauseCommand ??= new AsyncRelayCommand(Pause);
4165

@@ -51,6 +75,16 @@ public abstract partial class PausableProgressItemViewModelBase : ProgressItemVi
5175

5276
public virtual Task Cancel() => Task.CompletedTask;
5377

78+
private AsyncRelayCommand? retryCommand;
79+
public IAsyncRelayCommand RetryCommand => retryCommand ??= new AsyncRelayCommand(Retry);
80+
81+
public virtual Task Retry() => Task.CompletedTask;
82+
83+
private AsyncRelayCommand? dismissCommand;
84+
public IAsyncRelayCommand DismissCommand => dismissCommand ??= new AsyncRelayCommand(Dismiss);
85+
86+
public virtual Task Dismiss() => Task.CompletedTask;
87+
5488
[RelayCommand]
5589
private Task TogglePauseResume()
5690
{

StabilityMatrix.Avalonia/ViewModels/Progress/DownloadProgressItemViewModel.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ private void OnProgressStateChanged(ProgressState state)
7171
}
7272
}
7373

74+
/// <summary>
75+
/// Downloads support manual retry when they reach the Failed state.
76+
/// </summary>
77+
public override bool SupportsRetry => true;
78+
79+
/// <summary>
80+
/// Downloads support dismiss, which cleans up all sidecar files when
81+
/// the user discards a failed download without retrying.
82+
/// </summary>
83+
public override bool SupportsDismiss => true;
84+
7485
/// <inheritdoc />
7586
public override Task Cancel()
7687
{
@@ -91,4 +102,23 @@ public override Task Resume()
91102
{
92103
return downloadService.TryResumeDownload(download);
93104
}
105+
106+
/// <inheritdoc />
107+
/// Resets the internal retry counter so the user gets a fresh 3-attempt budget,
108+
/// then re-registers the download in the service dictionary (it was removed on
109+
/// failure) and resumes it through the normal concurrency queue.
110+
public override Task Retry()
111+
{
112+
download.ResetAttempts();
113+
return downloadService.TryRestartDownload(download);
114+
}
115+
116+
/// <inheritdoc />
117+
/// Runs full cleanup (temp file + sidecar files) for a failed download the user
118+
/// chooses not to retry, then transitions to Cancelled so the service removes it.
119+
public override Task Dismiss()
120+
{
121+
download.Dismiss();
122+
return Task.CompletedTask;
123+
}
94124
}

StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,24 @@
113113
IsVisible="{Binding CanCancel}">
114114
<ui:SymbolIcon Symbol="Cancel" />
115115
</Button>
116+
117+
<!-- Retry button: only visible when download is in Failed state -->
118+
<Button
119+
Classes="transparent-full"
120+
Command="{Binding RetryCommand}"
121+
IsVisible="{Binding CanRetry}"
122+
ToolTip.Tip="Retry download">
123+
<ui:SymbolIcon Symbol="Refresh" />
124+
</Button>
125+
126+
<!-- Dismiss button: cleans up sidecar files for failed downloads that won't be retried -->
127+
<Button
128+
Classes="transparent-full"
129+
Command="{Binding DismissCommand}"
130+
IsVisible="{Binding CanDismiss}"
131+
ToolTip.Tip="Dismiss and clean up">
132+
<ui:SymbolIcon Symbol="Delete" />
133+
</Button>
116134
</StackPanel>
117135

118136
<ProgressBar

StabilityMatrix.Core/Models/TrackedDownload.cs

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Diagnostics.CodeAnalysis;
2+
using System.Security.Authentication;
23
using System.Text.Json.Serialization;
34
using AsyncAwaitBestPractices;
45
using NLog;
@@ -77,7 +78,9 @@ public class TrackedDownload
7778
[JsonIgnore]
7879
public Exception? Exception { get; private set; }
7980

81+
private const int MaxRetryAttempts = 3;
8082
private int attempts;
83+
private CancellationTokenSource? retryDelayCancellationTokenSource;
8184

8285
#region Events
8386
public event EventHandler<ProgressReport>? ProgressUpdate;
@@ -119,6 +122,13 @@ private void EnsureDownloadService()
119122
}
120123
}
121124

125+
private void CancelRetryDelay()
126+
{
127+
retryDelayCancellationTokenSource?.Cancel();
128+
retryDelayCancellationTokenSource?.Dispose();
129+
retryDelayCancellationTokenSource = null;
130+
}
131+
122132
private async Task StartDownloadTask(long resumeFromByte, CancellationToken cancellationToken)
123133
{
124134
var progress = new Progress<ProgressReport>(OnProgressUpdate);
@@ -184,6 +194,9 @@ internal void Start()
184194
$"Download state must be inactive or pending to start, not {ProgressState}"
185195
);
186196
}
197+
// Cancel any pending auto-retry delay (defensive: Start() accepts Inactive state).
198+
CancelRetryDelay();
199+
187200
Logger.Debug("Starting download {Download}", FileName);
188201

189202
EnsureDownloadService();
@@ -201,13 +214,21 @@ internal void Start()
201214

202215
internal void Resume()
203216
{
204-
if (ProgressState != ProgressState.Inactive && ProgressState != ProgressState.Paused)
217+
// Cancel any pending auto-retry delay since we're resuming now.
218+
CancelRetryDelay();
219+
220+
if (
221+
ProgressState != ProgressState.Inactive
222+
&& ProgressState != ProgressState.Paused
223+
&& ProgressState != ProgressState.Pending
224+
)
205225
{
206226
Logger.Warn(
207227
"Attempted to resume download {Download} but it is not paused ({State})",
208228
FileName,
209229
ProgressState
210230
);
231+
return;
211232
}
212233
Logger.Debug("Resuming download {Download}", FileName);
213234

@@ -235,6 +256,9 @@ internal void Resume()
235256

236257
public void Pause()
237258
{
259+
// Cancel any pending auto-retry delay.
260+
CancelRetryDelay();
261+
238262
if (ProgressState != ProgressState.Working)
239263
{
240264
Logger.Warn(
@@ -252,6 +276,33 @@ public void Pause()
252276
OnProgressStateChanged(ProgressState);
253277
}
254278

279+
/// <summary>
280+
/// Cleans up temp file and all sidecar files (e.g. .cm-info.json, preview image)
281+
/// for a download that has already failed and will not be retried.
282+
/// This transitions the state to <see cref="ProgressState.Cancelled"/> so the
283+
/// service removes the tracking entry.
284+
/// </summary>
285+
public void Dismiss()
286+
{
287+
if (ProgressState != ProgressState.Failed)
288+
{
289+
Logger.Warn(
290+
"Attempted to dismiss download {Download} but it is not in a failed state ({State})",
291+
FileName,
292+
ProgressState
293+
);
294+
return;
295+
}
296+
297+
Logger.Debug("Dismissing failed download {Download}", FileName);
298+
299+
DoCleanup();
300+
301+
OnProgressStateChanging(ProgressState.Cancelled);
302+
ProgressState = ProgressState.Cancelled;
303+
OnProgressStateChanged(ProgressState);
304+
}
305+
255306
public void Cancel()
256307
{
257308
if (ProgressState is not (ProgressState.Working or ProgressState.Inactive))
@@ -264,6 +315,9 @@ public void Cancel()
264315
return;
265316
}
266317

318+
// Cancel any pending auto-retry delay.
319+
CancelRetryDelay();
320+
267321
Logger.Debug("Cancelling download {Download}", FileName);
268322

269323
// Cancel token if it exists
@@ -290,9 +344,12 @@ public void SetPending()
290344
}
291345

292346
/// <summary>
293-
/// Deletes the temp file and any extra cleanup files
347+
/// Deletes the temp file and, optionally, any extra cleanup files (e.g. sidecar metadata).
348+
/// Pass <paramref name="includeExtraCleanupFiles"/> as <c>false</c> when the download
349+
/// failed but may be retried — sidecar files (.cm-info.json, preview image) should survive
350+
/// so a manual retry doesn't need to recreate them.
294351
/// </summary>
295-
private void DoCleanup()
352+
private void DoCleanup(bool includeExtraCleanupFiles = true)
296353
{
297354
try
298355
{
@@ -303,6 +360,9 @@ private void DoCleanup()
303360
Logger.Warn("Failed to delete temp file {TempFile}", TempFileName);
304361
}
305362

363+
if (!includeExtraCleanupFiles)
364+
return;
365+
306366
foreach (var extraFile in ExtraCleanupFileNames)
307367
{
308368
try
@@ -316,6 +376,16 @@ private void DoCleanup()
316376
}
317377
}
318378

379+
/// <summary>
380+
/// Returns true for transient network/SSL exceptions that are safe to retry (ie: VPN tunnel resets or TLS re-key failures)
381+
/// (IOException, AuthenticationException, or either wrapped in an AggregateException).
382+
/// </summary>
383+
private static bool IsTransientNetworkException(Exception? ex) =>
384+
ex is IOException or AuthenticationException
385+
|| ex?.InnerException is IOException or AuthenticationException
386+
|| ex is AggregateException ae
387+
&& ae.InnerExceptions.Any(e => e is IOException or AuthenticationException);
388+
319389
/// <summary>
320390
/// Invoked by the task's completion callback
321391
/// </summary>
@@ -349,7 +419,7 @@ private void OnDownloadTaskCompleted(Task task)
349419
// Set the exception
350420
Exception = task.Exception;
351421

352-
if ((Exception is IOException || Exception?.InnerException is IOException) && attempts < 3)
422+
if (IsTransientNetworkException(Exception) && attempts < MaxRetryAttempts)
353423
{
354424
attempts++;
355425
Logger.Warn(
@@ -359,9 +429,39 @@ private void OnDownloadTaskCompleted(Task task)
359429
attempts
360430
);
361431

432+
// Exponential backoff: 2 s → 4 s → 8 s, capped at 30 s, ±500 ms jitter.
433+
// Gives the VPN tunnel time to re-key/re-route before reconnecting,
434+
// which prevents the retry from hitting the same torn connection.
435+
var delayMs =
436+
(int)Math.Min(2000 * Math.Pow(2, attempts - 1), 30_000) + Random.Shared.Next(-500, 500);
437+
Logger.Debug(
438+
"Download {Download} retrying in {Delay}ms (attempt {Attempt}/{MaxAttempts})",
439+
FileName,
440+
delayMs,
441+
attempts,
442+
MaxRetryAttempts
443+
);
444+
445+
// Persist Inactive to disk before the delay so a restart during backoff loads it as resumable.
362446
OnProgressStateChanging(ProgressState.Inactive);
363447
ProgressState = ProgressState.Inactive;
364-
Resume();
448+
OnProgressStateChanged(ProgressState.Inactive);
449+
450+
// Clean up the completed task resources; Resume() will create new ones.
451+
downloadTask = null;
452+
downloadCancellationTokenSource = null;
453+
downloadPauseTokenSource = null;
454+
455+
// Schedule the retry with a cancellation token so Cancel/Pause can abort the delay.
456+
retryDelayCancellationTokenSource?.Dispose();
457+
retryDelayCancellationTokenSource = new CancellationTokenSource();
458+
Task.Delay(Math.Max(delayMs, 0), retryDelayCancellationTokenSource.Token)
459+
.ContinueWith(t =>
460+
{
461+
if (t.IsCompletedSuccessfully)
462+
Resume();
463+
})
464+
.SafeFireAndForget();
365465
return;
366466
}
367467

@@ -377,11 +477,17 @@ private void OnDownloadTaskCompleted(Task task)
377477
ProgressState = ProgressState.Success;
378478
}
379479

380-
// For failed or cancelled, delete the temp files
381-
if (ProgressState is ProgressState.Failed or ProgressState.Cancelled)
480+
// For cancelled, delete the temp file and any sidecar metadata.
481+
// For failed, only delete the temp file — sidecar files (.cm-info.json, preview image)
482+
// are preserved so a manual retry doesn't need to recreate them.
483+
if (ProgressState is ProgressState.Cancelled)
382484
{
383485
DoCleanup();
384486
}
487+
else if (ProgressState is ProgressState.Failed)
488+
{
489+
DoCleanup(includeExtraCleanupFiles: false);
490+
}
385491
// For pause, just do nothing
386492

387493
OnProgressStateChanged(ProgressState);
@@ -392,6 +498,17 @@ private void OnDownloadTaskCompleted(Task task)
392498
downloadPauseTokenSource = null;
393499
}
394500

501+
/// <summary>
502+
/// Resets the retry counter and silently sets state to Inactive without firing events.
503+
/// Must be called before re-adding to TrackedDownloadService to avoid events
504+
/// firing while the download is absent from the dictionary.
505+
/// </summary>
506+
public void ResetAttempts()
507+
{
508+
attempts = 0;
509+
ProgressState = ProgressState.Inactive;
510+
}
511+
395512
public void SetDownloadService(IDownloadService service)
396513
{
397514
downloadService = service;

StabilityMatrix.Core/Services/ITrackedDownloadService.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,7 @@ TrackedDownload NewDownload(string downloadUrl, FilePath downloadPath) =>
1515
NewDownload(new Uri(downloadUrl), downloadPath);
1616
Task TryStartDownload(TrackedDownload download);
1717
Task TryResumeDownload(TrackedDownload download);
18+
Task TryRestartDownload(TrackedDownload download);
19+
1820
void UpdateMaxConcurrentDownloads(int newMax);
1921
}

0 commit comments

Comments
 (0)