Frontend Labs
⋯

Part VIII — Writing safe, leak-free C

Parts I–VII taught you the mechanisms. This Part is the discipline layer on top: the leak shapes that actually show up in real bug trackers (rarely "forgot one free"), a checklist for writing new C defensively from the first line, and a bad-code/good-code catalog of the mistakes that keep recurring across beginner and intermediate C — each with the exact compiler flag or tool that would have caught it.

44. Memory leaks, in depth: every shape they actually take in production

Section 10.2 showed the textbook leak: allocate, never free. Real leaks in real codebases almost never look that clean — they hide in error paths, loops, and partial failures. Every example below is a pattern you will meet again.

44.1 The error-path leak — the single most common real-world leak

A function acquires several resources in sequence; an early one succeeds, a later one fails, and the function returns without releasing what it already acquired.

// BAD
typedef struct { FILE *log; char *buf1; char *buf2; } Ctx;

int setup_bad(Ctx *ctx) {
    ctx->log = fopen("/tmp/app.log", "w");
    if (!ctx->log) return -1;

    ctx->buf1 = malloc(1024);
    if (!ctx->buf1) return -1;      // LEAK: ctx->log is still open, never fclose'd

    ctx->buf2 = malloc(2048);
    if (!ctx->buf2) return -1;      // LEAK: ctx->log AND ctx->buf1 both leak here

    return 0;
}
// GOOD — Section 4.2's single forward goto to one cleanup label
int setup_good(Ctx *ctx) {
    int rc = -1;
    ctx->log = NULL; ctx->buf1 = NULL; ctx->buf2 = NULL;

    ctx->log = fopen("/tmp/app.log", "w");
    if (!ctx->log) goto cleanup;

    ctx->buf1 = malloc(1024);
    if (!ctx->buf1) goto cleanup;

    ctx->buf2 = malloc(2048);
    if (!ctx->buf2) goto cleanup;

    rc = 0;
cleanup:
    if (rc != 0) {
        free(ctx->buf1);              // free(NULL) is always safe (Section 10.3) —
        free(ctx->buf2);               // no need to check which ones actually got allocated
        if (ctx->log) fclose(ctx->log);
    }
    return rc;
}

Every resource this function might hold gets exactly one release path, reached from every failure point — this is why Section 4.2 introduced goto cleanup as idiomatic C rather than an anti-pattern: the alternative (repeating the right subset of cleanup calls before every return) is what actually produces error-path leaks, because it's easy to add a fourth resource later and forget to update three of the four early-return blocks.

44.2 The lost-reference leak

// BAD
void lost_reference_bad(void) {
    char *buf = malloc(100);
    buf = malloc(200);   // the 100-byte block's address is gone — nothing points to it anymore,
    free(buf);            // so it can never be freed for the rest of the program's life
}

// GOOD
void lost_reference_good(void) {
    char *buf = malloc(100);
    free(buf);             // release it BEFORE the variable that names it is reassigned
    buf = malloc(200);
    free(buf);
}

This is the leak hiding behind innocuous-looking code like result = process(result) where process allocates a new buffer and the caller forgot the old result needed freeing first — very common in string-building loops (s = concat(s, next_word), repeated).

44.3 The partial-cleanup leak in an array of allocations

// BAD
char **alloc_rows_bad(int n, int width) {
    char **rows = malloc((size_t)n * sizeof(char *));
    for (int i = 0; i < n; i++) {
        rows[i] = malloc((size_t)width);
        if (!rows[i]) {
            free(rows);      // LEAK: rows[0..i-1] were allocated and are now unreachable —
            return NULL;      // freeing the array of POINTERS did not free what they pointed to
        }
    }
    return rows;
}

// GOOD
char **alloc_rows_good(int n, int width) {
    char **rows = malloc((size_t)n * sizeof(char *));
    if (!rows) return NULL;
    for (int i = 0; i < n; i++) {
        rows[i] = malloc((size_t)width);
        if (!rows[i]) {
            for (int j = 0; j < i; j++) free(rows[j]);  // unwind exactly what succeeded so far
            free(rows);
            return NULL;
        }
    }
    return rows;
}

This is the array version of 44.1's error-path leak, and it generalizes to any structure built incrementally: a linked list built node-by-node, a hash table resized bucket-by-bucket — the rule is always "the cleanup path must free every element that succeeded before the one that failed, not just the outer container."

44.4 The realloc-failure leak (revisiting Section 10.1)

// BAD
void grow_bad(char **buf, size_t new_size) {
    *buf = realloc(*buf, new_size);   // if realloc fails, it returns NULL — and the ORIGINAL
}                                       // block's address, which *buf held, is now overwritten
                                        // and gone: a leak, not just "buf is now NULL"

// GOOD
int grow_good(char **buf, size_t new_size) {
    char *tmp = realloc(*buf, new_size);
    if (!tmp) return -1;    // *buf is untouched and still valid — free it if the caller gives up,
    *buf = tmp;               // or keep using the old, smaller buffer and try again later
    return 0;
}

44.5 Leaks aren't just malloc — every acquire needs a matching release

// BAD — leaks a file descriptor and a mutex lock, not "memory," but the
// same resource-lifetime discipline applies
int read_config_bad(const char *path, pthread_mutex_t *lock) {
    pthread_mutex_lock(lock);
    FILE *f = fopen(path, "r");
    if (!f) return -1;              // LEAK: lock is never unlocked — every future
                                      // caller of this function deadlocks forever
    // ... read ...
    fclose(f);
    pthread_mutex_unlock(lock);
    return 0;
}

A held mutex that's never unlocked, a socket that's never closed, a mmaped region that's never munmapped — all are "leaks" in the sense that matters: a finite resource acquired and never released. The ownership discipline from Section 10.3 (one _new for one _free, one lock for one unlock, one open for one close) is the same fix for all of them.

44.6 Finding leaks you already wrote

# Linux: the standard leak-triage tool, no recompilation needed
valgrind --leak-check=full --show-leak-kinds=all ./app

# Clang/GCC: LeakSanitizer, built into ASan on Linux (bundle with -fsanitize=address)
clang -std=gnu17 -g -fsanitize=address -o app app.c
ASAN_OPTIONS=detect_leaks=1 ./app     # prints each leak's allocation stack trace on exit

Valgrind's leak-kind vocabulary is worth knowing: definitely lost (nothing in the program points to this block anymore — a real leak, exactly Sections 44.1–44.3), still reachable (a global/static pointer still points to it at exit — usually fine, e.g. a cache freed by process exit instead of explicit code), and possibly lost (a pointer to the interior of the block still exists, but not to its start — often a custom allocator or intrusive-pointer pattern, worth a second look).


Generated from real-life-c-guide.md · 8 Oct 2026 16:36