Fix SignManifest 500 sur ZIPs de 14 Go via OVH mutualisé

Bug rapporté depuis le backoffice : cliquer « 🔁 Hash » sur une nouvelle
version renvoyait un 500 Internal Server Error générique (« Please contact
the server administrator at postmaster@asterionvr.com »). L'opérateur ne
pouvait donc plus signer une release après upload SFTP du ZIP.

Root cause : hash_file('sha256', $zip) sur un fichier de 14 Go via le SAN
mutualisé OVH prend 5-10 min. Le max_execution_time PHP par défaut (30-60s)
tue le process bien avant. Apache remonte alors 500 avec son boilerplate
par défaut, sans log utile pour l'opérateur.

Fix en défense en profondeur :

1. set_time_limit(0) + ignore_user_abort(true) au début de run(). Couvre
   TOUS les callers (admin web, cron, CLI). ignore_user_abort évite qu'un
   refresh de l'onglet backoffice interrompe un hash en cours (10 min = ~un
   café — l'opérateur peut être tenté de refresh).

2. Passage de hash_file() → hash_init + hash_update_stream en boucle
   16 Mo par chunk. Deux bénéfices :
   • flush() entre chaque chunk = heartbeat pour le proxy Apache/OVH front,
     évite un timeout côté serveur web même si PHP a le droit de continuer.
   • set_time_limit(300) glissant à chaque chunk = si un chunk prend +5 min
     c'est vraiment un disque HS, pas juste un gros fichier — on n'est pas
     bloqué sur un unique timer géant.

Mémoire : hash_update_stream() ne buffere pas, streaming pur, aucun risque
d'OOM même sur 14 Go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 13:32:01 +02:00
parent ff05edbe7e
commit 46d7c461c5

View File

@@ -83,7 +83,37 @@ final class SignManifest
} }
$start = microtime(true); $start = microtime(true);
$sha = hash_file('sha256', $path); // Streamed hash en chunks 16 Mo. Deux bénéfices vs hash_file() atomique :
// 1. On peut envoyer un heartbeat (flush()) entre chunks pour que le
// proxy Apache/OVH ne timeout pas la requête pendant les ~5-10 min
// que prend un SHA-256 sur un ZIP de 14 Go via SAN mutualisé.
// 2. On peut relever set_time_limit() à chaque chunk (fenêtre glissante)
// au lieu de faire un unique set_time_limit(0) à la caller.
// Mémoire : hash_update_stream() ne buffere pas — c'est du streaming pur.
$fp = @fopen($path, 'rb');
if ($fp === false) {
return ['sha256' => '', 'fromCache' => false, 'durationMs' => 0];
}
try {
$ctx = hash_init('sha256');
// 16 Mo = compromis entre nombre d'appels PHP et pression CPU par read()
$chunkBytes = 16 * 1024 * 1024;
while (!feof($fp)) {
hash_update_stream($ctx, $fp, $chunkBytes);
// Fenêtre glissante : autorise ~5 min de plus avant que PHP ne
// timeout. Sur un fichier de 14 Go / chunks 16 Mo = ~900 itérations,
// donc si un chunk prend >5 min c'est vraiment que le disque est HS.
@set_time_limit(300);
// Heartbeat côté front — évite Apache RequestTimeout / OVH proxy
// timeout sur les grosses requêtes. Silencieux si output buffering
// est actif (pas fatal).
@ob_flush();
@flush();
}
$sha = hash_final($ctx);
} finally {
fclose($fp);
}
$duration = (int)((microtime(true) - $start) * 1000); $duration = (int)((microtime(true) - $start) * 1000);
$cache[$key] = ['size' => $size, 'mtime' => $mtime, 'sha256' => $sha]; $cache[$key] = ['size' => $size, 'mtime' => $mtime, 'sha256' => $sha];
@@ -114,6 +144,16 @@ final class SignManifest
*/ */
public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null): array public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null): array
{ {
// Sur OVH mutualisé, un SHA-256 d'un ZIP de 14 Go peut prendre 5-10 min via
// le SAN partagé. Le max_execution_time par défaut (30-60s) tue le process
// → Apache retourne 500 Internal Server Error avec le boilerplate
// postmaster@… — c'est le mode d'échec principal du bouton « Hash » au
// backoffice. On désactive la limite ici (couvre AUSSI les callers CLI et
// cron, pas seulement l'admin web). ignore_user_abort évite qu'un refresh
// ou une fermeture d'onglet côté opérateur interrompe un hash en cours.
@set_time_limit(0);
@ignore_user_abort(true);
if (!is_file($this->manifestPath)) { if (!is_file($this->manifestPath)) {
$this->out("Manifest introuvable : {$this->manifestPath}"); $this->out("Manifest introuvable : {$this->manifestPath}");
return ['ok' => false, 'log' => $this->log]; return ['ok' => false, 'log' => $this->log];