Tekninen artikkeli

HotXLS, zlib-ng CRC32 ja pinon ylivuoto Delphi-säikeissä

HotXLS voi kaataa Delphi-työsäikeen ilman napattavaa poikkeusta, kun se laskee tarkistussumman suurelle työarkin XML-osalle yhdellä kutsulla: zlib-ng vaihtaa Chorba-algoritmiinsa noin 119 kt:n syötteen yläpuolella, ja tuon algoritmin geneerinen C-muunnelma varaa apumuistitaulukon, joka on tarpeeksi suuri räjäyttääkseen oletusarvoisen 1 Mt:n säikeen pinon. Delphi ei koskaan saa tilaisuutta reagoida, koska pinon ylivuoto ei ole sellainen poikkeus, jonka nappaamiseen try/except on rakennettu

HotXLS on natiivi Delphi- ja C++Builder-kirjasto Excel-työkirjojen lukemiseen ja kirjoittamiseen, ja kaatuminen jäljittyi sen työarkin kirjoittimeen. Ensimmäinen merkki ongelmasta oli tukipyyntö: yön yli ajettava vientityö kaatui noin kaksi kertaa viikossa, aina kesken ajon, ilman Delphi-poikkeusikkunaa ja ilman kirjattua virhettä, vain prosessi, joka katosi, ja Windows Error Reporting -merkintä, joka ei osoittanut mihinkään hyödylliseen. Sen toistaminen työpöydällä oli aivan eri asia. Pienet työkirjat tallentuivat hyvin. Suuretkin työkirjat tallentuivat hyvin, kunhan tallennus ajettiin pääsäikeessä debuggeri jo kiinnitettynä. Vasta todellinen erä tuotantokokoisia tiedostoja, joka kulki läpi todellisen monisäikeisen vientipolun, toi kaatumisen esiin, siihen mennessä levy-I/O, muistipaine ja epäilyttävä malli oli jo kukin suljettu pois

Miten työarkin tallennus muuttuu yhdeksi jättimäiseksi CRC32-kutsuksi

XLSX-tiedostot ovat ZIP-säiliöitä, ja ZIP-muoto vaatii CRC-32-tarkistussumman jokaiselle merkinnälle, tallennettuna sekä paikalliseen tiedosto-otsikkoon että keskushakemistoon. HotXLS laskee tuon tarkistussumman kutsumalla pientä käärettä nimeltä ZLibCRC32, joka puolestaan kutsuu zlib-ng:n omaa crc32-rutiinia heti, kun SaveAs on lopettanut työarkin XML:n kokoamisen muistiin, ja pitkään tuo kutsu kantoi koko pakkaamattoman puskurin yhdessä kutsussa. Se on järkevä suunnittelu pienelle työarkille. Siitä tulee yksi hyvin suuri kutsu heti, kun arkki on sellainen, joka käsitellään artikkelissamme suurten työkirjojen suorituskyky HotXLS:ssä, jossa yhden arkin XML rutiininomaisesti ylittää muutaman sadan kilotavun ennen kuin se koskaan pakataan

Miksi zlib-ng tarvitsee jättimäisen pinopuskurin CRC32:ta varten?

zlib-ng ei käytä yhtä CRC-32-toteutusta jokaiselle kutsulle. Kokorajan alapuolella se käy puskurin läpi taulukkohauilla ja taittamistempuilla, jotka eivät tarvitse merkityksellistä lisämuistia, ja tuon rajan yläpuolella, noin 119 kt:ssa, tarkalleen 118 960 tavussa siinä käännöksessä, johon HotXLS linkittyy, se vaihtaa erikoistuneeseen nopeaan algoritmiin nimeltä Chorba. Tuon polun geneerinen C-toteutus vaihtaa muistia nopeuteen: se varaa apumuistitaulukon pinolle kasan sijaan, mitoitettuna tekemään algoritmin sisäsilmukasta nopea, ei mahtumaan mukavasti mihin tahansa pinobudjettiin, jota kutsuva säie sattuu kantamaan. Mikään tästä ei ole näkyvissä kutsujan puolelta. Tarkistussummafunktio on normaalisti lehtikutsu, lue muutama tavu, palauta luku, ei mainitsemisen arvoista varausta, ja tuo oletus pätee valtaosalle zlib-ng:hen tehdyistä kutsuista aina siihen asti, kunnes tarpeeksi suuri puskuri, joka ylittää Chorba-rajan, kävelee sisään yhteen niistä

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;

