Article technique

HotXLS, CRC32 de zlib-ng, et débordement de pile dans les threads Delphi

HotXLS peut faire planter un thread de travail Delphi sans aucune exception interceptable lorsqu'il calcule la somme de contrôle d'une grande partie XML de feuille de calcul en un seul appel : zlib-ng bascule vers son algorithme Chorba au-delà d'environ 119 Ko d'entrée, et la variante C générique de cet algorithme alloue un tableau de travail assez grand pour faire exploser la pile de thread par défaut de 1 Mo. Delphi n'a jamais la chance de réagir, car un débordement de pile n'est pas le genre d'exception que try/except a été conçu pour intercepter

HotXLS est une bibliothèque Delphi et C++Builder native pour lire et écrire des classeurs Excel, et le plantage a été retracé jusqu'à son écrivain de feuille de calcul. Le premier signe de problème fut un ticket de support : une tâche d'export nocturne plantait environ deux fois par semaine, toujours en cours d'exécution, sans boîte de dialogue d'exception Delphi et sans erreur journalisée, juste un processus qui disparaissait et une entrée de Rapport d'erreurs Windows qui ne menait nulle part d'utile. Reproduire cela à un poste de travail était une tout autre affaire. Les petits classeurs s'enregistraient bien. Les grands classeurs s'enregistraient bien aussi, tant que la sauvegarde s'exécutait sur le thread principal avec un débogueur déjà attaché. Il a fallu un véritable lot de fichiers de taille production passant par le vrai chemin d'export multithread pour ramener le plantage à la maison, moment auquel les E/S disque, la pression mémoire, et un modèle suspect avaient déjà chacun été écartés

Comment une sauvegarde de feuille de calcul se transforme en un unique appel CRC32 géant

Les fichiers XLSX sont des conteneurs ZIP, et le format ZIP exige une somme de contrôle CRC-32 pour chaque entrée, enregistrée à la fois dans l'en-tête de fichier local et dans le répertoire central. HotXLS calcule cette somme de contrôle en appelant un petit wrapper nommé ZLibCRC32, qui appelle à son tour la propre routine crc32 de zlib-ng une fois que SaveAs a terminé d'assembler le XML d'une feuille de calcul en mémoire, et pendant longtemps cet appel portait l'intégralité du tampon non compressé en une seule invocation. C'est une conception raisonnable pour une petite feuille de calcul. Cela devient un appel très volumineux dès l'instant où une feuille est du genre couvert dans notre guide sur la performance des grands classeurs dans HotXLS, où le XML d'une seule feuille dépasse couramment quelques centaines de kilo-octets avant même d'être compressé

Pourquoi zlib-ng a-t-il besoin d'un tampon de pile géant pour CRC32 ?

zlib-ng n'utilise pas une seule implémentation CRC-32 pour chaque appel. En dessous d'un seuil de taille, il parcourt le tampon avec des recherches de table et des astuces de pliage qui ne nécessitent aucune mémoire supplémentaire significative, et au-dessus de ce seuil, environ 119 Ko, précisément 118 960 octets dans la version à laquelle HotXLS se lie, il bascule vers un algorithme rapide spécialisé appelé Chorba. L'implémentation en C générique de ce chemin échange de la mémoire contre de la vitesse : elle alloue un tableau de travail sur la pile plutôt que sur le tas, dimensionné pour rendre la boucle interne de l'algorithme rapide, pas pour tenir confortablement dans quel que soit le budget de pile que le thread appelant se trouve porter. Rien de tout cela n'est visible du côté de l'appelant. Une fonction de somme de contrôle est normalement un appel feuille, lire quelques octets, renvoyer un nombre, aucune allocation qui vaille la peine d'être mentionnée, et cette hypothèse se vérifie pour l'écrasante majorité des appels dans zlib-ng, jusqu'à ce qu'un tampon assez grand pour franchir le seuil Chorba s'y présente

function BuildWorksheetPartCrc(const XmlBytes: TBytes): LongWord;
begin
  // One call over the whole worksheet XML buffer: fine for a small
  // sheet, but a large enough input pushes zlib-ng onto its Chorba
  // fast path and that path's stack-hungry scratch buffer
  Result := ZLibCRC32(0, XmlBytes[0], Length(XmlBytes));
end;

