HotXLS lahko sesuje delovno nit Delphi brez izjeme, ki bi jo bilo mogoče prestreči, ko v enem klicu izračuna kontrolno vsoto velikega dela XML delovnega lista: zlib-ng nad približno 119 KB vhoda preklopi na algoritem Chorba, njegova različica generic-C pa na skladu dodeli pomožno polje, dovolj veliko, da preseže privzeti sklad niti velikosti 1 MB. Delphi se nima priložnosti odzvati, ker prekoračitev sklada ni vrsta izjeme, za lovljenje katere je bil zgrajen try/except
HotXLS je izvorna knjižnica Delphi in C++Builder za branje in pisanje delovnih zvezkov Excel, sesutje pa je izviralo iz njenega zapisovalnika delovnih listov. Prvi znak težave je bila zahteva za podporo: nočni izvozni posel se je sesul približno dvakrat na teden, vedno sredi izvajanja, brez pogovornega okna izjeme Delphi in brez zabeležene napake, le proces je izginil, vnos Windows Error Reporting pa ni kazal nikamor uporabnemu. Ponoviti to za mizo je bila povsem druga stvar. Majhni delovni zvezki so se shranili brez težav. Tudi veliki so se shranili brez težav, če je shranjevanje teklo v glavni niti z že priključenim razhroščevalnikom. Šele dejanski paket produkcijsko velikih datotek, ki je tekel skozi pravo večnitno izvozno pot, je sesutje zanesljivo priklical, do takrat pa so bili diskovni V/I, pritisk pomnilnika in sumljiva predloga že izločeni
Kako se shranjevanje delovnega lista spremeni v en velik klic CRC32
Datoteke XLSX so vsebniki ZIP, format ZIP pa zahteva kontrolno vsoto CRC-32 za vsak vnos, zapisano tako v lokalni glavi datoteke kot v osrednjem imeniku. HotXLS to kontrolno vsoto izračuna tako, da pokliče majhen ovoj z imenom ZLibCRC32, ta pa nato pokliče lastno rutino crc32 zlib-ng, ko SaveAs konča sestavljanje XML delovnega lista v pomnilniku, pri čemer je ta klic dolgo prenašal celoten nestisnjen medpomnilnik v enem samem priklicu. To je razumna zasnova za majhen delovni list. V trenutku, ko je list tak, kot je opisan v našem vodniku po zmogljivosti velikih delovnih zvezkov v HotXLS, pa postane en zelo velik klic, saj XML posameznega lista pred stiskanjem pogosto preseže nekaj sto kilobajtov
Zakaj zlib-ng potrebuje velik medpomnilnik CRC32 na skladu
zlib-ng za vsak klic ne uporablja ene same izvedbe CRC-32. Pod pragom velikosti prehodi medpomnilnik z iskanjem po tabelah in zvijanjem, ki ne zahtevata omembe vrednega dodatnega pomnilnika, nad tem pragom, približno 119 KB oziroma natančno 118.960 bajtov v gradnji, na katero se povezuje HotXLS, pa preklopi na specializiran hitri algoritem z imenom Chorba. Izvedba generic-C te poti zamenja pomnilnik za hitrost: pomožno polje dodeli na skladu namesto na kopici, njegova velikost pa je določena za hiter notranji zanki algoritma, ne za udobno prileganje poljubnemu skladu, ki ga ima klicna nit. S strani klicatelja nič od tega ni vidno. Funkcija za kontrolno vsoto je običajno listni klic, prebere nekaj bajtov in vrne število brez omembe vredne dodelitve, ta domneva pa velja za veliko večino klicev v zlib-ng, dokler se vanj ne poda dovolj velik medpomnilnik za prečkanje praga 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;
Zakaj so ga opazile delovne niti, interaktivno razhroščevanje pa ne
Za sprožitev tega sesutja sta potrebna dva pogoja hkrati: del XML delovnega lista mora biti dovolj velik za prečkanje praga Chorba zlib-ng, nit pa mora imeti le običajni privzeti sklad namesto prostornejšega. Produkcijski izvozni posli izpolnijo oba. Izvajajo se kot strežniški paketni posli, ki pisanje HotXLS razporedijo med skupino delovnih niti, vsaka z privzetim skladom 1 MB, ki ga Windows rezervira, če klicatelj ne zahteva večjega, in vsaka obdeluje dovolj velike uporabniške delovne zvezke, da je to pomembno. Razhroščevanje za mizo ni zanesljivo izpolnilo nobenega pogoja: vzorčne datoteke so bile običajno manjše od praga, izvajanja po korakih pa so navadno potekala v glavni niti in ne v pravkar ustvarjeni delovni niti, zato se pogoja, ki sta se morala v produkciji poravnati, pri razvijalčevi mizi skoraj nikoli nista
Iskanje sesutja, ki je krivdo pripisalo napačni funkciji
Poročila o sesutju, ki jih je ekipa lahko pridobila, so kazala na mesto znotraj funkcije deflate zlib-ng, ne na kodo HotXLS in niti ne očitno na kodo CRC-32. Ta podrobnost je prvi krog preiskave usmerila proti poti stiskanja: velikosti medpomnilnikov, posredovane funkciji deflate, bitom okna, ravni stiskanja in vsem običajnim osumljencem izvornega sesutja, ki prihaja iz kodeka. Nobeden ni zdržal preverjanja
Varljiv vrhnji okvir
Prekoračitev sklada je nenavadna vrsta sesutja za simbolizacijo, saj je kazalec sklada ob poročanju že šel mimo prostora, rezerviranega zanj. Karkoli je ustvarilo poročilo o sesutju, je naslov napake najverjetneje razrešilo na najbližji simbol, ki ga je še lahko našlo, najbližja izvožena vstopna točka ob pravem krivcu pa je bila po naključju deflate. Dejanska napaka je bila v dodelitvi pomožnega medpomnilnika Chorba znotraj poti CRC-32, prevedeni v isto knjižnico in dovolj blizu v binarni datoteki, da jo je bilo mogoče zamenjati za funkcijo, ki se je dejansko izvajala
Bisekcija s časovnimi žigi namesto razhroščevalnika
Sesutje, ki s seboj potegne celoten proces, običajni seji razhroščevalnika Delphi ne pusti ničesar, kar bi lahko prestregla, zato se je ekipa oprla na kontrolne točke GetTickCount, vstavljene okoli vsakega sumljivega klica, in ročno bisekcijo po poti shranjevanja, da je zožila operacijo, ki je tekla v trenutku smrti procesa. Poleg tega je znana osnovna gradnja obdelala iste produkcijske datoteke vzporedno s trenutno, posebej zato, da bi pred nadaljevanjem po toku preverila, ali gre za regresijo v spremembah tega kroga. Šele ko sta bila oba preverjanja uspešna, se je preiskava ustalila pri odvisnosti tretje osebe, ki je z veljavnim vhodom počela nekaj nepričakovanega
Zakaj try/except ne more prestreči prekoračitve sklada
Prekoračitev sklada ni izjema, ki bi jo koda Delphi namenoma kdaj sprožila, in tudi ni dostavljena na način, kako Windows dostavi kršitev dostopa ali deljenje z nič. Pojavi se kot strojna napaka varovalne strani, poročana prek istega mehanizma strukturiranega ravnanja z izjemami, na katerem temelji try/except Delphi, vendar v natančnem trenutku sprožitve običajno ni več prostora na skladu za zagon obravnavalnika, razveljavitev čistilne kode ali celo urejeno dokončanje poročanja o napaki. V delovni niti s privzeto rezervacijo 1 MB, kjer je pomožni medpomnilnik že porabil večino preostalega prostora, izvajalnik nima več s čim delati
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;
Ta blok except je videti kot varnostna mreža in pri večini napak tudi je, vendar tukaj ničesar ne naredi. Ekipa je to potrdila v praksi: try/except ni prestregel ničesar, tudi blok finally ni zanesljivo dobil priložnosti za izvajanje, upravljavec pa je videl mrtev proces brez vnosa v dnevnik na ravni aplikacije, natančno tako, kot je opisala prvotna zahteva za podporo
Popravek: CRC32 hranite v kosih po 64 KB namesto v enem velikem klicu
Popravek, ki ga je poslal HotXLS, ne spreminja ničesar v zlib-ng in ničesar pri ravni stiskanja, uporabljeni za zapis delovnega zvezka. ZLibCRC32 zdaj prehodi vhod v stalnih kosih po 64 KB oziroma 65536 bajtov, zlib-ng pokliče crc32 enkrat za vsak kos in tekočo vrednost kontrolne vsote prenaša iz enega klica v naslednjega. CRC-32 je po zasnovi inkrementalni algoritem, zato je kontrolna vsota, zgrajena iz več kosov, bit za bit enaka tisti, izračunani v enem klicu nad istimi bajti: popravek spremeni način razdelitve dela, ne pa tega, kar izrač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;
Za delovanje tega ni bilo treba spremeniti ničesar v okoliškem klicu SaveAs in ničesar v vnosih ZIP, ki jih zapisuje HotXLS: vrednost CRC-32, ki konča v lokalni glavi datoteke in osrednjem imeniku, je natančno tista, ki bi jo ustvaril en sam velik klic, le sestavljena iz manjših delov. Znižanje različice zlib-ng ali vrnitev k počasnejši izvedbi CRC-32 z malo dodeljevanja bi prav tako preprečila sesutje, vendar za resnično ceno pri vsaki datoteki, ki se nikoli ni približala pragu, zato ni bila poslana nobena od teh možnosti
Kaj to pomeni, če kličete zlib-ng iz lastnih delovnih niti
Način odpovedi s prekoračitvijo sklada, opisan tukaj, ni posebej povezan s preglednicami. Vsaka aplikacija, ki zlib-ng preda velik medpomnilnik za stiskanje, razstiskanje ali kontrolno vsoto iz niti, ki ima le privzeti sklad platforme, lahko trči ob isto oviro, ker knjižnica izbira algoritem glede na velikost vhoda, nekateri algoritmi pa predvidevajo, da je na skladu dovolj prostora. Dve obrambi delujeta brez poseganja v zlib-ng: podajanje velikih medpomnilnikov rutinam, občutljivim na velikost, v stalnih kosih neposredno odstrani sprožilni pogoj pri algoritmih, ki so po naravi inkrementalni, kjer delitev na kose ni mogoča, pa je druga ročica dodelitev večjega sklada klicni niti. Oboje je cenejše od spoznavanja nedokumentiranega praga velikosti iz produkcijskega poročila o sesutju, ki krivdo pripiše napačni funkciji
Ta prag je ostal neopažen, dokler ga dovolj velik produkcijski delovni zvezek ni prestopil v napačni vrsti niti, kar je natanko vrsta napake, ki se pokaže šele, ko koda deluje z resničnimi datotekami namesto z majhnimi testnimi primeri. Pot z razdeljenim CRC-32 se zdaj dobavlja kot del standardne poti pisanja v Excelovi komponenti HotXLS za Delphi in C++Builder, brez nastavitev za klicatelja in brez lastnosti, ki bi jo vklopila ali izklopila