[FIX] qemu : reprendre un téléchargement d'image coupé
Une image de VM pèse un demi-gigaoctet : une coupure y est bien plus probable que sur une page. Le contrôle de complétude existait — sans lui un .part tronqué passait pour une image, donnant un qcow2 valide mais VIDE — mais il jetait les octets reçus et renvoyait l'utilisateur à un « curl -C - » tapé à la main. Le transfert se reprend désormais sur le même miroir, trois essais, par un « Range » qui demande la suite ; on ne change de miroir qu'ensuite. Un serveur qui ignore le Range rend le fichier entier, et l'on repart alors de zéro plutôt que de doubler les octets déjà là. Vérifié : 5 tests contre un serveur qui coupe au tiers, image reconstituée à l'octet près, et le .part tronqué part quand les reprises s'épuisent. --- EN --- A VM image weighs half a gigabyte: an interruption is far likelier there than on a page. The completeness check existed — without it a truncated .part passed for an image, giving a valid but EMPTY qcow2 — but it threw away the bytes received and sent the user back to a hand-typed "curl -C -". The transfer now resumes on the same mirror, three attempts, through a "Range" asking for the rest; only then does it change mirror. A server ignoring the Range returns the whole file, and we restart from zero rather than doubling the bytes already there. Checked: 5 tests against a server cutting at one third, image reassembled byte for byte, and the truncated .part goes when the retries run out. Assisted-by: Claude Opus 5
This commit is contained in:
parent
4734c682a9
commit
44bc1b093c
2 changed files with 176 additions and 15 deletions
|
|
@ -1531,7 +1531,21 @@ def ensure_emulator(
|
|||
DOWNLOAD_TIMEOUT = 30
|
||||
|
||||
|
||||
def _download_one(url: str, tmp: Path, timeout: int) -> None:
|
||||
# Reprises d'un transfert COUPÉ, sur le même miroir, avant d'en changer.
|
||||
# Une image de VM pèse un demi-gigaoctet : une coupure y est bien plus
|
||||
# probable que sur une page, et repartir de zéro à chaque fois peut ne jamais
|
||||
# aboutir. Trois essais, puis le miroir suivant.
|
||||
REPRISES_MAX = 3
|
||||
|
||||
|
||||
class TelechargementTronque(OSError):
|
||||
"""Le serveur a annoncé une taille, et la connexion a coupé avant.
|
||||
|
||||
Distinguée d'une erreur réseau ordinaire parce qu'elle se REPREND : les
|
||||
octets déjà écrits restent bons, et un « Range » demande la suite."""
|
||||
|
||||
|
||||
def _download_one(url: str, tmp: Path, timeout: int, depuis: int = 0) -> None:
|
||||
"""Télécharge url -> tmp en streaming, avec timeout et barre de %.
|
||||
Lève une exception en cas d'échec réseau (miroir suivant à essayer)."""
|
||||
is_tty = sys.stdout.isatty()
|
||||
|
|
@ -1568,7 +1582,7 @@ def _download_one(url: str, tmp: Path, timeout: int) -> None:
|
|||
# il était validé comme « complet » -> qcow2 valide mais VIDE (juste
|
||||
# l'en-tête) -> VM qui ne boote pas, et cache empoisonné réutilisé ensuite.
|
||||
if total > 0 and done < total:
|
||||
raise OSError(
|
||||
raise TelechargementTronque(
|
||||
f"téléchargement incomplet : {done}/{total} octets reçus "
|
||||
"(connexion interrompue)"
|
||||
)
|
||||
|
|
@ -1607,19 +1621,38 @@ def download_image(
|
|||
for i, url in enumerate(urls, 1):
|
||||
tag = "" if len(urls) == 1 else f" (miroir {i}/{len(urls)})"
|
||||
print(f" Téléchargement{tag} : {url}", flush=True)
|
||||
try:
|
||||
_download_one(url, tmp, timeout)
|
||||
tmp.replace(dest)
|
||||
return
|
||||
except urllib.error.HTTPError as exc: # image absente sur ce miroir
|
||||
tmp.unlink(missing_ok=True)
|
||||
had_404 = had_404 or exc.code == 404
|
||||
print(f"\n Échec : HTTP {exc.code}", flush=True)
|
||||
errors.append(f"{url} -> HTTP {exc.code}")
|
||||
except Exception as exc: # réseau/timeout : miroir suivant
|
||||
tmp.unlink(missing_ok=True)
|
||||
print(f"\n Échec : {exc}", flush=True)
|
||||
errors.append(f"{url} -> {exc}")
|
||||
# Une coupure se REPREND sur le même miroir avant d'en changer :
|
||||
# les octets déjà écrits restent bons, et le .part survit entre deux
|
||||
# essais. Tout autre échec — 404, timeout, réseau — passe au miroir
|
||||
# suivant sans insister.
|
||||
for essai in range(1, REPRISES_MAX + 1):
|
||||
depuis = tmp.stat().st_size if tmp.exists() else 0
|
||||
try:
|
||||
_download_one(url, tmp, timeout, depuis if essai > 1 else 0)
|
||||
tmp.replace(dest)
|
||||
return
|
||||
except TelechargementTronque as exc:
|
||||
if essai < REPRISES_MAX:
|
||||
recus = tmp.stat().st_size if tmp.exists() else 0
|
||||
print(
|
||||
f"\n Coupé à {recus} octets, reprise"
|
||||
f" {essai}/{REPRISES_MAX - 1}...",
|
||||
flush=True,
|
||||
)
|
||||
continue
|
||||
tmp.unlink(missing_ok=True)
|
||||
print(f"\n Échec : {exc}", flush=True)
|
||||
errors.append(f"{url} -> {exc}")
|
||||
except urllib.error.HTTPError as exc: # absente de ce miroir
|
||||
tmp.unlink(missing_ok=True)
|
||||
had_404 = had_404 or exc.code == 404
|
||||
print(f"\n Échec : HTTP {exc.code}", flush=True)
|
||||
errors.append(f"{url} -> HTTP {exc.code}")
|
||||
except Exception as exc: # réseau/timeout : miroir suivant
|
||||
tmp.unlink(missing_ok=True)
|
||||
print(f"\n Échec : {exc}", flush=True)
|
||||
errors.append(f"{url} -> {exc}")
|
||||
break
|
||||
hint = (
|
||||
"\n Image introuvable (404) : cette version est probablement EOL et"
|
||||
" a été retirée du miroir. Choisissez une version LTS encore"
|
||||
|
|
|
|||
128
test/test_qemu_download_resume.py
Normal file
128
test/test_qemu_download_resume.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Un transfert coupé se reprend-il, et le fichier est-il intact ?
|
||||
|
||||
Une image de VM pèse un demi-gigaoctet : une coupure y est bien plus probable
|
||||
que sur une page, et repartir de zéro à chaque fois peut ne jamais aboutir. Le
|
||||
contrôle de complétude existait déjà — sans lui un .part tronqué passait pour
|
||||
une image, donnant un qcow2 valide mais VIDE, et une VM qui ne démarre pas —
|
||||
mais il jetait les octets reçus.
|
||||
|
||||
Deux pièges que ces tests gardent :
|
||||
|
||||
- les octets déjà écrits sont CONSERVÉS entre deux essais, et la suite est
|
||||
demandée par « Range ». Les jeter rendrait chaque reprise aussi longue que
|
||||
le premier essai ;
|
||||
- un serveur qui IGNORE « Range » rend 200 et le fichier entier : il faut
|
||||
alors repartir de zéro, sans quoi les octets déjà là seraient doublés et
|
||||
l'image illisible.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(RACINE))
|
||||
|
||||
from script.qemu import deploy_qemu as d # noqa: E402
|
||||
|
||||
CONTENU = b"".join(bytes([i % 251]) for i in range(60_000))
|
||||
|
||||
|
||||
class Serveur:
|
||||
"""Un serveur qui coupe ses `coupures` premiers transferts au tiers."""
|
||||
|
||||
def __init__(self, coupures, honore_range=True):
|
||||
self.restantes = coupures
|
||||
self.honore_range = honore_range
|
||||
self.demandes = []
|
||||
contexte = self
|
||||
|
||||
class Poignee(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
rng = self.headers.get("Range")
|
||||
contexte.demandes.append(rng)
|
||||
debut = 0
|
||||
if rng and contexte.honore_range:
|
||||
debut = int(rng.split("=")[1].split("-")[0])
|
||||
self.send_response(206)
|
||||
self.send_header(
|
||||
"Content-Range",
|
||||
f"bytes {debut}-{len(CONTENU) - 1}/{len(CONTENU)}",
|
||||
)
|
||||
else:
|
||||
self.send_response(200)
|
||||
reste = CONTENU[debut:]
|
||||
self.send_header("Content-Length", str(len(reste)))
|
||||
self.end_headers()
|
||||
if contexte.restantes > 0:
|
||||
contexte.restantes -= 1
|
||||
self.wfile.write(reste[: len(reste) // 3])
|
||||
else:
|
||||
self.wfile.write(reste)
|
||||
|
||||
self.httpd = http.server.HTTPServer(("127.0.0.1", 0), Poignee)
|
||||
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return f"http://127.0.0.1:{self.httpd.server_address[1]}/image.qcow2"
|
||||
|
||||
def arrete(self):
|
||||
self.httpd.shutdown()
|
||||
|
||||
|
||||
class TestLaReprise(unittest.TestCase):
|
||||
def telecharge(self, serveur):
|
||||
self.addCleanup(serveur.arrete)
|
||||
with tempfile.TemporaryDirectory() as coin:
|
||||
dest = Path(coin) / "image.qcow2"
|
||||
d.download_image([serveur.url], dest, dry_run=False, timeout=10)
|
||||
return dest.read_bytes()
|
||||
|
||||
def test_une_coupure_est_reprise(self):
|
||||
serveur = Serveur(coupures=1)
|
||||
self.assertEqual(CONTENU, self.telecharge(serveur))
|
||||
|
||||
def test_deux_coupures_sont_reprises(self):
|
||||
serveur = Serveur(coupures=2)
|
||||
self.assertEqual(CONTENU, self.telecharge(serveur))
|
||||
|
||||
def test_la_suite_est_demandee_par_range(self):
|
||||
"""Sans Range, la reprise retéléchargerait tout."""
|
||||
serveur = Serveur(coupures=1)
|
||||
self.telecharge(serveur)
|
||||
self.assertIsNone(serveur.demandes[0])
|
||||
self.assertTrue(serveur.demandes[1].startswith("bytes="))
|
||||
|
||||
def test_un_serveur_qui_ignore_range_repart_de_zero(self):
|
||||
"""Sinon les octets déjà là seraient doublés, et l'image illisible."""
|
||||
serveur = Serveur(coupures=1, honore_range=False)
|
||||
self.assertEqual(CONTENU, self.telecharge(serveur))
|
||||
|
||||
def test_au_dela_des_reprises_le_miroir_suivant(self):
|
||||
"""Trois essais, puis on change de miroir plutôt que d'insister."""
|
||||
serveur = Serveur(coupures=d.REPRISES_MAX)
|
||||
self.addCleanup(serveur.arrete)
|
||||
with tempfile.TemporaryDirectory() as coin:
|
||||
dest = Path(coin) / "image.qcow2"
|
||||
with self.assertRaises(SystemExit):
|
||||
d.download_image([serveur.url], dest, False, timeout=10)
|
||||
self.assertFalse(
|
||||
dest.with_suffix(dest.suffix + ".part").exists(),
|
||||
"le .part tronqué doit partir, il empoisonnerait le cache",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue