Техническа статия

HotXLS, zlib-ng CRC32 и препълване на стека в нишки на Delphi

HotXLS може да срине работна нишка на Delphi без изключение, което може да бъде прихванато, когато изчислява контролната сума на голяма XML част от работен лист с едно извикване: zlib-ng преминава към алгоритъма Chorba над приблизително 119 KB входни данни, а неговият вариант за generic-C заделя временен масив, достатъчно голям, за да надхвърли стандартния стек от 1 MB на нишката. Delphi не успява да реагира, защото препълването на стека не е типът изключение, за който е създадено try/except

HotXLS е библиотека на роден Delphi и C++Builder код за четене и запис на работни книги на Excel, а сривът водеше към писателя на работни листове. Първият сигнал беше заявка към поддръжката: нощна задача за експорт се сриваше приблизително два пъти седмично, винаги по време на изпълнение, без диалог за изключение на Delphi и без записана грешка, само с изчезнал процес и запис в Windows Error Reporting, който не даваше полезна насока. Възпроизвеждането пред работната станция беше съвсем друга задача. Малките работни книги се записваха нормално. Големите също се записваха нормално, стига записът да се изпълняваше в главната нишка с вече прикачен дебъгер. Нужно беше реално множество производствени файлове да премине през истинския многопоточен път за експорт, за да се прояви сривът, а дотогава дисковият вход и изход, натискът върху паметта и подозрителният шаблон вече бяха изключени като причини

Как записът на работен лист се превръща в едно огромно извикване на CRC32

XLSX файловете са ZIP контейнери, а ZIP форматът изисква контролна сума CRC-32 за всеки запис, която се съхранява както в локалната заглавка на файла, така и в централната директория. HotXLS изчислява тази контролна сума чрез малка обвивка с име ZLibCRC32, която от своя страна извиква собствената процедура crc32 на zlib-ng, след като SaveAs завърши изграждането на XML за работния лист в паметта, и дълго време това извикване предаваше целия некомпресиран буфер с една операция. Това е разумен дизайн за малък работен лист. Той се превръща в едно много голямо извикване, когато листът е от типа, разгледан в нашето ръководство за производителност на големи работни книги в HotXLS, където XML файлът на един лист редовно надхвърля няколкостотин килобайта още преди да бъде компресиран

Защо zlib-ng се нуждае от огромен буфер в стека за CRC32

zlib-ng не използва една и съща реализация на CRC-32 за всяко извикване. Под определен праг той обхожда буфера чрез таблични справки и техники за сгъване, които не изискват съществена допълнителна памет, а над този праг, приблизително 119 KB или точно 118 960 байта в компилацията, с която се свързва HotXLS, преминава към специализиран бърз алгоритъм с име Chorba. Реализацията на този път за generic-C заменя памет срещу скорост: заделя временен масив в стека, а не в динамичната памет, с размер, избран за бърз вътрешен цикъл на алгоритъма, не за удобно побиране в бюджета на стека, с който разполага извикващата нишка. От страната на извикващия това не се вижда. Функцията за контролна сума обикновено е крайно извикване, което прочита няколко байта и връща число без забележимо заделяне, и това допускане е вярно за огромното мнозинство извиквания към zlib-ng, докато в него не попадне буфер, достатъчно голям, за да премине прага на 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;

Защо работните нишки го проявяваха, а интерактивното дебъгване не

За да се предизвика този срив, трябва едновременно да са изпълнени две условия: XML част от работен лист, достатъчно голяма, за да премине прага на Chorba в zlib-ng, и нишка само с обичайния стандартен стек, а не с по-голям резерв. Производствените задачи за експорт изпълняваха и двете условия. Те работят като пакетни задачи от страна на сървъра, които разпределят записите на HotXLS между пул от работни нишки, като всяка носи стандартния стек от 1 MB, резервиран от Windows, освен ако извикващият не поиска повече, и всяка обработва клиентски работни книги с достатъчен размер. Дебъгването пред работната станция рядко изпълняваше някое от условията: примерните файлове обикновено бяха по-малки от прага, а изпълнението стъпка по стъпка се случваше в главната нишка, а не в току-що създадена работна нишка, така че двете условия, които трябваше да се срещнат в производствена среда, почти никога не се срещаха на бюрото на разработчика

Проследяване на срив, който обвиняваше грешната функция

Наличните доклади за срива сочеха към място във функцията за deflate на zlib-ng, а не към код на HotXLS и неочевидно към кода за CRC-32. Само тази подробност насочи първия етап на разследването към пътя за компресиране: размерите на буферите, подадени към deflate, битовете на прозореца, нивото на компресия и всички обичайни заподозрени при нативен срив, излязъл от кодек. Нито един от тях не издържа на проверката

Подвеждащият най-горен кадър

