技術記事

関数結果へのFillCharがObject Pascalで文字列をリークさせる

レコードを返すDelphiまたはFPCの関数は、呼び出しのたびに新しくゼロ埋めされたResultを得るわけではない。その隠れたResult変数は正確に一度だけゼロで始まり、呼び出しの間にそれを自動的に再びゼロにするものは何もない。そのためそれをクリアすることはその関数自身の仕事である。そのクリアをFillChar(Result, SizeOf(Result), 0)で行うと、2回目の呼び出し以降、そのルーチンは生きている文字列や動的配列の参照を解放するのではなく上書きしてしまい、その参照が指していたヒープブロックを孤立させてしまう

これが噛みつくシナリオはありふれている。バッチ処理がサードパーティ製PDFの山を開き、すべてのページのすべての注釈を歩き、コメントテキストを監査ログに引き出す。そのループには何も危険なところはないように見える:すべての呼び出しは単純なレコードを返す単純な関数であり、ポインタなど見当たらず、手動のメモリ管理に似たものは何もない。レコード内部の参照カウントは、特定のライブラリに固有の癖ではなく単純なObject Pascalの会計ルールであり、文字列や動的配列を保持するレコード型とFillCharを混在させるどんなDelphiまたはFPCのコードベースも同じ欠陥にさらされる

なぜレコード結果へのFillCharは文字列をリークさせるのか

FillCharが文字列をリークさせるのは、それが上書きしているデータの種類を全く知らないからだ。FillChar(X, Count, Value)はどんな変数に対しても機能する:それはCountバイトの型無しブロックを取り、そのすべてにValueを刻印する、それがその契約のすべてである。これがまさにFillCharを高速かつ汎用的にしている理由だ、なぜならそれは決してXの型を検査せず、基礎となるバイトが何を意味するかによって決して分岐しないからだ。レコード内部のUnicodeStringWideStringのフィールドは、文字自体ではない;それは文字データの前に参照カウントを運ぶヒープブロックへのポインタである。FillCharはたまたまポインタ値を保持しているひとにぎりのバイトを見て、それをIntegerDoubleのフィールドを上書きするのと全く同じようにゼロで上書きする。そのポインタは消え、それが最初に減算すべきだった参照カウントは決して触れられず、それが指していたブロックは、それを参照するものが何も残らないまま割り当てられたまま座り続ける

コンパイラはレコード内部の文字列と動的配列をどう追跡するか

Object Pascalは、コンパイラが代入とスコープ終了にわたって正しさを保つために追加のコードを実行しなければならない型を「管理された(managed)」と呼ぶ。AnsiStringUnicodeStringWideStringのような長い文字列型がそれに該当し、動的配列、インターフェース、Variantもそうであり、それらのいずれかをフィールドとして含むどんなレコードや固定配列もそうである。管理されたフィールドごとに、コンパイラは静かに、そうでなければ面倒で手作業では間違えやすい記帳作業を発行する:代入時に参照カウントを増やし、保持する変数が上書きされるかスコープを外れたときにそれを減らし、そのカウントがゼロに達したら基礎となるブロックを解放する。この仕組みこそが、通常のPascalコードがstringを手動で確保・解放することが決してない理由であり、ある動的配列を別のものに代入することが手動のコピーループではなく安価で安全な操作である理由である。System.DefaultFinalizeは、必要に応じてその同じ解放ロジックを呼び出す2つの文書化された方法であり、レコードのクリアコードは、生のメモリ埋めの代わりにそれらを呼ぶべきである

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;

なぜリークは2回目の呼び出しからしか始まらないのか

ループ内の最初の呼び出しは常に無害であり、これがまさにこの欠陥をテストで見逃しやすくしている。管理されたレコード型のローカル変数はゼロで始まり、1回のループパスから次へと自動的にそれを再びゼロにするものは何もない。そのため、あるループが関数の戻り値をその変数に代入する最初の時点では、そのDescriptionContentsTextフィールドはまだnilである。FillCharはnilをゼロで上書きし、参照カウントに関する限り何も変わらず、その呼び出しは完全に正しく見えるまま戻る。2回目の呼び出しは異なる:同じローカル変数はすでに最初の呼び出しがそこに書き込んだものを保持しており、新しい呼び出しのResultは、新鮮で空のメモリではなくその同じストレージに直接書き込まれる。その2回目の呼び出しの先頭にあるFillCharは、もはやnilではないフィールドをゼロにし、そのバイトパターンの下流にあるすべては、それ以降静かに間違ったものになる。関数を一度だけ呼んで結果を検査するテストは、決してこの問題を見ることがない;同じ宛先に対してその関数を繰り返し呼ぶループ、あるいはどんなコードパスだけがそれを露呈させる

本物のリーク:注釈、しおり、リンクレコード

PDFiumPasは、バージョン1.56.4より前に、それぞれ少なくとも1つの管理されたフィールドを保持するレコードを返す3つの関数の中でまさにこの欠陥を出荷していた:ページレベルの注釈リーダーはContentsTextAuthorTextの文字列を運ぶTPdfAnnotationを返し、しおりリーダーはTitle文字列を運ぶTBookmarkを返し、リンク注釈リーダーはActionPath文字列とPoints動的配列を運ぶTLinkAnnotationを返す。この3つすべてが以下に示すのと同じ形で開始していた:生のFillCharResultをクリアし、その後基礎となるページデータから一度に1フィールドずつ埋める。監査リストやレビューパネルを構築する通常の方法である、あるページのすべての注釈を1つずつ歩くことは、注釈リーダーをループの中で呼び、最初の1回を過ぎたすべてのパスで前の注釈のテキストをリークさせていた;テキストを持つ注釈が異常に多く仕込まれたPDFは、そのプロセスが実行され続ける限り、長時間実行されるプロセスのメモリを成長させることができた。この修正は各関数の1行に触れた: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ではなく、1呼び出し下にあるvar引数だからだ。SetBookmarkDataはその出力をvar Data: TBookmarkとして受け取り、以前はその本体の先頭でFillCharを使ってDataをクリアしていた;実際にTBookmarkを返す公開関数であるGetBookmarkSetBookmarkDataを呼び、自身のResultをそのままvar引数として渡す。var引数は参照渡しされるため、SetBookmarkData内部のDataGetBookmark内部のResultは2つの名前を持つ同じストレージであり、関数自身のResultに適用されるどんなエイリアシングのリスクも、それを参照で受け取るどんなヘルパールーチンにも全く同じように直接適用される。文字通りレコードの戻り値の型を宣言している関数だけをレビューすることは、この形を見逃してしまう;その検索は、Resultが転送されるあらゆるvarout引数も追わなければならない

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は4つのDoubleフィールドだけを保持し他には何もなく、それをFillCharでクリアしても何も解放されない、なぜなら解放すべき参照カウントされたものが何もないからだ。この2つのケースを分けるチェックは述べるのがシンプルである:そのレコードのどんなフィールドも、どんなネストの深さであっても、stringAnsiStringWideString、動的配列、インターフェース、あるいはVariant型を持っているか?あるレコードは、そのフィールドの1つがそれ自体、数層下に文字列を埋め込んでいるレコードであれば、トップレベルでは完全に数値的に見えてもこのテストに失敗しうる。そのためこのチェックは、最も外側のフィールドリストで止まるのではなく、ネストされたレコードを最後まで追わなければならない。既存のコードベースをこのパターンについて監査することは、網羅的というより機械的である:ターゲットがレコード変数であるすべてのFillChar呼び出しを検索し、そのレコードのフィールドリストを上記の管理された型のリストと照合する。PDFiumPas自身のv1.56.4の監査は、まさにその検索をライブラリ全体にわたって実行し、この露出を1つのユニットで見つけた;他のすべてのFillChar呼び出しサイトはすでに単純な数値レコードをクリアしており、そこではFillCharが正しいツールだったし、今もそうである

ここで再利用されるResultを危険にするのと同じコンパイラの挙動は、このコードベースの他所にある関連するDelphi対FPCの不一致の一群も引き起こしている;クロスコンパイラの落とし穴に関する関連記事は、FPCとDelphiが、単一の式の内部でレコード結果の一時オブジェクトが正確にいつファイナライズされるかについて食い違うケースを扱っている。これは、関数のレコードResultが常に見た目通りの新鮮な専用ストレージであるとは限らないという同じ根本的な事実の別の症状である。この記事全体を通して進行例として使われている注釈ループも仮想のものではない:それはまさに注釈レビューパネルの構築の際に書くであろうページごとの歩みであり、それこそがまさに、たった1行のFillCharを遅いメモリリークに変えたコードの形である

これらのいずれも、ライブラリを切り替えたり誰かのコンパイル済みコードのバグを追いかけたりする必要はない:これはObject Pascal言語自体の性質であり、あらゆるDelphiとFPCの開発者が日常的に扱うものであり、それを探すべきだと知っていれば修正は1回の関数呼び出しである。ここで説明した注釈、しおり、リンク注釈のAPIは、このブログの他所で扱われているPDFの読み取り・レンダリング・注釈の面の残りとともに、Delphi、C++Builder、Lazarus/FPC向けPDFiumコンポーネントの一部として出荷される