Ein Testfundament für Windows-Failure-Paths mit Application Verifier

· · Windows-Entwicklung, Fehleruntersuchung, Industriekamera, Application Verifier, Failure-Path-Tests, Handle-Leak

Application Verifier ist ein starkes Werkzeug, wenn man Anomalien, die im nativen Windows-Code oder an der Win32-Grenze auftreten, vorzeitig sichtbar machen möchte. Besonders wenn man Handle-Anomalien, Heap-Beschädigung oder Failure Paths bei geringen Ressourcen testen will, lassen sich damit Probleme, die reine Normalpfad-Tests nie zeigen würden, deutlich früher aufdecken.

In Teil 1, Untersuchung eines Absturzes einer Industriekamera-Anwendung nach Langzeitbetrieb - Der Handle-Leak (Teil 1), haben wir den Fall einer Steuerungsanwendung untersucht, die nach langem Dauerbetrieb abstürzte, und festgestellt, dass die Ursache ein Handle-Leak war. Allein die Logs zu verstärken, ist aber erst die halbe Miete. Was man wirklich braucht, ist vorab prüfen zu können, ob man auch dann noch „erkennt, was passiert ist“, wenn künftig ein unerwarteter Programmierfehler einen Memory-Leak, einen Handle-Leak, einen Teilausfall oder eine vergessene Freigabe verursacht.

Dafür haben wir Application Verifier eingesetzt. Es ist ein Werkzeug, mit dem sich zur Laufzeit Prüfungen und Fault Injection in Code einbringen lassen, der im nativen Windows-Code oder an der Win32-Grenze läuft. In der Praxis besonders praktisch ist, dass man speicher- oder ressourcenmangelähnliche Ausfallmuster vorziehen kann, ohne den Speicher der Maschine wirklich aufzubrauchen.

In diesem zweiten Teil erklären wir, was Application Verifier ist, was es kann und wie man es im Kontext einer Industriekamera-Steuerungsanwendung in ein Testfundament für Failure-Path-Tests einbaut.

Inhaltsverzeichnis

  1. Zuerst das Fazit (in einem Satz)
  2. Was ist Application Verifier?
    • 2.1. In einem Satz
    • 2.2. Wann es wirkt
    • 2.3. Was man davon hat
    • 2.4. Beschaffen und lokal aktivieren
  3. Was Application Verifier kann
    • 3.1. Basics: Handles / Heaps / Locks / Memory / TLS und mehr
    • 3.2. Low Resource Simulation: Speicher- und Ressourcenmangel vorziehen
    • 3.3. Page Heap und der Debugger
    • 3.4. !avrf / !htrace / Logs
  4. Warum wir es diesmal eingeführt haben
    • 4.1. Das Ziel ist nicht nur „Bugs finden“
    • 4.2. Speichermangelähnliche Phänomene auslösen
    • 4.3. Prüfen, ob sich Handle-Anomalien im Ernstfall nachverfolgen lassen
  5. Wie man speicher- oder ressourcenmangelähnliche Phänomene auslöst
    • 5.1. Die Idee hinter Low Resource Simulation
    • 5.2. Was sich zum Scheitern bringen lässt
    • 5.3. Anwendung in der Praxis
  6. Wie man Handle-Anomalien betrachtet
    • 6.1. Die Handles-Prüfung
    • 6.2. Open-/Close-Stacks mit !htrace betrachten
    • 6.3. Kombination mit eigenen Logs
  7. Aufbau eines Testfundaments für Failure Paths
    • 7.1. Die Ausführungseinheit auf eine Harness ausrichten
    • 7.2. Das Testmenü aufteilen
    • 7.3. Was man erfasst
    • 7.4. Abnahmekriterien
    • 7.5. Hinweise
  8. Grober Entscheidungsleitfaden
  9. Zusammenfassung
  10. Quellen

1. Zuerst das Fazit (in einem Satz)

  • Application Verifier ist ein Werkzeug, das Fehlverwendungen an der unmanaged/nativen Grenze von Windows zur Laufzeit leichter auffindbar macht
  • Der Nutzen liegt nicht nur im „Bugs finden“, sondern darin, Failure Paths, die sonst selten auftreten, vorzuziehen
  • Handles erkennt invalid handles, Heaps macht Heap-Beschädigung sichtbar, und Low Resource Simulation erlaubt Fault Injection für speicher- oder ressourcenmangelähnliche Situationen
  • Die Leck-Untersuchung einer lange laufenden, dauerhaft aktiven EXE allein Application Verifier zu überlassen, ist keine gute Idee — realistisch ist die Kombination mit Handle Count und einem eigenen Log des Ressourcen-Lifecycle
  • In einem Testfundament für Failure Paths liest sich das Ergebnis leichter, wenn man einen normalen Verifier-Run und einen Fault-Injection-Run getrennt durchführt
  • Auch wenn man eine DLL testen möchte, wird Application Verifier auf der Test-EXE aktiviert, die diese DLL tatsächlich ausführt

Kurz gesagt ist Application Verifier ein Werkzeug, das die „lästigen Bugs“ rund um Windows’ native/Win32-Grenze ans Licht zerrt. Besonders in einer Welt wie der von Geräte-Steuerungsanwendungen, in der sich native SDKs, P/Invoke und Win32-APIs ganz selbstverständlich mischen, passt es sehr gut.

2. Was ist Application Verifier?

2.1. In einem Satz

Application Verifier ist ein Laufzeitverifikationswerkzeug für Windows-User-Mode-Anwendungen. Es überwacht, wie eine laufende Anwendung OS-APIs nutzt und mit Ressourcen umgeht, erkennt verdächtige Verwendungen und kann gezielt Fehler injizieren.

