Функция на Delphi или FPC, возвращающая запись, не получает свежий, обнулённый Result при каждом вызове. Эта скрытая переменная Result становится нулевой ровно один раз, и ничто не обнуляет её автоматически заново между вызовами, так что очистка на входе — собственная обязанность функции. Выполните эту очистку через FillChar(Result, SizeOf(Result), 0), и начиная со второго вызова процедура перезапишет живую ссылку на строку или динамический массив, вместо того чтобы её освободить, осиротив тот блок кучи, на который эта ссылка указывала
Сценарий, где это кусается, приземлённый. Пакетный процесс открывает стопку сторонних PDF и обходит каждую аннотацию на каждой странице, вытягивая текст комментария в журнал аудита. Ничто в этом цикле не выглядит опасным: каждый вызов — обычная функция, возвращающая обычную запись, никаких указателей в поле зрения, ничего, напоминающего ручное управление памятью вообще. Подсчёт ссылок внутри записи — обычное правило учёта Object Pascal, а не причуда, специфичная для какой-то одной библиотеки, и любая кодовая база на Delphi или FPC, смешивающая FillChar с типами записей, несущими строки или динамические массивы, подвержена тому же дефекту
Почему FillChar на результате-записи приводит к утечке строк?
FillChar приводит к утечке строк потому, что понятия не имеет, какие данные перезаписывает. FillChar(X, Count, Value) работает с любой переменной вообще: он берёт нетипизированный блок из Count байтов и проставляет каждый из них значением Value, и это весь контракт. Именно это делает FillChar быстрым и универсальным, потому что он никогда не проверяет тип X и никогда не ветвится на том, что означают лежащие в основе байты. Поле UnicodeString или WideString внутри записи — это не сами символы; это указатель на блок кучи, несущий счётчик ссылок перед данными символов. FillChar видит горстку байтов, случайно хранящих значение указателя, и перезаписывает их нулём точно так же, как перезаписал бы поле Integer или Double. Указатель исчезает, счётчик ссылок, который следовало сначала уменьшить, никогда не трогается, а блок, на который он указывал, остаётся выделенным, но без единой оставшейся на него ссылки
Как компилятор отслеживает строки и динамические массивы внутри записи
Object Pascal называет тип управляемым, когда компилятору приходится выполнять дополнительный код, чтобы поддерживать его корректность при присваивании и выходе из области видимости. Длинные строковые типы, такие как AnsiString, UnicodeString и WideString, подходят под это, как и динамические массивы, интерфейсы и Variant, наряду с любой записью или массивом фиксированного размера, содержащим что-то из этого в качестве поля. Для каждого управляемого поля компилятор незаметно выдаёт учётный код, который иначе был бы утомительным и легко ошибочным при ручной реализации: увеличить счётчик ссылок при присваивании, уменьшить его, когда хранящая переменная перезаписывается или выходит из области видимости, и освободить лежащий в основе блок, как только этот счётчик достигнет нуля. Именно этот механизм — причина, по которой обычный код на Pascal никогда вручную не выделяет и не освобождает string, и почему присвоение одного динамического массива другому — дешёвая, безопасная операция, а не ручной цикл копирования. System.Default и Finalize — два документированных способа вызвать ту же логику освобождения по требованию, и именно их должен вызывать код очистки записи вместо сырого заполнения памяти
type
TLineItem = record
Description: string; // managed: reference-counted
Quantity: Integer; // unmanaged: plain ordinal
end;
function GetLineItem(Index: Integer): TLineItem;
begin
FillChar(Result, SizeOf(Result), 0); // clears bytes, not the reference
Result.Quantity := Source[Index].Qty;
Result.Description := Source[Index].Text;
end;
var
Item: TLineItem;
I: Integer;
begin
for I := 0 to High(Source) do
begin
Item := GetLineItem(I); // second pass onward: leaks the prior Description
Log.Add(Item.Description);
end;
end;
Почему утечка начинается только со второго вызова?
Первый вызов в цикле всегда безобиден, и именно это делает этот дефект легко упускаемым при тестировании. Локальная переменная управляемого типа записи изначально нулевая, и ничто не обнуляет её автоматически заново между одним проходом цикла и следующим, так что в первый раз, когда цикл присваивает возвращаемое значение функции этой переменной, её поле Description или ContentsText всё ещё nil. FillChar перезаписывает nil нулём, что ничего не меняет с точки зрения счётчика ссылок, и вызов возвращается, выглядя совершенно корректным. Второй вызов другой: та же локальная переменная уже держит то, что первый вызов в неё записал, и Result нового вызова пишется прямо в то же хранилище, а не в свежую, пустую память. FillChar в начале этого второго вызова обнуляет поле, которое уже не nil, и всё, что следует за этой битовой картиной, с этого момента молча неверно. Тест, вызывающий функцию один раз и проверяющий результат, никогда не увидит проблему; только цикл или любой путь кода, вызывающий функцию многократно с тем же назначением, её обнаруживает
Реальная утечка: аннотации, закладки и записи ссылок
PDFiumPas несла именно этот дефект до версии 1.56.4, в трёх функциях, каждая из которых возвращает запись, несущую как минимум одно управляемое поле: читатель аннотаций уровня страницы возвращает TPdfAnnotation, несущую строки ContentsText и AuthorText, читатель закладок возвращает TBookmark, несущую строку Title, а читатель аннотаций-ссылок возвращает TLinkAnnotation, несущую строку ActionPath и динамический массив Points. Все три открывались той же формой, показанной ниже: очистить Result сырым FillChar, затем заполнить поля по одному из лежащих в основе данных страницы. Обход каждой аннотации на странице по одной, обычный способ построить список аудита или панель рецензирования, вызывал читатель аннотаций в цикле и терял текст предыдущей аннотации на каждом проходе, начиная со второго; PDF, созданный с необычно большим числом текстонесущих аннотаций, мог раздувать память долго работающего процесса на всё время, пока этот процесс продолжал работать. Исправление затронуло одну строку в каждой функции: замена FillChar(Result, SizeOf(Result), 0) на Result := Default(TPdfAnnotation) оказалась достаточной, потому что присвоение Default управляемой записи выполняет обычную последовательность компилятора «сначала освободить, потом очистить» вместо сырого заполнения памяти
function GetPageAnnotation(Page: FPDF_PAGE; Index: Integer): TPdfAnnotation;
var
Annotation: FPDF_ANNOTATION;
ContentLength: LongWord;
begin
Annotation := FPDFPage_GetAnnot(Page, Index);
FillChar(Result, SizeOf(Result), 0); // clears bytes, not a live reference
Result.Subtype := DecodeAnnotationSubtype(FPDFAnnot_GetSubtype(Annotation));
ContentLength := FPDFAnnot_GetStringValue(Annotation,
FPDFANNOT_TEXTTYPE_Contents, nil, 0);
if ContentLength >= 4 then
begin
SetLength(Result.ContentsText, ContentLength div 2 - 1);
FPDFAnnot_GetStringValue(Annotation, FPDFANNOT_TEXTTYPE_Contents,
Pointer(Result.ContentsText), ContentLength);
end;
end;
Та же опасность за параметром var
Читатель закладок показывает более тонкую версию той же проблемы, потому что запись, очищаемая FillChar, — не собственный Result функции, а параметр var на один вызов ниже. SetBookmarkData принимает свой вывод как var Data: TBookmark и раньше очищал Data в начале своего тела через FillChar; GetBookmark, публичная функция, реально возвращающая TBookmark, вызывает SetBookmarkData и передаёт собственный Result напрямую как этот аргумент var. Параметр var передаётся по ссылке, так что Data внутри SetBookmarkData и Result внутри GetBookmark — одно и то же хранилище под двумя именами, и любой риск алиасинга, применимый к собственному Result функции, применяется точно так же напрямую к любой вспомогательной процедуре, получающей его по ссылке. Ревью только тех функций, что буквально объявляют возвращаемый тип-запись, упускает эту форму; поиск должен также следовать за каждым параметром var и out, в который перенаправляется Result
procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
BufferSize: LongWord;
begin
Data := Default(TBookmark); // fixed: was FillChar(Data, SizeOf(Data), 0)
Data.Handle := Bookmark;
if Bookmark <> nil then
begin
BufferSize := FPDFBookmark_GetTitle(Bookmark, nil, 0);
if BufferSize >= 4 then
begin
SetLength(Data.Title, BufferSize div 2 - 1);
FPDFBookmark_GetTitle(Bookmark, PWideChar(Data.Title), BufferSize);
end;
end;
end;
function TPdf.GetBookmark(const Title: WString): TBookmark;
begin
CheckActive;
SetBookmarkData(FPDFBookmark_Find(FDocument, PWideChar(Title)), Result);
end;
Когда FillChar всё ещё правильный выбор?
FillChar по-прежнему корректен, и часто немного дешевле, для записи, построенной целиком из порядковых типов, полей с плавающей точкой, массивов фиксированного размера из них или других обычных записей, составленных из того же, потому что компилятору там нечего финализировать. Собственный тип прямоугольника в PDFiumPas — именно такой случай: TPdfRectangle несёт четыре поля Double и ничего больше, и очистка его через FillChar ничего не освобождает, потому что нечего освобождать со счётчиком ссылок. Проверка, разделяющая эти два случая, формулируется просто: есть ли у какого-либо поля записи, на любой глубине вложенности, тип string, AnsiString, WideString, динамический массив, интерфейс или Variant? Запись может выглядеть совершенно числовой на верхнем уровне и всё же не проходить эту проверку, если одно из её полей само является записью, скрывающей строку на несколько слоёв глубже, так что проверка должна следовать через вложенные записи до конца, а не останавливаться на самом внешнем списке полей. Аудит существующей кодовой базы на этот паттерн механичен, а не исчерпывающ: искать каждый вызов FillChar, чья цель — переменная-запись, а затем проверять список полей этой записи по приведённому выше списку управляемых типов. Собственный аудит v1.56.4 в PDFiumPas выполнил именно такой поиск по всей библиотеке и нашёл эту уязвимость в одном модуле; каждое другое место вызова FillChar уже очищало обычную числовую запись, где FillChar был и остаётся правильным инструментом
То же поведение компилятора, что делает переиспользуемый Result здесь опасным, также лежит в основе родственного семейства расхождений между Delphi и FPC в других местах этой кодовой базы; сопутствующая статья о подводных камнях кросс-компиляции описывает случай, где FPC и Delphi расходятся ровно в том, когда финализируется временная переменная результата-записи внутри одного выражения, — другой симптом того же лежащего в основе факта, что Result-запись функции не всегда является тем свежим, приватным хранилищем, каким кажется. Цикл по аннотациям, используемый в качестве сквозного примера в этой статье, тоже не гипотетичен: это тот же постраничный обход, что вы бы написали при построении панели рецензирования аннотаций, — именно та форма кода, что изначально превратила однострочный FillChar в медленную утечку памяти
Ничто из этого не требует смены библиотек или охоты за ошибкой в чьём-то чужом скомпилированном коде: это свойство самого языка Object Pascal, с которым ежедневно работает каждый разработчик на Delphi и FPC, а исправление — единственный вызов функции, как только знаешь, что искать. Описанные здесь API аннотаций, закладок и аннотаций-ссылок поставляются как часть компонента PDFium для Delphi, C++Builder и Lazarus/FPC, наряду с остальной поверхностью чтения, рендеринга и аннотирования PDF, описанной в других статьях этого блога