Teknisk artikel

HotXLS, zlib-ng CRC32 och stacköverspill i Delphi-trådar

HotXLS kan krascha en Delphi-arbetstråd utan något fångstbart undantag när den beräknar en checksumma för en stor kalkylblads-XML-del i ett anrop: zlib-ng byter till sin Chorba-algoritm ovanför ungefär 119 KB indata, och den generiska C-varianten av den algoritmen allokerar en arbetsmatris stor nog att spränga igenom standardens 1 MB-trådstack. Delphi får aldrig en chans att reagera, eftersom ett stacköverspill inte är den typ av undantag try/except byggdes för att fånga

HotXLS är ett nativt Delphi- och C++Builder-bibliotek för att läsa och skriva Excel-arbetsböcker, och kraschen spårades tillbaka till dess kalkylbladsskrivare. Det första tecknet på problem var ett supportärende: ett nattligt exportjobb kraschade ungefär två gånger i veckan, alltid mitt i körningen, utan någon Delphi-undantagsdialog och ingen loggad felindikation, bara en process som försvann och en Windows-felrapporteringspost som inte ledde någonstans. Att reproducera det vid skrivbordet var en helt annan sak. Små arbetsböcker sparades utan problem. Stora arbetsböcker sparades också utan problem, så länge sparningen kördes på huvudtråden med en debugger redan ansluten. Det krävdes en riktig batch produktionsstora filer som körde genom den riktiga flertrådade exportvägen för att verkligen framkalla kraschen, vid vilken tidpunkt disk-I/O, minnestryck, och en misstänkt mall redan hade uteslutits var för sig

Hur en kalkylbladssparning blir ett enda jättelikt CRC32-anrop

XLSX-filer är ZIP-behållare, och ZIP-formatet kräver en CRC-32-checksumma för varje post, registrerad både i det lokala filhuvudet och den centrala katalogen. HotXLS beräknar den checksumman genom att anropa en liten omslagsfunktion vid namn ZLibCRC32, som i sin tur anropar zlib-ng:s egen crc32-rutin när SaveAs har avslutat att sammanställa ett kalkylblads XML i minnet, och under lång tid bar det anropet hela den okomprimerade bufferten i ett enda anrop. Det är en rimlig design för ett litet kalkylblad. Det blir ett mycket stort anrop i det ögonblick ett blad är av den typ som täcks i vår guide till prestanda för stora arbetsböcker i HotXLS, där ett enda blads XML rutinmässigt går förbi några hundra kilobyte innan det ens komprimeras

Varför behöver zlib-ng en jättelik stackbuffert för CRC32?

zlib-ng använder inte en CRC-32-implementation för varje anrop. Under en storlekströskel går den igenom bufferten med tabelluppslagningar och vikningstrick som inte behöver någon meningsfull extra minne, och ovanför den tröskeln, ungefär 119 KB, exakt 118 960 byte i den byggnation HotXLS länkar mot, byter den till en specialiserad snabbalgoritm kallad Chorba. Den generiska C-implementationen av den vägen byter minne mot hastighet: den allokerar en arbetsmatris på stacken snarare än på heapen, dimensionerad för att göra algoritmens inre slinga snabb, inte för att bekvämt passa in i vilken stackbudget den anropande tråden än råkar bära. Inget av detta är synligt från anroparens sida. En checksummafunktion är normalt ett lövanrop, läs några byte, returnera ett tal, ingen allokering värd att nämna, och det antagandet håller för den överväldigande majoriteten av anrop till zlib-ng ända tills en buffert stor nog att korsa Chorba-tröskeln kommer in 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;

Varför arbetstrådar såg det och interaktiv felsökning aldrig gjorde det

Att utlösa den här kraschen kräver två villkor samtidigt: en kalkylblads-XML-del stor nog att korsa zlib-ng:s Chorba-tröskel, och en tråd som bara har den vanliga standardstacken snarare än något rymligare. Produktionsexportjobb träffar båda. De körs som serversidiga batchjobb som sprider HotXLS-skrivningar över en pool av arbetstrådar, var och en bärande standardens 1 MB-stack som Windows reserverar om inte anroparen ber om mer, och var och en bearbetande kundarbetsböcker stora nog att spela roll. Skrivbordsfelsökning träffade inget av villkoren pålitligt: exempelfiler var vanligtvis mindre än tröskeln, och steg-för-steg-körningar tenderade att ske på huvudtråden snarare än inuti en nyligen spawnad arbetare, så de två villkoren som var tvungna att sammanfalla i produktion nästan aldrig sammanföll vid en utvecklares skrivbord

Att jaga en krasch som skyllde på fel funktion

Kraschrapporterna teamet kunde få tag i pekade på en plats inuti zlib-ng:s deflate-funktion, inte på någon HotXLS-kod, och inte uppenbart på CRC-32-koden heller. Den detaljen skickade den första utredningsomgången mot komprimeringsvägen: buffertstorlekar skickade till deflate, fönsterbitar, komprimeringsnivå, alla de vanliga misstänkta för en nativ krasch som kommer ut ur en kodek. Ingen av dem höll

En missvisande toppram

