v0.3: resumable downloads with HTTP Range, Polly retry, persistent state
This is the robustness layer needed for real 14 GB builds. A 99%-complete
download that gets interrupted no longer means re-downloading 14 GB.
DownloadManager
---------------
- Range: bytes={resumeFrom}- on every (re)attempt; reads resumeFrom from
the actual size of the .partial file so retries always resume from real
on-disk state, not from a remembered counter.
- If-Range: ETag (or Last-Modified fallback). When the server returns 200
instead of 206 we know the resource changed under us, so we discard
.partial and start fresh.
- Polly resilience pipeline: 6 retries, exponential 1-32s with jitter,
on HttpRequestException / IOException / TimeoutException / 5xx / 408 /
429. Each retry re-evaluates resumeFrom from disk, so the server is
asked only for what's actually missing.
- state.json persisted every 5s OR every 100 MiB, whichever comes first,
via atomic write-then-rename. Holds url, total, downloaded, sha256,
etag, last-modified, and the .partial path.
- Disk-space check happens once at fresh-start (1.05x expected size); a
resume doesn't redo it.
- On final success: SHA-256 of the assembled .partial verified, then
atomic rename to .zip and state.json deleted.
DownloadStateStore
------------------
- New IDownloadStateStore in PSLauncher.Core/Downloads.
- Stores under %LocalAppData%/PSLauncher/downloads/.
- Save / Load / Discard / ScanResumable. Tolerates malformed state files
by ignoring them.
UI hint
-------
VersionRowViewModel now has ResumableBytes; when > 0, the install button
label switches to "↻ Reprendre (X%)" computed from
ResumableBytes / Remote.Download.SizeBytes. MainViewModel.RebuildList
queries IDownloadManager.GetResumableState(version) for each remote-only
row and populates ResumableBytes. Both the featured hero card and the
compact rows bind to InstallButtonLabel.
API change
----------
IDownloadManager gains GetResumableState(version) and
DiscardResumableState(version) so callers (and the UI) can reason about
in-progress downloads without poking at the filesystem directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Core.Configuration;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using PSLauncher.Core.Integrity;
|
||||
using PSLauncher.Models;
|
||||
|
||||
@@ -9,118 +12,274 @@ namespace PSLauncher.Core.Downloads;
|
||||
public sealed class DownloadManager : IDownloadManager
|
||||
{
|
||||
private const int BufferSize = 1 << 20; // 1 MiB
|
||||
private const int StateFlushBytesInterval = 100 * 1024 * 1024; // 100 MiB
|
||||
private const double StateFlushSecondsInterval = 5.0;
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly IConfigStore _configStore;
|
||||
private readonly IDownloadStateStore _stateStore;
|
||||
private readonly IIntegrityService _integrity;
|
||||
private readonly ILogger<DownloadManager> _logger;
|
||||
private readonly ResiliencePipeline _retryPipeline;
|
||||
|
||||
public DownloadManager(
|
||||
HttpClient http,
|
||||
IConfigStore configStore,
|
||||
IDownloadStateStore stateStore,
|
||||
IIntegrityService integrity,
|
||||
ILogger<DownloadManager> logger)
|
||||
{
|
||||
_http = http;
|
||||
_configStore = configStore;
|
||||
_stateStore = stateStore;
|
||||
_integrity = integrity;
|
||||
_logger = logger;
|
||||
|
||||
_retryPipeline = new ResiliencePipelineBuilder()
|
||||
.AddRetry(new RetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = 6,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
Delay = TimeSpan.FromSeconds(1),
|
||||
MaxDelay = TimeSpan.FromSeconds(32),
|
||||
UseJitter = true,
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex =>
|
||||
ex is HttpRequestException
|
||||
or IOException
|
||||
or TimeoutException
|
||||
|| (ex is HttpResumableException re && re.IsTransient)),
|
||||
OnRetry = args =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Download retry #{Attempt} after {Delay} : {Error}",
|
||||
args.AttemptNumber + 1, args.RetryDelay, args.Outcome.Exception?.Message);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Télécharge le ZIP et retourne le chemin du fichier final (SHA-256 vérifié).
|
||||
/// v0.2 : pas de reprise — un échec impose de tout recommencer. Resume en v0.3.
|
||||
/// </summary>
|
||||
public DownloadState? GetResumableState(string version) => _stateStore.Load(version);
|
||||
|
||||
public void DiscardResumableState(string version) => _stateStore.Discard(version);
|
||||
|
||||
public async Task<string> DownloadAsync(
|
||||
DownloadJob job,
|
||||
IProgress<DownloadProgress>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var dir = Path.Combine(_configStore.ConfigDirectory, "downloads");
|
||||
Directory.CreateDirectory(dir);
|
||||
var partial = Path.Combine(dir, $"proserve-{job.Version}.zip.partial");
|
||||
var final = Path.Combine(dir, $"proserve-{job.Version}.zip");
|
||||
var partialPath = _stateStore.GetPartialPath(job.Version);
|
||||
var finalPath = Path.ChangeExtension(partialPath, null); // strip ".partial" → .zip
|
||||
|
||||
// Vérif d'espace disque sur le volume du cache (× 1.05 pour marge)
|
||||
if (job.ExpectedSize > 0)
|
||||
// 1. Récupère l'état existant (s'il y en a un, et qu'il correspond à la même URL/sha)
|
||||
var existing = _stateStore.Load(job.Version);
|
||||
if (existing is not null && (existing.Url != job.Url.ToString() || existing.ExpectedSha256 != job.ExpectedSha256))
|
||||
{
|
||||
var drive = new DriveInfo(Path.GetPathRoot(dir)!);
|
||||
_logger.LogInformation("Cached state for {Version} mismatches new job (url/sha changed) — discarding", job.Version);
|
||||
_stateStore.Discard(job.Version);
|
||||
existing = null;
|
||||
}
|
||||
|
||||
long resumeFrom = 0;
|
||||
if (existing is not null && File.Exists(partialPath))
|
||||
{
|
||||
resumeFrom = new FileInfo(partialPath).Length;
|
||||
// Si la state.json est en avance par rapport au .partial réel, on s'aligne sur le .partial
|
||||
if (resumeFrom != existing.DownloadedBytes)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"State/file size mismatch ({StateBytes} vs {FileBytes}) — using file size",
|
||||
existing.DownloadedBytes, resumeFrom);
|
||||
existing.DownloadedBytes = resumeFrom;
|
||||
}
|
||||
}
|
||||
else if (File.Exists(partialPath))
|
||||
{
|
||||
// Fichier partiel sans state : on repart à 0 par sécurité
|
||||
File.Delete(partialPath);
|
||||
resumeFrom = 0;
|
||||
}
|
||||
|
||||
// 2. Vérif espace disque (uniquement à l'initial, pas au resume)
|
||||
if (resumeFrom == 0 && job.ExpectedSize > 0)
|
||||
{
|
||||
var drive = new DriveInfo(Path.GetPathRoot(_stateStore.GetDownloadsDirectory())!);
|
||||
var needed = (long)(job.ExpectedSize * 1.05);
|
||||
if (drive.AvailableFreeSpace < needed)
|
||||
throw new IOException(
|
||||
$"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}");
|
||||
}
|
||||
|
||||
if (File.Exists(partial)) File.Delete(partial);
|
||||
|
||||
_logger.LogInformation("Downloading {Url} -> {Path}", job.Url, partial);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, job.Url);
|
||||
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
var total = resp.Content.Headers.ContentLength ?? job.ExpectedSize;
|
||||
|
||||
await using (var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false))
|
||||
await using (var dst = new FileStream(partial, FileMode.Create, FileAccess.Write, FileShare.None, BufferSize, useAsync: true))
|
||||
var state = existing ?? new DownloadState
|
||||
{
|
||||
var buffer = new byte[BufferSize];
|
||||
long read = 0;
|
||||
var sw = Stopwatch.StartNew();
|
||||
long lastReportRead = 0;
|
||||
var lastReport = sw.Elapsed;
|
||||
Version = job.Version,
|
||||
Url = job.Url.ToString(),
|
||||
TotalBytes = job.ExpectedSize,
|
||||
DownloadedBytes = 0,
|
||||
ExpectedSha256 = job.ExpectedSha256,
|
||||
PartialPath = partialPath,
|
||||
StartedAtUtc = DateTime.UtcNow,
|
||||
LastChunkAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
int n;
|
||||
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
|
||||
read += n;
|
||||
// 3. Tentatives avec retry — chaque tentative continue depuis la taille actuelle du .partial
|
||||
await _retryPipeline.ExecuteAsync(async innerCt =>
|
||||
{
|
||||
await DownloadChunkAsync(job, state, partialPath, progress, innerCt).ConfigureAwait(false);
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
var elapsed = sw.Elapsed;
|
||||
if ((elapsed - lastReport).TotalMilliseconds >= 250)
|
||||
{
|
||||
var deltaBytes = read - lastReportRead;
|
||||
var deltaSeconds = (elapsed - lastReport).TotalSeconds;
|
||||
var bps = deltaSeconds > 0 ? deltaBytes / deltaSeconds : 0;
|
||||
TimeSpan? eta = null;
|
||||
if (bps > 0 && total > 0) eta = TimeSpan.FromSeconds((total - read) / bps);
|
||||
progress?.Report(new DownloadProgress(read, total, bps, eta));
|
||||
lastReportRead = read;
|
||||
lastReport = elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
await dst.FlushAsync(ct).ConfigureAwait(false);
|
||||
progress?.Report(new DownloadProgress(read, total, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
// Vérif taille
|
||||
var actualSize = new FileInfo(partial).Length;
|
||||
// 4. Vérif taille finale
|
||||
var actualSize = new FileInfo(partialPath).Length;
|
||||
if (job.ExpectedSize > 0 && actualSize != job.ExpectedSize)
|
||||
{
|
||||
File.Delete(partial);
|
||||
_stateStore.Discard(job.Version);
|
||||
throw new InvalidDataException(
|
||||
$"Taille téléchargée incorrecte : attendu {job.ExpectedSize}, obtenu {actualSize}");
|
||||
}
|
||||
|
||||
// Vérif SHA-256
|
||||
if (!string.IsNullOrEmpty(job.ExpectedSha256) && !job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase))
|
||||
// 5. Vérif SHA-256
|
||||
if (!string.IsNullOrEmpty(job.ExpectedSha256) &&
|
||||
!job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Verifying SHA-256...");
|
||||
var sha = await _integrity.ComputeSha256Async(partial, null, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Verifying SHA-256 of {Path}…", partialPath);
|
||||
var sha = await _integrity.ComputeSha256Async(partialPath, null, ct).ConfigureAwait(false);
|
||||
if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(partial);
|
||||
_stateStore.Discard(job.Version);
|
||||
throw new InvalidDataException(
|
||||
$"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée (à corriger côté serveur)");
|
||||
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée");
|
||||
}
|
||||
|
||||
if (File.Exists(final)) File.Delete(final);
|
||||
File.Move(partial, final);
|
||||
return final;
|
||||
if (File.Exists(finalPath)) File.Delete(finalPath);
|
||||
File.Move(partialPath, finalPath);
|
||||
_stateStore.Discard(job.Version); // efface aussi le state.json puisqu'on a fini
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
private async Task DownloadChunkAsync(
|
||||
DownloadJob job,
|
||||
DownloadState state,
|
||||
string partialPath,
|
||||
IProgress<DownloadProgress>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var resumeFrom = File.Exists(partialPath) ? new FileInfo(partialPath).Length : 0;
|
||||
if (state.TotalBytes > 0 && resumeFrom >= state.TotalBytes) return; // déjà tout là
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, job.Url);
|
||||
if (resumeFrom > 0)
|
||||
{
|
||||
req.Headers.Range = new RangeHeaderValue(resumeFrom, null);
|
||||
// If-Range : si l'ETag du serveur a changé entre temps, on veut recommencer proprement
|
||||
if (!string.IsNullOrEmpty(state.Etag))
|
||||
{
|
||||
req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(state.LastModified) &&
|
||||
DateTimeOffset.TryParse(state.LastModified, out var lm))
|
||||
{
|
||||
req.Headers.IfRange = new RangeConditionHeaderValue(lm);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("HTTP GET {Url} range={Resume}-", job.Url, resumeFrom);
|
||||
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
|
||||
if (resumeFrom > 0 && resp.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
// Le serveur a renvoyé 200 alors qu'on demandait du Range : la ressource a changé.
|
||||
_logger.LogWarning("Server returned 200 for ranged request — resource changed, restarting fresh");
|
||||
resp.Dispose();
|
||||
File.Delete(partialPath);
|
||||
_stateStore.Discard(job.Version);
|
||||
// On retry sera traité par Polly via une exception
|
||||
throw new HttpResumableException("Resource changed on server, restart", isTransient: true);
|
||||
}
|
||||
|
||||
// 5xx / 408 / 429 → exception transitoire pour Polly
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var transient = (int)resp.StatusCode is 408 or 429 or >= 500;
|
||||
throw new HttpResumableException(
|
||||
$"HTTP {(int)resp.StatusCode} {resp.ReasonPhrase}",
|
||||
isTransient: transient);
|
||||
}
|
||||
|
||||
// Capture ETag et Last-Modified du serveur si présents (pour les futures reprises)
|
||||
if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag;
|
||||
if (resp.Content.Headers.LastModified is { } lm2) state.LastModified = lm2.ToString("R");
|
||||
|
||||
// Total : si le serveur ne fournit pas Content-Length, on garde la valeur du manifest
|
||||
var contentLength = resp.Content.Headers.ContentLength ?? 0;
|
||||
var total = state.TotalBytes;
|
||||
if (resp.StatusCode == HttpStatusCode.PartialContent && resp.Content.Headers.ContentRange is { Length: { } cl })
|
||||
total = cl;
|
||||
else if (resp.StatusCode == HttpStatusCode.OK && contentLength > 0)
|
||||
total = contentLength;
|
||||
if (total > 0) state.TotalBytes = total;
|
||||
|
||||
// Si on est sur un 200 alors qu'on partait de 0, on écrit en append (FileMode.Append depuis position 0)
|
||||
// Si on est sur un 206 avec resumeFrom>0, on append.
|
||||
var mode = resumeFrom > 0 ? FileMode.Append : FileMode.Create;
|
||||
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
await using var dst = new FileStream(partialPath, mode, FileAccess.Write, FileShare.None, BufferSize, useAsync: true);
|
||||
|
||||
var buffer = new byte[BufferSize];
|
||||
long read = resumeFrom;
|
||||
var sw = Stopwatch.StartNew();
|
||||
var lastReport = sw.Elapsed;
|
||||
long lastReportBytes = read;
|
||||
var lastFlush = sw.Elapsed;
|
||||
long lastFlushBytes = read;
|
||||
|
||||
int n;
|
||||
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
|
||||
read += n;
|
||||
state.DownloadedBytes = read;
|
||||
state.LastChunkAtUtc = DateTime.UtcNow;
|
||||
|
||||
var elapsed = sw.Elapsed;
|
||||
|
||||
// Progression UI
|
||||
if ((elapsed - lastReport).TotalMilliseconds >= 250)
|
||||
{
|
||||
var deltaBytes = read - lastReportBytes;
|
||||
var deltaSeconds = (elapsed - lastReport).TotalSeconds;
|
||||
var bps = deltaSeconds > 0 ? deltaBytes / deltaSeconds : 0;
|
||||
TimeSpan? eta = null;
|
||||
if (bps > 0 && state.TotalBytes > 0) eta = TimeSpan.FromSeconds((state.TotalBytes - read) / bps);
|
||||
progress?.Report(new DownloadProgress(read, state.TotalBytes, bps, eta));
|
||||
lastReport = elapsed;
|
||||
lastReportBytes = read;
|
||||
}
|
||||
|
||||
// Persist state.json toutes les 5s ou tous les 100 Mo
|
||||
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
|
||||
read - lastFlushBytes >= StateFlushBytesInterval)
|
||||
{
|
||||
await dst.FlushAsync(ct).ConfigureAwait(false);
|
||||
_stateStore.Save(state);
|
||||
lastFlush = elapsed;
|
||||
lastFlushBytes = read;
|
||||
}
|
||||
}
|
||||
|
||||
await dst.FlushAsync(ct).ConfigureAwait(false);
|
||||
_stateStore.Save(state);
|
||||
progress?.Report(new DownloadProgress(read, state.TotalBytes, 0, TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exception interne pour signaler à Polly qu'on doit retry.</summary>
|
||||
internal sealed class HttpResumableException : Exception
|
||||
{
|
||||
public bool IsTransient { get; }
|
||||
public HttpResumableException(string message, bool isTransient) : base(message)
|
||||
{
|
||||
IsTransient = isTransient;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user