Artigo Técnico

O FillChar num Resultado de Função Provoca Fugas de Cadeias de Texto em Object Pascal

Uma função em Delphi ou FPC que devolve um registo não recebe um Result novo e zerado a cada chamada. Essa variável Result oculta começa a zero exatamente uma vez, e nada a volta a zerar automaticamente entre chamadas, pelo que limpá-la à entrada é tarefa da própria função. Fazer essa limpeza com FillChar(Result, SizeOf(Result), 0) significa que, a partir da segunda chamada, a rotina sobrescreve uma referência a uma cadeia de texto ou a um array dinâmico ainda viva, em vez de a libertar, deixando órfão o bloco de heap para onde essa referência apontava

O cenário em que isto morde é banal. Um processo em lote abre uma pilha de PDFs de terceiros e percorre cada anotação em cada página, extraindo o texto do comentário para um registo de auditoria. Nada nesse ciclo parece perigoso: cada chamada é uma simples função que devolve um simples registo, sem ponteiros à vista, nada que se pareça sequer com gestão manual de memória. A contagem de referências dentro de um registo é uma regra de contabilidade normal do Object Pascal, não uma particularidade específica de nenhuma biblioteca, e qualquer base de código em Delphi ou FPC que misture FillChar com tipos de registo que contenham cadeias de texto ou arrays dinâmicos está exposta ao mesmo defeito

Porque é que o FillChar num resultado de registo provoca fugas de cadeias de texto?

O FillChar provoca fugas de cadeias de texto porque não tem qualquer noção do tipo de dados que está a sobrescrever. O FillChar(X, Count, Value) funciona sobre qualquer variável: recebe um bloco não tipificado de Count bytes e carimba cada um deles com Value, e esse é o contrato inteiro. É exatamente isto que torna o FillChar rápido e de uso genérico, porque nunca inspeciona o tipo de X e nunca ramifica com base no significado dos bytes subjacentes. Um campo UnicodeString ou WideString dentro de um registo não são os próprios caracteres; é um ponteiro para um bloco de heap que transporta uma contagem de referências antes dos dados de carácter. O FillChar vê um punhado de bytes que calham conter um valor de ponteiro e sobrescreve-os com zero exatamente como sobrescreveria um campo Integer ou Double. O ponteiro desaparece, a contagem de referências que deveria ter sido decrementada primeiro nunca é tocada, e o bloco para onde apontava fica alocado sem que nada o continue a referenciar

Como o compilador acompanha cadeias de texto e arrays dinâmicos dentro de um registo

O Object Pascal chama gerido a um tipo quando o compilador tem de correr código extra para o manter correto ao longo de atribuições e saídas de âmbito. Os tipos de cadeia longa como AnsiString, UnicodeString, e WideString qualificam-se, e o mesmo se aplica a arrays dinâmicos, interfaces, e Variants, juntamente com qualquer registo ou array de tamanho fixo que contenha um destes como campo. Para cada campo gerido, o compilador emite discretamente a contabilidade que, de outra forma, seria tediosa e fácil de fazer mal à mão: incrementar uma contagem de referências na atribuição, decrementá-la quando a variável que a detém é sobrescrita ou sai de âmbito, e libertar o bloco subjacente assim que essa contagem chega a zero. É essa maquinaria que faz com que código Pascal comum nunca aloque ou liberte manualmente uma string, e que atribuir um array dinâmico a outro seja uma operação barata e segura, em vez de um ciclo de cópia manual. O System.Default e o Finalize são as duas formas documentadas de invocar essa mesma lógica de libertação a pedido, e são o que o código de limpeza de um registo deveria chamar em vez de um preenchimento de memória em bruto

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;

Porque é que a fuga só começa na segunda chamada?

A primeira chamada num ciclo é sempre inofensiva, o que é exatamente o que torna este defeito fácil de passar despercebido em testes. Uma variável local de um tipo de registo gerido começa a zero, e nada a volta a zerar automaticamente entre uma passagem do ciclo e a seguinte, pelo que, na primeira vez que um ciclo atribui o valor de retorno de uma função a essa variável, o seu campo Description ou ContentsText ainda é nil. O FillChar sobrescreve nil com zero, o que nada altera no que toca à contagem de referências, e a chamada regressa parecendo inteiramente correta. A segunda chamada é diferente: a mesma variável local já detém o que a primeira chamada lá escreveu, e o Result da nova chamada é escrito diretamente nesse mesmo espaço de memória, em vez de memória nova e vazia. O FillChar no topo dessa segunda chamada zera um campo que já não é nil, e tudo o que depende desse padrão de bytes fica silenciosamente errado a partir daí. Um teste que chame a função uma única vez e inspecione o resultado nunca verá o problema; só um ciclo, ou qualquer caminho de código que chame a função repetidamente contra o mesmo destino, o expõe

Uma fuga real: anotações, marcadores, e registos de ligação