Anders als „statische Analyse“ oder „Unit-Tests“ ist es ein Werkzeug, das zeigt, wie etwas tatsächlich zusammenbricht, wenn dieser Codepfad wirklich durchlaufen wird. Es eignet sich deshalb hervorragend, um Failure Paths aufzudecken, die im gewöhnlichen Funktionstest unsichtbar bleiben.

Test-HarnessSteuerungsanwendung / SDK-WrapperApplication VerifierWin32-API / native DLL / OS-Ressourcenverifier stopDebugger-AusgabeAppVerifier-LogsEigenes structured Log

2.2. Wann es wirkt

Besonders wirksam ist es in Situationen wie diesen.

  • Native DLLs oder ein Kamera-SDK werden aufgerufen
  • P/Invoke- oder COM-Grenzen werden überschritten
  • Handles, Heaps, Locks und virtueller Speicher werden direkt oder indirekt stark genutzt
  • Auf dem normalen Pfad stürzt kaum etwas ab, aber die Lifetime-Verwaltung wirkt auf Failure Paths brüchig
  • „Gibt gelegentlich seltsame Fehler zurück“ tritt eher auf als „stürzt ab“

Umgekehrt ist es kein Werkzeug, um Objektgraphen in einer rein managed Welt nachzuverfolgen. Auch bei einer C#-Anwendung wirkt es also durchaus stark, wenn native SDKs oder die Win32-Grenze eine dicke Schicht bilden — es ist aber nicht dafür gedacht, rein managed Heap-Leaks allein damit vollständig zu untersuchen.

2.3. Was man davon hat

In der Praxis läuft der Nutzen im Wesentlichen auf drei Punkte hinaus.

  1. Fehlverwendungen an der nativen Grenze werden früh gestoppt
    • invalid handle
    • heap corruption
    • Lock-Fehlverwendung
    • Fehlverwendung von Virtual-Memory-APIs und mehr
  2. Ausfallmuster, die nur bei geringen Ressourcen auftreten, lassen sich vorziehen
    • Das malloc-Äquivalent schlägt gelegentlich fehl
    • CreateEvent oder CreateFile schlagen gelegentlich fehl
    • VirtualAlloc schlägt fehl
  3. In Kombination mit dem Debugger lässt sich leichter nachverfolgen
    • !avrf
    • !htrace
    • !heap -p -a
    • Logs der verifier stops

Das Problem bei Geräte-Steuerungsanwendungen ist, dass man „nicht weiß, was auf dem Failure Path passiert ist“. Application Verifier trägt erheblich dazu bei, dieses „Nicht-Wissen“ zu verringern.

2.4. Beschaffen und lokal aktivieren

Zunächst klären wir, wie man sich das Werkzeug beschafft. Fehlt dieser Schritt, bleibt alles Folgende graue Theorie.

Application Verifier ist im Windows SDK enthalten. Windows allein bringt es nicht mit — starten Sie den SDK-Installer und aktivieren Sie im Auswahlbildschirm der Komponenten das Häkchen bei „Application Verifier“. Die ausführbare Datei heißt appverif.exe.

Für den Einsatz gelten drei Voraussetzungen.

  • Der ausführende Benutzer muss Mitglied der Administrators-Gruppe auf dieser Maschine sein
  • ARM64EC wird nicht unterstützt
  • Das Prüfziel muss unmanaged (nativer) Code sein

Das Verhältnis zwischen GUI und Kommandozeile lässt sich am besten so verstehen:

  Was passiert
GUI (appverif.exe) Schreibt den Namen der Ziel-EXE und die Kombination der zu aktivierenden Tests in die Registry
Kommandozeile (appverif -enable ...) Schreibt genau dieselbe Registry-Einstellung, nur per Befehl
Zur Laufzeit Beim Start der Ziel-EXE wird diese Einstellung gelesen, die Verifier-DLL geladen und die Win32-API-Hooks eingebracht

Das heißt: Egal welchen Weg Sie wählen, es passiert dasselbe. In der Praxis nutzt man beim ersten manuellen Versuch die GUI und in CI oder Skripten die Kommandozeile.

Die Bedienung der GUI läuft so ab: Im linken Bereich „Applications“ mit Rechtsklick „Add Application“ wählen, um die Ziel-EXE hinzuzufügen, im rechten Bereich „Tests“ Häkchen wie bei Basics setzen und „Save“ klicken. Zum Deaktivieren im selben „Applications“-Bereich mit Rechtsklick „Delete Application“ wählen und erneut „Save“ klicken.

Daraus ergeben sich zwei wichtige Einschränkungen.

  • Ein bereits laufender Prozess lässt sich nicht nachträglich aktivieren. Da der Hook beim Laden der DLL eingebracht wird, muss die Reihenfolge „erst konfigurieren, dann starten“ sein.
  • Die Einstellung bleibt bestehen, bis sie ausdrücklich entfernt wird. Lässt man sie nach einem vermeintlich einmaligen Test einfach stehen, startet diese EXE auf dieser Maschine fortan immer unter dem Verifier.

Die beim Erkennen entstehenden Logs werden standardmäßig im Binärformat unter %USERPROFILE%\AppVerifierLogs gespeichert und lassen sich über die GUI oder die Kommandozeile in XML umwandeln und auswerten.

3. Was Application Verifier kann

3.1. Basics: Handles / Heaps / Locks / Memory / TLS und mehr

Das Basisset von Application Verifier heißt Basics. Hier sind die in der Praxis am häufigsten genutzten Prüfungen gebündelt.

