diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss
index 2640a7e..1639d5a 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.11"
+#define MyAppVersion "0.28.12"
#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 241b974..2ce162d 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.11
- 0.28.11.0
- 0.28.11.0
+ 0.28.12
+ 0.28.12.0
+ 0.28.12.0
true
diff --git a/src/PSLauncher.Core/Downloads/DownloadManager.cs b/src/PSLauncher.Core/Downloads/DownloadManager.cs
index 5aa35d0..23b373c 100644
--- a/src/PSLauncher.Core/Downloads/DownloadManager.cs
+++ b/src/PSLauncher.Core/Downloads/DownloadManager.cs
@@ -479,19 +479,26 @@ 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
+ // 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. Cas observé chez l'utilisateur :
- // ~80% des reprises post-pause failaient la vérif hash.
+ // pre-alloc sparse) → SHA-256 fail.
//
- // 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.
+ // 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;
@@ -500,15 +507,12 @@ public sealed class DownloadManager : IDownloadManager
{
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
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;
+ onBytes(seg.Index, inFlight);
inFlight = 0;
}
}
@@ -517,6 +521,7 @@ public sealed class DownloadManager : IDownloadManager
if (inFlight > 0)
{
seg.DownloadedBytes += inFlight;
+ onBytes(seg.Index, inFlight);
inFlight = 0;
}
seg.Completed = (seg.DownloadedBytes >= seg.Length);