Technischer Artikel

Stille Stub-Fehler in der Pascal-PDF-Bibliothek

Wenn eine Delphi-Bibliothek eine Build-Konfiguration ohne das visuelle Framework bekommt, leben die Fehler in den Ersatzklassen. Nicht die Plattform, nicht der Compiler: die Stellvertreter. PDFlibPas hat eine Grafikschicht, die Bitmap-, Canvas-, Font-, Metadatei- und Drucker-Äquivalente für Builds ohne die VCL bereitstellt, und die Portierung auf Free Pascal brachte jeden Fehlermodus ans Licht, den ein Stellvertreter haben kann. Sie sortieren sich sauber nach Diagnosekosten, und die Reihenfolge ist das Gegenteil dessen, was die Intuition nahelegt

Ein Stellvertreter, der auslöst, ist billig zu finden; die Exception nennt die Methode. Ein Stellvertreter, der leere Daten zurückgibt, ist teuer, denn der Fehler taucht mehrere Schichten von seiner Ursache entfernt auf. Ein Stellvertreter, der Erfolg zurückgibt, ist der schlimmste von allen, denn der Rückgabecode ist gültig, der Fehlercode ist null, keine Exception wird ausgelöst, und der einzige Beweis, dass etwas schiefging, liegt in den Bytes, die herauskamen

Drei Formen stiller Stub-Fehler in einer Pascal-PDF-Bibliothek, geordnet nach Diagnosekosten, vom auslösenden Stub bis zu Erfolg über leerer Ausgabe
Ein auslösender Stellvertreter ist billig zu diagnostizieren, leere Daten sind teuer, und eine Erfolgsmeldung über leerem Artefakt ist am schwersten zu finden

Form drei: eine gültige Bildkennung über leerem XObject

Der Vektor-Metadatei-Konverter war in der Nicht-VCL-Konfiguration ein leerer Prozedurrumpf. Alles darüber arbeitete weiter. Die EMF-Import-Einstiegspunkte und der Canvas-Capture-Einstiegspunkt liefen durch und gaben eine legale Bildkennung zurück, die der Aufrufer dann auf eine Seite setzte. Was in der Datei landete, war ein Form-XObject mit einer Contentlänge von null. Die Seite renderte weiß

Nichts meldete ein Problem, und das schließt das eigene Demonstrationsprogramm der Bibliothek für diese Funktion ein, das eine leere Seite zeichnete und es nicht bemerkte. Es gab keinen fehlgeschlagenen Rückgabewert zu prüfen, denn die Aufrufsequenz war tatsächlich komplett erfolgreich; das Einzige, was falsch war, war die Größe des erzeugten Streams. Diese Fehlerklasse zu diagnostizieren heißt, eine andere Frage zu stellen: nicht „Ist der Aufruf fehlgeschlagen“, sondern „Ist das Artefakt plausibel“. Ein Form-XObject der Länge null, ein Bild mit null Pixeln, eine Seite mit null Content-Bytes, das sind die Assertions, die es fangen

Die Korrektur hat zwei Hälften, und die zweite Hälfte vergisst man leicht. Erstens: Die leere Implementierung auslösen lassen, damit der Fehler überhaupt einen Kanal hat. Zweitens: Diese Exception an der Bildfabrik in ein Null-Ergebnis umwandeln und Null-Prüfungen an den zwei Stellen ergänzen, die eine Bildkennung konsumieren, denn sonst wird aus dem „sauberen Scheitern“ direkt eine Access Violation, wenn der Seitenbaum ins Leere dereferenziert. Ein Stub, der wirft, ist nur dann eine Verbesserung, wenn die Aufrufer auf einen Fehler vorbereitet waren, den sie bisher nie empfangen konnten

Form zwei: leere Daten, drei Schichten vom Absturz entfernt