Ebene Was geprüft wird Einsatz in diesem Kontext
Handles Verwendung eines invalid handle Ob geschlossene / defekte Handles angefasst werden
Heaps Heap-Beschädigung Aufdecken von Pufferüberläufen und use-after-free an der native-SDK-Grenze
Leak Nicht freigegebene Ressourcen zum Zeitpunkt des DLL-Unload Tests kurzlebiger Harnesses und Fälle mit Unload
Locks / SRWLock Fehlverwendung von Locks Prüfung von Race Conditions zwischen Reconnect und Shutdown
Memory Fehlverwendung von VirtualAlloc / MapViewOfFile usw. Prüfung von Anomalien bei großen Puffern und Shared Memory
TLS Fehlverwendung der Thread-Local-Storage-API Absicherung für nativen Code mit komplexen Thread-Grenzen
Threadpool Konsistenz der Threadpool-API und des Worker-Zustands Unterstützung bei vielen Callbacks oder asynchroner Verarbeitung

Entscheidend ist: nicht „hinterher aus dem Absturz herauslesen“, sondern „verdächtige Verwendung sofort an Ort und Stelle stoppen“. Bei Fehlern, die erst nach Langzeitbetrieb auftreten, zahlt sich dieses Vorziehen erheblich aus.

3.2. Low Resource Simulation: Speicher- und Ressourcenmangel vorziehen

Das ist der in der Praxis besonders nützliche Teil, denn man kann Phänomene nahe an Speicher- oder Ressourcenmangel auslösen, ohne wirklich das RAM aufzubrauchen.

Die Idee dahinter ist einfach.

  • Einen bestimmten API-Aufruf
  • mit einer festgelegten Wahrscheinlichkeit
  • absichtlich fehlschlagen lassen

Damit lassen sich Error Paths durchlaufen, die sonst so gut wie nie erreicht werden.

Konkret lassen sich damit gezielt Phänomene wie diese auslösen.

  • HeapAlloc oder VirtualAlloc schlägt fehl
  • CreateFile schlägt fehl
  • CreateEvent schlägt fehl
  • MapViewOfFile schlägt fehl
  • OLE/COM-Allokationen wie SysAllocString schlagen fehl

Das ist deutlich handhabbarer, als tatsächlich versuchen, die ganze Maschine durch echten Speichermangel zu quälen. Außerdem lässt sich Fault Injection gezielt auf einzelne DLLs beschränken. Bei Konstellationen wie Geräte-Steuerungsanwendungen, in denen eigene Wrapper mit Vendor-SDKs gemischt sind, ist das ausgesprochen praxistauglich.

3.3. Page Heap und der Debugger

Für Heap-Beschädigung ist die Kombination aus Heaps und Page Heap stark. Besonders full page heap hat den Vorteil, mithilfe von Guard Pages genau im Moment der Beschädigung anzuhalten.

Das ist allerdings ziemlich schwergewichtig. Statt eines langen erschöpfenden Laufs ist es praktikabler, sich auf Szenarien nahe an der Reproduktion zu beschränken und unter dem Debugger laufen zu lassen.

Als Vorgehen ist deshalb folgende Aufteilung realistisch.

  • Zunächst breit mit Basics ansetzen
  • Wird der Heap verdächtig, full page heap einsetzen
  • Wird es zu schwer, auf light page heap zurückfallen
  • Bei produktionsnahen Langzeittests hauptsächlich auf eigene Logs setzen

Letztlich ist AppVerifier kein Zauberstab, sondern ein Werkzeug, dessen Klinge man je nach Situation wechselt.

3.4. !avrf / !htrace / Logs

Zunächst ein Begriff. Der bisher schon mehrfach erwähnte verifier stop ist das Erkennungsereignis, das Application Verifier auslöst, wenn es zu dem Schluss kommt: „diese Verwendung ist falsch“. Er ist keine bloße Logzeile — läuft man unter einem Debugger, bricht die Ausführung genau dort ab. Stops sind nummeriert und werden etwa als VERIFIER STOP 00000300 angezeigt. Es gibt Stops, nach denen man fortfahren kann, und solche, nach denen das nicht möglich ist (bei denen nur das Beenden des Prozesses bleibt).

Application Verifier gibt sich nicht mit dem Auslösen eines Stops zufrieden. Dank Debugger-Erweiterungen und Logs lässt sich leichter nachvollziehen, was passiert ist.

  • !avrf
    • Zeigt die aktuellen Verifier-Einstellungen und den gerade aufgetretenen Stop
  • !htrace
    • Zeigt die Stacks von Open, Close und ungültiger Referenz eines Handles
  • !heap -p -a
    • Verfolgt in Kombination mit Page Heap den beschädigten Heap-Block
  • AppVerifier-Logs
    • Halten die Logs zum Zeitpunkt eines Stops fest

Besonders hilfreich ist, dass bei aktiviertem Handles automatisch auch Handle-Tracing aktiviert wird. Damit lässt sich im Nachhinein leicht nachvollziehen, „wo dieses Handle geöffnet und wo es geschlossen wurde“.

4. Warum wir es diesmal eingeführt haben

4.1. Das Ziel ist nicht nur „Bugs finden“

Unser Ziel war diesmal nicht einfach, „mit AppVerifier einen einzelnen Bug zu finden“. Praktischer ausgedrückt wollten wir Folgendes prüfen.

  • Tritt künftig auf einem anderen Failure Path erneut ein Ressourcenleck auf,
  • bleibt der Kontext dann verlässlich im Log erhalten?
  • Lässt sich das zusammen mit den Debugger-Informationen vollständig nachverfolgen?
  • Landet man nicht in einem Zustand von „unklar, was passiert ist“?

Wir haben es also nicht nur als Detektor, sondern als Test unseres Beobachtungsfundaments eingesetzt.

4.2. Speichermangelähnliche Phänomene auslösen

