Technisch artikel

HotXLS, zlib-ng CRC32 en stack-overflow in Delphi-threads

HotXLS kan een Delphi-workerthread laten crashen zonder afvangbare uitzondering wanneer het een groot werkblad-XML-onderdeel in één aanroep controleert met een checksum: zlib-ng schakelt boven ongeveer 119 KB invoer over op zijn Chorba-algoritme, en de generieke C-variant van dat algoritme wijst een scratch-array toe die groot genoeg is om dwars door de standaard 1 MB-threadstack heen te breken. Delphi krijgt nooit de kans om te reageren, omdat een stack-overflow niet het soort uitzondering is waarvoor try/except is gebouwd om op te vangen

HotXLS is een native Delphi- en C++Builder-bibliotheek voor het lezen en schrijven van Excel-werkmappen, en de crash was terug te voeren op de werkbladschrijver ervan. Het eerste teken van problemen was een supportticket: een nachtelijke exporttaak crashte ongeveer twee keer per week, altijd halverwege een run, zonder Delphi-uitzonderingsdialoog en zonder gelogde fout, gewoon een proces dat verdween en een Windows-foutrapportage-item dat nergens naartoe leidde. Het reproduceren aan een bureau was een heel andere zaak. Kleine werkmappen sloegen prima op. Grote werkmappen sloegen ook prima op, zolang het opslaan draaide op de hoofdthread met een debugger al gekoppeld. Er was een echte batch productiegrote bestanden nodig die door het echte multithreaded exportpad liepen om de crash naar boven te halen, en tegen die tijd waren schijf-I/O, geheugendruk, en een verdacht sjabloon elk al uitgesloten

Hoe een werkbladopslag verandert in één gigantische CRC32-aanroep

XLSX-bestanden zijn ZIP-containers, en het ZIP-formaat vereist een CRC-32-checksum voor elk item, vastgelegd in zowel de lokale bestandsheader als de centrale directory. HotXLS berekent die checksum door een kleine wrapper genaamd ZLibCRC32 aan te roepen, die op zijn beurt de eigen crc32-routine van zlib-ng aanroept zodra SaveAs klaar is met het in het geheugen opbouwen van de XML van een werkblad, en lange tijd droeg die aanroep de volledige ongecomprimeerde buffer in één enkele invocatie. Dat is een redelijk ontwerp voor een klein werkblad. Het wordt één zeer grote aanroep zodra een blad van het soort is dat wordt behandeld in onze gids over prestaties van grote werkmappen in HotXLS, waar de XML van één blad routinematig ruim voorbij enkele honderden kilobytes komt voordat deze ooit wordt gecomprimeerd

Waarom heeft zlib-ng een gigantische stackbuffer nodig voor CRC32?

zlib-ng gebruikt niet één CRC-32-implementatie voor elke aanroep. Onder een groottedrempel loopt het door de buffer met tabelopzoekingen en vouwtrucs die geen noemenswaardig extra geheugen nodig hebben, en boven die drempel, ongeveer 119 KB, precies 118.960 bytes in de build waaraan HotXLS linkt, schakelt het over naar een gespecialiseerd snel algoritme genaamd Chorba. De generieke C-implementatie van dat pad ruilt geheugen in voor snelheid: het wijst een scratch-array toe op de stack in plaats van op de heap, met een grootte die de binnenste lus van het algoritme snel maakt, niet om comfortabel te passen binnen welk stackbudget de aanroepende thread ook toevallig draagt. Niets daarvan is zichtbaar vanaf de kant van de aanroeper. Een checksumfunctie is normaal gesproken een leaf-aanroep, lees wat bytes, geef een getal terug, geen toewijzing het vermelden waard, en die aanname klopt voor de overweldigende meerderheid van aanroepen naar zlib-ng, tot een buffer groot genoeg om de Chorba-drempel te overschrijden er eentje binnenwandelt

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;

Waarom workerthreads het zagen en interactief debuggen nooit

Deze crash triggeren vereist twee voorwaarden tegelijk: een werkblad-XML-onderdeel groot genoeg om de Chorba-drempel van zlib-ng te overschrijden, en een thread die alleen de gewone standaardstack heeft in plaats van iets ruimers. Productie-exporttaken voldeden aan beide. Ze draaien als server-side batchtaken die HotXLS-schrijfbewerkingen verdelen over een pool workerthreads, elk met de standaard 1 MB-stack die Windows reserveert tenzij een aanroeper om meer vraagt, en elk verwerkt klantwerkmappen die groot genoeg zijn om ertoe te doen. Bureau-debuggen voldeed betrouwbaar aan geen van beide voorwaarden: voorbeeldbestanden waren meestal kleiner dan de drempel, en stap-voor-stap-runs vonden meestal plaats op de hoofdthread in plaats van in een net gespawnde worker, dus de twee voorwaarden die in productie moesten samenvallen, vielen bijna nooit samen op het bureau van een ontwikkelaar

Een crash najagen die de verkeerde functie de schuld gaf

De crashrapporten die het team in handen kon krijgen, wezen naar een locatie binnen de deflate-functie van zlib-ng, niet naar enige HotXLS-code, en ook niet duidelijk naar de CRC-32-code. Dat ene detail stuurde de eerste ronde van het onderzoek richting het compressiepad: buffergroottes doorgegeven aan deflate, window bits, compressieniveau, alle gebruikelijke verdachten voor een native crash uit een codec. Geen daarvan hield stand

Een misleidend bovenste frame

