HotXLS може обвалити робочий потік Delphi без жодного винятку, придатного для перехоплення, коли обчислює контрольну суму великої частини XML аркуша одним викликом: zlib-ng перемикається на свій алгоритм Chorba понад приблизно 119 КБ вхідних даних, а варіант цього алгоритму на звичайному C виділяє тимчасовий масив, достатньо великий, щоб пробити типовий стек потоку в 1 МБ. 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 КБ, точно 118 960 байтів у збірці, з якою компонується HotXLS, він перемикається на спеціалізований швидкий алгоритм на ім'я Chorba. Реалізація цього шляху на звичайному 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 МБ, який Windows резервує, якщо викликач не попросить більше, і кожен обробляє книги клієнтів, достатньо великі, щоб мати значення. Налагодження за робочим столом не вражало жодної з умов надійно: файли-зразки зазвичай були меншими за поріг, а покрокові прогони, як правило, відбувалися в головному потоці, а не всередині щойно породженого робочого, тож дві умови, яким довелося зійтися в продакшені, майже ніколи не збігалися за столом розробника
Погоня за збоєм, що звинувачував не ту функцію
Звіти про збій, які команда змогла отримати, вказували на місце всередині функції deflate zlib-ng, не на жоден код HotXLS, і не очевидно на код CRC-32 теж. Ця одна деталь спрямувала перший прохід розслідування на шлях стиснення: розміри буферів, передані в deflate, біти вікна, рівень стиснення, усі звичайні підозрювані для рідного збою, що виходить із кодека. Жоден із них не витримав перевірки
Оманливий верхній кадр
Переповнення стека — дивний тип збою для символізації, бо на момент, коли про нього повідомляють, вказівник стека вже пробіг далі того місця, яке було для нього зарезервоване. Що б не сформувало цей звіт про збій, воно, найімовірніше, розв'язало адресу помилки до найближчого символу, який ще міг знайти, і найближчою експортованою точкою входу, що сиділа поряд зі справжнім винуватцем, виявився deflate. Справжня несправність сиділа у виділенні тимчасового буфера Chorba всередині шляху CRC-32, скомпільованого в ту саму бібліотеку, достатньо близько в бінарному файлі, щоб її сплутати з функцією, що насправді виконувалася
Бісекція за мітками часу замість налагоджувача
Збій, що обвалює весь процес, не залишає нічого для звичайної сесії налагоджувача Delphi, щоб перехопити, тож команда відкотилася до контрольних точок GetTickCount, розкиданих навколо кожного підозрілого виклику, та ручної бісекції по шляху збереження, звужуючи, яка операція виконувалася в момент, коли процес загинув. Поряд із цим, відома робоча базова збірка запускала ті самі промислові файли пліч-о-пліч із поточною, спеціально щоб виключити регресію у власних змінах цього раунду, перш ніж дивитися далі вгору за течією. Лише коли обидві перевірки повернулися чистими, розслідування зупинилося на сторонній залежності, що робить щось несподіване з цілком дійсним входом
Чому try/except не перехоплює переповнення стека?
Переповнення стека — не той виняток, який код Delphi коли-небудь піднімає навмисно, і його не доставляють так, як Windows доставляє порушення доступу чи ділення на нуль. Воно проявляється як апаратна помилка сторінки-охоронця, про яку повідомляється через той самий механізм структурованої обробки винятків, на якому побудований try/except Delphi, але в точний момент спрацювання зазвичай не залишається місця стека для запуску обробника, розгортання коду очищення чи навіть чистого завершення звітування про несправність. На робочому потоці, що несе лише типове резервування в 1 МБ, з тимчасовим буфером такого розміру, що вже спожив більшість того, що залишалося, для середовища виконання нічого не залишається для роботи
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 КБ замість одного гігантського виклику
Виправлення, яке випустив HotXLS, нічого не змінює в самому zlib-ng й нічого в рівні стиснення, що використовується для запису книги. ZLibCRC32 тепер обходить вхід фіксованими зрізами по 64 КБ, по 65536 байтів кожен, викликаючи 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 для Delphi та C++Builder, і викликачу нічого не потрібно налаштовувати, і немає властивості, що вмикає чи вимикає це