OpenCppCoverage in Docker

Wieso (zur Hölle) funktioniert OpenCppCoverage eigentlich nicht in Windows Docker Containern?

Denn während man auf normalen Desktop Rechnern und Servern mit OpenCppCoverage schöne Statistiken erhält, welche Codezeilen durchgelaufen sind (und welche nicht berührt wurden), erhalten wir mit dem selben Tool im Container die Meldung:

cannot find the name of the module


Spoiler: Docker wird von OpenCppCoverage aktuell leider nicht unterstützt und es gibt aktuell keinen Workaround für das Problem.
Aber: Vielleicht können wir dem Thema demnächst mal nachgehen und eine Teillösung ausprobieren.

Vorgeschichte - Neulich in der Firma …

Der Chef will bunte Tortendiagramme, ob und wie viel unsere Unit-Tests den Code wirklich abprüfen.
Manuell konnte man dies immer schon am Desktop-Rechner mit OpenCppCoverage mit einem Plugin in Visual Studio tun, doch eigentlich ist es ja viel einfacher, wenn am Build-Server beim Ausführen aller Tests auch ein Coverage-Report generiert wird.

… tja, und der Build-Server baut eben auf Docker auf und dort wird jede Coverage mit dem Fehler cannot find the name of the module abgewürgt.

Analyse

Der bzw. die OpenCppCoverage Entwickler haben ein kleines Test-Tool erstellt, welches das Problem aufzeigt:

Benutzt wird die Windows Debug API und diese liefert normalerweise bei jedem Ladevorgang einer DLL eine File-Handle (hfile) zum geöffneten Modul. So kann man dann Debug-Symbole aus dem Modul laden und die richtigen Quellcode-Zeilen zum ausgeführten Binärcode ausfindig machen.

Unter Docker liefert die Debug-API (WaitForDebugEvent()) aber offenbar nur NULL für hfile, was die weitere Code-Coverage verhindert.

Ich habe daher mal ebenfalls ein Test-Projekt mit Debugging-API-Code zusammengebastelt um das bei mir besser nachvollziehen zu können. Die Quellcodes hierfür sind im CLASSROOM veröffentlicht.

Und ja, hfile ist tatsächlich NULL, wenn man z.B. cmd.exe damit debuggen möchte um seine Module im LOAD_DLL_DEBUG_EVENT zu erkennen.
Danach habe ich einen Ordner meines Hosts in den Container gemountet um andere Tools zu testen und wieder war der hfile Member auf NULL gesetzt.

Danach machte ich aber etwas ganz Verrücktes:
Ich erzeugte im Docker-Container einen Test Ordner auf Laufwerk C:\test und kopierte meine Test-Programme vom gemounteten Host-Ordner dort hin.

Kaum startete ich den Debugging-Test, kamen auch schon die ersten korrekten hfile Werte, die mit GetFinalPathNameByHandleW() in einen Dateipfad umgewandelt werden konnten.
Die Pfad-Angaben sind im NT-Format, z.B.: \\\\?\\c:\\path\\to\\module.dll.

Teil-Lösung 1: Man muss Dateien in einen Container-interne Ordner kopieren, damit die Debug-APIs gültige Werte liefern. Denn mit Host-Volumes funktioniert es nicht.

Das Verhalten (wenngleich sehr verstörend) ist sogar nachvollziehbar, schließlich handelt es sich um “virtuelle Pfade”, die ins Dateisystem von Docker “hineingehackt” werden. Dass die von manchen APIs anders behandelt werden, ist zwar lästig, vielleicht aber auch sicherheitstechnisch notwendig.

Nachteil: Dieser Workaround läuft letztendlich nicht mit allen Modulen. ntdll.dll ist da trotzdem ausgenommen, und Tools wie cmd.exe und andere System-DLLs liegen grundsätzlich an einem Ort, der aus anderen Docker Layern zusammengeklebt ist. Die kann man da nicht weg kopieren und somit fehlen ihre Daten.
… aber für die Code-Coverage wäre das egal, da interessieren wir uns ja für “unseren” Code und nicht für fremde System-DLLs.

ImagePath im Remote-Prozess

Wenn es uns weiters um den Image-Pfad eines geladenen Moduls geht, gibt es noch eine weiter Möglichkeit:

LOAD_DLL_DEBUG_INFO führt nämlich noch die Member lpImageName und fUnicode mit sich.
lpImageName hält die Speicheradresse zu einem Pointer zu einem String mit dem Pfad des Moduls. Dieser Parameter ist zwar nur optional befüllt, doch in meinen Tests war er auch im Docker Container stets ausgefüllt.

Das Problem ist nur, der Pointer verweist auf Speicher im Remote-Prozess. Wir müssen daher mit der ReadProcessMemory Zeichen für Zeichen herauspicken.

Mein Demo-Code hierfür sieht etwa so aus:

 1static int print_remote_image_path(HANDLE hProcess, void* lpImageName, int unicode)
 2{
 3  int chars_printed = 0;
 4  char* remote_address = NULL;
 5  SIZE_T returned = 0;
 6  SIZE_T char_len = unicode ? sizeof(wchar_t) : sizeof(char);
 7  wchar_t buffer[4] = { 0 };
 8  char* ptr_char = (char*)&buffer[0];
 9
10  if(ReadProcessMemory(hProcess, lpImageName, &remote_address, sizeof(remote_address), &returned))
11  {
12    if(remote_address != NULL)
13    {
14      while(ReadProcessMemory(hProcess, remote_address, ptr_char, char_len, &returned))
15      {
16        if(unicode)
17        {
18          if(buffer[0] == 0) break;
19          wprintf(L"%c", buffer[0]);
20        }
21        else
22        {
23          if(*ptr_char == 0) break;
24          printf("%c", *ptr_char);
25        }
26        remote_address += char_len;
27        ++chars_printed;
28      }
29    }
30  }
31  return chars_printed;
32}

Man lädt also zuerst den Pointer zum String über die Adresse in lpImageName, und je nachdem, ob es ein Unicode oder ANSI String ist, wird Byte für Byte aus der Zieladresse gezogen, bis wir einen \0 Terminator finden, welcher das Ende des String andeutet.

Teil-Lösung 2: Wir extrahieren den Pfad des geladenen Moduls aus dem Zielprozess und öffnen das Modul manuell.

Nachteil: Der Parameter ist als “optional” markiert und ich weiß auch noch nicht, ob dieser Pfad und die manuelle Öffnung dann für eine Coverage benutzt werden kann.

Fazit

Die schnellere Lösung war es natürlich, mit dem GCC und anderen Tools eine vergleichbare Code-Coverage unter Linux laufen zu lassen.
Es ist generell ärgerlich, dass unter Windows viele OpenSource-Tools bis heute Kinderkrankheiten haben und nur sündteure “Enterprise-Produkte” Abhilfe versprechen.

Wie auch immer … es wäre mal ein netter Sidequest, den OpenCppCoverage Code zu studieren und vielleicht lassen sich die beiden beschriebenen Teillösungen dazu nutzen, auch mit diesem eigentlich großartigen Tool neue Coverage-Reports unter Windows Docker Containern erstellen zu lassen.

… mal sehen, was die Zukunft bringt.