HotXLS može srušiti radnu nit Delphi bez izuzetka koji je moguće uhvatiti kada u jednom pozivu računa kontrolnu sumu velikog XML dela radnog lista: zlib-ng iznad približno 119 KB ulaza prelazi na algoritam Chorba, a generička C varijanta tog algoritma dodeljuje pomoćni niz dovoljno velik da probije podrazumevani stek niti od 1 MB. Delphi nema priliku da reaguje, jer prelivanje steka nije vrsta izuzetka koju je try/except napravljen da uhvati
HotXLS je izvorna Delphi i C++Builder biblioteka za čitanje i pisanje Excel radnih svezaka, a pad je poticao iz upisivača radnih listova. Prvi znak problema bio je zahtev podršci: noćni posao izvoza padao je otprilike dvaput nedeljno, uvek usred obrade, bez Delphi dijaloga sa izuzetkom i bez zabeležene greške, samo bi proces nestao, a Windows Error Reporting unos nije upućivao ni na šta korisno. Ponoviti pad za radnim stolom bilo je sasvim drugačije. Male radne sveske su se čuvale bez problema. I velike radne sveske su se čuvale bez problema, sve dok se čuvanje izvršavalo u glavnoj niti sa već priključenim programom za otklanjanje grešaka. Tek je stvarni paket produkcijskih datoteka kroz pravi višenitni put izvoza pouzdano izazvao pad, pošto su do tada diskovni U/I, pritisak memorije i sumnjivi predložak već bili isključeni
Kako se čuvanje radnog lista pretvara u jedan veliki poziv CRC32
XLSX datoteke su ZIP kontejneri, a ZIP format zahteva CRC-32 kontrolnu sumu za svaki unos, zapisanu i u lokalnom zaglavlju datoteke i u centralnom direktorijumu. HotXLS računa tu kontrolnu sumu pozivanjem malog omotača po imenu ZLibCRC32, koji zatim poziva sopstvenu rutinu crc32 iz zlib-ng kada SaveAs završi sastavljanje XML-a radnog lista u memoriji, a dugo je taj poziv prenosio ceo nekompresovani bafer u jednom pozivu. To je razumno rešenje za mali radni list. Postaje jedan veoma veliki poziv čim list postane nalik onome opisanom u našem vodiču za performanse velikih radnih svezaka u HotXLS, gde XML jednog lista redovno pređe nekoliko stotina kilobajta pre nego što se uopšte kompresuje
Zašto je zlib-ng-u potreban ogroman bafer na steku za CRC32
zlib-ng ne koristi jednu implementaciju CRC-32 za svaki poziv. Ispod praga veličine prolazi kroz bafer pomoću tabela i tehnika savijanja koje ne zahtevaju značajnu dodatnu memoriju, a iznad tog praga, približno 119 KB, odnosno tačno 118.960 bajtova u verziji na koju se HotXLS povezuje, prelazi na specijalizovani brzi algoritam po imenu Chorba. Generička C implementacija te putanje menja memoriju za brzinu: pomoćni niz dodeljuje na steku, a ne na hipu, i određuje mu veličinu tako da unutrašnja petlja algoritma bude brza, a ne tako da udobno stane u budžet steka koji slučajno ima pozivajuća nit. Ništa od toga nije vidljivo pozivaocu. Funkcija za kontrolnu sumu je obično listni poziv, pročita bajtove i vrati broj, bez alokacije vredne pomena, a ta pretpostavka važi za ogromnu većinu poziva zlib-ng-u sve dok u nju ne uđe bafer dovoljno velik da pređe prag 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;
Zašto su radne niti to videle, a interaktivno otklanjanje grešaka nije
Da bi došlo do ovog pada, moraju se istovremeno ispuniti dva uslova: XML deo radnog lista mora biti dovoljno velik da pređe prag Chorba u zlib-ng-u, a nit mora imati samo uobičajeni podrazumevani stek, umesto prostranijeg. Produkcijski poslovi izvoza ispunjavaju oba uslova. Oni rade kao serverski paketni poslovi koji raspoređuju HotXLS upise među skup radnih niti, pri čemu svaka nosi podrazumevani stek od 1 MB koji Windows rezerviše ako pozivalac ne zatraži više, i svaka obrađuje korisničke radne sveske dovoljno velike da to bude važno. Otklanjanje grešaka za radnim stolom nije pouzdano ispunjavalo nijedan uslov: probne datoteke su obično bile manje od praga, a pokretanje korak po korak najčešće se odvijalo u glavnoj niti, a ne u novostvorenoj radnoj niti, pa se dva uslova koja su u produkciji morala poklopiti gotovo nikada nisu poklapala za programerskim stolom
Potraga za padom koji je krivicu pripisao pogrešnoj funkciji
Izveštaji o padu do kojih je tim uspeo da dođe pokazivali su lokaciju unutar funkcije deflate u zlib-ng-u, a ne u HotXLS kodu i ne očigledno u kodu CRC-32. Taj jedan detalj usmerio je prvi krug istrage ka putanji kompresije: veličine bafera prosleđene funkciji deflate, bitovi prozora, nivo kompresije, svi uobičajeni osumnjičeni za izvorni pad koji dolazi iz kodeka. Nijedan se nije održao
Obmanjujući vršni okvir
Prelivanje steka je neobična vrsta pada za simbolizaciju, jer je do trenutka prijavljivanja pokazivač steka već prošao prostor koji je za njega rezervisan. Šta god da je proizvelo izveštaj o padu najverovatnije je razrešilo adresu greške na najbliži simbol koji je još moglo da pronađe, a najbliža izvezena ulazna tačka pored stvarnog krivca slučajno je bila deflate. Stvarna greška bila je u dodeli pomoćnog bafera Chorba unutar putanje CRC-32, kompajliranoj u istoj biblioteci i dovoljno blizu u binarnoj datoteci da se zameni funkcijom koja se zaista izvršavala
Bisekcija pomoću vremenskih oznaka umesto programa za otklanjanje grešaka
Pad koji obori ceo proces ne ostavlja ništa što bi obična sesija Delphi programa za otklanjanje grešaka mogla da uhvati, pa se tim vratio kontrolnim tačkama GetTickCount postavljenim oko svakog sumnjivog poziva i ručnoj bisekciji kroz putanju čuvanja, sužavajući operaciju koja je bila u toku u trenutku kada je proces umro. Uz to je poznata dobra osnovna verzija pokrenula iste produkcijske datoteke uporedo sa trenutnom verzijom, posebno da bi se isključila regresija u promenama iz tog kruga pre dalje potrage uzvodno. Tek kada su obe provere bile čiste istraga se usmerila na zavisnost treće strane koja je sa potpuno ispravnim ulazom radila nešto neočekivano
Zašto try/except ne uspeva da uhvati prelivanje steka
Prelivanje steka nije izuzetak koji Delphi kod namerno podiže i ne isporučuje se na način na koji Windows isporučuje povredu pristupa ili deljenje nulom. Pojavljuje se kao hardverska greška zaštitne stranice, prijavljena kroz isti mehanizam strukturiranog rukovanja izuzecima na kojem je zasnovan Delphi try/except, ali u trenutku kada se aktivira obično više nema prostora na steku za pokretanje rukovaoca, razmotavanje koda za čišćenje ili čak uredno dovršavanje prijave greške. Na radnoj niti sa samo podrazumevanom rezervacijom od 1 MB, kada je pomoćni bafer te veličine već potrošio većinu preostalog prostora, izvršnom okruženju ne preostaje ništa sa čim bi radilo
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;
Taj blok except izgleda kao sigurnosna mreža i protiv većine grešaka to i jeste, ali ovde ne pomaže. Tim je to potvrdio u praksi: try/except nije uhvatio ništa, ni blok finally nije pouzdano dobio priliku da se izvrši, a operater je video mrtav proces bez ijednog zapisa na nivou aplikacije, upravo onako kako je opisano u prvobitnom zahtevu podršci
Rešenje: hranite CRC32 u blokovima od 64 KB umesto jednim ogromnim pozivom
Rešenje koje je HotXLS isporučio ne menja ništa u samom zlib-ng-u niti nivo kompresije koji se koristi za upis radne sveske. ZLibCRC32 sada prolazi kroz ulaz u fiksnim blokovima od 64 KB, odnosno 65536 bajtova, poziva crc32 iz zlib-ng-a jednom po bloku i prenosi tekuću vrednost kontrolne sume iz jednog poziva u sledeći. CRC-32 je po konstrukciji inkrementalni algoritam, pa je kontrolna suma sastavljena iz više blokova bit po bit identična onoj izračunatoj jednim pozivom nad istim bajtovima: rešenje menja način podele posla, a ne ono što se računa
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;
Ništa u okolnom pozivu SaveAs nije moralo da se promeni da bi ovo radilo, niti su se promenili ZIP unosi koje HotXLS upisuje: vrednost CRC-32 koja završava u lokalnom zaglavlju datoteke i centralnom direktorijumu potpuno je ista kao ona koju bi proizveo jedan ogroman poziv, samo sastavljena iz manjih delova. Vraćanje na stariju verziju zlib-ng-a ili prelazak na sporiju implementaciju CRC-32 sa malo alokacija takođe bi sprečili pad, ali uz stvarnu cenu za svaku datoteku koja se nikada nije približila pragu, zbog čega nijedna opcija nije isporučena
Šta ovo znači ako zlib-ng pozivate iz sopstvenih radnih niti
Opisani način otkazivanja zbog prelivanja steka nema posebne veze sa tabelama. Svaka aplikacija koja zlib-ng-u prosledi veliki bafer, bilo za kompresiju, dekompresiju ili kontrolnu sumu, iz niti koja ima samo podrazumevani stek platforme može naići na isti zid, jer biblioteka bira algoritam prema veličini ulaza, a neki od tih algoritama pretpostavljaju da na steku ima dovoljno prostora. Dve zaštite rade bez menjanja samog zlib-ng-a: prosleđivanje velikih bafera rutinama osetljivim na veličinu u fiksnim blokovima potpuno uklanja uslov koji pokreće problem kod svakog algoritma koji je prirodno inkrementalan, a kada deljenje na blokove nije moguće, drugi potez je da pozivajućoj niti dodelite stek veći od podrazumevanog. Bilo koja opcija je jeftinija od otkrivanja nedokumentovanog praga veličine kroz produkcijski izveštaj o padu koji krivicu pripisuje pogrešnoj funkciji
Ovaj konkretni prag ostao je neprimećen sve dok ga dovoljno velika produkcijska radna sveska nije prešla na pogrešnoj vrsti niti, upravo kao kod grešaka koje se pojave tek kada kod radi sa stvarnim datotekama umesto malih probnih primera. Putanja CRC-32 sa blokovima sada se isporučuje kao deo standardne putanje upisa u HotXLS Excel komponenti za Delphi i C++Builder, bez podešavanja za pozivaoca i bez svojstva kojim bi se uključila ili isključila