O PDFiumPas distribuiu exatamente este defeito antes da versão 1.56.4, em três funções que devolvem cada uma um registo com pelo menos um campo gerido: o leitor de anotações ao nível da página devolve um TPdfAnnotation que transporta as cadeias de texto ContentsText e AuthorText, o leitor de marcadores devolve um TBookmark que transporta uma cadeia de texto Title, e o leitor de anotações de ligação devolve um TLinkAnnotation que transporta uma cadeia de texto ActionPath e um array dinâmico Points. As três abriam com a mesma forma mostrada abaixo: limpar Result com um FillChar em bruto, e depois preencher os campos um a um a partir dos dados subjacentes da página. Percorrer cada anotação de uma página, uma de cada vez, a forma normal de construir uma lista de auditoria ou um painel de revisão, chamava o leitor de anotações num ciclo e provocava a fuga do texto da anotação anterior em cada passagem depois da primeira; um PDF construído com um número invulgarmente elevado de anotações com texto podia fazer crescer a memória de um processo de longa duração enquanto esse processo continuasse a correr. A correção tocou numa linha em cada função: substituir FillChar(Result, SizeOf(Result), 0) por Result := Default(TPdfAnnotation) foi suficiente, porque atribuir Default a um registo gerido corre a sequência normal de libertação-e-depois-limpeza do compilador, em vez de um preenchimento de memória em bruto

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;

O mesmo risco por trás de um parâmetro var

O leitor de marcadores mostra uma versão mais subtil do mesmo problema, porque o registo que é limpo com FillChar não é o próprio Result da função, mas um parâmetro var uma chamada abaixo. O SetBookmarkData recebe a sua saída como var Data: TBookmark e costumava limpar Data no topo do seu corpo com FillChar; o GetBookmark, a função pública que efetivamente devolve um TBookmark, chama SetBookmarkData e passa o seu próprio Result diretamente como esse argumento var. Um parâmetro var é passado por referência, pelo que Data dentro de SetBookmarkData e Result dentro de GetBookmark são o mesmo espaço de memória sob dois nomes, e qualquer risco de aliasing que se aplique ao próprio Result de uma função aplica-se de forma igualmente direta a qualquer rotina auxiliar que o receba por referência. Rever apenas as funções que declaram literalmente um tipo de retorno de registo não apanha esta forma; a pesquisa tem de seguir também cada parâmetro var e out para onde um Result seja reencaminhado

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;

Quando é que o FillChar continua a ser a escolha certa?

O FillChar continua a ser correto, e muitas vezes ligeiramente mais barato, para um registo construído inteiramente a partir de ordinais, campos de vírgula flutuante, arrays de tamanho fixo desses, ou outros registos simples feitos do mesmo, porque não há nada nele para o compilador finalizar. O próprio tipo de retângulo do PDFiumPas é exatamente esse caso: o TPdfRectangle contém quatro campos Double e nada mais, e limpar um com FillChar não liberta nada porque não há nada com contagem de referências para libertar. A verificação que separa os dois casos é simples de enunciar: algum campo do registo, a qualquer profundidade de aninhamento, tem o tipo string, AnsiString, WideString, um array dinâmico, uma interface, ou um Variant? Um registo pode parecer perfeitamente numérico ao nível de topo e ainda assim falhar esse teste se um dos seus campos for, ele próprio, um registo que enterra uma cadeia de texto algumas camadas abaixo, pelo que a verificação tem de seguir os registos aninhados até ao fim, em vez de parar na lista de campos mais externa. Auditar uma base de código existente à procura deste padrão é mecânico, não exaustivo: procurar cada chamada a FillChar cujo alvo seja uma variável de registo, e depois verificar a lista de campos desse registo contra a lista de tipos geridos acima. A própria auditoria da v1.56.4 do PDFiumPas correu exatamente essa pesquisa por toda a biblioteca e encontrou esta exposição numa única unidade; todos os outros pontos de chamada a FillChar já limpavam um registo puramente numérico, onde o FillChar era, e continua a ser, a ferramenta certa

O mesmo comportamento do compilador que torna aqui perigoso um Result reutilizado também está por trás de uma família relacionada de divergências entre Delphi e FPC noutros pontos desta base de código; um artigo relacionado sobre armadilhas entre compiladores aborda um caso em que o FPC e o Delphi discordam sobre exatamente quando um temporário de resultado de registo é finalizado dentro de uma única expressão, um sintoma diferente do mesmo facto subjacente de que o Result de registo de uma função nem sempre é o espaço de memória novo e privado que aparenta ser. O ciclo de anotações usado como exemplo ao longo deste artigo também não é hipotético: é a mesma travessia página a página que se escreveria ao construir um painel de revisão de anotações, exatamente a forma de código que transformou um FillChar de uma linha numa fuga de memória lenta em primeiro lugar

Nada disto exige mudar de biblioteca ou perseguir um erro no código compilado de outra pessoa: é uma propriedade da própria linguagem Object Pascal, com que todo o programador Delphi e FPC trabalha diariamente, e a correção é uma única chamada de função assim que se sabe o que procurar. As APIs de anotações, marcadores, e anotações de ligação aqui descritas fazem parte do PDFium Component para Delphi, C++Builder, e Lazarus/FPC, a par do restante conjunto de leitura, renderização, e anotação de PDF abordado noutros artigos deste blog