17.2 C könyvtári mag és CLI megvalósítási labor

A könyvtári mag és a parancssori program közötti határ akkor hasznos, ha nem pusztán két forrásfájlt választ el, hanem két külön szerződést is. A mag bájtsorozatot értelmez, állapotot tart fenn és értékként visszaadható eredményt készít. A CLI fájlt nyit, olvasási hibát kezel, jelentést formáz, majd kilépési kóddal jelzi a művelet eredményét.

A Projektkövetelmény, protokoll és ownership térkép kanonikus projektszerződését itt valósítjuk meg: ugyanazt a rekordnyelvtant, CLI-felületet, kimeneti formátumot és kilépésikód-rendszert. Az önállóan elkészíthető laborhoz a szükséges részleteket megismételjük; eltérés esetén a 17.1 szerződése az irányadó. A TCP-adapter és a 39. fejezet C++ portja ugyanezt a könyvtári interfészt használja: a parser nem ismer sem FILE objektumot, sem file descriptort, sem hálózati kapcsolatot.

17.2.1 Implementációs szerződés és laborprofil

A bemenet nulla vagy több, LF-fel lezárt rekordból áll:

név=érték\n

A \n itt egyetlen, 0x0a értékű oktettet jelöl, nem két nyomtatható karaktert.

Elem Szerződés
Név 1–16 ASCII kisbetű, kizárólag a–z
Elválasztó Egyetlen =
Érték 1–5 decimális számjegy, értéke legfeljebb 65535
Vezető nullák Megengedettek, de beleszámítanak az öt számjegybe
Rekordlezárás Kötelező LF; CRLF nem elfogadott
Üres bemenet Érvényes, nulla rekorddal
Maximális bemenet 1 048 576 C bájt
Ismétlődő név Megengedett; minden rekord külön számít
Eredmény Rekordszám, értékösszeg, elfogadott bájtok száma

A parser nem tárolja el a neveket: ez a változat a rekordfolyam szerkezeti érvényességét és összesített számait vizsgálja, nem metrikanevekből épít adatbázist.

Nyelvi és platformbesorolás. A könyvtár ISO C17 nyelvi és könyvtári eszközöket használ, 8 bites C bájtot, megfelelő méretű size_t típust és ASCII-kompatibilis karakterkészletet feltételezve. A CLI Linux/POSIX kiegészítést használ a SIGPIPE kezelésére. Nem használunk exact-width egész típust, így például a uint64_t elérhetősége nem előfeltétel.

A labor profilja a központi profiltábla lab-gcc profilja: x86-64 Linux, System V ABI, LP64, GCC 13.3.0, glibc 2.39, GNU binutils 2.42, explicit -std=c17, GNU nyelvi bővítések nélkül. A buildszkriptek clang ága a lab-clang profilhoz tartozik. A labor buildjét és tesztjeit a lab-gcc profilban lefuttattuk; az eredményeket az alfejezet végi buildjegyzőkönyv rögzíti. A saját laborban a tényleges fordító-, libc- és eszközverziókat a központi tábla mintájára kell rögzíteni.

17.2.1.1 A CLI stabil külső felülete

A használat:

diag [FILE|-]

Argumentum nélkül vagy - argumentummal a program a standard bemenetet olvassa. Siker esetén a standard kimeneten pontosan egy sor jelenik meg:

OK records=2 sum=19 bytes=11

A mezősorrend, az ASCII szóközök és a záró LF a szerződés részei.

Kilépési kód Jelentés Például
0 Teljes siker A jelentés kiírása és flush művelete is sikerült
2 Hibás CLI-használat Túl sok argumentum
3 Bemeneti I/O-hiba Megnyitás, olvasás vagy saját fájl lezárása
4 Hibás bemenet Szintaxis, csonkolás vagy méretlimit
5 Erőforrás- vagy belső állapothiba Allokáció vagy jelkezelési beállítás
6 Jelentésírási hiba Sikertelen formázott kiírás vagy flush

Parserhiba esetén a standard hibakimenet formátuma:

ERR code=truncated offset=10

Más hibákhoz nem kapcsolunk értelmetlen bájtoffsetet, például:

ERR code=open

A hibakimenet írása best effort: hibás stderr esetén üzenetet nem tudunk garantálni. A kilépési kódok a program rendes vezérlési útjaira vonatkoznak, nem például a folyamat külső, kényszerített megszüntetésére.

17.2.2 Fájlszerkezet, tulajdonlás és tárolóválasztás

A projekt elrendezése:

include/
    diag.h
src/
    diag.c
    main.c
tests/
    test_diag.c
    spec_examples.c
    cli_args.sh
    golden.in
    golden.out
    truncated.in
    truncated.err
build/

Az opaque parser olyan típus, amelynek deklarációja publikus, objektumreprezentációja viszont csak a megvalósításban látható. A kliens ezért nem tudja közvetlenül elrontani például az aktuális számjegyszámot. A határ nem teszi automatikusan örökké ABI-kompatibilissé a teljes API-t, de elrejti a parser belső méretét és elrendezését.

Erőforrás vagy adat Tulajdonos Élettartam, érvénytelenítés
Parserobjektum CLI vagy teszt Létrehozástól dg_destroy hívásig
Allokátor-context Az API hívója A parser megsemmisítéséig élnie kell
Olvasópuffer CLI Automatikus tároló a main végéig
dg_view által jelölt adat A hívó A dg_feed hívás alatt olvasható; a parser nem őrzi meg
Másolt dg_error A másolatot tároló hívó Független a parser és a bemenet későbbi élettartamától
Saját megnyitott FILE CLI Pontosan egyszer lezárandó
stdin, stdout, stderr Futási környezet A CLI kölcsönzött hozzáféréssel használja

Miért nincs arena? Az arena közös élettartamú objektumcsoportok egyszerű felszabadítására alkalmas. Itt azonban nem őrzünk rekordokat vagy neveket: a parser egyetlen, állandó méretű objektum. Egy arena bevezetése csak további állapotot és hibalehetőséget teremtene.

A byte view nem tulajdonló, hosszával együtt átadott adatszelet. Nem nullterminált sztring, ezért beágyazott nullbájt esetén sem rövidül meg „magától”: a parser azt szintaktikai hibaként látja.

17.2.3 A könyvtári mag

17.2.3.1 Publikus header: include/diag.h

A következő fájl önállóan include-olható C17 header. Nem teszi szükségessé, hogy a kliens előtte más headert is beillesszen.

#ifndef DIAG_H
#define DIAG_H

#include <stdbool.h>
#include <stddef.h>

#define DG_MAX_INPUT ((size_t)1048576UL)

typedef struct dg_parser dg_parser;

typedef struct {
    const unsigned char *data;
    size_t size;
} dg_view;

typedef enum {
    DG_OK = 0,
    DG_SYNTAX,
    DG_TRUNCATED,
    DG_TOO_LARGE,
    DG_STATE
} dg_code;

typedef struct {
    dg_code code;
    size_t offset;
} dg_error;

typedef struct {
    size_t bytes;
    size_t records;
    unsigned long long sum;
} dg_report;

typedef struct {
    void *(*allocate)(size_t size, void *context);
    void (*release)(void *pointer, void *context);
    void *context;
} dg_allocator;

/* NULL allocator selects malloc/free. */
dg_parser *dg_create(const dg_allocator *allocator);
void dg_destroy(dg_parser *parser);

/* Input is borrowed only for the duration of this call. */
bool dg_feed(dg_parser *parser, dg_view input);

/* Declares end of input. Repeated successful finish is allowed. */
bool dg_finish(dg_parser *parser);

/* Returned values do not borrow from parser. */
dg_error dg_get_error(const dg_parser *parser);
dg_report dg_get_report(const dg_parser *parser);

/* Returns a static string; the caller must not release it. */
const char *dg_code_name(dg_code code);

#endif

A hívási előfeltételek fontosak:

A konstruktor lemásolja az allokátorleírót, de nem másolja le a context által jelölt objektumot. A context élettartama ezért továbbra is a hívó felelőssége.

17.2.3.2 Megvalósítás: src/diag.c

A parser két fázist különböztet meg: névolvasást és számolvasást. A névolvasás nulla névhosszal egyben rekordhatárt is jelent.

#include "diag.h"

#include <limits.h>
#include <stdint.h>
#include <stdlib.h>

_Static_assert(CHAR_BIT == 8, "8-bit C bytes required");
_Static_assert(SIZE_MAX >= 1048577UL, "size_t too narrow");
_Static_assert('a' == 97 && 'z' == 122 &&
               '0' == 48 && '9' == 57 &&
               '=' == 61 && '\n' == 10,
               "ASCII-compatible execution character set required");

enum phase {
    PH_KEY = 0,
    PH_NUMBER
};

struct dg_parser {
    dg_allocator allocator;
    dg_report report;
    dg_error error;
    size_t key_length;
    unsigned digits;
    unsigned long value;
    enum phase phase;
    bool finished;
};