Auf einer gewöhnlichen Entwicklungsmaschine tatsächlich Speichermangel auszulösen, ist ziemlich umständlich. Wird zudem die ganze Maschine instabil, wird der Test selbst zunehmend von Störgeräuschen überlagert.

Deshalb haben wir uns entschieden, mit Low Resource Simulation gezielt die Failure Paths zu durchlaufen, die bei Speicher- oder Ressourcenmangel wahrscheinlich auftreten würden.

Damit lassen sich Fragen wie diese leichter beantworten.

  • Bleiben cameraId und phase im Log, wenn CreateEvent fehlschlägt?
  • Läuft der Cleanup korrekt, nachdem die Initialisierung nur halb abgeschlossen wurde?
  • Bricht der Retry nicht zusammen, wenn VirtualAlloc fehlschlägt?
  • Kommt das Handle zurück, wenn CreateFile auf dem Save-Path fehlschlägt?

Betonen möchten wir: Nicht das Auslösen der Anomalie selbst ist das Ziel, sondern dass sich der Ausfall im Ernstfall nachvollziehen lässt.

4.3. Prüfen, ob sich Handle-Anomalien im Ernstfall nachverfolgen lassen

Wie schon beim Handle-Leak aus Teil 1 gilt bei allem rund um Handles: die Stelle, an der es zuletzt abstürzt, und die eigentliche Ursache klaffen leicht auseinander.

Deshalb wollten wir Folgendes bestätigen.

  • Lässt sich beim Auftreten eines invalid handle stop mit !htrace Open und Close nachvollziehen?
  • Lässt sich das mit resourceId / sessionId / phase aus dem eigenen Log verknüpfen?
  • Geht der Handle Count nach dem Fehlschlag wieder zurück?
  • Sind Leck-Differenzen leicht erkennbar, wenn die Harness ein kurzlebiger Prozess ist?

Wird das sichtbar, kommt man von einem bloßen „ein Bug ist aufgetreten“ bis zu „in welcher Zuständigkeit die Lifetime-Verwaltung zusammengebrochen ist“.

5. Wie man speicher- oder ressourcenmangelähnliche Phänomene auslöst

5.1. Die Idee hinter Low Resource Simulation

Low Resource Simulation ist, salopp gesagt, Fault Injection. Statt eine Niedrig-Ressourcen-Umgebung originalgetreu nachzubilden, geht es eher darum, die typischen API-Fehlschläge, die bei knappen Ressourcen auftreten, künstlich einzumischen.

Der Einsatzbereich ist entsprechend klar umrissen.

  • Prüfen des Aufräumverhaltens auf dem Failure Path
  • Prüfen der Robustheit von Retry / Reconnect
  • Prüfen der Initialisierung bei gemischtem Teilerfolg und Teilfehlschlag
  • Prüfen, ob auch bei „normalerweise nie auftretenden Fehlern“ ein Log erhalten bleibt

Der Trick dabei ist, nicht von Anfang an alles gleichzeitig fehlschlagen zu lassen. Schaltet man sofort alles auf einmal ein, explodiert das Log, und man verliert den Überblick, „was man eigentlich betrachtet“.

5.2. Was sich zum Scheitern bringen lässt

Mit Low Resource Simulation lassen sich typischerweise die folgenden API-Klassen probabilistisch scheitern lassen.

Klasse Beispiel Beispiel in einer Geräte-Steuerungsanwendung
Heap_Alloc Heap-Allokation Temporäre Puffer, Bild-Metadaten, interne Allokationen des SDK-Wrappers
Virtual_Alloc Allokation virtuellen Speichers Größere Framebuffer, Ringpuffer
File CreateFile u. Ä. Öffnen von Speicherpfaden und Logdateien
Event CreateEvent u. Ä. Frame-Ready-Benachrichtigung, Stop-/Reconnect-Synchronisation
MapView CreateMapView u. Ä. Shared Memory und Memory-Mapped Files
Ole_Alloc SysAllocString u. Ä. COM-/OLE-Grenze
Wait WaitForXXX-Familie Rund um fehlgeschlagenes synchrones Warten
Registry Registry-Zugriff Lesen/Schreiben von Einstellungen und treibernahen Konfigurationen

In der Praxis kommt es darauf an, nicht alles gleichzeitig zu öffnen, sondern gezielt mit dem einzusteigen, was dem aktuell interessierenden Failure Path am nächsten liegt.

5.3. Anwendung in der Praxis

Als Kommandozeilenbeispiel sieht das etwa so aus.

appverif /verify CameraHarness.exe
appverif /verify CameraHarness.exe /faults
appverif -enable lowres -for CameraHarness.exe -with heap_alloc=20000 virtual_alloc=20000 file=20000 event=20000
appverif -query lowres -for CameraHarness.exe

Reines Copy-Paste bringt nichts, wenn die Absicht dahinter nicht klar ist — deshalb hier Zeile für Zeile, was jeweils passiert.

Befehl Was er tut
appverif /verify CameraHarness.exe Aktiviert die Basics-Testgruppe für CameraHarness.exe
appverif /verify CameraHarness.exe /faults Aktiviert zusätzlich Fault Injection. Betrifft dabei aber nur OLE_ALLOC und HEAP_ALLOC
appverif -enable lowres -for CameraHarness.exe -with heap_alloc=20000 ... Aktiviert lowres (Low Resource Simulation) und legt Art und Wahrscheinlichkeit der fehlschlagenden APIs einzeln fest
appverif -query lowres -for CameraHarness.exe Zeigt an, was aktuell mit welcher Wahrscheinlichkeit eingestellt ist
appverif /n CameraHarness.exe Entfernt die Einstellungen für diese EXE (dasselbe Ziel wie -disable * -for oder -delete settings -for)