Miksi työsäikeet näkivät sen ja interaktiivinen debuggaus ei koskaan

Tämän kaatumisen laukaiseminen vaatii kaksi ehtoa samanaikaisesti: työarkin XML-osan, joka on tarpeeksi suuri ylittääkseen zlib-ng:n Chorba-rajan, ja säikeen, jolla on vain tavallinen oletuspino eikä mitään väljempää. Tuotantovientityöt osuvat molempiin. Ne ajetaan palvelinpuolen eräajoina, jotka levittävät HotXLS-kirjoitukset työsäiepoolin yli, kukin kantaen oletusarvoista 1 Mt:n pinoa, jonka Windows varaa, ellei kutsuja pyydä lisää, ja kukin käsitellen asiakastyökirjoja, jotka ovat tarpeeksi suuria ollakseen merkityksellisiä. Työpöytädebuggaus ei osunut kumpaankaan ehtoon luotettavasti: näytetiedostot olivat yleensä rajaa pienempiä, ja askel-askeleelta-ajot pyrkivät tapahtumaan pääsäikeessä tuoreen erillisen työsäikeen sisällä ajamisen sijaan, joten kaksi ehtoa, joiden piti täsmätä tuotannossa, eivät melkein koskaan täsmänneet kehittäjän työpöydällä

Väärää funktiota syyttäneen kaatumisen jäljittäminen

Kaatumisraportit, jotka tiimi sai käsiinsä, osoittivat sijaintiin zlib-ng:n deflate-funktion sisällä, ei mihinkään HotXLS-koodiin, eikä ilmeisesti CRC-32-koodiinkaan. Tuo yksi yksityiskohta ohjasi tutkimuksen ensimmäisen kierroksen pakkauspolkua kohti: deflate-funktiolle välitetyt puskurikoot, ikkunabitit, pakkaustaso, kaikki tavalliset epäillyt natiiville kaatumiselle, joka tulee koodekista. Yksikään niistä ei pitänyt paikkaansa

Harhaanjohtava ylin kehys

Pinon ylivuoto on outo kaatumistyyppi symboloitavaksi, koska siihen mennessä, kun se raportoidaan, pinon osoitin on jo ajanut sille varatun tilan ohi. Mikä tahansa tuotti tuon kaatumisraportin, todennäköisimmin ratkaisi vikaantuneen osoitteen lähimpään symboliin, jonka se pystyi vielä löytämään, ja lähin viety sisääntulopiste todellisen syyllisen vieressä sattui olemaan deflate. Todellinen vika istui Chorba-apumuistitaulukon varauksessa CRC-32-polun sisällä, käännettynä samaan kirjastoon, tarpeeksi lähellä binäärissä sekoitettavaksi funktioon, joka todella oli käynnissä

Bisektointi aikaleimoilla debuggerin sijaan

Kaatuminen, joka kaataa koko prosessin, ei jätä mitään tavallisen Delphi-debuggerisession napattavaksi, joten tiimi turvautui GetTickCount-tarkistuspisteisiin, jotka pudotettiin jokaisen epäillyn kutsun ympärille, ja manuaaliseen bisektioon tallennuspolun yli, kaventaen sitä, mikä toiminto oli kesken hetkellä, jolloin prosessi kuoli. Sen rinnalla tunnetusti toimiva perusversio ajoi samat tuotantotiedostot rinnakkain nykyisen version kanssa, nimenomaan sulkeakseen pois regression tuon kierroksen omissa muutoksissa ennen kuin katsottiin pidemmälle alavirtaan. Vasta kun molemmat tarkistukset tulivat puhtaina takaisin, tutkimus asettui kolmannen osapuolen riippuvuuteen, joka teki jotain odottamatonta täysin pätevällä syötteellä

Miksi try/except epäonnistuu pinon ylivuodon nappaamisessa?

Pinon ylivuoto ei ole poikkeus, jonka Delphi-koodi koskaan nostaa tarkoituksella, eikä sitä toimiteta samalla tavalla kuin Windows toimittaa access violationin tai jaon nollalla. Se ilmenee laitteistotason suojasivuvikana, raportoituna saman jäsennellyn poikkeuskäsittelymekanismin kautta, jolle Delphin try/except on rakennettu, mutta sillä tarkalla hetkellä, kun se laukeaa, ei normaalisti ole pinotilaa jäljellä käsittelijän ajamiseen, siivouskoodin purkamiseen tai edes vian siistiin raportointiin loppuun asti. Työsäikeessä, joka kantaa vain oletusarvoista 1 Mt:n varausta, apumuistipuskurin ollessa sen kokoinen, joka on jo kuluttanut suurimman osan jäljellä olevasta, mitään ei ole jäljellä, jonka kanssa ajonaikainen ympäristö voisi työskennellä

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;