static void *default_allocate(size_t size, void *context)
{
    (void)context;
    return malloc(size);
}

static void default_release(void *pointer, void *context)
{
    (void)context;
    free(pointer);
}

static bool fail(dg_parser *parser, dg_code code)
{
    if (parser->error.code == DG_OK) {
        parser->error.code = code;
        parser->error.offset = parser->report.bytes;
    }
    return false;
}

dg_parser *dg_create(const dg_allocator *allocator)
{
    dg_allocator chosen = {
        default_allocate, default_release, NULL
    };

    if (allocator != NULL) {
        chosen = *allocator;
    }

    dg_parser *parser =
        chosen.allocate(sizeof *parser, chosen.context);

    if (parser == NULL) {
        return NULL;
    }

    *parser = (dg_parser){ .allocator = chosen };
    return parser;
}

void dg_destroy(dg_parser *parser)
{
    if (parser != NULL) {
        dg_allocator allocator = parser->allocator;
        allocator.release(parser, allocator.context);
    }
}

bool dg_feed(dg_parser *parser, dg_view input)
{
    if (parser->error.code != DG_OK) {
        return false;
    }
    if (parser->finished) {
        return fail(parser, DG_STATE);
    }

    for (size_t i = 0; i < input.size; ++i) {
        if (parser->report.bytes == DG_MAX_INPUT) {
            return fail(parser, DG_TOO_LARGE);
        }

        unsigned char byte = input.data[i];

        if (parser->phase == PH_KEY) {
            if (byte >= 'a' && byte <= 'z') {
                if (parser->key_length == 16) {
                    return fail(parser, DG_SYNTAX);
                }
                ++parser->key_length;
            } else if (byte == '=' && parser->key_length != 0) {
                parser->phase = PH_NUMBER;
                parser->digits = 0;
                parser->value = 0;
            } else {
                return fail(parser, DG_SYNTAX);
            }
        } else {
            if (byte >= '0' && byte <= '9') {
                unsigned long digit = (unsigned long)(byte - '0');

                if (parser->digits == 5 ||
                    parser->value > (65535UL - digit) / 10UL) {
                    return fail(parser, DG_SYNTAX);
                }

                parser->value = parser->value * 10UL + digit;
                ++parser->digits;
            } else if (byte == '\n' && parser->digits != 0) {
                ++parser->report.records;
                parser->report.sum += parser->value;
                parser->phase = PH_KEY;
                parser->key_length = 0;
            } else {
                return fail(parser, DG_SYNTAX);
            }
        }

        ++parser->report.bytes;
    }

    return true;
}

bool dg_finish(dg_parser *parser)
{
    if (parser->error.code != DG_OK) {
        return false;
    }

    if (parser->phase != PH_KEY || parser->key_length != 0) {
        return fail(parser, DG_TRUNCATED);
    }

    parser->finished = true;
    return true;
}

dg_error dg_get_error(const dg_parser *parser)
{
    return parser->error;
}

dg_report dg_get_report(const dg_parser *parser)
{
    return parser->report;
}

const char *dg_code_name(dg_code code)
{
    switch (code) {
    case DG_OK:        return "ok";
    case DG_SYNTAX:    return "syntax";
    case DG_TRUNCATED: return "truncated";
    case DG_TOO_LARGE: return "limit";
    case DG_STATE:     return "state";
    }
    return "state";
}

17.2.3.3 Offset, tartós hibaadat és számtani korlátok

Az offset nulla alapú. Szintaktikai hibánál az első elutasított bájt helyét, csonkolásnál a bemenet végét jelöli. A limit túllépésekor az első már nem engedélyezett bájt offsetje pontosan DG_MAX_INPUT.

A számláló csak sikeresen feldolgozott bájt után nő. Emiatt a hibát kiváltó bájt helye további korrekció nélkül rendelkezésre áll. A hiba ragadós: az első hiba után minden újabb feed és finish sikertelen, az eredeti hibaadat változatlan marad.

A dg_error nem tartalmaz bemenetbe mutató pointert vagy ideiglenes formázópuffert. A másolata a parser megsemmisítése után is használható. A hibakód szöveges neve statikus sztring; azt sem kell felszabadítani.

A számfeldolgozásnál legyen vv az eddigi érték, dd az új számjegy, M=65535M=65535. A következő érték:

v′=10v+d. v' = 10v+d.

Mivel 0≤d≤90\le d\le9, a megengedhetőség feltétele:

v≤⌊M−d10⌋. v \le \left\lfloor\frac{M-d}{10}\right\rfloor.

