Teknisk artikel

HotXLS, zlib-ng CRC32 og stack overflow i Delphi-tråde

HotXLS kan crashe en Delphi-arbejdstråd uden nogen fangbar undtagelse, når den tjeksummer en stor regneark-XML-del i ét kald: zlib-ng skifter til sin Chorba-algoritme over omtrent 119 KB input, og den generiske-C-variant af den algoritme allokerer et scratch-array stort nok til at sprænge gennem den standard 1 MB-tråd-stak. Delphi får aldrig chancen for at reagere, fordi et stack overflow ikke er den slags undtagelse, try/except blev bygget til at fange

HotXLS er et native Delphi- og C++Builder-bibliotek til at læse og skrive Excel-projektmapper, og crashet sporede tilbage til dens regnearks-skriver. Det første tegn på problemer var en supportsag: et natligt eksport-job crashede omtrent to gange om ugen, altid midt i kørslen, uden nogen Delphi-undtagelsesdialog og ingen logget fejl, bare en proces der forsvandt og en Windows Error Reporting-post, der ikke pegede noget sted hen. At genskabe det ved et skrivebord var en helt anden sag. Små projektmapper gemte fint. Store projektmapper gemte også fint, så længe gemningen kørte på hovedtråden med en debugger allerede tilknyttet. Det krævede en faktisk batch af produktions-store filer, der kørte gennem den rigtige multi-trådede eksportvej, at bringe crashet hjem, på hvilket tidspunkt disk-I/O, hukommelsestryk og en mistænkt skabelon hver især allerede var udelukket

Hvordan en regneark-gemning bliver til ét kæmpe CRC32-kald

XLSX-filer er ZIP-containere, og ZIP-formatet kræver en CRC-32-tjeksum for hver post, registreret i både den lokale fil-header og den centrale mappe. HotXLS beregner den tjeksum ved at kalde en lille wrapper ved navn ZLibCRC32, som til gengæld kalder zlib-ngs egen crc32-rutine, når SaveAs er færdig med at samle et regnearks XML i hukommelsen, og i lang tid bar det kald hele den ukomprimerede buffer i ét enkelt kald. Det er et fornuftigt design til et lille regneark. Det bliver til ét meget stort kald, i det øjeblik et ark er den slags dækket i vores guide til ydeevne ved store projektmapper i HotXLS, hvor et enkelt arks XML rutinemæssigt løber forbi et par hundrede kilobyte, før det nogensinde komprimeres

Hvorfor har zlib-ng brug for en kæmpe stak-buffer til CRC32?

zlib-ng bruger ikke én CRC-32-implementering til hvert kald. Under en størrelsestærskel gennemgår den bufferen med tabelopslag og folde-tricks, der ikke kræver nogen nævneværdig ekstra hukommelse, og over den tærskel, omtrent 119 KB, præcis 118.960 bytes i den build HotXLS linker mod, skifter den til en specialiseret hurtig algoritme kaldet Chorba. Den generiske-C-implementering af den vej bytter hukommelse for hastighed: den allokerer et scratch-array på stakken frem for heapen, dimensioneret til at gøre algoritmens indre løkke hurtig, ikke til at passe komfortabelt inden for hvilket som helst stak-budget den kaldende tråd tilfældigvis bærer. Intet af det er synligt fra kalderens side. En tjeksum-funktion er normalt et blad-kald, læs nogle bytes, returner et tal, ingen allokering værd at diskutere, og den antagelse holder for langt de fleste kald ind i zlib-ng lige indtil en buffer stor nok til at krydse Chorba-tærsklen vandrer ind i én

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 arbejdstråde så det, og interaktiv fejlfinding aldrig gjorde

At udløse dette crash kræver to betingelser på samme tid: en regneark-XML-del stor nok til at krydse zlib-ngs Chorba-tærskel, og en tråd der kun har den almindelige standard-stak frem for noget rummeligere. Produktions-eksport-jobs ramte begge. De kører som server-side batch-jobs, der spreder HotXLS-skrivninger over en pool af arbejdstråde, hver bærende den standard 1 MB-stak Windows reserverer, medmindre en kalder beder om mere, og hver behandlende kunde-projektmapper store nok til at betyde noget. Skrivebords-fejlfinding ramte ingen af betingelserne pålideligt: eksempelfiler var som regel mindre end tærsklen, og enkelt-trins-kørsler havde en tendens til at ske på hovedtråden frem for inde i en friskt-spawnet arbejder, så de to betingelser der skulle stemme overens i produktion, stemte næsten aldrig overens ved en udviklers skrivebord

Jagten på et crash, der beskyldte den forkerte funktion

De crash-rapporter teamet kunne få fat i, pegede på en placering inde i zlib-ngs deflate-funktion, ikke på nogen HotXLS-kode, og heller ikke tydeligt på CRC-32-koden. Den ene detalje sendte den første omgang af undersøgelsen mod komprimeringsvejen: buffer-størrelser sendt til deflate, vindues-bits, komprimeringsniveau, alle de sædvanlige mistænkte for et nativt crash, der kommer ud af en codec. Ingen af dem holdt

En misvisende topramme

