Technický článek

HotXLS, zlib-ng CRC32 a přetečení zásobníku ve vláknech Delphi

HotXLS dokáže spadnout pracovní vlákno v Delphi bez jakékoli zachytitelné výjimky, když jedním voláním spočítá kontrolní součet velké části XML listu: zlib-ng přepne na algoritmus Chorba nad zhruba 119 KB vstupu, a generická C varianta tohoto algoritmu alokuje pomocné pole dost velké na to, aby prorazilo výchozí 1 MB zásobník vlákna. Delphi nikdy nedostane šanci zareagovat, protože přetečení zásobníku není druh výjimky, na jejíž zachytávání byl try/except stavěn

HotXLS je nativní knihovna pro Delphi a C++Builder pro čtení a zápis sešitů Excelu, a pád se vystopoval zpátky k jejímu zapisovači listů. Prvním znamením problému byl lístek podpory: přesnoční exportní úloha padala zhruba dvakrát týdně, vždy uprostřed běhu, bez dialogu výjimky Delphi a bez zalogované chyby, jen proces, který zmizel, a záznam Windows Error Reporting, který nikam neukazoval. Reprodukovat to u stolu bylo úplně jiné dilema. Malé sešity se ukládaly v pořádku. Velké sešity se také ukládaly v pořádku, pokud uložení běželo na hlavním vlákně s už připojeným debuggerem. Trvalo skutečnou dávku souborů produkční velikosti běžících přes skutečnou vícevláknovou exportní cestu, než se pád podařilo přinést domů, a v tu chvíli už byly vyloučeny I/O disku, tlak paměti i podezřelá šablona

Jak se uložení listu promění na jedno obří volání CRC32

Soubory XLSX jsou kontejnery ZIP a formát ZIP vyžaduje kontrolní součet CRC-32 pro každý záznam, zaznamenaný jak v lokální hlavičce souboru, tak v centrálním adresáři. HotXLS tento kontrolní součet počítá zavoláním malého obalu jménem ZLibCRC32, který zase volá vlastní rutinu crc32 zlib-ng poté, co SaveAs dokončí sestavení XML listu v paměti, a dlouho toto volání neslo celý nekomprimovaný buffer v jediném volání. To je rozumný návrh pro malý list. Stane se z toho jedno velmi velké volání v okamžiku, kdy je list tím druhem popsaným v našem průvodci výkonem velkých sešitů v HotXLS, kde XML jednoho listu běžně přesáhne pár set kilobajtů ještě předtím, než se vůbec zkomprimuje

Proč zlib-ng potřebuje pro CRC32 obří buffer na zásobníku?

zlib-ng nepoužívá jednu implementaci CRC-32 pro každé volání. Pod prahovou velikostí prochází buffer přes vyhledávací tabulky a triky se skládáním, které nepotřebují žádnou významnou dodatečnou paměť, a nad tímto prahem, zhruba 119 KB, přesně 118 960 bajtů v sestavení, proti kterému se HotXLS linkuje, přepne na specializovaný rychlý algoritmus jménem Chorba. Generická implementace v C této cesty vyměňuje paměť za rychlost: alokuje pomocné pole na zásobníku místo na haldě, o velikosti navržené tak, aby byla vnitřní smyčka algoritmu rychlá, ne aby se pohodlně vešla do jakéhokoli rozpočtu zásobníku, který volající vlákno náhodou nese. Nic z toho není viditelné ze strany volajícího. Funkce kontrolního součtu je normálně listové volání, přečti nějaké bajty, vrať číslo, žádná alokace za zmínku, a tento předpoklad platí pro drtivou většinu volání do zlib-ng, přesně dokud do jednoho nevstoupí buffer dost velký na to, aby překročil práh Chorba

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;

Proč to viděla pracovní vlákna a interaktivní ladění nikdy

Vyvolat tento pád vyžaduje dvě podmínky současně: část XML listu dost velkou na to, aby překročila práh Chorba v zlib-ng, a vlákno, které má jen obyčejný výchozí zásobník místo něčeho prostornějšího. Produkční exportní úlohy zasáhly obě. Běží jako dávkové úlohy na straně serveru, které rozprostírají zápisy HotXLS přes fond pracovních vláken, každé nese výchozí 1 MB zásobník, který Windows rezervuje, pokud volající nepožádá o víc, a každé zpracovává sešity zákazníků dost velké na to, aby na tom záleželo. Ladění u stolu nezasáhlo ani jednu podmínku spolehlivě: vzorové soubory byly obvykle menší než práh, a běhy krok za krokem se obvykle odehrávaly na hlavním vlákně místo uvnitř čerstvě spuštěného pracovního vlákna, takže dvě podmínky, které se musely v produkci sejít, se u stolu vývojáře skoro nikdy nesešly

Honba za pádem, který obviňoval špatnou funkci

Zprávy o pádu, které se týmu podařilo získat, ukazovaly na místo uvnitř deflátovací funkce zlib-ng, ne na žádný kód HotXLS, a ani zjevně ne na kód CRC-32. Tento jediný detail nasměroval první průchod vyšetřování ke kompresní cestě: velikosti bufferů předávané do deflate, bity okna, úroveň komprese, všichni obvyklí podezřelí pro nativní pád vycházející z kodeku. Žádný z nich se neudržel

Zavádějící vrchní rámec