Auch die Bedeutung der Argumente sollte man kennen.

  • Die Wahrscheinlichkeit wird in Millionsteln angegeben. Zulässig sind ganze Zahlen von 0 bis 1.000.000; 20000 bedeutet 20000 / 1.000.000, also 2 %. Es heißt also nicht „einmal alle 20.000 Aufrufe“. Auch Microsofts Dokumentation nennt -with registry=20000 file=20000 als Beispiel dafür, Registry- und Datei-APIs mit 2 % Wahrscheinlichkeit fehlschlagen zu lassen.
  • Nach /faults lassen sich Wahrscheinlichkeit, Karenzzeit und DLL-Name aneinanderreihen. Das Format ist /faults [Wahrscheinlichkeit [Karenzzeit in ms [DLL ...]]]. Lässt man die Wahrscheinlichkeit weg, gilt 5 %, lässt man die Karenzzeit weg, gelten 500 Millisekunden. Die Karenzzeit bedeutet „in dieser Zeit nach dem Prozessstart keine Fehler injizieren“ und soll verhindern, dass schon der Startvorgang selbst scheitert und sich gar nichts mehr testen lässt.
  • /n deaktiviert. Man kann sich n in etwa als „no verifier“ vorstellen — der Gegenbefehl, damit nichts dauerhaft aktiviert bleibt.

Die Ausgabe von -query lowres sieht ungefähr so aus. Hier lässt sich prüfen, ob die gewünschten Wahrscheinlichkeiten eingetragen sind und ob nicht versehentlich auch nicht gewollte Klassen geöffnet wurden.

Settings for CameraHarness.exe:
Test [lowres] enabled.

Include = *
Exclude =
TimeOut = 2000 (0x7D0)
WAIT = 0 (0x0)
HEAP_ALLOC = 20000 (0x4E20)
VIRTUAL_ALLOC = 0 (0x0)
REGISTRY = 0 (0x0)
FILE = 20000 (0x4E20)
EVENT = 20000 (0x4E20)
MAP_VIEW = 0 (0x0)
OLE_ALLOC = 0 (0x0)
STACKS = false

Include und Exclude grenzen die Zielmodule ein, TimeOut ist die Zeit direkt nach dem Start, in der kein Fault injiziert wird. Die Standardwerte hängen davon ab, wie die Einstellung vorgenommen wurde — verlassen Sie sich nicht auf Annahmen, sondern prüfen Sie es sicherheitshalber anhand dieser Ausgabe.

Als Vorgehen bietet sich Folgendes an.

  1. Zuerst nur mit Basics den Normalpfad durchlaufen
  2. Anschließend Low Resource Simulation hinzufügen und mit Fault Injection durchlaufen
  3. Bei Bedarf nur den gewünschten Fehlern wie file oder event eine Wahrscheinlichkeit geben
  4. Soll gezielt eine bestimmte DLL angesprochen werden, die Injektion auf diese DLL beschränken

Die Abkürzung /faults ist praktisch, konzentriert sich allein aber hauptsächlich auf OLE_ALLOC und HEAP_ALLOC. Wer die Failure Paths von CreateFile oder CreateEvent sehen möchte, ist mit -enable lowres -with file=... event=... sicherer bedient.

Bei Geräte-Steuerungsanwendungen liest sich das Ergebnis meist leichter, wenn man sich auf den Camera-Wrapper oder die DLL des Save-Path beschränkt, statt Fehler über die ganze Anwendung zu verstreuen.

Hier auch gleich die konkrete Schreibweise für dieses „auf eine DLL beschränken“. Das dritte und jedes weitere Argument nach /faults gibt das Zielmodul an.

appverif /verify CameraHarness.exe /faults 50000 1000 CameraSdkWrapper.dll

Damit lässt sich beim Start von CameraHarness.exe nur die 1000 Millisekunden nach dem Start beginnende Verarbeitung, die von CameraSdkWrapper.dll ausgeht, mit 5 % Wahrscheinlichkeit (50000 / 1.000.000) fehlschlagen. Der Modulname wird inklusive Dateiendung angegeben, ohne Pfad. Es lassen sich nicht nur .dll-, sondern auch andere geladene Module wie .ocx angeben.

Ob die Einschränkung wirkt, lässt sich an den Zeilen Include und Exclude von appverif -query lowres -for CameraHarness.exe prüfen. Steht dort noch *, ist weiterhin der gesamte Prozess betroffen.

Läuft der Prozess unter dem Debugger, lässt sich der Umfang auch nachträglich ändern.

!avrf -trg dll CameraSdkWrapper.dll
!avrf -skp dll VendorSdk.dll

-trg bedeutet „hier gezielt ansetzen“, -skp bedeutet „das hier auslassen“. Mit !avrf -flt lässt sich die aktuelle Fault-Injection-Einstellung prüfen, mit !avrf -flt stacks 10 der Stack der zuletzt injizierten Fehler.

So lassen sich beispielsweise Szenarien wie diese bauen.

  • CreateEvent-Fehlschlag unmittelbar nach Beginn eines Reconnects
  • CreateFile-Fehlschlag beim Beginn des Speicherns
  • Fehlschlag bei der Allokation eines temporären Puffers
  • SysAllocString-Fehlschlag bei einer COM-Konvertierung
  • Prüfung des Fehlerpfads von Wait-APIs

All das wird von reinen Normalpfad-Tests praktisch nie durchlaufen. Genau deshalb lohnt es sich, es gezielt zu erzwingen.

6. Wie man Handle-Anomalien betrachtet

6.1. Die Handles-Prüfung

