HotXLS može srušiti Delphi radnu dretvu bez ikakve iznimke koju je moguće uhvatiti kad jednim pozivom izračunava kontrolni zbroj (checksum) velikog XML dijela radnog lista: zlib-ng iznad otprilike 119 KB ulaza prelazi na svoj algoritam Chorba, a generička C varijanta tog algoritma alocira pomoćno polje dovoljno veliko da probije zadani stog dretve od 1 MB. Delphi nikad ne dobiva priliku reagirati, jer prelijevanje stoga nije vrsta iznimke koju je try/except izgrađen hvatati
HotXLS je izvorna Delphi i C++Builder biblioteka za čitanje i pisanje Excel radnih bilježnica, a rušenje se pratilo natrag do njezinog pisača radnih listova. Prvi znak problema bio je tiket podrške: noćni izvozni posao rušio se otprilike dvaput tjedno, uvijek usred izvođenja, bez Delphi dijaloga s iznimkom i bez zabilježene pogreške, samo proces koji nestane i unos u Windows Error Reporting koji nikamo nije vodio. Reprodukcija za radnim stolom bila je sasvim druga priča. Male radne bilježnice spremale su se bez problema. Velike radne bilježnice također su se spremale bez problema, dok god je spremanje išlo na glavnoj dretvi s već povezanim debuggerom. Trebalo je pravi paket datoteka veličine produkcijskih podataka koje prolaze kroz stvarnu višedretvenu putanju izvoza da bi se rušenje dogodilo, a do tog trenutka su I/O diska, pritisak na memoriju i sumnjivi predložak već bili isključeni kao uzrok
Kako spremanje radnog lista postaje jedan golemi CRC32 poziv
XLSX datoteke su ZIP kontejneri, a ZIP format zahtijeva CRC-32 kontrolni zbroj za svaki unos, zabilježen i u lokalnom zaglavlju datoteke i u centralnom direktoriju. HotXLS izračunava taj kontrolni zbroj pozivom malog omotača (wrapper) po imenu ZLibCRC32, koji zauzvrat poziva zlib-ngovu vlastitu crc32 rutinu čim SaveAs završi sastavljanje XML-a radnog lista u memoriji, a dugo je vremena taj poziv nosio cijeli nekomprimirani spremnik u jednom pozivu. To je razuman dizajn za mali radni list. Postaje jedan vrlo velik poziv čim je list one vrste opisane u našem vodiču o performansama velikih radnih bilježnica u HotXLS-u, gdje XML jednog lista redovito premašuje nekoliko stotina kilobajta prije nego što je uopće komprimiran
Zašto zlib-ngu treba golem spremnik na stogu za CRC32?
zlib-ng ne koristi jednu implementaciju CRC-32 za svaki poziv. Ispod praga veličine prolazi kroz spremnik pomoću pretraživanja tablica i trikova preklapanja koji ne trebaju značajnu dodatnu memoriju, a iznad tog praga, otprilike 119 KB, točno 118.960 bajtova u izgradnji uz koju se HotXLS povezuje, prelazi na specijalizirani brzi algoritam nazvan Chorba. Generička C implementacija te putanje mijenja memoriju za brzinu: alocira pomoćno polje na stogu, a ne na hrpi, dimenzionirano tako da unutarnja petlja algoritma bude brza, ne da udobno stane unutar bilo kojeg proračuna stoga koji pozivajuća dretva slučajno nosi. Ništa od toga nije vidljivo sa strane pozivatelja. Funkcija kontrolnog zbroja obično je „list" poziv (leaf call): pročitaj nešto bajtova, vrati broj, bez alokacije vrijedne spomena, a ta pretpostavka vrijedi za ogromnu većinu poziva u zlib-ng sve dok se ne pojavi spremnik dovoljno velik da prijeđe Chorba prag
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 ga radne dretve viđale, a interaktivno debugiranje nikad
Pokretanje ovog rušenja zahtijeva dva uvjeta istovremeno: XML dio radnog lista dovoljno velik da prijeđe zlib-ngov Chorba prag, i dretvu koja ima samo obični zadani stog, a ne nešto prostranije. Produkcijski izvozni poslovi pogađaju oba uvjeta. Oni se izvode kao poslužiteljski skupni (batch) poslovi koji raspoređuju HotXLS pisanja kroz skup radnih dretvi, od kojih svaka nosi zadani stog od 1 MB koji Windows rezervira osim ako pozivatelj ne zatraži više, i svaka od njih obrađuje korisničke radne bilježnice dovoljno velike da budu bitne. Debugiranje za radnim stolom pouzdano nije pogađalo nijedan od ta dva uvjeta: uzorak datoteka obično je bio manji od praga, a izvođenja korak-po-korak obično su se događala na glavnoj dretvi, a ne unutar tek pokrenute radne dretve, pa se dva uvjeta koja su se u produkciji morala poklopiti gotovo nikad nisu poklopila za stolom programera
Praćenje traga rušenja koje je krivilo pogrešnu funkciju
Izvještaji o rušenju do kojih je tim uspio doći pokazivali su na lokaciju unutar zlib-ngove funkcije deflate, ne na ikakav HotXLS kod, a niti očito na CRC-32 kod. Taj jedan jedini detalj usmjerio je prvi krug istrage prema putanji kompresije: veličine spremnika predane funkciji deflate, bitovi prozora, razina kompresije — sve uobičajene osumnjičene za izvorno rušenje koje dolazi iz kodeka. Nijedan se od njih nije pokazao točnim
Zavaravajući gornji okvir stoga
Prelijevanje stoga čudna je vrsta rušenja za simboliziranje, jer u trenutku kad se prijavi, pokazivač stoga već je prešao prostor koji mu je bio rezerviran. Što god je proizvelo taj izvještaj o rušenju najvjerojatnije je razriješilo adresu greške na najbliži simbol koji je još mogao pronaći, a najbliža izvezena ulazna točka koja se nalazila uz stvarnog krivca slučajno je bila deflate. Stvarna greška nalazila se u alokaciji Chorba pomoćnog spremnika unutar CRC-32 putanje, kompiliranoj u istu biblioteku, dovoljno blizu u binarnoj datoteci da se zamijeni za funkciju koja je zapravo bila u tijeku
Binarno pretraživanje pomoću vremenskih oznaka umjesto debuggera
Rušenje koje obara cijeli proces ne ostavlja ništa što bi normalna Delphi debugger sesija mogla uhvatiti, pa se tim oslonio na GetTickCount kontrolne točke postavljene oko svakog sumnjivog poziva i ručno binarno pretraživanje kroz putanju spremanja, sužavajući koja je operacija bila u tijeku u trenutku smrti procesa. Uz to, poznato ispravna osnovna (baseline) izgradnja pokretala je iste produkcijske datoteke usporedno s trenutnom, konkretno kako bi se isključila regresija u vlastitim promjenama tog kruga prije daljnjeg traženja uzvodno. Tek nakon što su obje provjere ispale čiste, istraga se skrasila na ovisnosti treće strane koja je radila nešto neočekivano s potpuno valjanim ulazom
Zašto try/except ne uspijeva uhvatiti prelijevanje stoga?
Prelijevanje stoga nije iznimka koju Delphi kod ikad namjerno izbacuje, a niti se isporučuje na način na koji Windows isporučuje narušavanje pristupa (access violation) ili dijeljenje s nulom. Pojavljuje se kao hardverska greška zaštitne stranice (guard-page fault), prijavljena kroz isti mehanizam strukturiranog rukovanja iznimkama na kojem je izgrađen Delphijev try/except, no u točnom trenutku kad se okine obično ne preostaje prostora na stogu za pokretanje rukovatelja, odmotavanje koda za čišćenje, ili čak dovršavanje čistog prijavljivanja greške. Na radnoj dretvi koja nosi samo zadanu rezervaciju od 1 MB, uz pomoćni spremnik te veličine koji je već potrošio većinu preostalog prostora, runtime okruženju ne ostaje ništa s čime bi moglo raditi
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 except blok izgleda kao sigurnosna mreža, i protiv većine kvarova to i jest, no ovdje ne čini ništa. Tim je to potvrdio u praksi: try/except nije uhvatio ništa, finally blok također nikad nije pouzdano dobio priliku pokrenuti se, a operater je vidio mrtav proces bez ikakvog zapisa u dnevniku na razini aplikacije — točno ono što je izvorni tiket podrške opisivao
Ispravak: predaja CRC32-u u odsječcima od 64 KB umjesto jednog golemog poziva
Ispravak koji je HotXLS isporučio ne mijenja ništa u samom zlib-ngu niti u razini kompresije korištenoj za pisanje radne bilježnice. ZLibCRC32 sada prolazi kroz ulaz u fiksnim odsječcima od 64 KB, 65536 bajtova svaki, pozivajući zlib-ngovu crc32 funkciju jednom po odsječku i provlačeći tekuću vrijednost kontrolnog zbroja iz jednog poziva u sljedeći. CRC-32 je po konstrukciji inkrementalan algoritam, pa je kontrolni zbroj izgrađen preko nekoliko odsječaka bit-po-bit identičan onome izračunatom u jednom pozivu nad istim bajtovima: ispravak mijenja kako se posao dijeli, a ne što se izračunava
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 se u okolnom pozivu SaveAs nije moralo promijeniti da bi ovo funkcioniralo, a nije se promijenilo ni ništa u ZIP unosima koje HotXLS piše: CRC-32 vrijednost koja završi u lokalnom zaglavlju datoteke i centralnom direktoriju točno je ona vrijednost koju bi proizveo jedan golemi poziv, samo sastavljena od manjih dijelova. Snižavanje verzije zlib-nga (downgrade) ili prelazak na sporiju implementaciju CRC-32 koja štedi na alokaciji također bi izbjegli rušenje, no uz stvaran trošak za svaku datoteku koja se od početka nikad nije ni približila pragu, zbog čega nijedno od ta dva rješenja nije isporučeno
Što ovo znači ako zlib-ng pozivate iz vlastitih radnih dretvi
Ovdje opisan način kvara prelijevanjem stoga nema nikakve veze specifično s proračunskim tablicama. Svaka aplikacija koja zlib-ngu preda velik spremnik, bilo za kompresiju, dekompresiju ili kontrolni zbroj, iz dretve koja nosi samo zadani stog platforme, može naletjeti na isti tip zida, jer biblioteka bira svoj algoritam prema veličini ulaza, a neki od tih algoritama pretpostavljaju da ima stoga na pretek. Dvije obrane djeluju bez diranja samog zlib-nga: predaja velikih spremnika u rutine osjetljive na veličinu u fiksnim dijelovima potpuno uklanja uvjet okidača za svaki algoritam koji je po prirodi inkrementalan, a gdje dijeljenje na odsječke nije opcija, davanje pozivajućoj dretvi stoga većeg od zadanog na platformi druga je poluga. Bilo koje od ta dva jeftinije je nego saznati za nedokumentirani prag veličine iz produkcijskog izvještaja o rušenju koji krivi pogrešnu funkciju
Ovaj konkretan prag ostao je nevidljiv sve dok ga dovoljno velika produkcijska radna bilježnica nije prešla na pogrešnoj vrsti dretve, što je upravo ona vrsta kvara koja se pokaže tek kad kod krene raditi sa stvarnim datotekama umjesto malim testnim uzorcima. Odsječena CRC-32 putanja sada se isporučuje kao dio standardnog cjevovoda pisanja u HotXLS Excel komponenti za Delphi i C++Builder, bez ičega što bi pozivatelj trebao konfigurirati i bez svojstva koje je uključuje ili isključuje