using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using Microsoft.Win32.SafeHandles;
using Polly;
using Polly.Retry;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.Integrity;
using PSLauncher.Models;
namespace PSLauncher.Core.Downloads;
///
/// Téléchargement HTTP avec :
/// - reprise par Range requests (If-Range sur ETag/Last-Modified) ;
/// - retry exponentiel via Polly (1-32 s, 6 tentatives, jitter) ;
/// - téléchargement parallèle multi-segments (config ) ;
/// - vérif SHA-256 streaming en fin de DL ;
/// - persistance d'état (state.json) toutes les 5 s ou 100 Mo pour reprise après crash.
///
/// Le multi-segments contourne le throttling per-connection d'Apache (notamment OVH mutualisé)
/// : chaque segment ouvre une connexion TCP séparée et écrit à son offset dans le fichier
/// pré-alloué via SetLength. Speed-up typique observé : ×4 à ×10.
///
public sealed class DownloadManager : IDownloadManager
{
private const int BufferSize = 4 << 20; // 4 MiB par segment (avant 1 MiB)
private const int StateFlushBytesInterval = 50 * 1024 * 1024; // 50 MiB
private const double StateFlushSecondsInterval = 5.0;
private const long MultiSegmentMinSize = 50 * 1024 * 1024; // < 50 MiB → single-segment
private readonly HttpClient _http;
private readonly IDownloadStateStore _stateStore;
private readonly IIntegrityService _integrity;
private readonly LocalConfig _config;
private readonly ILogger _logger;
private readonly ResiliencePipeline _retryPipeline;
// URL active partagée entre tous les segments d'un DL en cours, et lock pour
// refresher de façon coordonnée quand l'URL HMAC expire (TTL serveur = 6 h).
// Plusieurs segments peuvent recevoir 403 simultanément — un seul doit faire
// l'appel /api/download-url/.
private Uri _currentUrl = new("about:blank");
private DateTime _lastUrlRefresh = DateTime.MinValue;
private readonly SemaphoreSlim _urlRefreshLock = new(1, 1);
public DownloadManager(
HttpClient http,
IDownloadStateStore stateStore,
IIntegrityService integrity,
LocalConfig config,
ILogger logger)
{
_http = http;
_stateStore = stateStore;
_integrity = integrity;
_config = config;
_logger = logger;
_retryPipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
// 10 tentatives × backoff 1→32s avec jitter ≈ ~3 min de retry budget
// par segment. Couvre les outages PHP-FPM courts en multi-PC (où des
// segments peuvent être tués mid-stream par request_terminate_timeout)
// sans donner l'impression d'une boucle infinie en cas de panne réelle.
MaxRetryAttempts = 10,
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromSeconds(1),
MaxDelay = TimeSpan.FromSeconds(32),
UseJitter = true,
ShouldHandle = new PredicateBuilder().Handle(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();
}
public DownloadState? GetResumableState(string version) => _stateStore.Load(version);
public void DiscardResumableState(string version) => _stateStore.Discard(version);
public Task DownloadAsync(
DownloadJob job,
IProgress? progress,
CancellationToken ct)
=> DownloadAsync(job, progress, null, ct);
///
/// Récupère l'URL active. Si la dernière récup date de moins de 5 s,
/// retourne celle en cache (évite que 8 segments en 403 simultanés
/// déclenchent 8 requêtes /api/download-url/). Sinon refresh via le
/// callback du job. Si pas de callback, retourne l'URL initiale du job.
///
private async Task GetOrRefreshUrlAsync(DownloadJob job, bool forceRefresh, CancellationToken ct)
{
await _urlRefreshLock.WaitAsync(ct).ConfigureAwait(false);
try
{
// Premier appel : initialise avec l'URL du job
if (_currentUrl.Scheme == "about")
{
_currentUrl = job.Url;
_lastUrlRefresh = DateTime.UtcNow;
}
// Si refresh forcé (un segment a vu 403), tente de fetcher une nouvelle URL.
// Debounce 5 s pour fusionner les refresh en rafale des 8 segments.
if (forceRefresh && (DateTime.UtcNow - _lastUrlRefresh).TotalSeconds >= 5
&& job.RefreshUrlAsync is not null)
{
_logger.LogInformation("Refreshing signed URL for v{Version} (URL expired or 403)", job.Version);
var fresh = await job.RefreshUrlAsync(ct).ConfigureAwait(false);
if (fresh is not null)
{
_currentUrl = fresh;
_lastUrlRefresh = DateTime.UtcNow;
_logger.LogInformation("Signed URL refreshed for v{Version}", job.Version);
}
}
return _currentUrl;
}
finally
{
_urlRefreshLock.Release();
}
}
public async Task DownloadAsync(
DownloadJob job,
IProgress? progress,
IProgress? hashProgress,
CancellationToken ct)
{
// Reset URL state pour ce job (DownloadManager est singleton DI)
_currentUrl = job.Url;
_lastUrlRefresh = DateTime.UtcNow;
var partialPath = _stateStore.GetPartialPath(job.Version);
var finalPath = Path.ChangeExtension(partialPath, null); // strip ".partial" → .zip
// 1. Récupère l'état existant. On compare la ressource par son SHA-256 (signé via le
// manifest) plutôt que par URL : les URLs HMAC-signées changent à chaque appel
// (?exp=…&sig=…), donc une comparaison stricte d'URL invaliderait toujours le resume.
// On compare aussi le path (host+path sans query) pour détecter un changement de
// serveur/fichier, mais on ignore la querystring.
var existing = _stateStore.Load(job.Version);
if (existing is not null)
{
bool shaMismatch = !string.IsNullOrEmpty(job.ExpectedSha256)
&& !string.IsNullOrEmpty(existing.ExpectedSha256)
&& !string.Equals(existing.ExpectedSha256, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase);
bool pathMismatch = false;
if (Uri.TryCreate(existing.Url, UriKind.Absolute, out var oldUri))
{
pathMismatch = oldUri.Host != job.Url.Host || oldUri.AbsolutePath != job.Url.AbsolutePath;
}
if (shaMismatch || pathMismatch)
{
_logger.LogInformation("Cached state for {Version} mismatches new job (sha={Sha} path={Path}) — discarding",
job.Version, shaMismatch, pathMismatch);
_stateStore.Discard(job.Version);
existing = null;
}
else
{
// Même ressource : on rafraîchit juste l'URL stockée (utile pour les futures requêtes
// au sein de cette session, même si en pratique chaque segment requête via job.Url).
existing.Url = job.Url.ToString();
}
}
// 2. Choix single vs multi-segment
// Hard-cap à 32 : au-delà OVH mutualisé risque le rate-limit per-IP et le
// gain marginal devient négatif. 16 = défaut, 24-32 OK pour les serveurs
// qui le supportent. 1 = comportement single-segment legacy.
var requestedSegments = Math.Clamp(_config.ParallelDownloadSegments, 1, 32);
var useMultiSegment = requestedSegments > 1
&& job.ExpectedSize >= MultiSegmentMinSize
&& (existing is null || existing.Segments.Count > 0);
// Si l'état existant est en mode legacy (single-segment) et qu'on veut multi → on conserve le legacy
// pour pouvoir reprendre proprement (sinon on jetterait son DL).
if (existing is { Segments.Count: 0 } && File.Exists(partialPath))
{
_logger.LogInformation("Existing single-segment partial detected — staying single-segment for resume");
useMultiSegment = false;
}
// 3. Vérif espace disque (uniquement à l'initial, pas au resume)
if (existing is null && 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 (useMultiSegment)
{
return await DownloadMultiSegmentAsync(
job, partialPath, finalPath, existing, requestedSegments, progress, hashProgress, ct).ConfigureAwait(false);
}
return await DownloadSingleSegmentAsync(
job, partialPath, finalPath, existing, progress, hashProgress, ct).ConfigureAwait(false);
}
// ===================================================================
// ============== MULTI-SEGMENT (parallèle, fast path) ==============
// ===================================================================
private async Task DownloadMultiSegmentAsync(
DownloadJob job,
string partialPath,
string finalPath,
DownloadState? existing,
int segmentCount,
IProgress? progress,
IProgress? hashProgress,
CancellationToken ct)
{
var swPhase = Stopwatch.StartNew();
// 1. On fait CONFIANCE au manifest : pas de HEAD probe.
// Le HEAD ajoutait jusqu'à 30 s-5 min de latence quand le serveur faisait
// un md5_file() sur 14 Go à chaque requête (vu sur OVH/gate.php). Le manifest
// est signé Ed25519 donc sa taille est de toute façon fiable. Si le serveur
// ne supporte pas Range, on le détecte sur le premier segment (200 au lieu
// de 206) et on fallback sur single-segment.
long total = job.ExpectedSize;
if (total <= 0)
{
_logger.LogInformation("ExpectedSize=0 — falling back to single-segment");
return await DownloadSingleSegmentAsync(job, partialPath, finalPath, existing, progress, hashProgress, ct).ConfigureAwait(false);
}
// 2. State (resume vs fresh)
DownloadState state;
if (existing is not null && existing.Segments.Count > 0 && existing.TotalBytes == total && File.Exists(partialPath))
{
state = existing;
_logger.LogInformation("Resuming multi-segment DL for v{Version} ({Segments} segments, {Done}/{Total} bytes)",
job.Version, state.Segments.Count, state.DownloadedBytes, state.TotalBytes);
}
else
{
// Discard tout résidu (state mismatch ou partial sans state)
_stateStore.Discard(job.Version);
state = new DownloadState
{
Version = job.Version,
Url = job.Url.ToString(),
TotalBytes = total,
ExpectedSha256 = job.ExpectedSha256,
PartialPath = partialPath,
StartedAtUtc = DateTime.UtcNow,
LastChunkAtUtc = DateTime.UtcNow,
Segments = BuildSegments(total, segmentCount),
};
_stateStore.Save(state);
}
// 3. Pré-allocation du fichier final.
// Stratégie : on tente de marquer le fichier en SPARSE (FSCTL_SET_SPARSE)
// avant le SetLength, ce qui rend l'opération métadata-only (~milliseconde
// au lieu de 5-30 s sur HDD à cause du zero-fill NTFS).
// Pas besoin d'admin (contrairement à SetFileValidData). Aucune fuite de
// données : les zones non écrites sont lues comme zéros virtuels par NTFS.
// Si FSCTL_SET_SPARSE échoue (volume FAT32 ou autre), fallback sur SetLength
// classique.
var swAlloc = Stopwatch.StartNew();
await Task.Run(() =>
{
using var alloc = new FileStream(partialPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite);
if (alloc.Length != total)
{
bool sparseOk = SparseFileSupport.TrySetSparse(alloc.SafeFileHandle);
_logger.LogInformation("Pre-allocating {Path} to {Bytes} bytes (sparse={Sparse})", partialPath, total, sparseOk);
alloc.SetLength(total);
}
}, ct).ConfigureAwait(false);
swAlloc.Stop();
_logger.LogInformation("Pre-alloc done in {Ms} ms (HEAD skipped, total phase {TotalMs} ms)", swAlloc.ElapsedMilliseconds, swPhase.ElapsedMilliseconds);
// Une fois la pré-alloc terminée, on émet immédiatement un progress 0/total
// pour que le footer passe de « Préparation… » à « ⬇ v1.4.6 : 0 / 14 Go ».
// La vraie progression suit ~250 ms après quand le reporter task tick.
progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, null));
// 4. Lance les workers en parallèle.
// Compteur agrégé via Interlocked → pas de lock dans le hot path.
// Initialisé sur la somme des seg.DownloadedBytes DURABLES plutôt que sur
// state.DownloadedBytes (live aggregate persisté, qui peut être en avance
// de la réalité disque si pause survenue entre checkpoints). Sans ça, le
// footer afficherait "paused at X" mais segments resumeraient à un point
// en arrière, et l'aggregate finirait avec un drift cosmétique.
long aggregateBytes = state.Segments.Sum(s => s.DownloadedBytes);
// Action passée aux segments : juste un Add atomique (zéro contention).
void OnSegmentBytes(int _, long deltaBytes)
=> Interlocked.Add(ref aggregateBytes, deltaBytes);
// Reporter dédié : toutes les 250 ms, snapshot le compteur, calcule bps/ETA, reporte.
// Tous les 5 s ou 50 Mo, persist le state.json.
// Tourne sur un thread séparé pour ne jamais être starvé par les workers DL.
//
// Calcul du débit : on utilise une FENÊTRE GLISSANTE de ~3 s (12 samples
// de 250ms) plutôt qu'un delta instantané sur le dernier tick. Pourquoi :
// avec un delta tick-à-tick, dès qu'un segment finit OU que Polly retry
// entre 2 tentatives OU qu'il y a un micro-stall réseau, le compteur ne
// bouge pas pendant 1-2 ticks → bps = 0 → ETA = null → UI cache les
// deux 250-500 ms → clignotement permanent à l'écran (symptôme rapporté).
// Avec la fenêtre, un trou de 1-2 ticks est dilué dans 12 samples → le
// débit affiché reste stable. La latence d'adaptation est ~3s, ce qui
// est largement OK visuellement (l'opérateur ne perçoit pas 3s de retard
// sur un chiffre qui de toute façon fluctue de ±5-10 % au cours d'un DL).
const int BpsWindowSize = 12; // 12 × 250 ms = 3 s
using var reporterCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var reporterTask = Task.Run(async () =>
{
var sw = Stopwatch.StartNew();
var lastFlush = TimeSpan.Zero;
long lastFlushBytes = Interlocked.Read(ref aggregateBytes);
// Fenêtre glissante (time, bytes) pour lissage du débit.
var bpsWindow = new Queue<(TimeSpan At, long Bytes)>(BpsWindowSize + 1);
try
{
while (!reporterCts.IsCancellationRequested)
{
await Task.Delay(250, reporterCts.Token).ConfigureAwait(false);
var snapshot = Interlocked.Read(ref aggregateBytes);
var elapsed = sw.Elapsed;
// Push le nouveau sample, évince le plus ancien si on dépasse.
bpsWindow.Enqueue((elapsed, snapshot));
while (bpsWindow.Count > BpsWindowSize) bpsWindow.Dequeue();
// bps = (bytes_now - bytes_oldest_in_window) / time_span_window
// Fallback à 0 tant qu'on n'a pas au moins 2 samples (le 1er tick).
double bps = 0;
if (bpsWindow.Count >= 2)
{
var oldest = bpsWindow.Peek();
var span = (elapsed - oldest.At).TotalSeconds;
if (span > 0) bps = (snapshot - oldest.Bytes) / span;
}
TimeSpan? eta = null;
if (bps > 0 && total > snapshot)
eta = TimeSpan.FromSeconds((total - snapshot) / bps);
progress?.Report(new DownloadProgress(snapshot, total, bps, eta));
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
snapshot - lastFlushBytes >= StateFlushBytesInterval)
{
state.DownloadedBytes = snapshot;
state.LastChunkAtUtc = DateTime.UtcNow;
_stateStore.Save(state);
lastFlush = elapsed;
lastFlushBytes = snapshot;
}
}
}
catch (OperationCanceledException) { /* attendu en fin de DL */ }
}, reporterCts.Token);
var tasks = state.Segments
.Where(s => !s.Completed)
.Select(s => Task.Run(() =>
_retryPipeline.ExecuteAsync(async innerCt =>
{
await DownloadSegmentAsync(job, state, s, partialPath, OnSegmentBytes, innerCt).ConfigureAwait(false);
}, ct).AsTask(), ct))
.ToArray();
try
{
await Task.WhenAll(tasks).ConfigureAwait(false);
}
catch
{
reporterCts.Cancel();
try { await reporterTask.ConfigureAwait(false); } catch { /* ignore */ }
state.DownloadedBytes = Interlocked.Read(ref aggregateBytes);
_stateStore.Save(state);
throw;
}
// Stoppe le reporter et fais un dernier flush
reporterCts.Cancel();
try { await reporterTask.ConfigureAwait(false); } catch { /* ignore */ }
state.DownloadedBytes = Interlocked.Read(ref aggregateBytes);
_stateStore.Save(state);
progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, TimeSpan.Zero));
// 5. Vérif complétion des segments
// Le check `actualSize != total` ci-dessous est INSUFFISANT car le fichier est
// pré-alloué en sparse à `total` octets — sa taille filesystem matchera toujours
// peu importe ce qui a été écrit. Si un segment a échoué silencieusement (cas
// pré-0.24.5 ou bug futur), on aurait des trous (zéros NTFS) → SHA KO en sortie
// sans diagnostic clair. On vérifie explicitement que CHAQUE segment a atteint
// sa cible AVANT de lancer le SHA-256 (qui prend 30s-3min sur un 14 Go).
var incomplete = state.Segments.FirstOrDefault(s => !s.Completed || s.DownloadedBytes < s.Length);
if (incomplete is not null)
{
_stateStore.Save(state); // garde l'état pour permettre un resume manuel
throw new InvalidDataException(
$"Segment {incomplete.Index} incomplet : {incomplete.DownloadedBytes:N0}/{incomplete.Length:N0} octets " +
$"(Completed={incomplete.Completed}). Relance le téléchargement pour reprendre.");
}
var actualSize = new FileInfo(partialPath).Length;
if (actualSize != total)
{
_stateStore.Discard(job.Version);
throw new InvalidDataException($"Taille téléchargée incorrecte : attendu {total}, obtenu {actualSize}");
}
// 6. Vérif SHA-256
await VerifyAndFinalizeAsync(job, partialPath, finalPath, hashProgress, ct).ConfigureAwait(false);
return finalPath;
}
private static List BuildSegments(long total, int count)
{
var segments = new List(count);
long segSize = total / count;
for (int i = 0; i < count; i++)
{
long start = i * segSize;
long end = (i == count - 1) ? total - 1 : (start + segSize - 1);
segments.Add(new DownloadSegment
{
Index = i,
Start = start,
End = end,
DownloadedBytes = 0,
Completed = false,
});
}
return segments;
}
private async Task DownloadSegmentAsync(
DownloadJob job,
DownloadState state,
DownloadSegment seg,
string partialPath,
Action onBytes,
CancellationToken ct)
{
if (seg.Completed) return;
var segStart = seg.Start + seg.DownloadedBytes;
if (segStart > seg.End) { seg.Completed = true; return; }
// Récupère l'URL active (peut avoir été refreshée par un autre segment)
var url = await GetOrRefreshUrlAsync(job, forceRefresh: false, ct).ConfigureAwait(false);
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Range = new RangeHeaderValue(segStart, seg.End);
if (!string.IsNullOrEmpty(state.Etag))
req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag));
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
if (resp.StatusCode == HttpStatusCode.OK)
{
// Le serveur a renvoyé tout au lieu du range : la ressource a probablement changé
// (ou il n'a en fait pas accepté le range). On signale un retry à blanc.
throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true);
}
// 403 / 410 / 404 = URL HMAC expirée (gate.php renvoie 403 "Forbidden:
// expired"), OU le fichier a été renommé/supprimé côté serveur (cas
// typique : ré-upload du ZIP avec un nouveau nom pour invalider le cache
// OVH CDN). Dans les deux cas, refresh l'URL via le callback du job —
// l'endpoint /download-url/{version} renvoie la NOUVELLE URL pointant
// vers le fichier actuellement présent sur le serveur. Si refresh donne
// une URL DIFFÉRENTE de celle qu'on a utilisée dans la requête → retry
// avec la nouvelle. Sinon → c'est un vrai 404, on bubble.
//
// Note importante : on compare à `url` (la valeur utilisée dans la
// requête HTTP, capturée ligne 438 avant le send) plutôt qu'à un
// snapshot pris juste avant forceRefresh. Pourquoi : si 8 segments
// tombent en 404 simultanément, le PREMIER refresh met _currentUrl à
// jour, les autres voient le debounce 5s (no-op). Comparer à `url`
// (= URL réellement utilisée par CETTE requête, OLD) détecte
// correctement que _currentUrl est maintenant NEW → retry. Si on
// comparait à un snapshot pré-refresh, on raterait ce cas et on jetterait
// un faux 404 fatal sur tous les segments sauf le premier.
bool isUrlRefreshTrigger = resp.StatusCode == HttpStatusCode.Forbidden
|| resp.StatusCode == HttpStatusCode.Gone
|| resp.StatusCode == HttpStatusCode.NotFound
|| resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable;
if (isUrlRefreshTrigger && job.RefreshUrlAsync is not null)
{
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
bool urlChanged = !Uri.Equals(url, _currentUrl);
if (urlChanged)
{
_logger.LogInformation(
"Got HTTP {Status} on segment {Seg}, URL refreshed (old={Old} → new={New}), retrying",
(int)resp.StatusCode, seg.Index, url, _currentUrl);
throw new HttpResumableException(
$"HTTP {(int)resp.StatusCode} on segment {seg.Index}, URL refreshed",
isTransient: true);
}
// URL unchanged → soit le serveur insiste sur la même URL morte,
// soit le refresh a fallback sur le manifest cached (donc stale).
// 404 non-transient pour arrêter Polly et remonter une erreur claire
// qui guide l'opérateur vers « Vérifier les MAJ ».
if (resp.StatusCode == HttpStatusCode.NotFound)
{
throw new HttpResumableException(
$"HTTP 404 on segment {seg.Index} — le fichier a été renommé/supprimé côté serveur et le manifest local est obsolète. Clique « Vérifier les MAJ » avant de réessayer.",
isTransient: false);
}
// 416 unchanged URL = vrai mismatch de taille. On fait un HEAD probe
// pour obtenir la taille RÉELLE côté serveur et la rapporter dans le
// message d'erreur — l'opérateur saura ainsi s'il a un upload SFTP
// tronqué (file plus petit que prévu) ou un manifest qui ment sur
// sizeBytes. Cas concret : SFTP coupe au milieu d'un upload de 13 Go
// → le file fait 7 Go sur disque, mais le manifest dit 13 Go.
// Segment N qui demande bytes 9G-10G reçoit 416 (out of range).
if (resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
{
long? actualSize = null;
try
{
using var headReq = new HttpRequestMessage(HttpMethod.Head, url);
using var headResp = await _http.SendAsync(headReq, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
if (headResp.IsSuccessStatusCode)
actualSize = headResp.Content.Headers.ContentLength;
}
catch (Exception ex) { _logger.LogDebug(ex, "HEAD probe after 416 failed"); }
var actualStr = actualSize.HasValue ? $"{actualSize.Value:N0}" : "?";
var expectedStr = $"{state.TotalBytes:N0}";
_logger.LogError(
"HTTP 416 on segment {Seg} — actual server file size: {Actual} bytes, manifest expects: {Expected} bytes",
seg.Index, actualStr, expectedStr);
throw new HttpResumableException(
$"HTTP 416 on segment {seg.Index} — incohérence taille de fichier serveur : " +
$"le ZIP sur le serveur fait {actualStr} octets, le manifest attend {expectedStr} octets. " +
$"Probable cause : upload SFTP tronqué OU manifest avec sizeBytes/sha256 obsolètes. " +
$"Re-upload le ZIP complet puis « 🔁 Hasher les versions + signer » côté admin.",
isTransient: false);
}
throw new HttpResumableException(
$"Signed URL expired (HTTP {(int)resp.StatusCode}), refreshed for next try", isTransient: true);
}
if (!resp.IsSuccessStatusCode)
{
var transient = (int)resp.StatusCode is 408 or 429 or >= 500;
throw new HttpResumableException($"HTTP {(int)resp.StatusCode} on segment {seg.Index}", isTransient: transient);
}
if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag;
if (resp.Content.Headers.LastModified is { } lm) state.LastModified = lm.ToString("R");
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
await using var dst = new FileStream(partialPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite, BufferSize, useAsync: true);
dst.Seek(segStart, SeekOrigin.Begin);
// CHECKPOINT : durabilité ET anti-double-comptage.
//
// Durabilité : on n'incrémente seg.DownloadedBytes qu'APRÈS un FlushAsync
// réussi. Sans ça, sur pause/cancel, le buffer FileStream (4 MiB) ou le
// cache OS peuvent ne pas avoir atteint le disque, mais seg.DownloadedBytes
// les compte comme écrits → resume saute ces bytes → trou (zéros NTFS du
// pre-alloc sparse) → SHA-256 fail.
//
// Anti-double-comptage : on appelle ÉGALEMENT `onBytes` uniquement au
// checkpoint, avec le delta inFlight. Sans ça, un retry Polly mid-segment
// (très fréquent sur PHP-FPM OVH mutualisé qui coupe les requêtes longues)
// re-télécharge les bytes depuis le dernier checkpoint et les compte une
// 2e fois dans l'aggregate → footer affiche >100% en fin de DL.
// Avec onBytes au checkpoint : les bytes re-téléchargés n'ont jamais été
// reportés à la 1re tentative (l'erreur Polly est levée AVANT le checkpoint
// sur l'attempt failed), donc pas de double count.
//
// Trade-off : UI/footer update tous les 64 MiB par segment au lieu de
// chaque 4 MiB. Avec 16 segments en parallèle, ça reste ~10-20 updates/s
// au pic, le reporter task échantillonne à 4 Hz donc invisible côté UX.
const long FlushIntervalBytes = 64L * 1024 * 1024;
var buffer = new byte[BufferSize];
long inFlight = 0;
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);
inFlight += n;
if (inFlight >= FlushIntervalBytes)
{
await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false);
seg.DownloadedBytes += inFlight;
onBytes(seg.Index, inFlight);
inFlight = 0;
}
}
// Checkpoint final : flush + ack du reste du buffer.
await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false);
if (inFlight > 0)
{
seg.DownloadedBytes += inFlight;
onBytes(seg.Index, inFlight);
inFlight = 0;
}
seg.Completed = (seg.DownloadedBytes >= seg.Length);
// Si on est sorti de la boucle sans avoir atteint la fin du segment, le serveur a
// fermé la connexion sans erreur HTTP (typique : PHP-FPM hit `request_terminate_timeout`
// sur un gros segment, OVH coupe la connexion silencieusement, proxy intermédiaire qui
// ferme). Sans cette détection, le segment retourne `Completed=false` mais sans exception,
// Polly ne retry pas, le fichier final a des trous (zones sparse jamais écrites = zéros
// NTFS) et le SHA-256 échoue à la fin. On lance une exception transitoire pour forcer
// un retry — celui-ci reprendra à l'offset où on s'est arrêté grâce à seg.DownloadedBytes.
if (!seg.Completed)
{
var missing = seg.Length - seg.DownloadedBytes;
throw new HttpResumableException(
$"Segment {seg.Index} : connexion fermée prématurément, {missing:N0} octets manquants (probablement timeout PHP-FPM serveur)",
isTransient: true);
}
}
// ===================================================================
// ============= SINGLE-SEGMENT (legacy / fallback path) ============
// ===================================================================
private async Task DownloadSingleSegmentAsync(
DownloadJob job,
string partialPath,
string finalPath,
DownloadState? existing,
IProgress? progress,
IProgress? hashProgress,
CancellationToken ct)
{
long resumeFrom = 0;
if (existing is not null && File.Exists(partialPath))
{
resumeFrom = new FileInfo(partialPath).Length;
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))
{
File.Delete(partialPath);
resumeFrom = 0;
}
var state = existing ?? new DownloadState
{
Version = job.Version,
Url = job.Url.ToString(),
TotalBytes = job.ExpectedSize,
DownloadedBytes = 0,
ExpectedSha256 = job.ExpectedSha256,
PartialPath = partialPath,
StartedAtUtc = DateTime.UtcNow,
LastChunkAtUtc = DateTime.UtcNow,
};
await _retryPipeline.ExecuteAsync(async innerCt =>
{
await DownloadChunkAsync(job, state, partialPath, progress, innerCt).ConfigureAwait(false);
}, ct).ConfigureAwait(false);
var actualSize = new FileInfo(partialPath).Length;
if (job.ExpectedSize > 0 && actualSize != job.ExpectedSize)
{
_stateStore.Discard(job.Version);
throw new InvalidDataException(
$"Taille téléchargée incorrecte : attendu {job.ExpectedSize}, obtenu {actualSize}");
}
await VerifyAndFinalizeAsync(job, partialPath, finalPath, hashProgress, ct).ConfigureAwait(false);
return finalPath;
}
private async Task DownloadChunkAsync(
DownloadJob job,
DownloadState state,
string partialPath,
IProgress? progress,
CancellationToken ct)
{
var resumeFrom = File.Exists(partialPath) ? new FileInfo(partialPath).Length : 0;
if (state.TotalBytes > 0 && resumeFrom >= state.TotalBytes) return;
using var req = new HttpRequestMessage(HttpMethod.Get, job.Url);
if (resumeFrom > 0)
{
req.Headers.Range = new RangeHeaderValue(resumeFrom, null);
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)
{
_logger.LogWarning("Server returned 200 for ranged request — resource changed, restarting fresh");
resp.Dispose();
File.Delete(partialPath);
_stateStore.Discard(job.Version);
throw new HttpResumableException("Resource changed on server, restart", isTransient: true);
}
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);
}
if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag;
if (resp.Content.Headers.LastModified is { } lm2) state.LastModified = lm2.ToString("R");
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;
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;
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;
}
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));
}
// ===================================================================
// ====================== FINALISATION COMMUNE ======================
// ===================================================================
private async Task VerifyAndFinalizeAsync(
DownloadJob job,
string partialPath,
string finalPath,
IProgress? hashProgress,
CancellationToken ct)
{
// Évalue si on doit vérifier le hash :
// 1. Le manifest doit fournir un sha256 valide (pas vide, pas "REPLACE…")
// 2. L'algo demandé n'est pas "none"
// 3. La config locale n'a pas désactivé la vérification globale
bool hashAlgoSkipped = string.Equals(job.HashAlgorithm, "none", StringComparison.OrdinalIgnoreCase);
bool hasUsableSha = !string.IsNullOrEmpty(job.ExpectedSha256)
&& !job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase);
if (hasUsableSha && !hashAlgoSkipped && _config.VerifyDownloadHash)
{
_logger.LogInformation("Verifying SHA-256 of {Path}…", partialPath);
var sw = Stopwatch.StartNew();
var sha = await _integrity.ComputeSha256Async(partialPath, hashProgress, ct).ConfigureAwait(false);
sw.Stop();
_logger.LogInformation("SHA-256 verified in {Elapsed} ({MBps:0.#} MB/s)",
sw.Elapsed,
sw.Elapsed.TotalSeconds > 0 ? new FileInfo(partialPath).Length / sw.Elapsed.TotalSeconds / (1024.0 * 1024.0) : 0);
if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
{
_stateStore.Discard(job.Version);
throw new InvalidDataException(
$"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
}
}
else if (hashAlgoSkipped)
{
_logger.LogInformation("Hash verification skipped (manifest specifies hashAlgorithm=none)");
}
else if (!_config.VerifyDownloadHash)
{
_logger.LogInformation("Hash verification skipped (VerifyDownloadHash=false in local config)");
}
else
{
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée");
}
if (File.Exists(finalPath)) File.Delete(finalPath);
File.Move(partialPath, finalPath);
_stateStore.Discard(job.Version);
}
}
/// Exception interne pour signaler à Polly qu'on doit retry.
internal sealed class HttpResumableException : Exception
{
public bool IsTransient { get; }
public HttpResumableException(string message, bool isTransient) : base(message)
{
IsTransient = isTransient;
}
}
internal static class SparseFileSupport
{
private const uint FSCTL_SET_SPARSE = 0x000900C4;
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DeviceIoControl(
SafeFileHandle hDevice,
uint dwIoControlCode,
IntPtr lpInBuffer,
uint nInBufferSize,
IntPtr lpOutBuffer,
uint nOutBufferSize,
out uint lpBytesReturned,
IntPtr lpOverlapped);
///
/// Marque le fichier comme sparse via FSCTL_SET_SPARSE.
/// Une fois sparse, SetLength(14 GB) est métadata-only (< 100 ms partout).
/// Les zones non écrites sont lues comme zéros virtuels (pas de fuite de données
/// disque, contrairement à SetFileValidData qui exigerait SeManageVolumePrivilege).
/// Retourne false si le volume ne supporte pas sparse (ex. FAT32) — auquel cas
/// on retombe sur le SetLength classique avec zero-fill.
///
public static bool TrySetSparse(SafeFileHandle handle)
{
if (!OperatingSystem.IsWindows()) return false;
try
{
return DeviceIoControl(handle, FSCTL_SET_SPARSE,
IntPtr.Zero, 0, IntPtr.Zero, 0, out _, IntPtr.Zero);
}
catch
{
return false;
}
}
}