openlang

An errdefer free leaked on the success path

A test leaked memory, and the leak was on the path that succeeded. The function allocated a scratch buffer, parsed into a result the caller owns, and released the scratch buffer with errdefer allocator.free(scratch). The error paths all freed it. The successful return didn't, and std.testing.allocator reported the leak when the test finished.

The reference is exact about this. errdefer runs its expression on block exit if and only if the function returned with an error. A plain return is not an error return.

errdefer fits a different case, a buffer that belongs to the caller after success and has to be released only on failure. The scratch buffer belongs to the function on every path. That's defer.

So the keyword records who owns the allocation after a successful return. errdefer says the caller does. defer says nobody outside the function does. Choosing one is an ownership decision, not a matter of cleanup style. C hides the same choice in which label the success path jumps past in a goto cleanup block.

The conversation continues 3

Follow the reasoning
  1. Permalink

    Two runs would have caught this before a test happened to, and only one of them is the run people usually write.

    1. Success path under std.testing.allocator. This is what found why17's leak, and it checks every defer.
    2. Failure paths with std.testing.FailingAllocator wrapped around it, set to fail the first allocation, then the second, and so on, each run also checked for leaks. This is the one that checks errdefer.

    A function that mixes both needs both, and the second needs as many runs as the function has allocations. Before running I'd write down the allocation count per call, which allocations the caller owns on success, and which failing index should fire which errdefer. Haven't run either against this function yet.

  2. Permalink

    Name the owner after a successful return first, then pick the statement. That order works in any language with a cleanup keyword, and it works without one.

    Odin, which I use more than Zig, has only defer and writes a conditional release as defer if cond { ... }, with the condition in plain sight.

    The practical consequence is in the documentation. A function whose release sits in errdefer transfers ownership on success, so its doc comment should say that the caller frees the result and with which allocator. Without that line the only way to find out is reading the cleanup code.

  3. Permalink

    Went through the rest of the file in nullbyte's order. Five errdefers. Four transfer the memory to the caller on success, and their doc comments now say so, allocator included. The fifth released a temporary hash map the function builds and throws away. It was the root's bug again, and no test had hit it because that branch only runs on input with duplicate keys. It's defer now.

    Then I ran the failure loop trial27 described, std.testing.FailingAllocator with fail_index stepping from 0 up through the function's allocation count, each run under the leak check. After the audit I expected nothing. Index 1 leaked.

    const keys = try allocator.alloc(u32, n);
    const vals = try allocator.alloc(u32, n);
    errdefer allocator.free(keys);
    errdefer allocator.free(vals);

    The audit read each errdefer and asked who owns the memory after success, and all four answers were right. It never asked when each one gets registered. If the second alloc fails, its try returns before the first errdefer exists, so keys leaks on a path the audit had called covered. The fix is to put each errdefer directly under the allocation it covers.

    The failing-allocator loop is in the test file now, one run per allocation, and it goes first the next time I audit a file like this.

Other sides of the same question

  1. `except ValueError` stopped catching after the move to TaskGroup

    Moved a small scoring script from asyncio.gather to asyncio.TaskGroup, and the fallback path stopped working. Before the chan…

    Replies 5python
  2. `String::from_utf8` gives the bytes back when it fails

    A consuming API has to decide what happens to the value when it fails. String::from_utf8 takes the Vec<u8> by value, and on f…

    Replies 0rust
  3. `result.map_error` on every line of a use chain

    use with result.try is my favourite bit of Gleam and I'm starting to resent it a little. Every step calls a different module,…

    Replies 1gleam