Et stack overflow er en mærkelig slags crash at symbolisere, fordi på det tidspunkt det bliver rapporteret, har stak-pointeren allerede kørt forbi den plads, der var reserveret til den. Hvad end der producerede den crash-rapport, løste sandsynligvis den fejlende adresse til det nærmeste symbol, det stadig kunne finde, og det nærmeste eksporterede indgangspunkt siddende ved siden af den rigtige synder tilfældigvis var deflate. Den faktiske fejl sad i Chorba-scratch-buffer-allokeringen inde i CRC-32-vejen, kompileret ind i det samme bibliotek, tæt nok i binæren til at blive forvekslet med den funktion, der rent faktisk kørte

Bisektion med tidsstempler i stedet for en debugger

Et crash, der tager hele processen ned, efterlader intet for en normal Delphi-debugger-session at fange, så teamet faldt tilbage på GetTickCount-tjekpunkter droppet omkring hvert mistænkt kald og en manuel bisektion på tværs af gemme-vejen, og indsnævrede hvilken operation der var i flugt på det tidspunkt processen døde. Ved siden af det kørte en kendt-god baseline-build de samme produktionsfiler side om side med den aktuelle en, specifikt for at udelukke en regression i den omgangs egne ændringer, før man kiggede længere opstrøms. Først efter begge tjek kom rene tilbage, faldt undersøgelsen til ro på en tredjeparts-afhængighed, der gjorde noget uventet med et perfekt gyldigt input

Hvorfor fejler try/except at fange et stack overflow?

Et stack overflow er ikke en undtagelse, Delphi-kode nogensinde rejser med vilje, og det leveres heller ikke på den måde, Windows leverer en adgangskrænkelse eller en division-med-nul. Det viser sig som en hardware-guard-page-fejl, rapporteret gennem den samme strukturerede undtagelseshåndteringsmekanisme, Delphis try/except er bygget på, men på det præcise tidspunkt den udløses, er der normalt ingen stak-plads tilbage til at køre en handler, oprulle oprydningskode, eller endda færdiggøre rapporteringen af fejlen rent. På en arbejdstråd, der kun bærer standard 1 MB-reservationen, med en scratch-buffer af den størrelse, der allerede har forbrugt det meste af, hvad der var tilbage, er der intet tilbage for runtime'en at arbejde 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-blok ligner et sikkerhedsnet, og mod de fleste fejl er det ét, men den gør intet her. Teamet bekræftede lige så meget i praksis: try/except fangede intet, finally-blokken fik heller aldrig en pålidelig chance for at køre, og operatøren så en død proces med slet ingen applikationsniveau-log-post, præcis hvad den oprindelige supportsag beskrev

Løsningen: fodr CRC32 i 64 KB-skiver i stedet for ét kæmpe kald

Løsningen HotXLS sendte, ændrer intet ved selve zlib-ng og intet ved komprimeringsniveauet brugt til at skrive projektmappen. ZLibCRC32 gennemgår nu inputtet i faste 64 KB-skiver, 65536 bytes hver, kalder zlib-ngs crc32 én gang pr. skive og fører den kørende tjeksum-værdi fra ét kald til det næste. CRC-32 er en inkrementel algoritme af konstruktion, så en tjeksum bygget op over flere skiver er bit-for-bit identisk med én beregnet i ét enkelt kald over de samme bytes: fixen ændrer, hvordan arbejdet deles op, ikke hvad 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;

Intet ved det omgivende SaveAs-kald skulle ændres, for at dette kunne fungere, og intet ved de ZIP-poster HotXLS skriver, ændrede sig heller: CRC-32-værdien der ender i den lokale fil-header og den centrale mappe, er nøjagtig den værdi et enkelt kæmpe kald ville have produceret, bare samlet fra mindre stykker. At nedgradere zlib-ng eller falde tilbage til en langsommere, allokerings-let CRC-32-implementering ville også have undgået crashet, men til en reel omkostning for hver fil, der aldrig kom i nærheden af tærsklen i første omgang, hvilket er grunden til, at ingen af delene blev sendt

Hvad dette betyder, hvis man kalder zlib-ng fra sine egne arbejdstråde

Den stack-overflow-fejltilstand beskrevet her har intet at gøre med regneark specifikt. Enhver applikation, der overdrager zlib-ng en stor buffer, hvad enten det er til komprimering, dekomprimering eller en tjeksum, fra en tråd der kun bærer platformens standard-stak, kan ramme den samme slags mur, fordi biblioteket vælger sin algoritme efter input-størrelse, og nogle af de algoritmer antager, der er stak til overs. To forsvar fungerer uden at røre selve zlib-ng: at fodre store buffere ind i størrelsesfølsomme rutiner i faste bidder fjerner udløsningsbetingelsen helt for enhver algoritme, der naturligt er inkrementel, og hvor bidder ikke er en mulighed, er at give den kaldende tråd en stak større end platformens standard det andet håndtag. Enten er billigere end at finde ud af om en udokumenteret størrelsestærskel fra en produktions-crash-rapport, der beskylder den forkerte funktion

Netop denne tærskel forblev usynlig, indtil en tilstrækkelig stor produktions-projektmappe krydsede den på den forkerte slags tråd, hvilket er præcis den slags fejl, der først viser sig, når kode kører mod rigtige filer i stedet for små fixtures. Den bidopdelte CRC-32-vej leveres nu som en del af standard-skrive-pipelinen i HotXLS-Excel-komponenten til Delphi og C++Builder, uden noget for en kalder at konfigurere og ingen egenskab der slår den til eller fra