Systems Programming · Resource Management
Failure Atomicity and Cleanup on the Error Path
How partial construction is made safe: commit-or-rollback discipline, errdefer-style compensation, and the invariants a constructor must restore before returning an error.
A function that allocates two buffers, opens a socket, and then discovers the third step fails has three ways to leave: it can return an error and leave the caller holding resources it did not ask for, it can release them and return an error, or it can release some of them and corrupt a structure that the caller still believes is valid. Only the second is acceptable, and the mechanism that produces it reliably is the same in every language that does it well. The mechanism is not about calling free. It is about defining, for every intermediate state the function passes through, which of its invariants hold and who is responsible for the rest.
The half-built object problem
Constructors and factory functions have an awkward position in the type system. A C++ constructor has no return value, so the only way to signal failure is an exception, which means a constructor that throws has produced no object at all. A Rust new returning Result<T, E> has the same property from the other direction: the T never exists, so there is nothing to clean up beyond the values the constructor itself created and then abandoned. Go and C return a pointer plus an error, and this is where the problem becomes visible, because the language permits the caller to ignore the error and keep the half-built pointer.
The damage from a half-built object is not that memory is lost. It is that the object’s invariants are broken in a way that later operations cannot detect. A hash table whose bucket array was allocated but whose count field was never initialised will accept inserts and produce lookups that never terminate. A structure holding a pointer to a partially filled buffer and a length that was set from the requested size rather than the actual one will read uninitialised bytes.
The rule that prevents this is a rule about what a failing function may leave behind: on failure, the observable state of everything the caller owns must be exactly what it was before the call, and everything the function itself acquired must have been released. This is failure atomicity, and it is stronger than “free what was allocated”. It covers writes to caller-supplied structures, to global state, and to files.
Compensation rather than rollback
The tempting model is a transaction that rolls back. Most resource acquisitions cannot be rolled back, only compensated. Closing a descriptor is compensation for opening it, but the descriptor number may already have been reused by another thread between the open and the close, and the kernel’s file description is gone either way. A partially written file can be truncated, but if the write went to a file that already existed, the previous contents are unrecoverable.
So the practical design is to order the steps so that compensation is possible for everything that has happened so far, and to put the irreversible step last. The pattern has three phases:
- Acquire everything that can be acquired and released freely, in dependency order.
- Perform every check that can fail without side effects.
- Commit: perform the irreversible operation, which must then either succeed or be handled by a path that cannot return an error.
A rename is the canonical commit point for filesystems because it replaces a directory entry atomically. The pattern is to write the new content into a temporary file in the same directory, fsync it, then rename over the target. A failure at any earlier step leaves the original file untouched, and cleanup is a single unlink of the temporary. The irreversible step is the rename, and after it the function returns success without further fallible work. Writing directly into the target file has no such property: a failure halfway through leaves a truncated or mixed file that no compensation can restore.
The same shape appears in network protocols. A server that must register a session in two tables can insert into the second first only if a failure of the first insert can remove the second entry, which requires that the removal cannot fail. If both removals can fail, the order does not save the operation and the design needs a reconciliation pass.
Language mechanisms for automatic compensation
C++ uses destructors as the compensation mechanism and makes the rule enforceable: if a constructor throws, destructors run for the fully constructed members and base classes, and the object’s own storage is released. The remaining obligation is on the constructor body, which must ensure that any resource it acquired in the body is held by a member whose destructor will release it before the throw propagates. That is why the advice to acquire resources in member initialisers rather than in the constructor body is not a style preference. A member initialised with a smart pointer is already owned when the body begins, so a later throw cannot leak it.
Zig makes the pattern explicit in the language with errdefer, a defer that runs only when the enclosing block exits with an error:
fn openPair(alloc: std.mem.Allocator, a: []const u8, b: []const u8) !Pair {
const first = try alloc.dupe(u8, a);
errdefer alloc.free(first); // runs only on a later error return
const second = try alloc.dupe(u8, b);
// no errdefer needed: after this point the caller owns both
return .{ .a = first, .b = second };
}
The value of errdefer is that it distinguishes the two exits. A plain defer would free first on the success path as well, which is wrong because ownership has transferred to the caller. The distinction is exactly the commit boundary described above, expressed at the level of individual statements rather than phases.
Go has no equivalent and relies on named return values plus a single deferred closure that inspects the error:
func build(cfg Config) (s *Server, err error) {
s = &Server{}
if s.listener, err = net.Listen("tcp", cfg.Addr); err != nil {
return nil, err
}
defer func() {
if err != nil {
s.listener.Close()
}
}()
if s.pool, err = newPool(cfg.PoolSize); err != nil {
return nil, err
}
return s, nil
}
This works because the deferred closure reads the named err after the return values have been assigned. It is fragile in one specific way: an assignment that shadows err inside a nested scope leaves the named variable untouched, and the cleanup then runs on a success path or fails to run on a failure path. Linters flag this, but the underlying problem is that the mechanism depends on a variable name rather than on the control flow.
Invariants at the boundary
The most reliable formulation does not depend on the language at all. For each function, write down the invariant that holds when it returns success. Then write down what must hold when it returns failure: the caller’s structures unchanged, no acquired resource outstanding, and any output parameter left in a state the caller can safely ignore.
The second clause is the one that gets skipped. An output parameter that is a pointer to a struct is a live object even when the function returns an error, and the caller may free it. If the function has written a pointer into that struct and then failed, the caller’s free will release something the function still uses, or the function’s own cleanup will release something the caller now owns. The fix is to write output parameters only after the last fallible step, or to zero them at entry so that a failing call leaves a defined value.
A similar hazard exists in the opposite direction, with ownership that transfers conditionally. A function that takes ownership of a buffer on success and leaves it with the caller on failure forces every caller to branch on the result before deciding whether to free. The alternative is to make ownership unconditional, either by always taking it, or by never taking it and letting the caller free on both paths. Conditional transfer is the worst of the three because the rule cannot be expressed as a type.
What error paths hide
The reason error paths accumulate defects is that they are the least exercised code in a program. A test suite that covers the success path and a few representative failures leaves the combinations untested: the allocation that fails after the socket opened, the write that returns a short count, the second of two inserts that collides.
Two techniques reach these paths without requiring a fault-injection framework. The first is to make each fallible step a separate small function so the cleanup for that step is local and reviewable in isolation. The second is to test the cleanup path directly by constructing the failure: pass a configuration that makes the second step fail on purpose, and assert that the first step’s resource is gone. On Linux, exhausting a file descriptor table with a deliberately small RLIMIT_NOFILE in a child process is a straightforward way to make the allocation steps fail in order.
The ordering discipline from release order at scope exit supplies the sequence, but the guarantee that the sequence is correct comes from here: knowing which values are owned at each point. Once ownership can move between functions and scopes, that accounting becomes a type-level question rather than a comment, which is what move semantics addresses, and it is why the failure path of a shared owner is the hardest case of all in reference counting.
References
- ISO/IEC 9899:2018 — Programming languages — C — ISO/IEC
- Exceptions and Error Handling — C++ Core Guidelines — ISO C++ Standards Committee
- The Go Programming Language Specification — The Go Project
- Zig Language Reference — Zig Software Foundation