Der Metadatei-Canvas-Stellvertreter füllte seine physischen Abmessungen nicht. Dieser Wert geht in eine Seitengeometrieberechnung ein, also ergab die Berechnung null, also teilte die Bounding-Box-Berechnung durch null. Ein nackter Exception-Handler schluckte das, die Bildfabrik gab ein Null-Ergebnis zurück, und die Access Violation passierte schließlich im Seitenbaum, als die Null verwendet wurde. Drei Schichten zwischen Ursache und Symptom, mittendrin ein Exception-Handler, der die Beweise tilgt

Die Fehlerkette eines leeren Metadatei-Canvas-Stellvertreters in PDFlibPas, der drei Schichten nach der Division durch null in einer Access Violation endet
Die leere Abmessung treibt eine Geometrieberechnung auf null, ein nackter Handler tilgt die Exception, und die Null-Kennung bringt den Seitenbaum zum Absturz

Dieselbe Unit hatte zwei weitere Instanzen des Musters. Die Font-Klasse hatte leere Assign- und Konstruktorrümpfe, was mehr wiegt, als es aussieht, denn die Canvas-Font-Property ist schreibgeschützt: Eine Zuweisung daran ist der einzige Weg, einen Font zu liefern, also macht eine leere Implementierung die Fontauswahl still wirkungslos, und der Text kommt in dem heraus, was der Default war. Und ein Pixels-per-Inch-Wert von null ließ jeden Aufrufer, der einen Canvas aus Font-Metriken dimensioniert, einen Null-mal-Null-Canvas erzeugen, was eine leere Seite und eine Erfolgsmeldung ergibt

// Das Muster, nach dem man in einer Stellvertreter-Unit sucht: eine
// Methode, die weder auslöst noch irgendetwas tut. Beides kompiliert,
// und beides produziert „Erfolg“ ohne Ausgabe
procedure TMetafileCanvasStandIn.Create(...);
begin
  // kein inherited-Aufruf, keine Feldinitialisierung
end;

function TBitmapStandIn.LoadFromStream(Stream: TStream): Boolean;
begin
  Result := True;    // und das Bitmap bleibt leer
end;

Die Wide-Struktur, die nur das erste Zeichen behält

Das ist überhaupt kein Stellvertreter-Problem, aber es gehört in denselben Katalog, weil das Symptom ebenso weit von der Ursache entfernt ist. Die Drucker-Enumerationsstruktur war mit allen zwölf String-Membern als Zeiger auf Einzelbyte-Zeichen deklariert, während die Funktion, die sie füllt, die Wide-Character-Variante der Enumerations-API ist

Zeigergrößen sind identisch, also ist das Struktur-Layout korrekt und nichts stürzt ab. Stattdessen passiert Folgendes: Eine UTF-16-Zeichenkette als Einzelbyte-Zeichenkette gelesen stoppt am ersten Null-Byte, das bei jedem ASCII-Druckernamen die obere Hälfte des zweiten Zeichens ist. Jeder Druckername kam mit genau einem Zeichen zurück. Downstream scheiterte die Namensvalidierung, die Druckererzeugung und das Drucken für jeden echten Drucker auf der Maschine, und keines dieser Symptome zeigt auf eine Strukturdeklaration

UTF-16-Druckername auf ein Zeichen verkürzt, nachdem eine Wide-Win32-Struktur mit PAnsiChar-Membern statt PWideChar deklariert wurde
Einzelbyte-Member lesen einen UTF-16-Namen nur bis zu seinem ersten Null-Byte, also kommt jeder Druckername mit genau einem Zeichen zurück
// Falsch: richtige Größe, falscher Elementtyp. Kein Compile-Fehler,
// kein Absturz, jede Zeichenkette auf ein Zeichen verkürzt
type
  TPrinterInfo2Wrong = record
    pServerName: PAnsiChar;
    pPrinterName: PAnsiChar;
    // ... zehn weitere
  end;

// Richtig: eine *W-Struktur hat durchgehend Wide-Member
type
  TPrinterInfo2W = record
    pServerName: PWideChar;
    pPrinterName: PWideChar;
    // ... zehn weitere
  end;