Přetečení zásobníku je zvláštní druh pádu na symbolizaci, protože v okamžiku, kdy se hlásí, ukazatel zásobníku už proběhl za prostor, který pro něj byl rezervován. Cokoli tuto zprávu o pádu vyprodukovalo, nejspíš rozřešilo chybnou adresu na nejbližší symbol, který ještě dokázalo najít, a nejbližší exportovaný vstupní bod sedící vedle skutečného viníka náhodou byl deflate. Skutečná chyba sídlila v alokaci pomocného bufferu Chorba uvnitř cesty CRC-32, zkompilovaná do stejné knihovny, dost blízko v binárce na to, aby byla zaměněna za funkci, která skutečně běžela

Bisekce podle časových razítek místo debuggeru

Pád, který strhne celý proces, nenechá normální relaci debuggeru Delphi nic k zachycení, takže tým spadl zpátky na kontrolní body GetTickCount vhozené kolem každého podezřelého volání a ruční bisekci napříč cestou uložení, zužující, která operace byla v běhu v okamžiku, kdy proces zemřel. Vedle toho běželo známě dobré základní sestavení nad stejnými produkčními soubory paralelně s aktuálním, výslovně proto, aby se vyloučila regrese ve vlastních změnách tohoto kola dřív, než se hledalo dál proti proudu. Teprve poté, co obě kontroly vyšly čistě, se vyšetřování usadilo na závislosti třetí strany, která dělá něco neočekávaného s naprosto platným vstupem

Proč try/except selže při zachytávání přetečení zásobníku?

Přetečení zásobníku není výjimka, kterou by kód Delphi kdy vyvolal záměrně, a nedoručuje se ani tak, jak Windows doručuje access violation nebo dělení nulou. Projeví se jako chyba hardwarové ochranné stránky, hlášená přes stejný mechanismus strukturovaného zpracování výjimek, na kterém je try/except v Delphi postaven, ale přesně v okamžiku, kdy se spustí, normálně nezbývá žádný prostor na zásobníku ke spuštění obsluhovače, odvinutí úklidového kódu, nebo dokonce dokončení čistého hlášení chyby. Na pracovním vlákně nesoucím jen výchozí rezervaci 1 MB, s pomocným bufferem této velikosti, který už spotřeboval většinu z toho, co zbylo, nezůstává runtime nic, s čím by mohl pracovat

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;

Tento blok except vypadá jako záchranná síť a proti většině selhání jí je, ale tady nedělá nic. Tým to v praxi potvrdil: try/except nezachytil nic, blok finally také nikdy nedostal spolehlivou šanci se spustit, a operátor viděl mrtvý proces bez jakéhokoli záznamu v logu na úrovni aplikace, přesně to, co popisoval původní lístek podpory

Oprava: podat CRC32 v řezech po 64 KB místo jednoho obřího volání

Oprava, kterou HotXLS vydal, nemění nic na samotném zlib-ng a nic na úrovni komprese použité k zápisu sešitu. ZLibCRC32 teď prochází vstup v pevných řezech po 64 KB, 65536 bajtů každý, volá crc32 zlib-ng jednou na řez a protahuje běžící hodnotu kontrolního součtu z jednoho volání do dalšího. CRC-32 je konstrukčně inkrementální algoritmus, takže kontrolní součet sestavený přes několik řezů je bit po bitu identický s tím spočítaným v jediném volání nad stejnými bajty: oprava mění, jak se práce rozděluje, ne co počítá

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;

Nic na okolním volání SaveAs se kvůli tomu nemuselo změnit, a nic na záznamech ZIP, které HotXLS zapisuje, se také nezměnilo: hodnota CRC-32, která skončí v lokální hlavičce souboru a centrálním adresáři, je přesně ta hodnota, kterou by vyprodukovalo jedno obří volání, jen sestavená z menších kusů. Snížení verze zlib-ng nebo záložní přechod na pomalejší, na alokaci nenáročnou implementaci CRC-32 by se pádu také vyhnulo, ale za skutečnou cenu pro každý soubor, který se prahu ani zdaleka nepřiblížil, a proto se ani jedno z toho nevydalo

Co to znamená, pokud voláte zlib-ng z vlastních pracovních vláken

Režim selhání přetečením zásobníku popsaný zde nemá nic společného konkrétně s tabulkami. Jakákoli aplikace, která podá zlib-ng velký buffer, ať pro kompresi, dekompresi, nebo kontrolní součet, z vlákna, které nese jen výchozí zásobník platformy, může narazit na stejnou zeď, protože knihovna volí svůj algoritmus podle velikosti vstupu a některé z těchto algoritmů předpokládají, že je zásobníku nazbyt. Dvě obrany fungují bez toho, aby se dotkly samotného zlib-ng: podávání velkých bufferů do na velikost citlivých rutin v pevných blocích odstraní spouštěcí podmínku úplně pro každý algoritmus, který je přirozeně inkrementální, a tam, kde dělení na bloky není možnost, je dání volajícímu vláknu zásobníku většího než výchozí u platformy druhou páčkou. Kterékoli z toho je levnější než zjišťovat o nezdokumentovaném prahu velikosti ze zprávy o produkčním pádu, která obviňuje špatnou funkci

Tento konkrétní práh zůstal neviditelný, dokud jej dost velký produkční sešit nepřekročil na špatném druhu vlákna, což je přesně ten druh selhání, který se projeví teprve tehdy, když kód běží proti skutečným souborům místo malých testovacích fixtur. Cesta CRC-32 rozdělená na bloky se teď dodává jako součást standardní zapisovací pipeline v komponentě HotXLS Excel pro Delphi a C++Builder, bez čehokoli, co by volající musel konfigurovat, a bez vlastnosti, která by to zapínala nebo vypínala