Two `if`s, One Already-Freed Node
My delete_node printed the right list on every run I tried by hand. AddressSanitizer disagreed — and it was right, for a reason plain output could never have shown me.
This was a warm-up drill, not even the real thing — one dedicated session on linked lists before the Phase 1 Milestone Test, specifically to shake out delete_node before it counted. It still took six rounds and two separate bugs to get clean, and the way it got clean is the part worth writing down.
The setup
delete_node walks a list with prev/cur, looking for a target value, and has to handle three shapes of match — head, middle, tail — plus "not found" without touching anything. I wrote it as two separate, top-level if blocks, not an if/else if:
if (prev == NULL) {
*head = cur->next;
free(cur);
}
if (cur->data == target) {
prev->next = cur->next;
free(cur);
}It compiled clean. It even passed every test case I ran by hand, printing the correct list every single time.
What went wrong
Delete the head node, and the first if fires: *head gets reassigned, cur gets freed. Nothing stops execution from falling straight into the second if right after — which promptly evaluates cur->data == target on memory that was freed one line earlier. On the specific run I was testing, that read happened to come back looking plausible, because the allocator hadn't reused those bytes yet. The list printed correctly. The bug was real anyway — AddressSanitizer flagged it immediately as a genuine heap-use-after-free, the first time I'd reached for a sanitizer at all in this curriculum rather than trusting gcc -Wall and eyeballed output. The not-found case was worse: cur is NULL after walking off the end of the list, and the second block dereferences it unconditionally — a confirmed segfault, just one I hadn't happened to trigger yet.
Two independent ifs look mutually exclusive if you only ever picture the cases you designed them for. Nothing in the code actually enforces that — control just keeps flowing downward regardless of what the first block did.
The fix, round one — and a second bug
Restructuring to else if closed the use-after-free and the segfault in one move. But it opened an edge case I hadn't tested: an empty list, called with *head == NULL. prev is NULL there too — the walking loop never runs either way — so if (prev == NULL) alone can't tell "matched at the front" apart from "there was nothing to walk in the first place." Passed it my one-node and multi-node cases clean; crashed on the empty one.
prev == NULL is a necessary fact about a head-match, not a sufficient one. The fix needed a second, independent fact — that something was actually found — before treating the first case as confirmed:
if (cur != NULL && prev == NULL) {
*head = cur->next;
free(cur);
} else if (cur != NULL && cur->data == target) {
prev->next = cur->next;
free(cur);
}That version passed head, middle, tail, not-found, and empty-list, clean under -Wall -Wextra and AddressSanitizer.
Two rules I now check for on any search-then-act function. First: if exactly one of several cases should ever run, that has to be guaranteed by the control-flow structure itself — else/else if or an early return — not by two independent ifs that merely share a condition someone intended to be exclusive. Second: a tracking variable sitting at its initial value is evidence a specific thing happened, not proof of it — before trusting it, ask what else could have produced that same state. Here, an empty list and a head-match both leave prev at NULL; only checking whether a node was actually found separates them. Neither bug showed up in plain output. Both showed up the moment a sanitizer actually looked at the memory.
Notes from readers
Comments — via GitHub