Teknisk artikkel

HotXLS, zlib-ng CRC32, og stakkoverflyt i Delphi-tråder

HotXLS kan krasje en Delphi-arbeidertråd med intet fangbart unntak når den sjekksummer en stor regneark-XML-del i ett kall: zlib-ng bytter til sin Chorba-algoritme over omtrent 119 KB inndata, og den generiske C-varianten av den algoritmen allokerer et skrapearray stort nok til å sprenge gjennom standard 1 MB-tråd-stakken. Delphi får aldri sjansen til å reagere, fordi en stakkoverflyt ikke er den typen unntak try/except ble bygget for å fange

HotXLS er et nativt Delphi- og C++Builder-bibliotek for å lese og skrive Excel-arbeidsbøker, og krasjet sporet tilbake til regneark-skriveren dets. Det første tegnet på trøbbel var en support-sak: en natt-over-eksportjobb krasjet omtrent to ganger i uken, alltid midt i kjøringen, uten noen Delphi-unntaksdialog og ingen loggført feil, bare en prosess som forsvant og en Windows Error Reporting-oppføring som ikke pekte noe sted nyttig. Å reprodusere det ved et skrivebord var en helt annen sak. Små arbeidsbøker lagret greit. Store arbeidsbøker lagret også greit, så lenge lagringen kjørte på hovedtråden med en debugger allerede tilkoblet. Det krevde en faktisk batch med produksjonsstore filer som kjørte gjennom den ekte multi-trådede eksportveien for å bringe krasjet hjem, og på det tidspunktet var disk-I/O, minnepress, og en mistenkt mal hver allerede utelukket

Hvordan en regneark-lagring blir til ett gigantisk CRC32-kall

XLSX-filer er ZIP-containere, og ZIP-formatet krever en CRC-32-sjekksum for hver oppføring, registrert i både den lokale filheaderen og den sentrale katalogen. HotXLS beregner den sjekksummen ved å kalle en liten wrapper ved navn ZLibCRC32, som igjen kaller zlib-ngs egen crc32-rutine når SaveAs har fullført å sette sammen et regnearks XML i minnet, og i lang tid bar det kallet hele den ukomprimerte bufferen i ett enkelt kall. Det er et fornuftig design for et lite regneark. Det blir ett veldig stort kall i det øyeblikket et ark er av den typen dekket i vår guide til stor-arbeidsbok-ytelse i HotXLS, der ett enkelt arks XML rutinemessig løper forbi noen hundre kilobyte før det noensinne komprimeres

Hvorfor trenger zlib-ng en gigantisk stakkbuffer for CRC32?

zlib-ng bruker ikke én CRC-32-implementasjon for hvert kall. Under en størrelsesgrense går den gjennom bufferen med tabelloppslag og foldingstriks som ikke trenger noe meningsfullt ekstra minne, og over den grensen, omtrent 119 KB, presist 118 960 byte i bygget HotXLS lenker mot, bytter den til en spesialisert rask algoritme kalt Chorba. Den generiske C-implementasjonen av den veien bytter minne mot hastighet: den allokerer et skrapearray på stakken snarere enn heapen, dimensjonert for å gjøre algoritmens indre løkke rask, ikke for å passe komfortabelt inn i hvilket som helst stakkbudsjett den kallende tråden tilfeldigvis bærer. Ingenting av det er synlig fra kallerens side. En sjekksumfunksjon er normalt et blad-kall, les noen byte, returner et tall, ingen allokering verdt å nevne, og den antakelsen holder for det overveldende flertallet av kall inn i zlib-ng helt til en buffer stor nok til å krysse Chorba-grensen dukker inn i ett

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;

Hvorfor arbeidertråder så det og interaktiv debugging aldri gjorde

Å utløse dette krasjet krever to betingelser samtidig: en regneark-XML-del stor nok til å krysse zlib-ngs Chorba-grense, og en tråd som bare har den vanlige standard-stakken snarere enn noe romsligere. Produksjonseksportjobber traff begge. De kjører som server-side batch-jobber som sprer HotXLS-skrivinger over en pool med arbeidertråder, hver bærende standard 1 MB-stakken Windows reserverer med mindre en kaller ber om mer, og hver behandler kundearbeidsbøker store nok til å bety noe. Skrivebord-debugging traff ingen av betingelsene pålitelig: eksempelfiler var vanligvis mindre enn grensen, og steg-for-steg-kjøringer pleide å skje på hovedtråden snarere enn inne i en nyoppstartet arbeider, så de to betingelsene som måtte stemme overens i produksjon, stemte nesten aldri overens på en utviklers skrivebord

Å jage et krasj som ga skylden til feil funksjon

Krasjrapportene teamet klarte å få tak i, pekte på en plassering inne i zlib-ngs deflate-funksjon, ikke på noen HotXLS-kode, og heller ikke åpenbart på CRC-32-koden. Den ene detaljen sendte den første passeringen av etterforskningen mot komprimeringsveien: buffer-størrelser sendt til deflate, vindusbiter, komprimeringsnivå, alle de vanlige mistenkte for et nativt krasj som kommer ut av en kodek. Ingen av dem holdt stikk

En misvisende toppramme