Pourquoi les threads de travail l'ont vu et le débogage interactif jamais

Déclencher ce plantage nécessite deux conditions en même temps : une partie XML de feuille de calcul assez grande pour franchir le seuil Chorba de zlib-ng, et un thread qui ne porte que la pile par défaut ordinaire plutôt que quelque chose de plus spacieux. Les tâches d'export en production remplissaient les deux conditions. Elles s'exécutent comme des tâches par lot côté serveur qui répartissent les écritures HotXLS sur un pool de threads de travail, chacun portant la pile par défaut de 1 Mo que Windows réserve à moins qu'un appelant n'en demande plus, et chacun traitant des classeurs clients assez grands pour compter. Le débogage à un poste de travail ne remplissait fiablement ni l'une ni l'autre condition : les fichiers d'exemple étaient généralement plus petits que le seuil, et les exécutions pas à pas avaient tendance à se produire sur le thread principal plutôt qu'à l'intérieur d'un travailleur fraîchement engendré, si bien que les deux conditions qui devaient s'aligner en production ne s'alignaient presque jamais au poste d'un développeur

Traquer un plantage qui accusait la mauvaise fonction

Les rapports de plantage que l'équipe a pu obtenir pointaient vers un emplacement à l'intérieur de la fonction deflate de zlib-ng, pas vers du code HotXLS, et pas non plus évidemment vers le code CRC-32. Ce seul détail a orienté la première passe de l'investigation vers le chemin de compression : tailles de tampon passées à deflate, bits de fenêtre, niveau de compression, tous les suspects habituels pour un plantage natif provenant d'un codec. Aucun d'eux n'a tenu la route

Une image de pile trompeuse

Un débordement de pile est un genre étrange de plantage à symboliser, car au moment où il est rapporté, le pointeur de pile a déjà dépassé l'espace qui lui était réservé. Quel que soit l'outil ayant produit ce rapport de plantage, il a très probablement résolu l'adresse fautive vers le symbole le plus proche qu'il pouvait encore trouver, et le point d'entrée exporté le plus proche assis à côté du véritable coupable se trouvait être deflate. Le véritable défaut se trouvait dans l'allocation du tampon de travail Chorba à l'intérieur du chemin CRC-32, compilé dans la même bibliothèque, assez proche dans le binaire pour être confondu avec la fonction qui s'exécutait réellement

Bissection avec des horodatages plutôt qu'un débogueur

Un plantage qui fait tomber tout le processus ne laisse rien à intercepter pour une session de débogueur Delphi normale, si bien que l'équipe s'est repliée sur des points de contrôle GetTickCount déposés autour de chaque appel suspect et une bissection manuelle à travers le chemin de sauvegarde, réduisant progressivement quelle opération était en cours au moment où le processus est mort. Parallèlement à cela, une version de référence connue pour être saine a exécuté les mêmes fichiers de production côte à côte avec la version actuelle, spécifiquement pour écarter une régression dans les propres changements de cette itération avant de chercher plus en amont. Ce n'est qu'après que les deux vérifications soient revenues propres que l'investigation s'est installée sur une dépendance tierce faisant quelque chose d'inattendu avec une entrée parfaitement valide

Pourquoi try/except échoue-t-il à intercepter un débordement de pile ?

Un débordement de pile n'est pas une exception que le code Delphi lève jamais intentionnellement, et elle n'est pas non plus délivrée de la façon dont Windows délivre une violation d'accès ou une division par zéro. Elle se manifeste comme une faute de page de garde matérielle, rapportée via le même mécanisme de gestion d'exception structurée sur lequel le try/except de Delphi est construit, mais au moment exact où elle se déclenche, il ne reste normalement aucun espace de pile pour exécuter un gestionnaire, dérouler le code de nettoyage, ou même terminer de rapporter la faute proprement. Sur un thread de travail ne portant que la réservation par défaut de 1 Mo, avec un tampon de travail de cette taille ayant déjà consommé la plupart de ce qui restait, il ne reste rien avec quoi le runtime puisse travailler

procedure TExportWorker.Execute;
var
  Workbook: TXLSXWorkbook;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    try
      BuildWorksheet(Workbook);
      Workbook.SaveAs(FTargetFile);  // crashes the process here on a
                                      // large enough sheet: try/except
                                      // never gets a chance to run
    except
      on E: Exception do
        LogError('Export failed: ' + E.Message);
    end;
  finally
    Workbook.Free;
  end;
