Artykuł techniczny

Diagnozowanie cichych awarii stubów w bibliotece PDF

Gdy biblioteka Delphi zyskuje konfigurację buildu bez wizualnego frameworka, klasy zastępcze są miejscem, gdzie mieszkają błędy. Nie platforma, nie kompilator: zamienniki. PDFlibPas ma warstwę graficzną dostarczającą odpowiedników bitmapy, canvasu, fontu, metafila i drukarki dla buildów bez VCL, a portowanie jej na Free Pascal wyniosło na powierzchnię każdy tryb awarii, jaki zamiennik może mieć. Sortują się elegancko wg kosztu diagnozy, a kolejność jest odwrotna do tej, którą podpowiada intuicja

Zamiennik, który zgłasza wyjątek, jest tani w znalezieniu; wyjątek wymienia metodę. Zamiennik zwracający puste dane jest drogi, bo awaria pojawia się kilka warstw od swojej przyczyny. Zamiennik zwracający sukces jest najgorszy ze wszystkich, bo kod zwrotny jest poprawny, kod błędu zerowy, żaden wyjątek nie pada, a jedyne poszlaki, że coś poszło nie tak, leżą w bajtach, które wyszły

Trzy postacie cichej awarii stubu w pascalskiej bibliotece PDF uszeregowane wg kosztu diagnozy, od stubu zgłaszającego wyjątek po sukces nad pustym wynikiem
Zamiennik zgłaszający wyjątek jest tani w diagnozie, puste dane są drogie, a zwrot sukcesu nad pustym artefaktem jest najgorszy do znalezienia

Postać trzecia: poprawny identyfikator obrazu nad pustym XObject

Wektorowy konwerter metafili był pustym ciałem procedury w konfiguracji bez VCL. Wszystko nad nim dalej działało. Punkty wejścia importu EMF i punkt wejścia przechwytywania canvasu dochodziły do końca i zwracały legalny identyfikator obrazu, który wywołujący potem umieszczał na stronie. To, co wylądowało w pliku, to form XObject o długości zawartości zero. Strona renderowała się na biało

Nic nie zgłaszało problemu, włącznie z własnym programem demonstracyjnym biblioteki dla tej funkcji, który narysował pustą stronę i tego nie zauważył. Nie było niespełnionego kodu zwrotnego do sprawdzenia, bo sekwencja wywołań naprawdę w całości się powiodła; jedyną rzeczą, która była zła, był rozmiar wyprodukowanego strumienia. Diagnozowanie tej klasy defektów znaczy zadawanie innego pytania: nie „czy wywołanie zawiodło”, tylko „czy artefakt jest wiarygodny”. Form XObject o długości zero, obraz o zerze pikseli, strona o zerze bajtów zawartości — to są asercje, które to łapią

Poprawka ma dwie połówki i drugą łatwo zapomnieć. Po pierwsze, spraw, by pusta implementacja zgłaszała wyjątek, żeby awaria w ogóle miała kanał. Po drugie, przekonwertuj ten wyjątek na wynik null w fabryce obrazów i dodaj sprawdzenia null w dwóch miejscach konsumujących identyfikator obrazu, bo inaczej „czysta awaria” przechodzi prosto w naruszenie dostępu, gdy drzewo stron wyłuskuje nicość. Stub rzucający jest ulepszeniem tylko wtedy, gdy wywołujący byli przygotowani na awarię, której wcześniej nigdy nie mogli dostać

Postać druga: puste dane, trzy warstwy od awarii

Zamiennik canvasu metafila nie wypełniał swoich wymiarów fizycznych. Ta wartość dzieli się do obliczenia geometrii strony, więc obliczenie dało zero, więc obliczenie ramki ograniczającej dzieliło przez zero. Goły handler wyjątków to połknął, fabryka obrazów zwróciła wynik null, a naruszenie dostępu w końcu wydarzyło się w drzewie stron, gdy null został użyty. Trzy warstwy między przyczyną a objawem, z handlerem wyjątków pośrodku, wymazującym dowody

Łańcuch awarii pustego zamiennika canvasu metafila w PDFlibPas dochodzący do naruszenia dostępu trzy warstwy po dzieleniu przez zero
Pusty wymiar ściąga obliczenie geometrii do zera, goły handler wymazuje wyjątek, a identyfikator null wywala drzewo stron

Ta sama jednostka miała dwa kolejne wystąpienia wzorca. Klasa fontu miała puste ciała Assign i konstruktora, co znaczy więcej, niż wygląda, bo własność fontu canvasu jest tylko do odczytu: przypisanie do niej to jedyny sposób dostarczenia fontu, więc pusta implementacja czyni wybór fontu po cichu nieskutecznym, a tekst wychodzi w tym, co było domyślne. A wartość pikseli na cal równa zero sprawiała, że każdy wywołujący wymiarujący canvas z metryk fontu produkował canvas zero na zero, co daje pustą stronę i zwrot sukcesu

// Kształt, którego szukać w jednostce zamiennika: metoda, która ani
// nie zgłasza wyjątku, ani nic nie robi. Obie te metody się kompilują
// i obie produkują „sukces” bez żadnego wyjścia
procedure TMetafileCanvasStandIn.Create(...);
begin
  // ani wywołania inherited, ani inicjalizacji pól
end;

function TBitmapStandIn.LoadFromStream(Stream: TStream): Boolean;
begin
  Result := True;    // a bitmapa wciąż jest pusta
end;

Szeroka struktura, która zachowuje tylko pierwszy znak