Ett stacköverspill är en märklig typ av krasch att symbolisera, eftersom vid den tidpunkt den rapporteras har stackpekaren redan kört förbi utrymmet som reserverades för den. Vad som än producerade den kraschrapporten löste troligtvis upp den felande adressen till den närmaste symbol den fortfarande kunde hitta, och den närmaste exporterade ingångspunkten som satt bredvid den riktiga boven råkade vara deflate. Det faktiska felet satt i Chorba-arbetsbuffertallokeringen inuti CRC-32-vägen, kompilerad in i samma bibliotek, tillräckligt nära i binären för att misstas för funktionen som faktiskt kördes

Att söka med tidsstämplar istället för en debugger

En krasch som tar ner hela processen lämnar inget för en vanlig Delphi-debuggersession att fånga, så teamet föll tillbaka på GetTickCount-kontrollpunkter placerade runt varje misstänkt anrop och en manuell sökning över sparvägen, för att smala ner vilken operation som var i gång i det ögonblick processen dog. Vid sidan av det körde en känt fungerande basbyggnation samma produktionsfiler sida vid sida med den nuvarande, specifikt för att utesluta en regression i den omgångens egna ändringar innan man tittade längre uppströms. Först efter att båda kontrollerna kom tillbaka rena satte sig utredningen på ett tredjepartsberoende som gjorde något oväntat med en helt giltig indata

Varför misslyckas try/except med att fånga ett stacköverspill?

Ett stacköverspill är inget undantag Delphi-kod någonsin kastar med avsikt, och det levereras inte heller på det sätt Windows levererar en åtkomstöverträdelse eller en division med noll. Det yttrar sig som ett fel i en hårdvaruvaktsida, rapporterat genom samma strukturerade undantagshanteringsmekanism som Delphis try/except bygger på, men i exakt det ögonblick det utlöses finns det normalt inget stackutrymme kvar för att köra en hanterare, avveckla städkod, eller ens avsluta felrapporteringen rent. På en arbetstråd som bara bär standardreservationen på 1 MB, med en arbetsbuffert av den storleken som redan konsumerat det mesta av det som var kvar, finns det inget kvar för körtiden att jobba 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;

Det except-blocket ser ut som ett skyddsnät, och mot de flesta fel är det ett, men det gör ingenting här. Teamet bekräftade det i praktiken: try/except fångade ingenting, finally-blocket fick heller aldrig en pålitlig chans att köra, och operatören såg en död process utan någon applikationsnivåloggpost alls, exakt vad det ursprungliga supportärendet beskrev

Lösningen: mata CRC32 i 64 KB-skivor istället för ett jättelikt anrop

Lösningen HotXLS levererade ändrar ingenting om zlib-ng självt och ingenting om komprimeringsnivån som används för att skriva arbetsboken. ZLibCRC32 går nu igenom indatan i fasta 64 KB-skivor, 65536 byte var, anropar zlib-ng:s crc32 en gång per skiva och trär det löpande checksummevärdet från ett anrop till nästa. CRC-32 är en inkrementell algoritm till sin konstruktion, så en checksumma byggd upp över flera skivor är bit-för-bit identisk med en beräknad i ett enda anrop över samma byte: lösningen ändrar hur arbetet delas upp, inte vad det beräknar

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;

Inget om det omgivande SaveAs-anropet behövde ändras för att det här skulle fungera, och inget om ZIP-posterna HotXLS skriver ändrades heller: CRC-32-värdet som hamnar i det lokala filhuvudet och den centrala katalogen är exakt det värde ett enda jättelikt anrop skulle ha producerat, bara sammansatt av mindre bitar. Att nedgradera zlib-ng eller falla tillbaka till en långsammare, allokeringslätt CRC-32-implementation skulle också ha undvikit kraschen, men till en verklig kostnad för varje fil som aldrig kom i närheten av tröskeln från början, vilket är varför ingendera levererades

Vad detta betyder om du anropar zlib-ng från dina egna arbetstrådar

Stacköverspillets felläge som beskrivs här har inget att göra med kalkylblad specifikt. Vilken applikation som helst som ger zlib-ng en stor buffert, vare sig för komprimering, dekomprimering, eller en checksumma, från en tråd som bara bär plattformens standardstack kan träffa samma typ av vägg, eftersom biblioteket väljer sin algoritm efter indatastorlek och några av de algoritmerna antar att det finns stack att avvara. Två försvar fungerar utan att röra zlib-ng självt: att mata stora buffertar in i storlekskänsliga rutiner i fasta block tar bort utlösningsvillkoret helt för vilken algoritm som helst som är naturligt inkrementell, och där uppdelning inte är ett alternativ är att ge den anropande tråden en stack större än plattformens standard den andra spaken. Endera är billigare än att upptäcka en odokumenterad storlekströskel från en produktionskraschrapport som skyller på fel funktion

Den här specifika tröskeln förblev osynlig tills en tillräckligt stor produktionsarbetsbok korsade den på fel typ av tråd, vilket är exakt den typen av fel som bara visar sig när kod körs mot riktiga filer istället för små testfixturer. Den blockindelade CRC-32-vägen levereras nu som en del av standardskrivpipelinen i HotXLS Excel-komponenten för Delphi och C++Builder, utan något för en anropare att konfigurera och ingen egenskap som slår på eller av det