Une liaison Pascal sur une bibliothèque C se lit comme du Pascal ordinaire. Vous appelez une méthode, vous obtenez un enregistrement en retour, vous libérez ce que vous avez alloué. Le problème est que PDFium est une bibliothèque C et C++ avec sa propre convention d'appel, ses propres largeurs d'entiers et ses propres règles concernant l'attribution et la libération de la mémoire. Rien de tout cela ne franchit la frontière linguistique par magie. Chacun de ces contrats doit être réécrit manuellement dans les déclarations Pascal, et un seul mot erroné peut transformer un appel d'apparence propre en une corruption de pile, un offset tronqué ou une double libération. Un audit de la version v1.61.0 d'une liaison PDFium VCL a révélé un défaut de chaque type. Il est instructif de les passer en revue car ils ne sont pas spécifiques à cette liaison. Ce sont les risques permanents liés à l'enveloppement de toute API C en Delphi ou Lazarus.
cdecl fait partie du type de fonction, ce n'est pas une décoration
PDFium est du C compilé. Sur Win32, ses exportations et, plus important encore, les rappels (callbacks) qu'il appelle utilisent la convention d'appel cdecl. Avec cdecl, l'appelant nettoie la pile après le retour de l'appel. Le comportement par défaut de Delphi est register, tandis que le standard C Win32 pour les rappels est stdcall dans certaines bibliothèques, où c'est la fonction appelée qui effectue le nettoyage. Lorsqu'une structure transmet un pointeur de fonction à PDFium et que vous oubliez la mention cdecl sur le type de ce pointeur, les deux parties ne s'accordent pas sur qui doit ajuster le pointeur de pile. Soit les deux le font, soit aucun ne le fait, et le pointeur de pile dérive de la taille des arguments à chaque appel.
La raison pour laquelle ce défaut est difficile à identifier est que les dégâts ne se produisent pas localement. L'appel corrompu s'exécute et semble correct. Le désalignement apparaît plus tard, dans une autre fonction sans rapport dont le cadre repose désormais sur un pointeur de pile décalé de quelques octets. Cela se manifeste par une lecture erronée, une mauvaise adresse de retour ou un plantage avec une trace d'appels qui ne pointe nulle part près du rappel concerné. Le remplissage de formulaires (Form-fill) est le domaine classique où ce bug frappe, car l'interface de remplissage est un enregistrement rempli de rappels que PDFium appelle à son tour. L'un d'eux, FFI_OpenFile, transmet à PDFium une fonction qu'il appellera pour ouvrir un fichier externe, déclarée sous la forme function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. La mention cdecl finale est l'élément crucial. Omettez-la et le code compilera, se liera et s'exécutera parfaitement, jusqu'à ce que PDFium appelle la fonction. La convention appartient au type de la fonction lui-même. Ce n'est pas une option esthétique, et le compilateur ne signalera pas son absence car un type de fonction simple est parfaitement valide en Pascal. La seule défense est de traiter la convention d'appel comme un champ obligatoire pour chaque signature importée et chaque rappel transmis.
size_t correspond à la largeur d'un pointeur, soit 64 bits sur FPC Win64
Le second défaut est une incompatibilité de largeur d'entier qui n'apparaît que sur une seule cible. En C, size_t est défini pour être assez large pour contenir la taille de n'importe quel objet, ce qui se traduit par un entier non signé de 64 bits sur une plateforme 64 bits. Les interfaces de chargement progressif de PDFium communiquent via des offsets d'octets de type size_t. L'enregistrement du fournisseur de disponibilité FX_FILEAVAIL comporte un rappel IsDataAvail que PDFium appelle avec un offset et une taille, et le rappel AddSegment de l'enregistrement FX_DOWNLOADHINTS reçoit les mêmes informations. Les deux paramètres sont de type size_t.
IsDataAvail = function(
pThis : PFX_FILEAVAIL;
offset, size: size_t): FPDF_BOOL; cdecl;
AddSegment = procedure(
pThis : PFX_DOWNLOADHINTS;
offset, size: size_t); cdecl;
Si vous déclarez ces offsets dans un type 32 bits, la liaison fonctionnera sur Win32 et Delphi Win64, puis échouera silencieusement sur FPC et Lazarus Win64. La cause en est subtile. Sur FPC Win64, NativeUInt est un véritable type de 64 bits correspondant à la largeur de pointeur, et size_t lui est associé. La liaison contient un commentaire dans la section des types mettant précisément en garde contre le masquage de NativeUInt sur FPC, car le redéfinir en un alias 32 bits forcerait size_t sur 32 bits et corromprait chaque paramètre size_t transmis à la bibliothèque ou écrit par celle-ci. Un offset 64 bits arrivant dans un paramètre 32 bits perd sa moitié supérieure. Pour un petit fichier, chaque offset tient dans 32 bits et rien ne dysfonctionne. Pour un gros fichier, dès qu'un offset franchit la limite des quatre gigaoctets, la valeur tronquée pointe vers un tout autre endroit. PDFium demande alors si la mauvaise plage d'octets est disponible, et le chargement progressif se fige ou lit des données corrompues. Ce défaut reste invisible tant que le fichier n'est pas assez volumineux et que la cible n'est pas celle où size_t s'est effectivement élargi.
Une exception Pascal ne doit jamais remonter à travers un cadre C
La troisième catégorie concerne le modèle d'exception, que le C ne possède pas. Lorsque PDFium appelle l'un de vos rappels, votre code Pascal s'exécute au sein d'une pile de cadres C et C++ qui ignorent tout du mécanisme d'exceptions de Delphi. Si votre rappel lève une exception et la laisse se propager, elle va remonter à travers des cadres qui n'ont jamais été conçus pour cela. Les nettoyages internes de PDFium ne s'exécutent pas, ses invariants internes restent partiellement mis à jour, et le processus se retrouve dans un état que la bibliothèque n'a jamais prévu. Le contrat de ces rappels est un code de retour, pas une exception.
Deux rappels illustrent cette situation. FPDF_FILEWRITE est le réceptacle dans lequel PDFium écrit un document enregistré, et FPDF_FILEACCESS est la source à partir de laquelle il lit un document d'entrée. Tous deux sont implémentés ici sur un TStream Delphi, et tous deux peuvent échouer comme n'importe quel flux : disque saturé, flux fermé inopinément, lecture au-delà de la fin. Le rappel d'écriture enveloppe son écriture de flux et convertit tout échec en un code d'erreur PDFium au lieu de le laisser s'échapper.
function WriteBlock(
pThis: PFPDF_FILEWRITE;
pData: Pointer;
Size : LongWord): Integer; cdecl;
begin
// PDFium treats any non-1 return as a write failure. A Pascal exception
// must not unwind through this cdecl/C++ frame, so trap it and report
// failure instead.
Result := 0;
try
PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
Result := 1;
except
end;
end;
La partie lecture fait de même : une lecture en échec renvoie zéro pour respecter le contrat de FPDF_FILEACCESS au lieu de lever une exception à travers la frontière. Un bloc except vide sans nouvelle levée semble incorrect pour un développeur Pascal habitué à ne pas masquer les exceptions, et en Pascal classique, c'est effectivement une mauvaise pratique. Cependant, au niveau d'une frontière ABI, c'est la structure requise, car la seule valeur sûre à retourner à l'appelant C est un code de statut qu'il sait interpréter. L'échec se propage tout de même, mais via la valeur de retour, et le code d'appel en amont de la bibliothèque le signale sous forme de EPdfError une fois le contrôle revenu du côté Pascal.
Une double libération se cache sur le chemin d'erreur
Le quatrième défaut concerne la propriété des ressources. Un descripteur de document PDFium est ouvert par la bibliothèque et doit être fermé exactement une fois, via FPDF_CloseDocument. Le risque est qu'un chemin d'erreur libère un descripteur qu'un second nettoyage possède également. Imaginez une routine qui crée un objet wrapper, lui assigne un descripteur de document fraîchement ouvert, puis effectue d'autres configurations susceptibles d'échouer. Si cette configuration lève une exception, un gestionnaire de retour anticipé appelant FPDF_CloseDocument sur le descripteur brut va le fermer, puis le destructeur propre de l'objet wrapper le fermera à nouveau lors de sa libération. Le descripteur est alors libéré deux fois, ce qui entraîne un comportement indéfini et probablement un plantage.
L'audit a identifié ce problème sur un chemin d'importation d'imposition qui construit un TPdf autour d'un descripteur déjà ouvert. La correction consiste à faire du transfert de propriété la source unique de vérité. Une fois le descripteur affecté au champ du wrapper, le wrapper en devient propriétaire, et le seul nettoyage requis sur le chemin d'erreur est de libérer le wrapper. Le destructeur du wrapper appelle FPDF_CloseDocument pour vous, une seconde fermeture explicite entraînerait donc une double libération de ce document. Le gestionnaire d'erreurs corrigé libère l'objet et relève l'exception, assurant ainsi un chemin unique vers la fermeture.
Result := TPdf.Create(nil);
try
Result.FDocument := NewDoc; // Result now owns the handle
Result.InitializeFormFill;
Result.ReloadPage;
except
// Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
// here would double-free the same PDFium document.
Result.Free;
raise;
end;
Les enregistrements gérés et une bibliothèque pleine d'exportations nécessitent une libération explicite
La dernière catégorie concerne la mémoire gérée par le compilateur pour vous, qu'une habitude du C peut silencieusement corrompre. De nombreuses fonctions d'assistance de cette liaison renvoient un enregistrement (record) contenant un WideString ou un tableau dynamique. Il s'agit de champs à comptage de références, et le compilateur génère une comptabilité cachée pour maintenir leurs compteurs. Le réflexe hérité du C consiste à effacer un nouvel enregistrement avec FillChar(Result, SizeOf(Result), 0). Cela écrit des zéros sur la référence gérée au sein de l'enregistrement sans décrémenter préalablement son compteur. Le compilateur réutilise une variable temporaire masquée pour le résultat de la fonction à travers les itérations de boucle, de sorte qu'à la deuxième itération, FillChar écrase un pointeur de chaîne actif qui n'a jamais été libéré, provoquant une fuite de la chaîne pointée. Appelez cette fonction dans une boucle sur mille annotations et vous fuirez mille chaînes.
La correction consiste à laisser le langage effacer l'enregistrement comme il sait le faire, avec Default(T), ce qui libère tout champ géré avant de le mettre à zéro.
// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);
Un problème de propriété similaire se situe à la frontière du chargement de la bibliothèque. Cette liaison résout plusieurs centaines de pointeurs de fonctions de la DLL PDFium avec GetProcAddress après un LoadLibrary. Si une exportation requise est manquante, l'état partiellement lié est dangereux : des dizaines de pointeurs sont valides, le reste est à nil ou obsolète, et tout appel ultérieur via l'un d'eux saute dans un module qui peut déjà être déchargé. La liaison gère cela en déchargeant la bibliothèque et en exécutant un ClearAllBindings complet qui réinitialise chaque pointeur importé à nil chaque fois qu'une exportation requise ne peut pas être résolue. Après cela, aucun pointeur de fonction ne pointe vers un module déchargé, et un appel ultérieur échoue proprement avec une vérification de pointeur nul au lieu de brancher sur du code libéré.
Le wrapper est l'endroit où quatre contrats sont réécrits manuellement
Aucun de ces cinq défauts n'est exotique. Ce sont les modes de défaillance prévisibles d'une fine couche Pascal sur une API C, et ils se regroupent parce que cette couche est précisément l'endroit où quatre contrats distincts doivent être déclarés de nouveau. La convention d'appel doit être spécifiée comme cdecl sur chaque rappel. La largeur d'entier doit correspondre à size_t sur la cible où il s'élargit. Le modèle d'exception doit être converti en codes de retour à chaque rappel qui sort de Pascal. La propriété de chaque descripteur et de chaque champ géré doit être établie une fois et respectée sur tous les chemins, y compris les chemins d'erreur que personne ne teste avant la production. Si vous en manquez un, vous obtenez un défaut dont le symptôme se manifeste loin de sa cause, ce qui rend cette catégorie coûteuse. L'intérêt de l'audit résidait moins dans chaque correction individuelle que dans le fait de traiter chacun de ces points comme une discipline à vérifier sur l'ensemble de la liaison.
Si vous souhaitez voir la liaison effectuer un travail concret plutôt que de surveiller ses limites, les techniques de cache de rendu et de zoom présentées dans notre note sur les performances du cache de rendu et du zoom décrivent le chemin de rendu, et le guide de compilation croisée pour construire un lecteur Lazarus et FPC est l'endroit où le comportement de size_t sur Win64 décrit ici intervient réellement. Tous deux s'appuient sur le même travail de sécurité mémoire et d'ABI qui est fourni dans le composant PDFium pour Delphi, Lazarus et C++Builder, aux côtés des API de rendu, d'extraction de texte et de formulaires présentées ailleurs sur ce blog.