To w ogóle nie jest problem zamiennika, ale należy do tego samego katalogu, bo objaw jest równie daleko od przyczyny. Struktura enumeracji drukarek została zadeklarowana z wszystkimi dwunastoma polami tekstowymi wpisanymi jako wskaźniki na znaki jednobajtowe, podczas gdy funkcja, która ją wypełnia, jest wariantem szerokoznakowym API enumeracji

Rozmiary wskaźników są identyczne, więc układ struktury jest poprawny i nic nie pada. Zamiast tego czytanie łańcucha UTF-16 jako łańcucha jednobajtowego zatrzymuje się na pierwszym bajcie zerowym, który dla dowolnej drukarkowej nazwy ASCII jest górną połówką drugiego znaku. Każda nazwa drukarki wracała jako dokładnie jeden znak. W dół strumienia walidacja nazwy zawodziła, tworzenie drukarki zawodziło i drukowanie zawodziło dla każdej prawdziwej drukarki w maszynie, a żaden z tych objawów nie wskazuje na deklarację struktury

Nazwa drukarki UTF-16 skrócona do jednego znaku, gdy szeroka struktura Win32 została zadeklarowana z polami PAnsiChar zamiast PWideChar
Pola jednobajtowe czytają nazwę UTF-16 tylko do pierwszego bajtu zerowego, więc każda nazwa drukarki wraca z dokładnie jednym znakiem
// Źle: właściwy rozmiar, zły typ elementu. Żadnego błędu kompilacji,
// żadnej awarii, każdy łańcuch skrócony do jednego znaku
type
  TPrinterInfo2Wrong = record
    pServerName: PAnsiChar;
    pPrinterName: PAnsiChar;
    // ... dziesięć dalszych
  end;

// Dobrze: struktura *W ma szerokie pola na całej długości
type
  TPrinterInfo2W = record
    pServerName: PWideChar;
    pPrinterName: PWideChar;
    // ... dziesięć dalszych
  end;

Reguła, która z tego wynika, jest mechaniczna i warta stosowania bez myślenia: dla każdej struktury Win32, której nazwa kończy się na W, zweryfikuj, że każdy człon tekstowy jest wariantem szerokim, pole po polu. Mieszanie świata ANSI i szerokiego nie produkuje ani diagnostyki kompilatora, ani awarii, tylko ciche skrócenie, i to samo działa w odwrotną stronę dla wariantów ANSI

Goły handler wyjątków to prawdziwy przeciwnik

Każde z tych śledztw było spowalniane przez tę samą konstrukcję: handler łapiący wszystko i konwertujący to na zwrot false. To rozsądna rzecz wokół dekodera obrazów, bo uszkodzony obraz nie powinien zawalać zadania dokumentowego. To również przyrząd do wymazywania tej jednej informacji, której potrzebujesz

Praktyczną reakcją jest czasowe zrobienie z handlera hałaśliwego. Zrzucenie klasy wyjątku, komunikatu i śladu stosu z wnętrza gołego handlera, pod warunkiem debugowym, przekształca niewyjaśniony zwrot null w nazwany wyjątek z lokalizacją. W dwóch z trzech powyższych przypadków ten jeden krok zakończył śledztwo, bo wyjątkiem było dzielenie przez zero albo naruszenie dostępu w metodzie zamiennika, której nazwa mówiła wszystko

Checklista adoptowania ścieżki zamiennika

Cztery punkty, w kolejności, w jakiej się opłacają. Zanim wywołasz klasę zastępczą, przeczytaj metody, których zamierzasz użyć, i potwierdź, że każda ma realne ciało; puste ciało to nie szczegół implementacji, to brakująca funkcja. Preferuj zamienniki zgłaszające wyjątki nad zamiennikami zwracającymi neutralne wartości i sparuj to ze sprawdzeniami null w miejscach, gdzie fabryka może teraz legalnie nie zwrócić nic. Weryfikuj funkcję badając artefakt, nie kod zwrotny, bo cały tryb awarii tutaj to czysty kod zwrotny nad pustym artefaktem; bajtowy rozbior tego, co dokument faktycznie zawiera, to najszybszy sposób, by to zobaczyć, a artykuł o audycie rozmiaru pliku omawia to narzędzie. I gdy funkcja nie ma żywotnej implementacji zastępczej, kieruj dotknięte próbki ścieżką, która działa, i napisz dlaczego w komentarzu, zamiast zostawiać demonstrację, która po cichu produkuje puste wyjście

Szerszy punkt stosuje się daleko poza jedną biblioteką. Dowolna baza kodu z warunkową drugą implementacją, warstwą mocków, trybem headless, platformowym shimem jest wystawiona na postać trzecią. Przyczyna, dla której tak dobrze się kryje, jest taka, że każda brama jakości, na którą zespół normalnie liczy — kody zwrotne, kody błędów, wyjątki, statusy wyjścia — jest kanałem statusów, a postać trzecia utrzymuje je wszystkie czystymi. Tylko wyjście ją zdradza. To jest też rozumowanie stojące za sprawdzaniem artefaktów zamiast statusów przy obsłudze niezaufanego wejścia, opisane w artykule o parsowaniu niezaufanego PDF, i za porównywaniem wyrenderowanego wyjścia między silnikami zamiast ufania jednemu, opisane w renderowaniu wielosilnikowym

PDFlibPas to natywna biblioteka PDF w Object Pascalu dla Delphi, C++Builder i Free Pascala, a jej konfiguracja bez VCL to właśnie to, co umożliwia buildy headless i między-toolchainowe; aktualne pokrycie konfiguracji jest wypisane na stronie produktu losLab PDF Developer Library