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:
2026-05-01 09:55:31 +02:00
parent 6128f7d220
commit 9bdcdabb9e
9 changed files with 391 additions and 68 deletions

View File

@@ -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;
}
}