Bei allem rund um Handles beginnt man mit Handles. Damit lässt sich die Verwendung eines invalid handle leichter erkennen.

Typischerweise werden Fehler wie diese erkannt.

  • Ein bereits geschlossenes Handle wird erneut verwendet
  • Ein defekter Handle-Wert wird übergeben
  • Ein durch einen Teilfehlschlag nicht initialisiertes Handle wird verwendet
  • Die Lifetime bricht zusammen, und ein anderer Thread greift darauf zu

Was im Langzeitbetrieb nur als „gelegentlich tritt ein seltsamer Fehler auf“ sichtbar wäre, bringt der Verifier direkt an Ort und Stelle zum Stehen. Dieses Vorziehen hilft erheblich.

6.2. Open-/Close-Stacks mit !htrace betrachten

Handles ist deshalb so wertvoll, weil es gut mit Handle-Tracing zusammenspielt.

Ab hier kommt der Debugger zum Einsatz, deshalb installieren wir zuerst WinDbg. Es wird als Debugging Tools for Windows vertrieben und lässt sich über denselben Windows-SDK-Installer wie Application Verifier einrichten.

windbg -xd av -xd ch -xd sov CameraHarness.exe
!avrf
!htrace 0x00000ABC

Die Optionen in der ersten Zeile sind keine bloße Beschwörungsformel. Application Verifier wirft beim Erkennen drei Arten von Ausnahmen.

Option Ausnahme Wann sie auftritt
av Zugriffsverletzung (0xC0000005) Bei erkanntem Pufferüberlauf im Heap
ch Ungültiges Handle (0xC0000008) Bei erkannter Verwendung eines invalid handle
sov Stack-Überlauf (0xC00000FD) Wenn der anfängliche Stack als zu klein erkannt wird

-xd legt fest, diese Ausnahme erst beim second chance abzufangen. Der Hintergrund: den first chance verarbeitet Application Verifier selbst, um die Stop-Informationen aufzubauen — greift der Debugger schon davor ein, stört das. In einem bereits laufenden Debugger entspricht das der Eingabe von sxd av, sxd ch, sxd sov.

Mit !htrace möchte man im Wesentlichen Folgendes sehen.

  • Wo dieses Handle geöffnet wurde
  • Wo es geschlossen wurde
  • Ob es als invalid handle referenziert wurde
  • Ob sich mehr Opens angehäuft haben, als erwartet

Auch das reale Erscheinungsbild sei hier gezeigt. Die folgenden Beispiele stammen aus der offiziellen Dokumentation und nicht aus unserer eigenen Umgebung, die Form bleibt aber dieselbe.

Trifft man auf ein invalid handle, erscheint zunächst dies.

Invalid handle - code c0000008 (first chance)
===================================================
VERIFIER STOP 00000300: pid 0x558: invalid handle exception for current stack trace
        C0000008 : Exception code.
        0012FBF8 : Exception record. Use .exr to display it.
        0012FC0C : Context record. Use .cxr to display it.
        00000000 :
===================================================
This verifier stop is continuable.
After debugging it use 'go' to continue.
===================================================

Gibt man anschließend !avrf ein, erscheint, was aktuell aktiv ist und welcher Stop aufgetreten ist. Die letzte Zeile ist die Zusammenfassung.

0:000> !avrf
Global flags: 00000100
Application verifier global flag is set.
Application verifier settings (00000004):
   - no heap checking enabled!
   - handle checks
Page heap is not active for this process.
Current stop 00000300 : c0000008 0012fbf8 0012fc0c 00000000 .
    Using an invalid handle (either closed or simply bad).

Betrachtet man die Historie dieses Handles mit !htrace, erscheinen OPEN, CLOSE und BAD REFERENCE jeweils mit ihrem Stack aufgelistet.

0:000> !htrace 7DC

--------------------------------------
Handle 0x7DC - BAD REFERENCE:
0x801902BE: ntoskrnl!NtSetEvent+0x6C
0x010012C1: badhandle!mainCRTStartup+0xE3
--------------------------------------
Handle 0x7DC - CLOSE:
0x801E1EDD: ntoskrnl!NtClose+0x19
0x010012C1: badhandle!mainCRTStartup+0xE3
--------------------------------------
Handle 0x7DC - OPEN:
0x77DE265C: KERNEL32!CreateEventA+0x66
0x010011A0: badhandle!main+0x20
--------------------------------------

Die Lesart ist unkompliziert: Kommt nach CLOSE ein BAD REFERENCE, wird ein bereits geschlossenes Handle erneut verwendet. Der OPEN-Stack zeigt, wo dieses Handle erzeugt wurde.

Das Tückische an Handle-Leaks und Handle-Fehlverwendung ist, dass die API, die zuletzt gestolpert ist, nicht die eigentliche Ursache ist. Mit !htrace lässt sich die Historie dieses Handles ziemlich konkret nachvollziehen.

6.3. Kombination mit eigenen Logs

Trotzdem reicht Application Verifier allein nicht aus. Insbesondere die Leck-Untersuchung einer lange laufenden, dauerhaft aktiven EXE allein damit zu bewältigen, ist ziemlich mühsam.

Deshalb kombiniert man in der Praxis Folgendes.

  • Regelmäßig erfassten Handle Count
  • sessionId
  • resourceId
  • phase
  • Lifecycle-Logs von Create/Open und Close/Dispose
  • Dumps und Debugger-Ausgaben zum Zeitpunkt eines verifier stop

Damit lässt sich zum Beispiel so vorgehen.

  1. Der Heartbeat zeigt, dass der Trend des Handle Count verdächtig ist
  2. Das Lifecycle-Log grenzt Ressourcen ein, die zwar ein Create, aber kein Close haben
  3. Ein Verifier-Run bringt invalid handle oder Fehlverwendung vorzeitig zum Vorschein
  4. !htrace zeigt den Open-/Close-Stack