Ezt a szorzás előtt ellenőrizzük. Nem előbb számolunk egy esetleg túl nagy értéket, hogy utólag vizsgáljuk.

Bizonyításvázlat az összeg korlátjára. A legrövidebb rekord négy bájt, például a=0\n. Ha L=1048576L=1048576, akkor legfeljebb L/4=262144L/4=262144 teljes rekord fér el. Így:

sum≤262144⋅65535=17179607040. \text{sum}\le262144\cdot65535 =17179607040.

Ez belefér az ISO C17 által az unsigned long long számára garantált minimális értéktartományba. A bizonyítás a rögzített bemeneti limitre támaszkodik: a limit későbbi emelésekor ezt az érvelést is felül kell vizsgálni.

A feldolgozás legrosszabb esetben O(n)O(n) idő és O(1)O(1) parsermemória. Bájtanként korlátos számú művelet történik, nincs amortizált növekedés vagy rekordfüggő allokáció. A hívás után az inputpuffer azonnal újrahasználható.

17.2.4 A hibakezelt CLI

A CLI blokkoló, kötegelt feldolgozó. A fread használata itt nem helyettesíti a későbbi nonblocking állapotgépet.

A ciklus legfeljebb a fennmaradó keret plusz egy bájtját kéri ki a FILE adatfolyamból. Az extra bájt szükséges ahhoz, hogy megkülönböztessük a pontosan limitméretű bemenetet a túl hosszútól. Ez alkalmazási szintű korlát: a standard I/O belső előreolvasása külön mechanizmus.

17.2.4.1 Teljes program: src/main.c

A fájl C17 és Linux/POSIX környezetet igényel. A buildparancsban szereplő feature-test makró teszi elérhetővé a használt POSIX deklarációkat.

#include "diag.h"

#include <signal.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>

static void complain(const char *code)
{
    (void)fprintf(stderr, "ERR code=%s\n", code);
}

static int parser_failure(const dg_parser *parser)
{
    dg_error error = dg_get_error(parser);

    (void)fprintf(stderr, "ERR code=%s offset=%zu\n",
                  dg_code_name(error.code), error.offset);

    return error.code == DG_STATE ? 5 : 4;
}

int main(int argc, char **argv)
{
    int status = 0;
    FILE *input = stdin;
    bool own_input = false;
    dg_parser *parser = NULL;
    dg_report report = {0};
    size_t seen = 0;
    unsigned char buffer[4096];

    struct sigaction action = {0};
    action.sa_handler = SIG_IGN;

    if (sigemptyset(&action.sa_mask) == -1 ||
        sigaction(SIGPIPE, &action, NULL) == -1) {
        complain("setup");
        status = 5;
        goto cleanup;
    }

    if (argc > 2) {
        complain("usage");
        status = 2;
        goto cleanup;
    }

    if (argc == 2 && strcmp(argv[1], "-") != 0) {
        input = fopen(argv[1], "rb");
        if (input == NULL) {
            complain("open");
            status = 3;
            goto cleanup;
        }
        own_input = true;
    }

    parser = dg_create(NULL);
    if (parser == NULL) {
        complain("alloc");
        status = 5;
        goto cleanup;
    }

    for (;;) {
        size_t remaining = DG_MAX_INPUT - seen + 1;
        size_t wanted =
            remaining < sizeof buffer ? remaining : sizeof buffer;

        size_t count = fread(buffer, 1, wanted, input);

        if (ferror(input)) {
            complain("read");
            status = 3;
            goto cleanup;
        }

        if (count != 0) {
            dg_view view = { buffer, count };

            if (!dg_feed(parser, view)) {
                status = parser_failure(parser);
                goto cleanup;
            }
            seen += count;
        }

        if (feof(input)) {
            break;
        }

        if (count == 0) {
            complain("read");
            status = 3;
            goto cleanup;
        }
    }

    if (!dg_finish(parser)) {
        status = parser_failure(parser);
        goto cleanup;
    }

    report = dg_get_report(parser);

cleanup:
    if (own_input) {
        if (fclose(input) == EOF && status == 0) {
            complain("close");
            status = 3;
        }
    }

    dg_destroy(parser);

    if (status == 0) {
        if (fprintf(stdout,
                    "OK records=%zu sum=%llu bytes=%zu\n",
                    report.records, report.sum, report.bytes) < 0) {
            complain("write");
            status = 6;
        }

        if (fflush(stdout) == EOF) {
            if (status == 0) {
                complain("write");
            }
            status = 6;
        }
    }

    return status;
}