Tuo except-lohko näyttää turvaverkolta, ja useimpia epäonnistumisia vastaan se onkin sellainen, mutta se ei tee tässä mitään. Tiimi vahvisti tämän käytännössä: try/except ei napannut mitään, finally-lohkokaan ei koskaan saanut luotettavaa tilaisuutta ajaa, ja operaattori näki kuolleen prosessin ilman yhtäkään sovellustason lokimerkintää, täsmälleen sen, mitä alkuperäinen tukipyyntö kuvasi

Korjaus: syötä CRC32:lle 64 kt:n viipaleita yhden jättikutsun sijaan

Korjaus, jonka HotXLS julkaisi, ei muuta mitään zlib-ng:ssä itsessään eikä mitään työkirjan kirjoittamiseen käytetyssä pakkaustasossa. ZLibCRC32 käy nyt syötteen läpi kiinteinä 64 kt:n viipaleina, kukin 65536 tavua, kutsuen zlib-ng:n crc32-funktiota kerran per viipale ja ketjuttaen juoksevan tarkistussumman arvon yhdestä kutsusta seuraavaan. CRC-32 on rakenteeltaan inkrementaalinen algoritmi, joten useiden viipaleiden yli rakennettu tarkistussumma on bitilleen identtinen yhdessä kutsussa samojen tavujen yli laskettuun: korjaus muuttaa sitä, miten työ jaetaan, ei sitä, mitä se laskee

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;

Ympäröivälle SaveAs-kutsulle ei tarvinnut muuttaa mitään, jotta tämä toimisi, eikä ZIP-merkinnöille, jotka HotXLS kirjoittaa, muuttunut mitään myöskään: CRC-32-arvo, joka päätyy paikalliseen tiedosto-otsikkoon ja keskushakemistoon, on täsmälleen se arvo, jonka yksi jättikutsu olisi tuottanut, vain koottuna pienemmistä paloista. zlib-ng:n alentaminen vanhempaan versioon tai palautuminen hitaampaan, varauskevyempään CRC-32-toteutukseen olisi myös välttänyt kaatumisen, mutta todellisella kustannuksella jokaiselle tiedostolle, joka ei koskaan tullut lähellekään rajaa alun perinkään, minkä vuoksi kumpaakaan ei julkaistu

Mitä tämä tarkoittaa, jos kutsut zlib-ng:tä omista työsäikeistäsi

Tässä kuvattu pinon ylivuodon vikatila ei liity nimenomaan laskentataulukoihin. Mikä tahansa sovellus, joka antaa zlib-ng:lle suuren puskurin, olipa kyse pakkauksesta, purkamisesta tai tarkistussummasta, säikeestä, joka kantaa vain alustan oletuspinoa, voi osua samanlaiseen seinään, koska kirjasto valitsee algoritminsa syötteen koon perusteella, ja jotkin niistä algoritmeista olettavat, että pinoa on ylimääräistä. Kaksi puolustusta toimii koskematta itse zlib-ng:hen: suurten puskureiden syöttäminen kokoherkkiin rutiineihin kiinteinä lohkoina poistaa laukaisuehdon kokonaan mille tahansa algoritmille, joka on luonnostaan inkrementaalinen, ja missä lohkotus ei ole vaihtoehto, kutsuvan säikeen antaminen alustan oletusta suuremman pinon on toinen vipu. Kumpikin on halvempi kuin dokumentoimattoman kokorajan löytäminen tuotantokaatumisraportista, joka syyttää väärää funktiota

Tämä tietty raja pysyi näkymättömänä, kunnes tarpeeksi suuri tuotantotyökirja ylitti sen väärän tyyppisessä säikeessä, mikä on juuri sellainen vika, joka paljastuu vasta, kun koodi ajetaan todellisia tiedostoja vasten pienten testifixtuurien sijaan. Lohkotettu CRC-32-polku toimitetaan nyt osana vakiokirjoitusputkea HotXLS Excel -komponentissa Delphille ja C++Builderille, ilman mitään kutsujan määritettävää ja ilman ominaisuutta, joka kytkee sen päälle tai pois