16.5 C minőségi kapuk és hibajavítási jegyzőkönyv

A hibajavítás akkor válik felülvizsgálható mérnöki eredménnyé, ha más is meg tudja állapítani, mi váltotta ki a hibát, mely szerződés sérült, hogyan szünteti meg ezt a változtatás, és mit ellenőriztek ténylegesen. A „nálam már nem omlik össze” megfigyelés ehhez kevés: eltűnhetett a kiváltó körülmény anélkül, hogy a hiba megszűnt volna.

A C biztonsági hibaminták és védekezés a megsérthető szerződéseket, az ASan, UBSan, MSan, TSan és Valgrind az eszközök megfigyelési határait, az Unit test, property test és fuzzing pedig a tesztbemenetek előállítását tárgyalta. Most ezekből állítunk össze reprodukálható ellenőrzési ciklust.

16.5.1 Minőségi kapu és bizonyítékhatár

A minőségi kapu (quality gate) előre meghatározott elfogadási feltétel, amely egy azonosítható forrásállapotra, rögzített környezetben végzett ellenőrzések eredményét értékeli. Nem pusztán egy CI-job neve: tartalmazza a szükséges konfigurációt, a siker feltételeit és a megőrzendő bizonyítékokat.

Például:

A változás akkor fogadható el, ha a kétfordítós build új diagnosztika nélkül elkészül, minden kötelező teszt sikeres, és nincs kivizsgálatlan sanitizer- vagy statikuselemző-jelzés.

A „sikeres” jelentését is rögzíteni kell. A kihagyott vagy az infrastruktúra hibája miatt el sem indult teszt nem sikeres teszt. A támogatás hiánya külön állapot, amelyhez indoklás és jóváhagyott kivétel tartozik.

Az ellenőrzési ciklus:

  1. a trigger és a megfigyelt eltérés rögzítése;
  2. minimális reprodukció és regressziós teszt készítése;
  3. a kiváltó ok azonosítása;
  4. célzott javítás;
  5. az érintett minőségi kapuk ismételt ellenőrzése;
  6. a fennmaradó bizonytalanság dokumentálása.

A regressziós tesztnek lehetőség szerint a hibás változatot el kell utasítania, a javítottat pedig el kell fogadnia. Enélkül nem világos, hogy valóban a javítandó tulajdonságot ellenőrzi-e.

16.5.2 Kétfordítós build és diagnosztikai szabályzat

ISO C17: a példakód szabványos C17-et használ. Eszközprofil: az alábbi laborhoz kijelölt környezet Linux x86-64, System V ABI, LP64, GCC 14.2.0, LLVM/Clang és clang-tidy 18.1.8, glibc 2.39. A sanitizer-runtime a megfelelő fordítói telepítéshez tartozzon. Ez kijelölt, nem itt kipróbált profil; a parancsok nem futtatási eredmények.

A gcc-14 és clang-18 programnév önmagában nem rögzíti a javítóverziót. A környezetet csomagverzióval vagy konténerképdigesttel is azonosítani kell.

A GCC és a Clang eltérő diagnosztikát és optimalizálási döntéseket adhat. A két build ezért hasznos keresztellenőrzés, de nem két független helyességbizonyítás.

Profil Fordítók Cél
Debug GCC és Clang Könnyen vizsgálható állapot, regressziós tesztek
Release GCC és Clang Optimalizált és NDEBUG-os viselkedés ellenőrzése
ASan + UBSan GCC és Clang Instrumentált memória- és kiválasztott UB-ellenőrzések
TSan Külön Clang-build Konkurens tesztek vizsgálata

Fordítóeszköz-szabály: a TSan-profilt külön kell tartani az ASan-profiltól; nem minden sanitizer kombinálható. Minden profilt saját tárgyfájlokból kell felépíteni, és a szükséges instrumentációs kapcsolókat a végső linkelésnél is meg kell adni.

A warning policy legyen szigorú, de kezelhető. Saját kódban célszerű a kijelölt warningokat hibaként kezelni. Külső függőségekre külön szabályzat vonatkozhat. Elhallgattatás csak szűk hatókörben, indoklással és felülvizsgálható kivétellel történjen. Fordítófrissítéskor az új diagnosztikákat külön migrációban érdemes rendezni, nem a -Werror tartós kikapcsolásával.

16.5.3 Kidolgozott példa: részleges inicializálás

Egy objektum két puffert birtokol. A hiba: a második allokáció sikertelenségekor az első puffer felszabadítása elmarad. A normál sikeres teszt ezt nem gyakorolja.

A hiba-injektálás (failure injection) itt az adott sorszámú allokáció determinisztikus meghiúsítása. A regressziós teszt a sikeres eset mellett mindkét hibapontot ellenőrzi.

16.5.3.1 Szerződés és tulajdonlás

A pair_init előfeltétele, hogy a kimeneti objektum üres legyen. Siker esetén két puffer tulajdonosa lesz; hiba esetén üres marad. A függvény 0 értékkel jelez sikert, -1 értékkel allokációs hibát.

Objektum vagy hozzáférés Tulajdonos és élettartam Érvénytelenítés
Átmeneti pufferek Inicializálás közben a helyi tmp kezeli őket Hibás inicializáláskor felszabadítás
Sikeresen átadott pufferek A hívó p objektuma birtokolja őket pair_destroy
out, a, p paraméterpointer Kölcsönzött hozzáférés a hívó élő objektumaihoz; nincs eltárolás A hívó objektumának élettartamvége
Számlálóállapot A teszt aktuális iterációjának helyi objektuma Az iteráció vége

A tulajdonos objektum élettartamának vége C-ben nem szabadítja fel automatikusan a dinamikus tárolót: a pair_destroy hívás a szerződés része.

Az alábbi teljes gate.c programban a méretek C bájtban értendők. Nincs konkrét objektumlayoutra vonatkozó feltételezés.

#include <stddef.h>
#include <stdlib.h>

struct alloc_state {
    size_t calls;
    size_t fail_at; /* 0: nincs injektált hiba */
    size_t live;
};

struct pair {
    void *first;
    void *second;
};

static void *tracked_alloc(struct alloc_state *a, size_t bytes)
{
    ++a->calls;
    if (a->fail_at != 0 && a->calls == a->fail_at)
        return NULL;

    void *p = malloc(bytes);
    if (p != NULL)
        ++a->live;
    return p;
}

static void tracked_free(struct alloc_state *a, void *p)
{
    if (p != NULL) {
        free(p);
        --a->live;
    }
}

static int pair_init(struct pair *out, struct alloc_state *a)
{
    struct pair tmp = {0};

    tmp.first = tracked_alloc(a, 8);
    if (tmp.first == NULL)
        return -1;

    tmp.second = tracked_alloc(a, 16);
    if (tmp.second == NULL) {
        tracked_free(a, tmp.first);
        return -1;
    }

    *out = tmp; /* Logikai tulajdonátadás. */
    return 0;
}

static void pair_destroy(struct pair *p, struct alloc_state *a)
{
    tracked_free(a, p->second);
    tracked_free(a, p->first);
    *p = (struct pair){0};
}

int main(void)
{
    for (size_t fail = 0; fail <= 2; ++fail) {
        struct alloc_state a = {0, fail, 0};
        struct pair p = {0};
        int rc = pair_init(&p, &a);

        if (rc != (fail == 0 ? 0 : -1))
            return EXIT_FAILURE;
        if (a.calls != (fail == 1 ? 1u : 2u))
            return EXIT_FAILURE;

        if (fail == 0) {
            if (p.first == NULL || p.second == NULL || a.live != 2)
                return EXIT_FAILURE;
        } else {
            if (p.first != NULL || p.second != NULL || a.live != 0)
                return EXIT_FAILURE;
        }

        pair_destroy(&p, &a);
        if (p.first != NULL || p.second != NULL || a.live != 0)
            return EXIT_FAILURE;
    }

    return EXIT_SUCCESS;
}

A teszt feltételezi, hogy a nem injektált apró allokációk sikerülnek. Valós memóriahiány esetén nem nulla kilépési állapot keletkezhet; annak eredetét külön kell kivizsgálni. Az ellenőrzések szándékosan nem assert hívások: release profilban, NDEBUG mellett is megmaradnak.

16.5.3.2 Buildparancsok és statikus elemzés

A következő Bash-parancsok fordítanak, de nem indítják el a tesztprogramokat:

common=(-std=c17 -Wall -Wextra -Wpedantic -Wconversion
        -Wshadow -Wstrict-prototypes -Wmissing-prototypes
        -Wformat=2 -Werror)

gcc-14 "${common[@]}" -O0 -g3 gate.c -o gate-gcc-debug
clang-18 "${common[@]}" -O0 -g3 gate.c -o gate-clang-debug

gcc-14 "${common[@]}" -O2 -DNDEBUG gate.c -o gate-gcc-release
clang-18 "${common[@]}" -O2 -DNDEBUG gate.c -o gate-clang-release

gcc-14 "${common[@]}" -O1 -g -fno-omit-frame-pointer \
  -fsanitize=address,undefined -fno-sanitize-recover=all \
  gate.c -o gate-gcc-asan-ubsan

clang-18 "${common[@]}" -O1 -g -fno-omit-frame-pointer \
  -fsanitize=address,undefined -fno-sanitize-recover=all \
  gate.c -o gate-clang-asan-ubsan

clang-18 "${common[@]}" -O1 -g -fno-omit-frame-pointer \
  -fsanitize=thread gate.c -o gate-clang-tsan

gcc-14 "${common[@]}" -fanalyzer -c gate.c -o gate-analyzer.o

clang-tidy-18 \
  --checks='-*,clang-analyzer-*,bugprone-sizeof-expression' \
  gate.c -- -std=c17

Eszközfüggő feltételek: a GCC statikus elemzőjének elérhetősége a telepítéstől is függhet; a clang-tidy külön csomag lehet. Az ellenőrzéskészletet az adott verzióhoz kell rögzíteni. C projekthez ne kapcsoljunk be válogatás nélkül C++-specifikus modernizáló szabályokat.

Többfájlos projektben az elemző ugyanazokat az include-útvonalakat, makrókat és targetbeállításokat kapja, mint a build; ezt célszerű fordítási adatbázissal biztosítani. A clang-tidy diagnosztikája önmagában nem feltétlenül jelent sikertelen CI-jobot: az elfogadási szabályt és a kilépési állapot kezelését külön konfigurálni kell.

A példa egyszálú. TSan-buildje legfeljebb az instrumentált környezet próbája, nem konkurenciateszt. A valódi TSan-kapuhoz konkurens tesztterhelés szükséges.

16.5.4 Erőforrásmérleg és hibajavítási jegyzőkönyv

Tulajdonátadás nélküli vizsgálati tartományban legyen BB a még lezárandó erőforrások száma, AA a sikeres szerzéseké, RR az érvényes elengedéseké. Ekkor:

Bvég=Bkezdet+A−R. B_{\mathrm{vég}} = B_{\mathrm{kezdet}} + A - R.

A második allokáció injektált hibájánál kezdetben B=0B=0, majd egy sikeres szerzés történik: A=1A=1. A javított visszagörgetés egyszer elenged: R=1R=1, így Bvég=0B_{\mathrm{vég}}=0. A hibás ágban R=0R=0, ezért egy kötelezettség megmarad.

Bizonyításvázlat: minden sikeres szerzés eggyel növeli, minden hozzá tartozó érvényes elengedés eggyel csökkenti a mérleget; a műveletsor szerinti indukció adja az összefüggést. Feltétel, hogy minden műveletet számoljunk, és egy szerzéshez legfeljebb egy elengedés tartozzon.

A nulla számláló önmagában nem elég: hibás könyvelés, számlálatlan allokáció vagy egymást számszerűen elfedő hibák mellett félrevezető lehet. File descriptorokra és más erőforrásokra külön, azonosságot is követő nyilvántartás szükséges.

Egy review-zható mintajegyzőkönyv:

Mező Tartalom
Trigger A második allokáció sikertelen
Megsértett szerződés Sikertelen inicializálás után nem maradhat birtokolt puffer
Root cause A részleges sikerhez nem tartozott visszagörgetés
Változás Az első puffer felszabadítása a második allokáció hibájánál
Regresszió fail_at = 2; elvárt üres kimenet és nulla élő allokáció
Ellenőrzés Mindkét fordító, debug/release, statikus elemzés, ASan/UBSan
Nyitott korlát A teszt nem általános bizonyíték minden hívóra vagy erőforrásra

Az itt felsorolt eredmények elvárások, nem közölt mérések. A kitöltött jegyzőkönyvnek a tényleges kilépési állapotot és naplókat kell tartalmaznia.

16.5.4.1 Eredetkövetés: forrás, fordító és mérés

A bizonyíték eredetkövetése (provenance) legalább a következőket kapcsolja össze:

A szabványos nyelvi állítást, az eszköz dokumentált képességét és a saját mérési megfigyelést külön kell kezelni. Csak ténylegesen elolvasott forrásra hivatkozzunk; keresési kiindulópont nem bizonyíték. Ellenőrzött forrásjegyzet hiányában a dokumentációs ellenőrzés nyitott tétel marad.

Idő- vagy memóriaköltség mérésénél a terhelést és az ismétléseket is rögzíteni kell. Sanitizeres időeredmény nem azonos a release program teljesítményével.

16.5.5 Gyakori hibák

A zöld CI összetévesztése a helyességgel a legsúlyosabb értelmezési hiba. A zöld állapot csak a kijelölt ellenőrzések eredménye; a nem vizsgált bemenetek és ütemezések nyitva maradnak.

Szintén gyakori, hogy a teszt csak a visszatérési kódot nézi, miközben a javítandó hiba erőforrásszivárgás. A regresszióban ezért a hibakód mellett a kimeneti állapot és az erőforrásmérleg is ellenőrzendő.

Végül a globális diagnosztika-elhallgattatás látszólag helyreállítja a kaput, de megszünteti annak információértékét. Kivételhez konkrét jelzés, indoklás, felelős és felülvizsgálati feltétel tartozzon.

16.5.6 Feladatok

  1. Magyarázó: miért nem bizonyítja a hibamentes ASan/UBSan-futás, hogy minden erőforrás megfelelően lezárul?
  2. Kódolási: egészítsd ki a tesztet annak ellenőrzésével, hogy a pair_destroy kétszer is meghívható ugyanarra az objektumra.
  3. Diagnosztikai: egy változás debug profilban sikeres, release profilban hibás. A tesztelők azt mondják: „biztosan optimalizálóhiba”. Milyen bizonyítékokat kérnél, és mi legyen az elfogadás feltétele?

16.5.7 Megoldások

  1. A futás csak a végrehajtott útvonalakat vizsgálja, az eszközök pedig meghatározott hibakategóriákat keresnek. Például egy lezáratlan file descriptor nem általános ASan/UBSan-célpont. Az elfogadható válasz megnevezi mind a lefedettségi, mind az eszközérzékenységi korlátot, és külön erőforrásellenőrzést javasol.

  2. Az első destrukció utáni sikeres ellenőrzést követően illeszd be ezt a C17-részletet:

    pair_destroy(&p, &a);
    if (p.first != NULL || p.second != NULL || a.live != 0)
        return EXIT_FAILURE;

    Az első hívás nullázza a pointereket, ezért a második nem enged el újabb erőforrást. Az ellenőrzési feltétel: minden injektálási esetben nulla mérleg és üres objektum maradjon, runtime-diagnosztika nélkül.

  3. Először azonos forrást, bemenetet és pontos buildparancsokat kell kérni. Külön vizsgálandó az optimalizálás és a NDEBUG hatása; különösen az, hogy került-e szükséges mellékhatás assert kifejezésbe. Ezután minimális reprodukció, a C-szerződések ellenőrzése és második fordítós összehasonlítás következik. Elfogadás csak megindokolt root cause és a release profilban is aktív regressziós teszt alapján történhet.

16.5.8 Összefoglaló

A jó minőségi kapu nem hibamentességet ígér, hanem reprodukálható ellenőrzést. A kétfordítós build, a C-hez illesztett statikus elemzés, az elkülönített runtime-profilok és a hiba-injektálás egymást kiegészítő bizonyítékot adnak. A hibajavítási jegyzőkönyv ezt összekapcsolja a kiváltó okkal, a változással és a regresszióval — és ugyanilyen világosan megnevezi, mi maradt bizonyítatlan.