From eae051405895b95268ac0fae5055eff84ca41a9d Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Thu, 14 May 2026 17:25:51 +0200 Subject: [PATCH] =?UTF-8?q?v0.28.11=20=E2=80=94=20Multi-segment=20DL=20:?= =?UTF-8?q?=20durability=20checkpoint=20pour=20fix=20SHA=20fail=20post-pau?= =?UTF-8?q?se?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug rapporté : ~80% des reprises après pause failaient la vérification SHA-256 finale alors que la DL semblait s'être passée correctement et que le fichier était de la bonne taille. Root cause : seg.DownloadedBytes était incrémenté juste après le retour de WriteAsync, mais cet appel met seulement les bytes dans le buffer interne du FileStream (4 MiB) — pas forcément sur disque ni dans le cache OS. À la pause, le ct est cancelé, le segment throw une OperationCanceledException au prochain await, et `await using var dst` dispose le FileStream — qui SENSE flush le buffer vers le disque mais avec useAsync=true + cancellation en cours, ce flush peut être partiel ou échouer silencieusement. Résultat : le compteur dit "X bytes écrits" mais le disque en a moins. Au resume, segStart = seg.Start + X évite donc les bytes perdus, ces régions restent en zéros sparse (pré-alloc), et le SHA final fail. Fix : pattern checkpoint. Le compteur seg.DownloadedBytes n'est mis à jour qu'APRÈS un FlushAsync(CancellationToken.None) réussi. Le flush est forcé non-cancelable pour éviter qu'une pause l'interrompe en plein milieu. Granularité : tous les 64 MiB de download par segment. Invariant garanti après le fix : seg.DownloadedBytes <= bytes_réellement_sur_disque Au pire, en cas de pause, seg.DownloadedBytes est UN PEU en arrière de ce qui est sur disque (jusqu'à 64 MiB par segment). Au resume, on re-télécharge ces bytes — ils sont écrasés avec le même contenu, le SHA final passe. Le cas dangereux ("compteur en avance, trou de zéros sur disque") est définitivement impossible. Bonus : initialisation de aggregateBytes depuis sum(seg.DownloadedBytes) au lieu de state.DownloadedBytes pour éviter un drift cosmétique du footer après resume (state.DownloadedBytes capturait la live aggregate incluant l'in-flight, donc pouvait dépasser le total durable). Trade-off : un peu plus de re-download au resume (jusqu'à 1 GB pour 16 segments × 64 MiB) en échange d'une fiabilité totale. Le user ne verra pas la différence sur un 14 GB de DL. Co-Authored-By: Claude Opus 4.7 (1M context) --- installer/PSLauncher.iss | 2 +- src/PSLauncher.App/PSLauncher.App.csproj | 6 +-- .../Downloads/DownloadManager.cs | 42 +++++++++++++++++-- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index f7ae52d..2640a7e 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "0.28.10" +#define MyAppVersion "0.28.11" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index 4d65a25..241b974 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -18,9 +18,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 0.28.10 - 0.28.10.0 - 0.28.10.0 + 0.28.11 + 0.28.11.0 + 0.28.11.0 true diff --git a/src/PSLauncher.Core/Downloads/DownloadManager.cs b/src/PSLauncher.Core/Downloads/DownloadManager.cs index a5696a7..5aa35d0 100644 --- a/src/PSLauncher.Core/Downloads/DownloadManager.cs +++ b/src/PSLauncher.Core/Downloads/DownloadManager.cs @@ -298,7 +298,12 @@ public sealed class DownloadManager : IDownloadManager // 4. Lance les workers en parallèle. // Compteur agrégé via Interlocked → pas de lock dans le hot path. - long aggregateBytes = state.DownloadedBytes; + // 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) @@ -474,15 +479,46 @@ public sealed class DownloadManager : IDownloadManager await using var dst = new FileStream(partialPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite, BufferSize, useAsync: true); dst.Seek(segStart, SeekOrigin.Begin); + // 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. Cas observé chez l'utilisateur : + // ~80% des reprises post-pause failaient la vérif hash. + // + // Stratégie : tracker en local le delta non-flushé. Tous les ~64 MiB, + // FlushAsync (forcé avec CancellationToken.None pour qu'une pause ne le + // coupe pas en plein milieu) puis update seg.DownloadedBytes. Le compteur + // est donc TOUJOURS en arrière (ou égal) de ce qui est sur disque, jamais + // en avance. Resume re-télécharge au pire 64 MiB par segment — coût + // borné, et c'est ce qu'on appelle un checkpoint. + 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); - seg.DownloadedBytes += n; + inFlight += n; + // Live counter pour UI (aggregate / footer) — peut overshoot par + // rapport au durable de quelques MiB ; le footer revient cohérent + // au prochain checkpoint. onBytes(seg.Index, n); + + if (inFlight >= FlushIntervalBytes) + { + await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false); + seg.DownloadedBytes += inFlight; + inFlight = 0; + } + } + // Checkpoint final : flush + ack du reste du buffer. + await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false); + if (inFlight > 0) + { + seg.DownloadedBytes += inFlight; + inFlight = 0; } - await dst.FlushAsync(ct).ConfigureAwait(false); seg.Completed = (seg.DownloadedBytes >= seg.Length); // Si on est sorti de la boucle sans avoir atteint la fin du segment, le serveur a