Diese Kombination macht die Nachverfolgung erheblich einfacher.

7. Aufbau eines Testfundaments für Failure Paths

7.1. Die Ausführungseinheit auf eine Harness ausrichten

Application Verifier lässt sich nicht nachträglich auf einem bereits laufenden Prozess aktivieren. Erst konfigurieren, dann starten.

Zudem bleibt die Einstellung bestehen, bis sie ausdrücklich entfernt wird. In der Praxis lässt sich das deshalb leichter handhaben, wenn man sich eher an einer Test-Harness-EXE als an der eigentlichen Produktivanwendung orientiert.

Zum Beispiel eine Konstellation wie diese.

Szenario-RunnerCameraHarness.exeCameraSdkWrapper.dllVendor-SDKStrukturiertes LogDump / Debugger

Damit ergeben sich diese Vorteile:

  • Ein Szenario lässt sich pro Prozess ausführen
  • Leck-Differenzen sind leicht erkennbar
  • Das Ein- und Ausschalten der AppVerifier-Einstellung ist einfach
  • Auch beim Testen einer DLL lässt sich das über die EXE-Seite handhaben

Die Befehle sehen etwa so aus.

appverif /verify CameraHarness.exe
appverif /n CameraHarness.exe

/verify aktiviert Basics, /n entfernt die Einstellung (siehe auch die Tabelle in 5.3). Aktivierung vor dem Start, Deaktivierung ausdrücklich. Läuft man das auf Basis einer Harness, lassen sich Konfigurationsfehler leichter vermeiden.

7.2. Das Testmenü aufteilen

In einem Testfundament für Failure Paths ist es besser, nicht alles in einem Durchgang zu erledigen. Eine Aufteilung in etwa diese drei Spuren macht das Ergebnis leichter lesbar.

  1. Normalpfad + Basics
    • Keine Fehler injizieren
    • Bestätigen, dass kein verifier stop auftritt
  2. Fault-Injection-Spur
    • Low Resource Simulation
    • event / file / heap_alloc / virtual_alloc usw. gezielt fehlschlagen lassen
  3. Heap-Vertiefungsspur
    • Heaps
    • full page heap
    • Lokal unter dem Debugger reproduzieren

Trennt man das, geraten „bricht es schon bei normaler Nutzung“ und „bricht es nur bei geringen Ressourcen“ nicht so leicht durcheinander.

Besonders mit und ohne Fault Injection ändern sich die durchlaufenen Code-Pfade erheblich. Deshalb sollte man sowohl den Run ohne Fault als auch den Run mit Fault durchführen.

7.3. Was man erfasst

Mindestens Folgendes sollte man festhalten.

Kategorie Was benötigt wird
Anwendungslog cameraId, sessionId, phase, handleCount, error code
Prozesszustand Handle Count, Private Bytes, Thread Count
Debugger-Informationen !avrf, !htrace, bei Bedarf !heap -p -a
Dumps Bei einem verifier stop oder einem abnormalen Beenden
AppVerifier-Logs Aufzeichnung der Stops, bei Bedarf zur Auswertung nach XML exportiert

Bei Bedarf lassen sich auch die AppVerifier-Logs nach XML exportieren und auswerten. Allein daraus lässt sich die Ursache jedoch meist nicht abschließend klären — praxistauglicher ist die Annahme, sie gemeinsam mit den eigenen Logs zu lesen.

Viele Logs sind für sich genommen kein Verdienst. Wichtig ist, dass sich der Zusammenhang später herstellen lässt.

7.4. Abnahmekriterien

Auch als Abnahmekriterium ist „ist nicht abgestürzt“ zu schwach. In diesem Kontext brauchten wir mindestens Folgendes.

  • Im Normalpfad + Basics tritt kein verifier stop auf
  • Auch bei Fault Injection bleibt der erwartete Fehlschlag im Log erhalten
  • Halb initialisierte Ressourcen werden ordentlich aufgeräumt
  • Nach Reconnect / Retry kehrt Handle Count nahe an die Baseline zurück
  • Tritt ein verifier stop auf, lässt er sich über sessionId / phase / Stack nachverfolgen
  • Kein Fehlschlag endet als „unklar, was passiert ist“

Wichtig ist dabei, „bricht nicht“ und „lässt sich im Fall des Zusammenbruchs nachverfolgen“ getrennt zu bewerten.

7.5. Hinweise

Application Verifier ist zwar sehr praktisch, aber keine Magie.

  • Nicht tatsächlich durchlaufene Codepfade werden nicht geprüft
  • full page heap ist schwergewichtig
  • Auch auf Seiten eines Third-Party-SDK können Stops auftreten
  • Mit und ohne Fault Injection unterscheiden sich die durchlaufenen Codepfade erheblich
  • Es ist kein Werkzeug, um rein managed Heap-Leaks allein damit zu untersuchen

Die eigene Position lässt sich deshalb so beschreiben.

  • Langzeittrend: eigene Logs und Counter
  • Fehlverwendung an der nativen Grenze: Application Verifier
  • Wiederherstellung der Kausalität im Ernstfall: strukturiertes Log + Dump + Debugger

Diese Arbeitsteilung ist die praxistauglichste.

8. Grober Entscheidungsleitfaden

  • invalid handle oder double close wird vermutet
    • Handles + !htrace
  • heap corruption / use-after-free wird vermutet
    • Heaps + full page heap + !heap -p -a
  • Speicher- oder ressourcenmangelähnliche Phänomene sollen ausgelöst werden
    • Low Resource Simulation
  • Etwas bricht im Langzeitbetrieb allmählich zusammen
    • Zuerst das eigene Handle Count / Private Bytes / Lifecycle-Log
  • Eine DLL soll getestet werden
    • Application Verifier auf der Harness-EXE aktivieren, die diese DLL aufruft