A seen változó csak teljesen sikeres feed után változik. Ekkor legfeljebb a limitet érheti el, ezért a DG_MAX_INPUT - seen kivonás nem csordul alul.

Az olvasás után előbb az I/O-hibát ellenőrizzük. Ha ugyanaz a fread részadatot és hibajelzést is eredményez, az I/O-hiba elsőbbséget kap; a részadatból nem készül sikeres jelentés.

A cleanup ágban a fclose minden saját fájlon lefut, akkor is, ha már van korábbi hiba. A lezárási hiba csak akkor módosítja az eredményt, ha addig sikeres volt a feldolgozás. Így egy másodlagos takarítási hiba nem tünteti el az elsődleges parserhibát.

A jelentés a saját bemenet sikeres lezárása után jelenik meg. A %zu a size_t, a %llu az unsigned long long megfelelő formátuma.

Linux/POSIX viselkedés: megszűnt olvasóoldalú pipe esetén az alapértelmezett SIGPIPE megszakíthatná a folyamatot a saját hibakezelés előtt. Ezért a CLI figyelmen kívül hagyja ezt a jelet, és az írás hibáját vizsgálja. A sikeres fprintf még nem elég: pufferelt kimenetnél a hiba csak fflush alatt is megjelenhet.

A kimenet nem tranzakciós. Íráshiba után maradhat részleges jelentés a fogadónál; csak a nulla kilépési kód igazolja a teljes sikerességet.

17.2.5 Tesztek és fordítás

17.2.5.1 Könyvtári tesztek: tests/test_diag.c

A teszt C17-ben fordítható. A CHECK ellenőrzései NDEBUG mellett sem tűnnek el.

#include "diag.h"

#include <stdio.h>
#include <stdlib.h>

#define CHECK(condition) do {                                      \
    if (!(condition)) {                                            \
        (void)fprintf(stderr, "CHECK failed at line %d\n", __LINE__); \
        exit(EXIT_FAILURE);                                        \
    }                                                              \
} while (0)

static void test_splits(void)
{
    static const unsigned char input[] = "a=7\ncpu=12\n";
    const size_t length = sizeof input - 1;

    for (size_t split = 0; split <= length; ++split) {
        dg_parser *parser = dg_create(NULL);
        CHECK(parser != NULL);

        CHECK(dg_feed(parser, (dg_view){ input, split }));
        CHECK(dg_feed(parser,
                      (dg_view){ input + split, length - split }));
        CHECK(dg_finish(parser));
        CHECK(dg_finish(parser));

        dg_report report = dg_get_report(parser);
        CHECK(report.records == 2);
        CHECK(report.sum == 19);
        CHECK(report.bytes == 11);

        dg_destroy(parser);
    }
}

static void test_errors(void)
{
    unsigned char bad[] = "a=7\ncpu=12x\n";
    dg_parser *parser = dg_create(NULL);
    CHECK(parser != NULL);

    CHECK(!dg_feed(parser, (dg_view){ bad, sizeof bad - 1 }));
    dg_error saved = dg_get_error(parser);
    CHECK(saved.code == DG_SYNTAX);
    CHECK(saved.offset == 10);

    bad[10] = '\n';
    CHECK(!dg_feed(parser, (dg_view){ bad, sizeof bad - 1 }));
    CHECK(dg_get_error(parser).offset == saved.offset);
    CHECK(!dg_finish(parser));
    dg_destroy(parser);

    CHECK(saved.code == DG_SYNTAX);
    CHECK(saved.offset == 10);

    static const unsigned char truncated[] = "a=7\ncpu=12";
    parser = dg_create(NULL);
    CHECK(parser != NULL);
    CHECK(dg_feed(parser,
                  (dg_view){ truncated, sizeof truncated - 1 }));
    CHECK(!dg_finish(parser));
    CHECK(dg_get_error(parser).code == DG_TRUNCATED);
    CHECK(dg_get_error(parser).offset == 10);
    dg_destroy(parser);
}

struct test_heap {
    size_t allocations;
    size_t releases;
    bool fail;
};

static void *test_allocate(size_t size, void *context)
{
    struct test_heap *heap = context;
    ++heap->allocations;
    return heap->fail ? NULL : malloc(size);
}

static void test_release(void *pointer, void *context)
{
    struct test_heap *heap = context;
    ++heap->releases;
    free(pointer);
}

static void test_allocation(void)
{
    struct test_heap heap = {0};
    dg_allocator allocator = {
        test_allocate, test_release, &heap
    };

    heap.fail = true;
    CHECK(dg_create(&allocator) == NULL);
    CHECK(heap.allocations == 1);
    CHECK(heap.releases == 0);

    heap.fail = false;
    dg_parser *parser = dg_create(&allocator);
    CHECK(parser != NULL);

    static const unsigned char bad[] = "!";
    CHECK(!dg_feed(parser, (dg_view){ bad, 1 }));
    dg_destroy(parser);
    dg_destroy(NULL);

    CHECK(heap.allocations == 2);
    CHECK(heap.releases == 1);
}

static void test_limit(void)
{
    static const unsigned char record[] = "a=0\n";

    for (int extra = 0; extra < 2; ++extra) {
        dg_parser *parser = dg_create(NULL);
        CHECK(parser != NULL);

        for (size_t i = 0; i < DG_MAX_INPUT / 4; ++i) {
            CHECK(dg_feed(parser, (dg_view){ record, 4 }));
        }

        CHECK(dg_get_report(parser).bytes == DG_MAX_INPUT);

        if (extra == 0) {
            CHECK(dg_finish(parser));
        } else {
            CHECK(!dg_feed(parser, (dg_view){ record, 1 }));
            CHECK(dg_get_error(parser).code == DG_TOO_LARGE);
            CHECK(dg_get_error(parser).offset == DG_MAX_INPUT);
        }

        dg_destroy(parser);
    }
}

int main(void)
{
    test_splits();
    test_errors();
    test_allocation();
    test_limit();
    return 0;
}

A darabolási teszt a golden bemenet minden két részre bontását kipróbálja, beleértve az üres első vagy második részt. A input + length one-past pointer csak nulla hosszúságú view-ban szerepel, és nem dereferáljuk.

Bizonyításvázlat a darabolásfüggetlenségre. A parser minden állapotváltozása egy bemeneti bájthoz kötődik; a feed-hívások határán nincs implicit rekordlezárás vagy állapotnullázás. Ugyanaz a bájtsorozat ezért ugyanazt az állapotátmenet-sorozatot eredményezi. A végleges lezárás kizárólag a dg_finish feladata. A teszt ennek konkrét ellenőrzése, nem minden lehetséges bemenet teljes bizonyítása.

Az allokációs teszt lefedi a könyvtár egyetlen allokációs pontjának hibáját. Nem vizsgálja a libc belső allokációit, és önmagában nem bizonyítja a teljes CLI minden erőforráshibás útját.

17.2.5.2 Fordítás két fordítóval

Az alábbi parancsokat a tanuló külön laborban használhatja. Alapértelmezésben mindkét fordítóval (lab-gcc és lab-clang profil) épít; ha csak az egyik érhető el, a COMPILERS változóval szűkíthető, például COMPILERS=gcc sh build.sh.

set -eu

for cc in ${COMPILERS:-gcc clang}; do
    mkdir -p "build/$cc"

    "$cc" -std=c17 -O2 -g \
        -Wall -Wextra -Wpedantic -Wconversion -Wshadow \
        -Iinclude -c src/diag.c -o "build/$cc/diag.o"

    ar rcs "build/$cc/libdiag.a" "build/$cc/diag.o"

    "$cc" -std=c17 -O2 -g \
        -Wall -Wextra -Wpedantic -Wconversion -Wshadow \
        -D_POSIX_C_SOURCE=200809L -Iinclude \
        src/main.c "build/$cc/libdiag.a" -o "build/$cc/diag"

    "$cc" -std=c17 -O2 -g \
        -Wall -Wextra -Wpedantic -Wconversion -Wshadow \
        -Iinclude tests/test_diag.c "build/$cc/libdiag.a" \
        -o "build/$cc/test_diag"
done

A statikus könyvtár a rá hivatkozó forrásból képzett objektum után kerül a linkparancsba. A publikus header önmagában nem tartalmaz definíciót a parser függvényeihez; azokat a könyvtár szolgáltatja.

17.2.5.3 Golden és csonkolt bemenet

A fixture fájlokat az alábbi parancsok pontosan, a záró LF jelenlétét is egyértelművé téve állítják elő:

printf 'a=7\ncpu=12\n' > tests/golden.in
printf 'OK records=2 sum=19 bytes=11\n' > tests/golden.out

printf 'a=7\ncpu=12' > tests/truncated.in
printf 'ERR code=truncated offset=10\n' > tests/truncated.err

A golden teszt ismert bemenethez előre rögzített, bájtpontosan összehasonlítható kimenetet rendel. A csonkolt változatból csak az utolsó LF hiányzik, de ettől az egész feldolgozás sikertelen: nincs részleges sikerjelentés.

set -eu

for cc in ${COMPILERS:-gcc clang}; do
    "build/$cc/test_diag"

    "build/$cc/diag" tests/golden.in > "build/$cc/golden.actual"
    cmp tests/golden.out "build/$cc/golden.actual"

    rc=0
    "build/$cc/diag" tests/truncated.in \
        > "build/$cc/truncated.out" \
        2> "build/$cc/truncated.err" || rc=$?

    test "$rc" -eq 4
    test ! -s "build/$cc/truncated.out"
    cmp tests/truncated.err "build/$cc/truncated.err"
done

A parancssori felület maga is a szerződés része, ezért az argumentumfeldolgozásnak külön regressziós tesztje van. A szkript a kilépési kódot, a teljes standard kimenetet és a teljes hibakimenetet is összeveti a projektszerződés táblázatával:

#!/bin/sh
# tests/cli_args.sh DIAG -- az argumentumfeldolgozás regressziós tesztje
set -u
diag=$1
fail=0
errf=$(mktemp) || exit 2

expect() {  # várt_rc várt_stdout várt_stderr parancs...
    want_rc=$1 want_out=$2 want_err=$3
    shift 3
    out=$("$@" 2>"$errf") && rc=0 || rc=$?
    err=$(cat "$errf")
    if [ "$rc" != "$want_rc" ] || [ "$out" != "$want_out" ] ||
       [ "$err" != "$want_err" ]; then
        echo "FAIL: $* -> rc=$rc out=[$out] err=[$err]" >&2
        fail=1
    fi
}

expect 0 'OK records=2 sum=19 bytes=11' '' "$diag" tests/golden.in
expect 0 'OK records=2 sum=19 bytes=11' '' sh -c "\"$diag\" - < tests/golden.in"
expect 0 'OK records=2 sum=19 bytes=11' '' sh -c "\"$diag\" < tests/golden.in"
expect 2 '' 'ERR code=usage' "$diag" tests/golden.in extra
expect 3 '' 'ERR code=open' "$diag" tests/does-not-exist.in
expect 4 '' 'ERR code=truncated offset=10' "$diag" tests/truncated.in
rm -f "$errf"
exit $fail

Futtatása fordítónként: sh tests/cli_args.sh build/gcc/diag. Sikeres esetben nem ír semmit, és 0 kóddal lép ki.

Buildjegyzőkönyv (lab-gcc profil). A fenti forrásokat, a fixture-öket, a golden- és a csonkolási tesztet, a tests/cli_args.sh szkriptet és a sanitizeres könyvtártesztet Linux x86-64 környezetben, GCC 13.3.0, glibc 2.39 és GNU binutils 2.42 mellett lefuttattuk; a fordítás figyelmeztetés nélküli volt, minden ellenőrzés sikeres. A sanitizeres buildet GCC-vel (-fsanitize=address,undefined) futtattuk. Clanggal és a fent választott GCC 14.2.0-val ebben a jegyzőkönyvben nem fordítottunk; ezeket a saját laborban kell rögzíteni.

A könyvtár és tesztje külön diagnosztikai buildben is fordítható:

clang -std=c17 -O1 -g -Wall -Wextra -Wpedantic \
    -fsanitize=address,undefined -fno-omit-frame-pointer \
    -Iinclude src/diag.c tests/test_diag.c \
    -o build/test_diag_san

Ez fordítási recept, nem sanitizermentes futásról szóló állítás.

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

Hibaminta Miért hibás? Javítás
strlen használata az olvasópufferen Nem garantált a nullterminálás; a nullbájt is bemenet A fread által visszaadott hossz átadása
Rekordlezárás minden feed végén Az I/O-darabolás nem protokollhatár Állapot megőrzése a hívások között
Offset pointerként való tárolása A puffer újrahasználata érvényteleníti a tartalmi hivatkozást Számszerű offset és másolható hibakód
Sikerjelentés a finish előtt A végső rekord csonkolt lehet Explicit EOF-feldolgozás
fflush eredményének elhagyása A tényleges írás később is hibázhat Flush és hibakód ellenőrzése
A cleanup hibája mindig felülírja az eredményt Elveszhet az eredeti ok Elsődleges hiba megőrzése

17.2.7 Feladatok

17.2.7.1 Magyarázó feladat: prefix és teljes eredmény