Die Regel, die daraus entsteht, ist mechanisch, und es lohnt sich, sie bedingungslos anzuwenden: Bei jeder Win32-Struktur, deren Name auf W endet, prüfen, dass jedes String-Member die Wide-Variante ist, Feld für Feld. Die Vermischung der ANSI- und der Wide-Welt erzeugt weder eine Compilerdiagnose noch einen Absturz, nur stille Verkürzung, und umgekehrt gilt dasselbe für die ANSI-Varianten

Ein nackter Exception-Handler ist der eigentliche Gegner

Jede dieser Untersuchungen wurde vom selben Konstrukt ausgebremst: ein Handler, der alles fängt und in einen false-Rückgabewert umwandelt. Um einen Bilddecoder herum ist das vernünftig zu schreiben, denn ein korruptes Bild soll keinen Dokumentjob abwerfen. Es ist zugleich eine Vorrichtung, um genau die eine Information zu löschen, die Sie brauchen

Die praktische Antwort ist, den Handler vorübergehend laut zu machen. Das Ausgeben von Exceptionklasse, Meldung und Backtrace aus dem nackten Handler heraus, unter einer Debug-Condition, verwandelt eine unerklärliche Null-Rückgabe in eine benannte Exception mit einem Ort. In zwei der drei Fälle oben beendete dieser eine Schritt die Untersuchung, denn die Exception war eine Division durch null oder eine Access Violation in einer Stellvertreter-Methode, deren Name alles sagte

Checkliste für die Annahme eines Stellvertreterwegs

Vier Punkte, in der Reihenfolge ihres Ertrags. Bevor Sie eine Ersatzklasse aufrufen, lesen Sie die Methoden, die Sie gleich verwenden, und bestätigen Sie, dass jeder einen echten Rumpf hat; ein leerer Rumpf ist kein Implementierungsdetail, sondern ein fehlendes Feature. Ziehen Sie Stellvertreter, die auslösen, solchen vor, die neutrale Werte zurückgeben, und paaren Sie das mit Null-Prüfungen an den Stellen, an denen eine Fabrik nun legitim nichts zurückgeben kann. Verifizieren Sie ein Feature, indem Sie das Artefakt inspizieren, nicht den Rückgabecode, denn der ganze Fehlermodus hier ist ein sauberer Rückgabecode über leerem Artefakt; eine Byte-level-Aufschlüsselung dessen, was ein Dokument tatsächlich enthält, ist der schnellste Weg, das zu sehen, und der Artikel zum Dateigrößen-Audit behandelt dieses Werkzeug. Und wenn ein Feature keine brauchbare Ersatzimplementierung hat, routen Sie die betroffenen Beispiele auf den Weg, der funktioniert, und sagen in einem Kommentar warum, statt eine Demonstration zurückzulassen, die still leere Ausgabe produziert

Der allgemeinere Punkt gilt weit über eine Bibliothek hinaus. Jede Codebasis mit einer conditionalen zweiten Implementierung, einer Mock-Schicht, einem Headless-Modus, einem Plattform-Shim ist Form drei ausgesetzt. Der Grund, warum sie sich so gut versteckt: Jedes Qualitätstor, auf das sich ein Team normalerweise verlässt – Rückgabecodes, Fehlercodes, Exceptions, Exit-Status – ist ein Statuskanal, und Form drei hält alle davon sauber. Nur die Ausgabe verrät es. Das ist auch die Begründung dafür, bei nicht vertrauenswürdiger Eingabe Artefakte statt Status zu prüfen, beschrieben in dem Artikel zum nicht vertrauenswürdigen PDF-Parsing, und dafür, gerenderte Ausgabe über Engines hinweg zu vergleichen, statt einer zu vertrauen, beschrieben in Multi-Engine-Rendering

PDFlibPas ist eine native Object-Pascal-PDF-Bibliothek für Delphi, C++Builder und Free Pascal, und seine Nicht-VCL-Konfiguration ist es, die Headless- und Cross-Toolchain-Builds möglich macht; die aktuelle Konfigurationsabdeckung listet die Produktseite der losLab PDF Developer Library