Schaltet man von Anfang an alles gleichzeitig ein, entsteht meist nur ein Nebel aus Logs. Mit der Klinge anzusetzen, die dem gewünschten Failure Path am nächsten liegt, ist deutlich verständlicher.

9. Zusammenfassung

Application Verifier positioniert sich als Runtime-Verifier für die native/Win32-Grenze von Windows. Mit Handles / Heaps / Locks / Memory / TLS / Low Resource Simulation und mehr lassen sich Failure Paths, die sonst kaum auftreten, vorziehen und gezielt durchlaufen.

In diesem Kontext hat sich vor allem ausgezahlt, dass sich Handle-Anomalien im Ernstfall mit !htrace leicht nachverfolgen lassen, dass sich speicher- oder ressourcenmangelähnliche Phänomene auslösen lassen, ohne die ganze Maschine zu zerstören, und dass wir dabei prüfen konnten, ob unsere eigenen Logs im Ernstfall wirklich nützlich sind.

In der Praxis läuft es so ab, dass man Normalpfad + Basics von der Fault-Injection-Spur trennt, eine Harness-EXE bereitstellt und Szenarien in kurzlebigen Prozessen durchlaufen lässt. Darauf aufbauend kombiniert man das mit eigenen Logs, Dumps und Debugger-Informationen, während man den Trend von Langzeit-Lecks selbst mit eigenen Countern beobachtet — das ist die Arbeitsteilung.

Application Verifier ist ein Werkzeug, um „selten auftretenden Anomalien“ nicht zufällig entgegenzuwarten, sondern ihnen aktiv entgegenzugehen.

Bei Geräte-Steuerungsanwendungen ist es wichtig, dass nichts zusammenbricht — genauso wichtig ist aber, im Ernstfall erklären zu können, was passiert ist. In diesem Sinne halten wir es für ein durchweg praxistaugliches Werkzeug.

Teil 1: Untersuchung eines Absturzes einer Industriekamera-Anwendung nach Langzeitbetrieb - Der Handle-Leak (Teil 1)

10. Quellen

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Diese Fallstudien zeigen einen ähnlichen Ansatz für Analyse, Priorisierung oder Neugestaltung.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Was ist Application Verifier?
Es ist ein Laufzeitverifikationswerkzeug für Windows-User-Mode-Anwendungen. Es überwacht, wie eine laufende Anwendung OS-APIs nutzt und mit Ressourcen umgeht, erkennt verdächtige Verwendungen wie den Einsatz eines invalid handle oder heap corruption und kann gezielt Fehler injizieren. Anders als statische Analyse oder Unit-Tests ist es ein Werkzeug, das zeigt, wie etwas tatsächlich zusammenbricht, wenn dieser Codepfad wirklich durchlaufen wird — deshalb eignet es sich hervorragend, um Failure Paths aufzudecken, die im gewöhnlichen Funktionstest unsichtbar bleiben.
Lässt sich mit Application Verifier ein Speichermangel nachbilden?
Mit Low Resource Simulation lassen sich Phänomene nahe an Speicher- oder Ressourcenmangel vorziehen, ohne wirklich das RAM der Maschine aufzubrauchen. Der Mechanismus dahinter ist Fault Injection: API-Aufrufe wie HeapAlloc, VirtualAlloc, CreateFile oder CreateEvent werden mit einer festgelegten Wahrscheinlichkeit absichtlich zum Scheitern gebracht. Da sich die Fehlerinjektion auch gezielt auf einzelne DLLs beschränken lässt, ist das auch bei Konstellationen praktikabel, in denen eigene Wrapper mit Vendor-SDKs gemischt sind. Lässt man von Anfang an alles gleichzeitig scheitern, wird das Log allerdings unlesbar — der Trick ist, gezielt mit dem Failure Path zu beginnen, den man tatsächlich sehen möchte.
Lässt sich Application Verifier zur Untersuchung von Handle-Leaks einsetzen?
Aktiviert man die Handles-Prüfung, lässt sich die Verwendung eines invalid handle wie die Wiederverwendung eines bereits geschlossenen Handles erkennen, und Handle-Tracing wird automatisch mitaktiviert, sodass sich mit !htrace der Open-/Close-Stack dieses Handles nachvollziehen lässt. Die Leck-Untersuchung einer lange laufenden, dauerhaft aktiven EXE allein Application Verifier zu überlassen, ist allerdings unrealistisch. Praxistauglich ist eine Arbeitsteilung, bei der regelmäßig protokollierte Handle Counts und ein eigenes Log des Ressourcen-Lifecycle die Trend-Erkennung übernehmen, während der Verifier die Erkennung von Fehlverwendungen übernimmt.
Wie setzt man Application Verifier zum Testen einer DLL ein?
Aktiviert wird Application Verifier nicht auf der DLL selbst, sondern auf der Test-EXE, die diese DLL tatsächlich ausführt. Ein bereits laufender Prozess lässt sich nicht nachträglich aktivieren — die Einstellung muss vor dem Start vorgenommen werden. Da die Einstellung außerdem bestehen bleibt, bis sie ausdrücklich entfernt wird, lässt sie sich leichter handhaben, wenn man sie eher auf eine Test-Harness-EXE als auf die eigentliche Produktivanwendung ausrichtet. Führt man ein Szenario pro Prozess aus, lassen sich Leck-Differenzen leichter erkennen, und auch das Ein- und Ausschalten der Einstellung fällt leichter.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog