11.3 Hibakód, errno és hibaadatok élettartama

A hibakezelési szerződés nem pusztán azt mondja meg, hogy egy művelet sikertelen volt. A hívónak azt is tudnia kell, melyik művelet hibázott, milyen hibakategóriában, és meddig érvényesek a kapott hibaadatok. Különösen fontos ez cleanup közben: a felszabadítás vagy lezárás maga is hibázhat, és közben felülírhatja az eredeti hibára utaló állapotot.

A Tulajdonlási szerződés és cleanup ágak részben kialakított egyetlen lezárási felelősséget most hibamegőrzéssel egészítjük ki. A Formázás és megbízható szövegfeldolgozás tanulságát is megtartjuk: az eredmény értékét és a művelet sikerességét csak a konkrét API szerződése alapján lehet megkülönböztetni.

11.3.1 Három különböző hibajelzési modell

A hibajelző visszatérési érték, vagy sentinel, az eredmények közül fenntartott érték: például a fopen null pointerrel jelzi a sikertelenséget. Az explicit hibakód közvetlenül az eredmény része. Az errno ezzel szemben különálló, módosítható diagnosztikai állapot.

Modell Példa Mit kell vizsgálni?
Sentinel ISO C17 fopen A visszaadott pointer null-e
Sentinel és további hibakód POSIX fopen Null eredmény után az errno
Több összetevőből álló konverziós eredmény ISO C17 strtol Végpointer, visszatérési érték és az előírt módon előkészített errno
Explicit hibakód POSIX pthread_create A visszaadott kód: nulla siker, egyébként maga a hibaszám
Saját eredménytípus Az alábbi példa A visszaadott struktúra kategóriája és mezői

ISO C17: az errno az <errno.h> fejlécben meghatározott makró, amely módosítható, int típusú balértéket biztosít. Ne deklaráljuk saját kezűleg extern int errno alakban. Állapota szálanként különálló; nem egyetlen, minden szál által közösen írt egész változó.

Az errno nem általános sikerességi teszt. Korábbi hiba értéke megmaradhat benne, és egy sikeresen visszatérő könyvtári függvény is módosíthatja. Ezért az alábbi gondolatmenet hibás:

„Meghívtam egy függvényt, az errno nem nulla, tehát a függvény hibázott.”

Először a dokumentált sikertelenségi feltételt kell ellenőrizni. Az errno csak akkor értelmezhető, ha az adott API szerződése azt ehhez a helyzethez hozzárendeli. Az ISO C17 fopen null eredményére vonatkozó garanciájából önmagában nem következik POSIX-szerű errno-diagnosztika; ezt a POSIX-szerződés adja hozzá.

A strtol külön figyelmet igényel, mert például a nulla érvényes eredmény is lehet. Előtte errno = 0 szükséges a tartományhiba felismeréséhez; utána a végpointer jelzi, történt-e konverzió, az ERANGE pedig a dokumentált tartományhibát. Ez speciális szerződés, nem univerzális „nullázd, hívd, nézd” recept.

11.3.2 Hibakategória és újrapróbálhatóság

Az alkalmazási tartományhiba (domain error) azt jelenti, hogy a bemenet kívül esik az alkalmazás által elfogadott értékek vagy állapotok halmazán. Például egy időkorlát csak az 1≤t≤36001 \leq t \leq 3600 tartományban lehet érvényes. Ez nem azonos a matematikai könyvtár EDOM hibakódjával.

Az OS-hiba egy rendszerhez kötődő művelet hibája, például sikertelen fájlmegnyitás. A hiba eredetét nem az dönti el, hogy errno közvetítette-e: a strtol által jelzett ERANGE konverziós tartományhiba, nem fájlrendszer- vagy kernelhiba.

Az újrapróbálhatóság (retriable/non-retriable) ettől független döntés:

A retry-döntéshez tehát a hibakód mellett kell a művelet, a már bekövetkezett mellékhatás, az idempotencia és az időkeret is. A fclose sikertelensége például nem engedély arra, hogy ugyanazzal a FILE * értékkel ismét lezárást próbáljunk.

11.3.3 Kidolgozott hibaszerződés: az első hiba megőrzése

Írjunk olyan függvényt, amely időkorlátot ment egy szövegfájlba. Szerződése:

A path előfeltétele: a hívás teljes ideje alatt olvasható, érvényes nullterminált sztringre mutat. A függvény nem ellenőrizheti általánosan egy tetszőleges pointer érvényességét.

Profil: ISO C17 nyelv, Linux/POSIX libc-szerződés a hibaszámokhoz. Választott fordítási profil: GCC 14.2.0, glibc 2.40, x86-64 Linux. Az alábbi példa nem mért vagy igazoltan tesztelt futás eredménye.

/* error_contract.c */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>

enum error_kind {
    APP_OK,
    APP_TIMEOUT_RANGE,
    APP_OS_ERROR
};

enum operation {
    OP_NONE,
    OP_VALIDATE,
    OP_OPEN,
    OP_WRITE,
    OP_CLOSE
};

struct app_error {
    enum error_kind kind;
    enum operation operation;
    int os_errno;
    int cleanup_errno;
};

static struct app_error
write_timeout(const char *path, unsigned seconds)
{
    struct app_error error = { APP_OK, OP_NONE, 0, 0 };

    if (seconds < 1U || seconds > 3600U) {
        return (struct app_error) {
            APP_TIMEOUT_RANGE, OP_VALIDATE, 0, 0
        };
    }

    FILE *file = fopen(path, "w");
    if (file == NULL) {
        const int original_errno = errno;
        return (struct app_error) {
            APP_OS_ERROR, OP_OPEN, original_errno, 0
        };
    }

    if (fprintf(file, "timeout=%u\n", seconds) < 0) {
        const int original_errno = errno;
        error = (struct app_error) {
            APP_OS_ERROR, OP_WRITE, original_errno, 0
        };
    }

    if (fclose(file) == EOF) {
        const int close_errno = errno;

        if (error.kind == APP_OK) {
            error = (struct app_error) {
                APP_OS_ERROR, OP_CLOSE, close_errno, 0
            };
        } else {
            error.cleanup_errno = close_errno;
        }
    }

    /* A stream lezárult; a file értékét többé nem használjuk. */
    if (error.kind == APP_OS_ERROR) {
        errno = error.os_errno;
    }

    return error;
}

int main(int argc, char *argv[])
{
    if (argc != 2) {
        (void)fprintf(stderr, "usage: error_contract PATH\n");
        return EXIT_FAILURE;
    }

    const struct app_error error = write_timeout(argv[1], 30U);

    if (error.kind != APP_OK) {
        (void)fprintf(stderr,
                      "kind=%d operation=%d errno=%d cleanup_errno=%d\n",
                      (int)error.kind, (int)error.operation,
                      error.os_errno, error.cleanup_errno);
        return EXIT_FAILURE;
    }

    return EXIT_SUCCESS;
}

Fordítás a választott profilhoz tartozó gcc használatával:

gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -O2 \
    error_contract.c -o error_contract

A fprintf sikeressége még nem jelenti, hogy a pufferelt adat később is hibamentesen kiírható. Ezért a fclose eredményét önállóan ellenőrizzük. A sikeres lezárás sem tartóssági garancia; annak követelményeit a Fájlrendszer-műveletek, mmap és tartósság tárgyalja.

Objektum vagy erőforrás Tulajdonos Élettartam és érvényesség Hibakimenet
path mögötti sztring Hívó A függvény csak kölcsönzi a hívás idejére; felszabadítás vagy idő előtti élettartamvég érvényteleníti Előfeltétel-sértést a hibakód nem tesz biztonságossá
Megnyitott stream write_timeout Sikeres megnyitástól az egyszeri fclose hívásig Megnyitási, írási vagy lezárási OS-hiba
Visszaadott app_error Hívó Saját érték; könyvtári hívások nem érvénytelenítik, saját felülírása vagy élettartamvége igen Elsődleges és opcionális másodlagos hiba

Bizonyításvázlat. Megnyitási hiba esetén nincs lezárandó stream. Sikeres megnyitás után minden vezérlési út pontosan egyszer jut el a fclose hívásig. Írási hiba esetén az elsődleges hibakódot még a cleanup előtt lemásoljuk. A lezárási ág ezt csak akkor tölti ki, ha korábban nem volt hiba; egyébként kizárólag a másodlagos mezőt módosítja. Így az első hiba nem veszhet el.

A függvény nem tranzakciós: a "w" mód már megnyitáskor csonkolhatja a létező fájlt. Sikertelen eredmény mellett is lehet mellékhatás, ezért automatikus újrapróbálás vagy „hiba esetén minden változatlan” garancia nem következik belőle.

11.3.4 Hibaüzenetek és élettartamuk

ISO C17: a perror az aktuális errno alapján ír diagnosztikát a standard hibakimenetre. Ha a hibás művelet és a perror közé cleanup vagy naplózás kerül, már más hibát magyarázhat. Előbb mentsük el a hibaszámot; ha később perror szükséges, közvetlenül előtte állítsuk vissza.

A strerror(code) a megadott hibaszámhoz tartozó szöveget adja vissza. Ez nem hívó által birtokolt sztring: nem szabad felszabadítani vagy módosítani. Az ISO C17 szerződése alapján egy későbbi strerror hívás felülírhatja a tartalmát, és általános párhuzamos használati biztonságot sem feltételezhetünk.

A szálanként különálló errno tehát nem teszi automatikusan szálbiztossá a hibaüzenetek tárolását. Másik szálnak a hibaszám másolatát kell megfelelő szinkronizációval átadni, nem arra számítani, hogy ott ugyanaz az errno olvasható.

A strerror_r nem ISO C API, és neve mögött eltérő interfészek találhatók:

Változat Visszatérés Következmény
POSIX/XSI alak int státusz A hívó pufferébe ír; a sikerességet ellenőrizni kell
GNU/glibc alak char * A visszaadott pointer nem feltétlenül a megadott pufferre mutat

A glibc által láthatóvá tett alakot feature-test makrók, például _GNU_SOURCE, is befolyásolják. Hordozható wrapperhez a konkrét libc-verzió deklarációját és hibaszerződését ellenőrizni kell; a két alakot casttal összemosni hibás.

Tartós diagnosztikához általában jobb a hibakategória, művelet és numerikus kód érték szerinti tárolása, majd a szöveg előállítása a megjelenítés helyén. Ha a szöveget is meg kell őrizni, saját tárolóba kell másolni, explicit kapacitással és csonkolási szabállyal. A lokalizált üzenetszöveg nem stabil gépi hibakód.

11.3.5 Gyakori hibák és javításuk

A cleanup utáni errno elmentése túl késő: mindig közvetlenül a dokumentált hiba felismerése után másoljuk ki. Ugyanez vonatkozik a naplózó függvények elé kerülő mentésre.

Az első hibát egy későbbi lezárási hibával felülírni diagnosztikai információvesztés. A két esemény külön mezőt érdemel. Ha viszont korábban minden sikerült, a lezárási hiba lesz az elsődleges.

Hasonlóan hibás az összes nemnulla hibaszámot „OS-hiba” vagy „újrapróbálható hiba” kategóriába sorolni. A kategóriát a művelet szerződése, az újrapróbálhatóságot pedig a teljes végrehajtási helyzet határozza meg.

11.3.6 Feladatok

11.3.6.1 Magyarázó feladat

Egy sikeres fopen után az errno értéke EACCES. Bizonyít-e ez megnyitási hibát? Mit kell vizsgálni?

11.3.6.2 Kódolási feladat

Bővítsd a példát úgy, hogy a seconds == 0 és a seconds > 3600 külön alkalmazási hibakódot kapjon. Hibás bemenet esetén semmilyen fájlművelet ne történjen.

11.3.6.3 Diagnosztikai feladat

Tegyük fel, hogy a tesztadapter írási hibát jelez ENOSPC kóddal, majd a lezárás is hibázik EIO kóddal. Milyen mezőértékeket kell kapnia a hívónak? Mi történjen, ha csak a lezárás hibázik?

11.3.7 Megoldások

11.3.7.1 A sikeresség megállapítása

Nem bizonyít hibát. A nemnulla pointer a fopen sikerét jelzi; az errno ilyenkor nem használható a siker felülbírálására. Ellenőrzési feltétel: a hibakezelési ág kizárólag null eredmény esetén fusson.

11.3.7.2 Külön tartományhibák

Az alábbi ISO C17 részletek az előző teljes program megfelelő deklarációját és validációs ágát helyettesítik:

enum error_kind {
    APP_OK,
    APP_TIMEOUT_ZERO,
    APP_TIMEOUT_TOO_LARGE,
    APP_OS_ERROR
};
if (seconds == 0U) {
    return (struct app_error) {
        APP_TIMEOUT_ZERO, OP_VALIDATE, 0, 0
    };
}
if (seconds > 3600U) {
    return (struct app_error) {
        APP_TIMEOUT_TOO_LARGE, OP_VALIDATE, 0, 0
    };
}

Ellenőrzési értékek: 0, 1, 3600, 3601. A két szélső érvénytelen esetben a megnyitást helyettesítő tesztadapter hívásszáma nulla legyen.

11.3.7.3 Az elsődleges hiba megőrzése

Két hiba esetén az elvárt állapot: APP_OS_ERROR, OP_WRITE, os_errno == ENOSPC, cleanup_errno == EIO. Visszatéréskor az errno ismét ENOSPC.

Ha csak a lezárás hibázik: APP_OS_ERROR, OP_CLOSE, os_errno == EIO, cleanup_errno == 0. Ellenőrzési feltétel mindkét esetben az egyszeri lezárás és az elsődleges hibát megőrző eredmény.

11.3.8 Összefoglaló

A visszatérési érték mutatja meg, mikor vizsgálható a hibajelzés; az errno önmagában nem sikerességi teszt. Az eredeti hibát cleanup előtt kell elmenteni. A tartós hibaszerződés saját értékként tárolja a kategóriát, műveletet és hibakódot, a szöveges magyarázatot pedig külön kezeli. A tartományhiba, az OS-hiba és az újrapróbálhatóság három külön döntési szempont.