Препълването на стека е необичаен вид срив за символизиране, защото когато бъде докладвано, указателят на стека вече е преминал отвъд пространството, което е било резервирано за него. Вероятно инструментът, създал доклада за срива, е разрешил адреса на грешката до най-близкия символ, който все още е могъл да намери, а най-близката експортирана входна точка до истинския виновник се е оказала deflate. Реалната грешка е била в заделянето на временния буфер на Chorba в пътя за CRC-32, компилиран в същата библиотека, достатъчно близо в двоичния файл, за да бъде сбъркана с функцията, която действително се е изпълнявала

Бисекция с времеви маркери вместо дебъгер

Срив, който прекратява целия процес, не оставя нищо, което нормална сесия на дебъгера на Delphi да прихване, затова екипът премина към маркери от GetTickCount около всяко подозрително извикване и ръчна бисекция на пътя за запис, за да стесни операцията, която е била в ход, когато процесът е приключил. Успоредно с това базова компилация, за която е било известно, че работи, е обработила същите производствени файлове редом с текущата, конкретно за да бъде изключена регресия в промените от същия цикъл, преди разследването да продължи нагоре по веригата. Едва след като и двете проверки са дали чист резултат, разследването се е насочило към външна зависимост, която прави нещо неочаквано с напълно валиден вход

Защо try/except не успява да прихване препълване на стека

Препълването на стека не е изключение, което кодът на Delphi умишлено повдига, и не се доставя по начина, по който Windows доставя нарушение на достъпа или деление на нула. То се проявява като хардуерна грешка на защитната страница, докладвана чрез същия механизъм за структурирано обработване на изключения, върху който е изградено try/except в Delphi, но в точния момент на възникването ѝ обикновено вече не е останало място в стека за изпълнение на обработчик, размотаване на код за почистване или дори за чисто приключване на докладването. В работна нишка със стандартния резерв от 1 MB, когато временният буфер с този размер вече е изразходвал по-голямата част от наличното пространство, средата няма с какво да работи

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;

Този блок except изглежда като предпазна мрежа и срещу повечето грешки наистина е такава, но тук не помага. Екипът потвърди това на практика: try/except не прихвана нищо, блокът finally също не получи надеждна възможност да се изпълни, а операторът видя мъртъв процес без запис в журнала на приложението, точно както описваше първоначалната заявка към поддръжката

Поправката: подаване на CRC32 на блокове от 64 KB вместо с едно огромно извикване

Поправката, доставена от HotXLS, не променя самия zlib-ng и не променя нивото на компресия, използвано за запис на работната книга. Сега ZLibCRC32 обхожда входа на фиксирани блокове от 64 KB, по 65 536 байта, като извиква crc32 на zlib-ng веднъж за всеки блок и предава текущата стойност на контролната сума от едно извикване към следващото. CRC-32 по същество е инкрементален алгоритъм, затова контролна сума, изградена върху няколко блока, е бит по бит идентична с тази, изчислена с едно извикване върху същите байтове: поправката променя начина на разделяне на работата, а не изчисляваната стойност

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;

За да работи това, не е нужно да се променя нищо в обкръжаващото извикване на SaveAs и не се променя нищо в ZIP записите, които HotXLS създава: стойността CRC-32 в локалната заглавка на файла и централната директория е точно тази, която би произвело едно огромно извикване, само че е събрана от по-малки части. Понижаването на версията на zlib-ng или връщането към по-бавна реализация на CRC-32 с малко заделяне също би избегнало срива, но на реална цена за всеки файл, който никога не се доближава до прага, затова нито един от двата подхода не е доставен

Какво означава това, ако извиквате zlib-ng от собствените си работни нишки

Описаният тук режим на отказ при препълване на стека няма специална връзка с електронните таблици. Всяко приложение, което подава на zlib-ng голям буфер за компресиране, декомпресиране или изчисляване на контролна сума от нишка само със стандартния стек на платформата, може да се сблъска със същата граница, защото библиотеката избира алгоритъм според размера на входа, а някои от тези алгоритми предполагат, че има достатъчно свободно пространство в стека. Две защити работят без промяна на самия zlib-ng: подаването на големи буфери към чувствителни към размера процедури на фиксирани блокове премахва условието за задействане при алгоритъм, който естествено поддържа поетапна обработка, а когато разделянето на блокове не е възможно, задаването на стек на извикващата нишка, по-голям от стандартния за платформата, е другият лост. Всеки от тези подходи е по-евтин от това да научите за недокументиран праг на размера от производствен доклад за срив, който обвинява грешната функция

Този конкретен праг остана незабележим, докато достатъчно голяма производствена работна книга не го премина на неподходящия тип нишка, точно от онези повреди, които се проявяват едва когато кодът работи с реални файлове, а не с малки тестови образци. Пътят с блоково изчисляване на CRC-32 вече се доставя като част от стандартния конвейер за запис в компонента HotXLS Excel Component за Delphi и C++Builder, без извикващият да трябва да конфигурира нещо и без свойство за включване или изключване