A parser megkapja az a=7\ncpu=12 bemenetet. Milyen jelentést ad vissza közvetlenül a feed után? Mi történik finish esetén, és miért nem írhatja ki a CLI a prefix jelentését sikeres eredményként?

17.2.7.2 Kódolási feladat: numerikus határértékek

Bővítsd a tesztet a következő bemenetekkel:

Ellenőrizd az elfogadást, illetve az első hibás bájt offsetjét. A teszt minden parserobjektumot szabadítson fel.

17.2.7.3 Diagnosztikai feladat: sikeres parse, hibás kimenet

Linuxon irányítsd a golden bemenet jelentését a /dev/full eszközre. Milyen kilépési kódot vársz? Miért lehet hibás az a javítás, amely csak a fprintf eredményét ellenőrzi? Magyarázd meg azt is, miért nem várunk release hívást a sikertelen konstruktor után.

17.2.8 Megoldások

17.2.8.1 Prefix és teljes eredmény

A feed sikeres: még nem tudhatja, érkezik-e folytatás. A pillanatnyi jelentés:

A második rekord értéke már feldolgozható volt, de LF nélkül még nincs teljes rekord. A finish eredménye DG_TRUNCATED, offsetje 10. Az ellenőrzési feltétel: a CLI 4-es kóddal lép ki, és a standard kimenete üres. A prefixstatisztika diagnosztikai adat, nem végleges sikereredmény.

17.2.8.2 Numerikus határértékek

Az alábbi C17 részlet a tesztfájlba illeszthető. Hozzá kell adni a <string.h> include-ot, és a main függvényből meg kell hívni a tesztet.

static void test_numeric_boundaries(void)
{
    const struct {
        const char *input;
        dg_code code;
        size_t offset;
    } cases[] = {
        { "k=65535\n",  DG_OK,     0 },
        { "k=65536\n",  DG_SYNTAX, 6 },
        { "k=000000\n", DG_SYNTAX, 7 }
    };

    for (size_t i = 0; i < sizeof cases / sizeof cases[0]; ++i) {
        dg_parser *parser = dg_create(NULL);
        CHECK(parser != NULL);

        bool accepted = dg_feed(parser, (dg_view){
            (const unsigned char *)cases[i].input,
            strlen(cases[i].input)
        });

        if (cases[i].code == DG_OK) {
            CHECK(accepted);
            CHECK(dg_finish(parser));
            CHECK(dg_get_report(parser).sum == 65535);
        } else {
            CHECK(!accepted);
            dg_error error = dg_get_error(parser);
            CHECK(error.code == cases[i].code);
            CHECK(error.offset == cases[i].offset);
        }

        dg_destroy(parser);
    }
}

Itt a strlen helyes: a tesztadat garantáltan nullterminált sztringliterál, beágyazott nullbájt nélkül. A 65536 utolsó számjegye a 6-os offseten sérti az értékkorlátot; a hatodik nulla a 7-es offseten sérti a számjegyszám korlátját.

17.2.8.3 Hibás kimenet és allokációs hiba

A /dev/full Linux-specifikus diagnosztikai eszköz. Az ellenőrzés várt eredménye 6-os kilépési kód és ERR code=write hibasor, feltéve, hogy a hibakimenet írható.

rc=0
build/gcc/diag tests/golden.in > /dev/full 2> build/full.err || rc=$?
test "$rc" -eq 6

A rövid jelentés elférhet a standard I/O pufferében, ezért a fprintf sikeres lehet, miközben a fflush hibázik. Mindkét műveletet ellenőrizni kell.

Sikertelen konstruktor esetén az allokátor null pointert adott vissza: nem keletkezett tulajdonolt parserobjektum. Nincs mit felszabadítani. A teszt elvárt számlálói egy sikertelen, majd egy sikeres konstrukció és annak megsemmisítése után: két allokációs kísérlet, egy felszabadítás.

17.2.9 Rövid összefoglaló

A könyvtári mag kizárólag állapotot és másolható eredményt birtokol; a bemenetet csak a feed-hívás idejére kölcsönzi. A CLI feladata az I/O, a végleges lezárás, a stabil report és a hibakódok kezelése.

A helyességhez nem elég, hogy egy golden bemenet „jó számokat” ad. A darabolási határok, a csonkolt EOF, a pontos méretlimit, az allokációs hiba és a késleltetett kimeneti hiba ugyanannak a szerződésnek a részei. Ez a világos határ teszi lehetővé, hogy a következő lépésben a könyvtár változtatása nélkül építsünk hozzá nonblocking TCP-adaptert.