Powiązanie Pascala nad biblioteką C czyta się jak zwykły Pascal. Wywołujesz metodę, dostajesz z powrotem rekord, zwalniasz to, co zaalokowałeś. Kłopot w tym, że PDFium to biblioteka C i C++ z własną konwencją wywołań, własnymi szerokościami liczb całkowitych i własnymi regułami co do tego, kto jest właścicielem pamięci i kto ją zwalnia. Nic z tego nie przekracza granicy języków samodzielnie. Każdy z tych kontraktów trzeba przeformułować ręcznie w deklaracjach Pascala, a jedno błędne słowo zamienia czysto wyglądające wywołanie w uszkodzenie stosu, skrócony offset lub podwójne zwolnienie. Audyt powiązania komponentu PDFium z wersji v1.61.0 ujawnił po jednym błędzie każdego rodzaju. Warto je przeanalizować, bo nie są one specyficzne dla tego powiązania. To stałe zagrożenia opakowywania dowolnego API C w Delphi lub Lazarusie
cdecl jest częścią typu funkcji, a nie ozdobą
PDFium to skompilowany C. Na Win32 jego eksporty, a co ważniejsze, wywołania zwrotne, które wywołuje, używają konwencji wywołań cdecl. W konwencji cdecl to wywołujący sprząta stos po powrocie z wywołania. Natywnym domyślnym ustawieniem Delphi jest register, a standardem C dla wywołań zwrotnych na Win32 jest w niektórych bibliotekach stdcall, gdzie zamiast tego sprząta wywoływany. Gdy struktura przekazuje PDFium wskaźnik do funkcji, a ty zapomnisz o cdecl w typie tego wskaźnika, obie strony nie zgadzają się co do tego, kto dostosowuje wskaźnik stosu. Obie go poprawiają albo żadna tego nie robi, a wskaźnik stosu dryfuje o rozmiar argumentów przy każdym wywołaniu
Powodem, dla którego ten błąd jest trudny do znalezienia, jest to, że szkoda jest nielokalna. Uszkodzone wywołanie wraca i wygląda dobrze. Niewspółosiowość ujawnia się później, w jakiejś niezwiązanej funkcji, której ramka leży teraz na wskaźniku stosu przesuniętym o kilka bajtów, i objawia się jako dziki odczyt, zły adres powrotu lub awaria ze śladem stosu, który nie wskazuje nigdzie blisko wywołania zwrotnego, które faktycznie pomyliłeś. Wypełnianie formularzy to klasyczne miejsce, gdzie to gryzie, bo interfejs wypełniania formularzy to rekord pełen wywołań zwrotnych, które PDFium wywołuje z powrotem. Jedno z nich, FFI_OpenFile, przekazuje PDFium funkcję, którą wywoła, aby otworzyć zewnętrzny plik, zadeklarowaną jako function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Końcowe cdecl to punkt warty skopiowania. Pomiń je, a kod nadal się kompiluje, nadal linkuje i nadal działa aż do chwili, gdy PDFium wywoła tę funkcję. Konwencja należy do samego typu funkcji. Nie jest opcjonalnym cukrem składniowym, a kompilator nie ostrzeże cię, gdy jej brakuje, bo zwykły typ funkcji jest całkowicie legalnym typem Pascala. Jedyną obroną jest traktowanie konwencji wywołań jako obowiązkowego pola każdej importowanej sygnatury i każdego wywołania zwrotnego, które przekazujesz na zewnątrz
size_t ma szerokość wskaźnika, a na FPC Win64 oznacza to 64 bity
Drugim błędem jest niezgodność szerokości liczby całkowitej, która pojawia się tylko na jednym celu. C-owy size_t jest zdefiniowany jako wystarczająco szeroki, by pomieścić dowolny rozmiar obiektu, co na platformie 64-bitowej oznacza 64-bitową liczbę całkowitą bez znaku. Interfejsy ładowania progresywnego PDFium mówią offsetami bajtowymi size_t. Rekord FX_FILEAVAIL dostawcy dostępności niesie wywołanie zwrotne IsDataAvail, które PDFium wywołuje z offsetem i rozmiarem, a wywołanie zwrotne AddSegment rekordu FX_DOWNLOADHINTS otrzymuje to samo. Oba parametry są typu size_t
IsDataAvail = function(
pThis : PFX_FILEAVAIL;
offset, size: size_t): FPDF_BOOL; cdecl;
AddSegment = procedure(
pThis : PFX_DOWNLOADHINTS;
offset, size: size_t); cdecl;
Jeśli zadeklarujesz te offsety jako typ 32-bitowy, powiązanie działa na Win32 i na Delphi Win64, a potem cicho psuje się na FPC i Lazarusie Win64. Przyczyna jest subtelna. Na FPC Win64 NativeUInt jest prawdziwym 64-bitowym typem o szerokości wskaźnika, a size_t jest do niego aliasowany. Powiązanie ma w sekcji typów komentarz ostrzegający dokładnie przed przesłanianiem NativeUInt na FPC, bo przedefiniowanie go tam na alias 32-bitowy zmusiłoby size_t do 32 bitów i uszkodziłoby każdy parametr size_t przekazywany do biblioteki lub przez nią zapisywany. 64-bitowy offset docierający do parametru 32-bitowego traci swoją górną połowę. Dla małego pliku każdy offset mieści się w 32 bitach i nic nie jest nie tak. Dla dużego pliku, w chwili gdy offset przekracza linię czterech gigabajtów, skrócona wartość wskazuje gdzieś zupełnie indziej, PDFium pyta, czy dostępny jest niewłaściwy zakres bajtów, a ładowanie progresywne zatrzymuje się lub odczytuje śmieci. Błąd jest niewidoczny, dopóki plik nie jest wystarczająco duży, a celem jest ten, na którym size_t faktycznie się poszerzył
Wyjątek Pascala nigdy nie może rozwijać się przez ramkę C
Trzecia klasa dotyczy modelu wyjątków, którego C nie ma. Gdy PDFium wywołuje jedno z twoich wywołań zwrotnych, twój kod Pascala działa wewnątrz stosu ramek C i C++, które nic nie wiedzą o maszynerii wyjątków Delphi. Jeśli twoje wywołanie zwrotne zgłosi wyjątek i pozwoli mu się propagować, rozwija się on przez ramki, które nigdy nie były zbudowane do rozwijania. Własne sprzątanie PDFium się nie uruchamia, jego wewnętrzne niezmienniki pozostają w połowie zaktualizowane, a proces jest teraz w stanie, którego biblioteka nigdy nie przewidziała. Kontraktem dla tych wywołań zwrotnych jest kod powrotu, a nie wyjątek
Dwa wywołania zwrotne czynią to konkretnym. FPDF_FILEWRITE to ujście, do którego PDFium zapisuje zapisywany dokument, a FPDF_FILEACCESS to źródło, z którego odczytuje dokument wejściowy. Oba są tutaj zaimplementowane nad delphijskim TStream, i oba mogą zawieść tak, jak zawodzi każdy strumień: dysk się zapełnia, strumień jest zamykany pod tobą, odczyt wychodzi poza koniec. Wywołanie zwrotne zapisu opakowuje swój zapis do strumienia i zamienia każdą porażkę w kod porażki PDFium, zamiast pozwolić jej uciec
function WriteBlock(
pThis: PFPDF_FILEWRITE;
pData: Pointer;
Size : LongWord): Integer; cdecl;
begin
// PDFium traktuje każdy wynik różny od 1 jako porażkę zapisu. Wyjątek Pascala
// nie może rozwijać się przez tę ramkę cdecl/C++, więc przechwyć go i zgłoś
// porażkę zamiast tego.
Result := 0;
try
PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
Result := 1;
except
end;
end;
Strona odczytu robi to samo: nieudany odczyt zgłasza zero, by pasować do kontraktu FPDF_FILEACCESS, zamiast zgłaszać wyjątek przez granicę. Goły except bez ponownego zgłoszenia wygląda źle dla programisty Pascala wyszkolonego, by nigdy nie połykać wyjątków, i w zwykłym Pascalu jest to złe. Na granicy ABI jest to poprawny kształt, bo jedyną bezpieczną wartością do oddania wywołującemu z C jest kod stanu, który ten potrafi zinterpretować. Porażka nadal się propaguje, tyle że przez wartość powrotu, a kod wywołujący nad biblioteką wynosi ją na powierzchnię jako EPdfError, gdy tylko sterowanie wróci na pascalową stronę płotu
Podwójne zwolnienie ukrywa się na ścieżce błędu
Czwarty błąd dotyczy własności. Uchwyt dokumentu PDFium jest otwierany przez bibliotekę i musi być zamknięty dokładnie raz, przez FPDF_CloseDocument. Niebezpieczeństwem jest ścieżka błędu, która zwalnia uchwyt, którego właścicielem jest też inne sprzątanie. Wyobraź sobie procedurę, która tworzy obiekt opakowujący, przypisuje mu świeżo otwarty uchwyt dokumentu, a następnie wykonuje dalszą konfigurację, która może zawieść. Jeśli konfiguracja zgłosi wyjątek, obsługa wczesnego powrotu, która wywołuje FPDF_CloseDocument na surowym uchwycie, zamknie go, a potem destruktor samego obiektu opakowującego zamknie go ponownie, gdy obiekt zostanie zwolniony. Uchwyt jest zwalniany dwukrotnie, co jest zachowaniem niezdefiniowanym i prawdopodobną awarią
Audyt znalazł to na ścieżce importu w stylu impozycji, która buduje TPdf wokół już otwartego uchwytu. Naprawą jest uczynienie przeniesienia własności jedynym źródłem prawdy. Gdy uchwyt zostanie przypisany do pola opakowania, to opakowanie jest jego właścicielem, a jedynym sprzątaniem na ścieżce błędu jest zwolnienie opakowania. Destruktor opakowania wywołuje za ciebie FPDF_CloseDocument, więc drugie jawne zamknięcie podwójnie zwolniłoby ten sam dokument. Poprawiona obsługa błędu zwalnia obiekt i ponownie zgłasza wyjątek, a jest dokładnie jedna ścieżka do zamknięcia
Result := TPdf.Create(nil);
try
Result.FDocument := NewDoc; // Result jest teraz właścicielem uchwytu
Result.InitializeFormFill;
Result.ReloadPage;
except
// Result.Free zamyka uchwyt. Drugie FPDF_CloseDocument(NewDoc)
// tutaj podwójnie zwolniłoby ten sam dokument PDFium.
Result.Free;
raise;
end;
Zarządzane rekordy i biblioteka pełna eksportów — oba wymagają jawnego sprzątania
Ostatnia klasa dotyczy pamięci, którą kompilator zarządza w twoim imieniu, a którą nawyk z C cicho uszkodzi. Wiele funkcji pomocniczych tego powiązania zwraca rekord zawierający WideString lub tablicę dynamiczną. To pola zliczane przez odwołania, a kompilator emituje ukrytą księgowość, by utrzymywać ich liczniki. Instynktem przeniesionym z C jest wyczyszczenie świeżego rekordu przez FillChar(Result, SizeOf(Result), 0). To wbija zera na zarządzane odwołanie wewnątrz rekordu, nie zmniejszając go wpierw. Kompilator wielokrotnie używa jednej ukrytej tymczasowej zmiennej dla wyniku funkcji przez iteracje pętli, więc w drugiej iteracji FillChar nadpisuje żywy wskaźnik łańcucha, który nigdy nie został zwolniony, a łańcuch, na który wskazywał, wycieka. Wywołaj tę funkcję w pętli po tysiącu adnotacji, a wyciekniesz tysiąc łańcuchów
Naprawą jest pozwolić językowi wyczyścić rekord w sposób, który zna, za pomocą Default(T), który zwalnia każde zarządzane pole przed jego wyzerowaniem
// Default() zamiast FillChar: kompilator wielokrotnie używa jednej ukrytej
// tymczasowej dla wyniku funkcji przez iteracje pętli, więc FillChar wyzerowałby
// żywe wskaźniki WideString bez ich zwolnienia.
Result := Default(TPdfAnnotation);
Pokrewny problem własności żyje na granicy ładowania biblioteki. To powiązanie rozwiązuje kilkaset wskaźników do funkcji z DLL-a PDFium za pomocą GetProcAddress po LoadLibrary. Jeśli brakuje jednego wymaganego eksportu, częściowo powiązany stan jest niebezpieczny: dziesiątki wskaźników są ważne, reszta jest nil lub nieaktualna, a każde późniejsze wywołanie przez jeden z nich skacze do modułu, który może być już wyładowany. Powiązanie radzi sobie z tym, wyładowując bibliotekę i uruchamiając pełne ClearAllBindings, które resetuje każdy importowany wskaźnik z powrotem do nil za każdym razem, gdy wymagany eksport nie zostanie rozwiązany. Po tym żaden wskaźnik do funkcji nie zwisa do wyładowanego modułu, a późniejsze wywołanie zawodzi czysto przy sprawdzeniu wskaźnika nil, zamiast rozgałęziać się do zwolnionego kodu
Opakowanie to miejsce, gdzie cztery kontrakty są przeformułowywane ręcznie
Żaden z tych pięciu błędów nie jest egzotyczny. To przewidywalne tryby porażki cienkiej warstwy Pascala nad API C, a skupiają się one, bo ta warstwa jest dokładnie tam, gdzie cztery oddzielne kontrakty muszą zostać przedeklarowane. Konwencja wywołań musi być zapisana jako cdecl w każdym wywołaniu zwrotnym. Szerokość liczby całkowitej musi pasować do size_t na tym jednym celu, gdzie faktycznie się poszerza. Model wyjątków musi zostać przekonwertowany na kody powrotu w każdym wywołaniu zwrotnym, które przekracza wyjście z Pascala. Własność każdego uchwytu i każdego zarządzanego pola musi być określona raz i przestrzegana na każdej ścieżce, w tym na ścieżkach błędu, których nikt nie ćwiczy aż do produkcji. Pomiń którykolwiek z nich, a dostaniesz błąd, którego objaw pojawia się daleko od jego przyczyny, co czyni tę kategorię kosztowną. Wartość audytu leżała mniej w jakiejkolwiek pojedynczej naprawie, a bardziej w traktowaniu każdej z tych rzeczy jako własnej dyscypliny do sprawdzenia w całym powiązaniu
Jeśli chcesz zobaczyć powiązanie wykonujące prawdziwą pracę, a nie strzegące swoich krawędzi, techniki keszowania renderowania i masztabowania w naszej notatce o keszu renderowania i wydajności masztabowania pokazują ścieżkę renderowania, a przewodnik międzykompilatorowy w budowaniu przeglądarki Lazarus i FPC to miejsce, gdzie opisane tutaj zachowanie size_t na Win64 faktycznie ma znaczenie. Oba bazują na tej samej pracy nad bezpieczeństwem pamięci i ABI, która jest dostarczana w komponencie PDFium dla Delphi, Lazarusa i C++Buildera, obok API renderowania, ekstrakcji tekstu i formularzy omówionych gdzie indziej na tym blogu