Een stack-overflow is een vreemd soort crash om te symboliseren, omdat tegen de tijd dat deze wordt gerapporteerd, de stackpointer al voorbij de ruimte is gelopen die ervoor was gereserveerd. Wat die crashmelding ook produceerde, loste hoogstwaarschijnlijk het foutieve adres op naar het dichtstbijzijnde symbool dat het nog kon vinden, en het dichtstbijzijnde geëxporteerde entry point naast de echte boosdoener bleek toevallig deflate te zijn. De werkelijke fout zat in de Chorba-scratch-buffertoewijzing binnen het CRC-32-pad, gecompileerd in dezelfde bibliotheek, dicht genoeg bij elkaar in het binaire bestand om verward te worden met de functie die daadwerkelijk draaide

Bisecteren met tijdstempels in plaats van een debugger

Een crash die het hele proces onderuithaalt, laat niets over voor een normale Delphi-debugsessie om op te vangen, dus viel het team terug op GetTickCount-controlepunten geplaatst rond elke verdachte aanroep en een handmatige bisectie over het opslagpad, om te achterhalen welke bewerking gaande was op het moment dat het proces stierf. Daarnaast draaide een bekend-goede basisbuild dezelfde productiebestanden naast de huidige build, specifiek om een regressie in de eigen wijzigingen van die ronde uit te sluiten voordat er verder stroomopwaarts werd gekeken. Pas nadat beide controles schoon terugkwamen, richtte het onderzoek zich op een externe afhankelijkheid die iets onverwachts deed met een volkomen geldige invoer

Waarom vangt try/except geen stack-overflow op?

Een stack-overflow is geen uitzondering die Delphi-code ooit doelbewust opwerpt, en het wordt ook niet afgeleverd op de manier waarop Windows een access violation of een deling-door-nul aflevert. Het komt naar boven als een hardware-guard-page-fout, gerapporteerd via hetzelfde structured-exception-handling-mechanisme waarop Delphi's try/except is gebouwd, maar op het exacte moment dat deze afgaat, is er normaal gesproken geen stackruimte meer over om een handler te draaien, opruimcode af te wikkelen, of zelfs de fout netjes te melden. Op een workerthread die alleen de standaard 1 MB-reservering draagt, waarbij een scratchbuffer van die grootte al het meeste van wat overbleef heeft verbruikt, is er niets meer over waarmee de runtime kan werken

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;

Dat except-blok ziet eruit als een vangnet, en tegen de meeste storingen is het er ook een, maar hier doet het niets. Het team bevestigde dit in de praktijk: try/except ving niets op, het finally-blok kreeg ook nooit betrouwbaar de kans om te draaien, en de operator zag een dood proces zonder enig log-item op applicatieniveau, precies wat het oorspronkelijke supportticket beschreef

De oplossing: CRC32 voeden in plakken van 64 KB in plaats van één gigantische aanroep

De oplossing die HotXLS uitbracht, verandert niets aan zlib-ng zelf en niets aan het compressieniveau dat wordt gebruikt om de werkmap te schrijven. ZLibCRC32 loopt nu door de invoer in vaste plakken van 64 KB, elk 65536 bytes, roept de crc32 van zlib-ng eenmaal per plak aan en geeft de lopende checksumwaarde van de ene aanroep door aan de volgende. CRC-32 is van nature een incrementeel algoritme, dus een checksum die is opgebouwd over meerdere plakken is bit-voor-bit identiek aan een die in één enkele aanroep over dezelfde bytes wordt berekend: de oplossing verandert hoe het werk wordt opgesplitst, niet wat het berekent

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;

Niets aan de omringende SaveAs-aanroep hoefde te veranderen om dit te laten werken, en niets aan de ZIP-items die HotXLS schrijft veranderde ook: de CRC-32-waarde die uiteindelijk in de lokale bestandsheader en de centrale directory terechtkomt, is precies de waarde die één gigantische aanroep zou hebben geproduceerd, alleen samengesteld uit kleinere stukken. zlib-ng downgraden of terugvallen op een tragere, allocatie-lichte CRC-32-implementatie had de crash ook vermeden, maar tegen een reële kostenpost voor elk bestand dat nooit in de buurt van de drempel kwam, en dat is waarom geen van beide werd uitgebracht

Wat dit betekent als u zlib-ng aanroept vanuit uw eigen workerthreads

De hier beschreven stack-overflow-faalmodus heeft niets specifiek met spreadsheets te maken. Elke toepassing die zlib-ng een grote buffer overhandigt, of het nu voor compressie, decompressie, of een checksum is, vanuit een thread die alleen de platformstandaardstack draagt, kan tegen dezelfde soort muur aanlopen, omdat de bibliotheek zijn algoritme kiest op basis van invoergrootte en sommige van die algoritmen ervan uitgaan dat er stack over is om te gebruiken. Twee verdedigingen werken zonder zlib-ng zelf aan te raken: grote buffers in vaste stukken voeden aan groottegevoelige routines verwijdert de triggervoorwaarde volledig voor elk algoritme dat van nature incrementeel is, en waar opsplitsen geen optie is, de aanroepende thread een grotere stack geven dan de platformstandaard is de andere hendel. Beide zijn goedkoper dan erachter komen over een ongedocumenteerde groottedrempel via een productiecrashrapport dat de verkeerde functie de schuld geeft

Deze specifieke drempel bleef onzichtbaar totdat een voldoende grote productiewerkmap deze op het verkeerde soort thread overschreed, precies het soort storing dat pas naar boven komt zodra code tegen echte bestanden draait in plaats van kleine testfixtures. Het opgesplitste CRC-32-pad wordt nu standaard geleverd als onderdeel van de standaard schrijfpipeline in de HotXLS Excel-component voor Delphi en C++Builder, zonder dat een aanroeper iets hoeft te configureren en zonder eigenschap die het aan- of uitzet