v0.28.11 — Multi-segment DL : durability checkpoint pour fix SHA fail post-pause
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) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
#define MyAppName "PROSERVE Launcher"
|
#define MyAppName "PROSERVE Launcher"
|
||||||
#define MyAppShortName "PS_Launcher"
|
#define MyAppShortName "PS_Launcher"
|
||||||
#define MyAppVersion "0.28.10"
|
#define MyAppVersion "0.28.11"
|
||||||
#define MyAppPublisher "ASTERION VR"
|
#define MyAppPublisher "ASTERION VR"
|
||||||
#define MyAppURL "https://asterionvr.com"
|
#define MyAppURL "https://asterionvr.com"
|
||||||
#define MyAppExeName "PS_Launcher.exe"
|
#define MyAppExeName "PS_Launcher.exe"
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
<Product>PROSERVE Launcher</Product>
|
<Product>PROSERVE Launcher</Product>
|
||||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||||
<Version>0.28.10</Version>
|
<Version>0.28.11</Version>
|
||||||
<AssemblyVersion>0.28.10.0</AssemblyVersion>
|
<AssemblyVersion>0.28.11.0</AssemblyVersion>
|
||||||
<FileVersion>0.28.10.0</FileVersion>
|
<FileVersion>0.28.11.0</FileVersion>
|
||||||
|
|
||||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
|||||||
@@ -298,7 +298,12 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
|
|
||||||
// 4. Lance les workers en parallèle.
|
// 4. Lance les workers en parallèle.
|
||||||
// Compteur agrégé via Interlocked → pas de lock dans le hot path.
|
// 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).
|
// Action passée aux segments : juste un Add atomique (zéro contention).
|
||||||
void OnSegmentBytes(int _, long deltaBytes)
|
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);
|
await using var dst = new FileStream(partialPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite, BufferSize, useAsync: true);
|
||||||
dst.Seek(segStart, SeekOrigin.Begin);
|
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];
|
var buffer = new byte[BufferSize];
|
||||||
|
long inFlight = 0;
|
||||||
int n;
|
int n;
|
||||||
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
|
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
|
||||||
{
|
{
|
||||||
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
|
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);
|
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);
|
seg.Completed = (seg.DownloadedBytes >= seg.Length);
|
||||||
|
|
||||||
// Si on est sorti de la boucle sans avoir atteint la fin du segment, le serveur a
|
// Si on est sorti de la boucle sans avoir atteint la fin du segment, le serveur a
|
||||||
|
|||||||
Reference in New Issue
Block a user