En stakkoverflyt er en rar type krasj å symbolisere, fordi innen den rapporteres, har stakkpekeren allerede løpt forbi plassen som var reservert for den. Hva som enn produserte den krasjrapporten, løste sannsynligvis feiladressen mot det nærmeste symbolet den fortsatt kunne finne, og det nærmeste eksporterte inngangspunktet som satt ved siden av den ekte synderen, tilfeldigvis var deflate. Den faktiske feilen satt i Chorba-skrapebuffer-allokeringen inne i CRC-32-veien, kompilert inn i det samme biblioteket, nær nok i binærfilen til å bli forvekslet med funksjonen som faktisk kjørte

Å halvere med tidsstempler i stedet for en debugger

Et krasj som tar ned hele prosessen, etterlater ingenting for en normal Delphi-debugger-økt å fange, så teamet falt tilbake på GetTickCount-sjekkpunkter droppet rundt hvert mistenkt kall og en manuell halvering på tvers av lagringsveien, som innsnevret hvilken operasjon som var i gang i det øyeblikket prosessen døde. Ved siden av det kjørte et kjent-godt basisbygg de samme produksjonsfilene side om side med det gjeldende, spesifikt for å utelukke en regresjon i den rundens egne endringer før man så videre oppstrøms. Først etter at begge sjekkene kom tilbake rene, landet etterforskningen på en tredjepartsavhengighet som gjorde noe uventet med en fullstendig gyldig inndata

Hvorfor klarer ikke try/except å fange en stakkoverflyt?

En stakkoverflyt er ikke et unntak Delphi-kode noensinne kaster med hensikt, og den leveres heller ikke slik Windows leverer et tilgangsbrudd eller en divisjon-med-null. Den viser seg som en maskinvare-vaktside-feil (guard-page fault), rapportert gjennom den samme strukturerte unntakshåndteringsmekanismen Delphis try/except er bygget på, men i det eksakte øyeblikket den utløses, er det normalt ingen stakkplass igjen til å kjøre en håndterer, vikle ut opprydningskode, eller til og med fullføre rapporteringen av feilen rent. På en arbeidertråd som bare bærer standard 1 MB-reservasjonen, med en skrapebuffer av den størrelsen som allerede har konsumert det meste av det som var igjen, er det ingenting igjen for kjøretiden å jobbe med

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;

Den except-blokken ser ut som et sikkerhetsnett, og mot de fleste feil er den det, men den gjør ingenting her. Teamet bekreftet nettopp det i praksis: try/except fanget ingenting, finally-blokken fikk heller aldri en pålitelig sjanse til å kjøre, og operatøren så en død prosess uten noen applikasjonsnivå-loggoppføring i det hele tatt, nøyaktig det den opprinnelige support-saken beskrev

Løsningen: mat CRC32 i 64 KB-skiver i stedet for ett gigantisk kall

Løsningen HotXLS sendte ut, endrer ingenting ved selve zlib-ng og ingenting ved komprimeringsnivået brukt til å skrive arbeidsboken. ZLibCRC32 går nå gjennom inndataen i faste 64 KB-skiver, 65536 byte hver, og kaller zlib-ngs crc32 én gang per skive og tråder den løpende sjekksumverdien fra ett kall inn i det neste. CRC-32 er en inkrementell algoritme av konstruksjon, så en sjekksum bygget opp over flere skiver, er bit-for-bit identisk med en beregnet i ett enkelt kall over de samme bytene: løsningen endrer hvordan arbeidet deles opp, ikke hva det beregner

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;

Ingenting ved det omkringliggende SaveAs-kallet måtte endres for at dette skulle fungere, og ingenting ved ZIP-oppføringene HotXLS skriver, endret seg heller: CRC-32-verdien som ender opp i den lokale filheaderen og den sentrale katalogen, er nøyaktig verdien ett enkelt gigantisk kall ville produsert, bare satt sammen fra mindre biter. Å nedgradere zlib-ng eller falle tilbake til en tregere, allokerings-lett CRC-32-implementasjon ville også unngått krasjet, men til en reell kostnad for hver fil som aldri kom i nærheten av grensen i utgangspunktet, noe som er grunnen til at ingen av dem ble sendt ut

Hva dette betyr hvis du kaller zlib-ng fra dine egne arbeidertråder

Stakkoverflyt-feilmodusen beskrevet her har ingenting å gjøre med regneark spesifikt. Enhver applikasjon som overleverer zlib-ng en stor buffer, enten for komprimering, dekomprimering, eller en sjekksum, fra en tråd som bare bærer plattformens standard-stakk, kan treffe samme type vegg, fordi biblioteket velger sin algoritme etter inndatastørrelse, og noen av de algoritmene antar at det er stakk til overs. To forsvar fungerer uten å røre selve zlib-ng: å mate store buffere inn i størrelsessensitive rutiner i faste biter fjerner utløserbetingelsen fullstendig for enhver algoritme som naturlig er inkrementell, og der biting ikke er et alternativ, er å gi den kallende tråden en stakk større enn plattformstandarden den andre spaken. Begge deler er billigere enn å finne ut om en udokumentert størrelsesgrense fra en produksjonskrasjrapport som gir skylden til feil funksjon

Denne bestemte grensen forble usynlig inntil en produksjonsarbeidsbok stor nok krysset den på feil type tråd, noe som er nøyaktig den typen feil som først viser seg når kode kjører mot ekte filer i stedet for små testoppsett. Den biteinndelte CRC-32-veien følger nå med som en del av standard skrivepipeline i HotXLS Excel-komponenten for Delphi og C++Builder, uten noe for en kaller å konfigurere og ingen egenskap som skrur den på eller av