end;

Ce bloc except ressemble à un filet de sécurité, et contre la plupart des échecs, c'en est un, mais il ne fait rien ici. L'équipe l'a confirmé en pratique : try/except n'a rien intercepté, le bloc finally n'a jamais non plus eu de chance fiable de s'exécuter, et l'opérateur a vu un processus mort sans aucune entrée de journal au niveau applicatif, exactement ce que décrivait le ticket de support d'origine

La correction : alimenter CRC32 en tranches de 64 Ko plutôt qu'en un seul appel géant

La correction que HotXLS a livrée ne change rien à zlib-ng lui-même ni au niveau de compression utilisé pour écrire le classeur. ZLibCRC32 parcourt désormais l'entrée en tranches fixes de 64 Ko, 65536 octets chacune, appelant crc32 de zlib-ng une fois par tranche et enfilant la valeur de somme de contrôle courante d'un appel au suivant. CRC-32 est un algorithme incrémental par construction, si bien qu'une somme de contrôle construite sur plusieurs tranches est identique bit pour bit à une calculée en un seul appel sur les mêmes octets : la correction change comment le travail est réparti, pas ce qu'il calcule

function ZLibCRC32(crc: LongWord; const buffer; count: Longint): LongWord;
const
  // 64 KB keeps every call comfortably under the Chorba threshold
  CrcChunkSize = 65536;
var
  Cursor: PByte;
  ThisChunk: Longint;
begin
  Result := crc;
  Cursor := PByte(@buffer);
  while count > 0 do
  begin
    ThisChunk := count;
    if ThisChunk > CrcChunkSize then
      ThisChunk := CrcChunkSize;
    Result := zng_crc32(Result, Cursor, Cardinal(ThisChunk));
    Inc(Cursor, ThisChunk);
    Dec(count, ThisChunk);
  end;
end;

Rien dans l'appel SaveAs environnant n'a eu besoin de changer pour que cela fonctionne, et rien non plus dans les entrées ZIP que HotXLS écrit n'a changé : la valeur CRC-32 qui finit dans l'en-tête de fichier local et le répertoire central est exactement la valeur qu'un seul appel géant aurait produite, simplement assemblée à partir de morceaux plus petits. Rétrograder zlib-ng ou se replier sur une implémentation CRC-32 plus lente et économe en allocation aurait également évité le plantage, mais à un coût réel pour chaque fichier qui n'approchait même pas le seuil en premier lieu, ce qui explique pourquoi aucune des deux n'a été livrée

Ce que cela signifie si vous appelez zlib-ng depuis vos propres threads de travail

Le mode d'échec de débordement de pile décrit ici n'a rien à voir spécifiquement avec les feuilles de calcul. Toute application qui transmet à zlib-ng un grand tampon, que ce soit pour la compression, la décompression, ou une somme de contrôle, depuis un thread qui ne porte que la pile par défaut de la plateforme peut se heurter au même genre de mur, car la bibliothèque choisit son algorithme selon la taille d'entrée et certains de ces algorithmes supposent qu'il reste de la pile à revendre. Deux défenses fonctionnent sans toucher à zlib-ng lui-même : alimenter les grands tampons dans des routines sensibles à la taille par blocs fixes supprime purement et simplement la condition de déclenchement pour tout algorithme naturellement incrémental, et là où le découpage n'est pas une option, donner au thread appelant une pile plus grande que celle par défaut de la plateforme est l'autre levier. L'un ou l'autre est moins coûteux que de découvrir un seuil de taille non documenté à partir d'un rapport de plantage de production qui accuse la mauvaise fonction

Ce seuil particulier est resté invisible jusqu'à ce qu'un classeur de production assez grand le franchisse sur le mauvais type de thread, ce qui est exactement le genre d'échec qui n'apparaît qu'une fois que le code s'exécute contre de vrais fichiers plutôt que de petits jeux de test. Le chemin CRC-32 découpé fait désormais partie du pipeline d'écriture standard dans le composant Excel HotXLS pour Delphi et C++Builder, sans rien à configurer pour un appelant et aucune propriété qui l'active ou le désactive