I let Deepseek v4.1 Flash write a critique of the mulle-allocator library. It's hard to please. The whole point of the library is to simplify allocation code with "OOM" aborts. But the bot really believes in ENOMEM, so it's a losing battle to get a glowing review ...
A code review of mulle-allocator, written after building the sources,
running the shipped test suite, and running the alloca/coverage/reallocarray
tests again under ASAN+UBSAN.
Reviewed commit: b5b56f3 ("new cmake files"), version 9.0.0.
TL;DR #
A small, careful, well-tested abstraction for "which heap does this data
structure use". The plumbing is genuinely good: allocation funnels, overflow
checks, reallocarray-everywhere, a clean allocator vector struct, and an
unusually honest set of docs.
The policy is opinionated and polarising: out-of-memory aborts the process,
and the library is a mulle-c11-centric project. If that
policy matches your program, it is a solid choice. For a generic library or a
service that must degrade gracefully, it is the wrong tool and no amount of
polish fixes that.
What it is #
- Header-heavy: the hot paths are
static inlineinsrc/mulle-allocator.h; the only heavy lifting insrc/mulle-allocator.c. - An allocator is a struct of function pointers
(
MULLE_ALLOCATOR_BASE,src/mulle-allocator-struct.h:143):calloc,reallocarray,free,fail, plus an optional ABA reclaimer. - Everything funnels through one
reallocarrayvector:malloc(s) == reallocarray(NULL, 1, s),allocarray(n,s) == reallocarray(NULL, n, s). (src/mulle-allocator.h:331) - A default global allocator (
mulle_allocator_default) that the "foundation" patches at startup, plusmulle_allocator_stdlibandmulle_allocator_stdlib_nofree. - Extras: checked
size_multiply/size_add,_strictrealloc variants, an ABA-aware free,mulle_alloca_doscoped stack/heap buffers, andmulle_memset_uint32.
What is good #
The vector design is right #
Putting stdlib-shaped arguments first and the allocator pointer last
(src/mulle-allocator-struct.h:143) is a small but real choice: the common
reallocarray(block, n, size, allocator) call keeps the hot integer args in
argument registers and passes the context last. The comment explaining exactly
this (mulle-allocator-struct.h:70) is the kind of thing most projects never
write down.
One funnel, overflow checked #
Because malloc/realloc/allocarray all degrade to reallocarray, there is
exactly one place that multiplies and exactly one place that can wrap:
v_reallocarray builds on standard realloc and therefore performs and checks
n * size itself (src/mulle-allocator.c:164). calloc gets its own redundant
check unless you opt into MULLE_ALLOCATOR_TRUST_STDLIB
(src/mulle-allocator.c:249). This is exactly how I would structure it.
The _strict variants (src/mulle-allocator.c:79, :99) restore stdlib
free-and-return-NULL semantics for the zero case, which is the pragmatic escape
hatch for people migrating from raw realloc.
Checked size helpers #
mulle_allocator_size_multiply / mulle_allocator_size_add
(src/mulle-allocator.h:292, :311) let consumers check count * sizeof(T)
before asking for memory. They route overflow through the allocator's fail,
which is consistent and testable.
The never-NULL ergonomics are real #
The MULLE_C_NONNULL_RETURN (returns_nonnull) annotation on the whole public
API means callers truly can drop null checks and the compiler can remove them
downstream. I benchmark-tested nothing, but the generated code is clean.
Honest, unusually good documentation #
REVIEW-GUIDE.mdpre-empts the obvious review comments and says why each decision was made.- The "Caveats" and thread-safety sections in
README.mdtell you the failure modes instead of hiding them. src/mulle-allocator-struct.h:87spells out the "we trust your vector" contract precisely.src/mulle-memset.c:66explains why a memset utility lives in an allocator library (dependency-graph reasons, with the actual users listed).
This is rare and it is the main reason the review took a fraction of the usual time. The candidness also makes the design debatable rather than mysterious.
Testing and CI are genuinely thorough #
test/is split intocoverage/,fails/(crash/abort paths driven by aSIGABRThandler),alloca/,reallocarray/,memset/.- Expected stdout files are committed and compared; my rebuild reproduced every one of them.
test/coverage.jsonreports 98.9% line / 85.4% branch, and after742ab5bcountsmulle-allocator.handmulle-memset.ctoo, so the denominator is honest (the earlier 100% covered onlymulle-allocator.c)..github/workflows/mulle-sde-ci.ymlruns a separate ASAN+UBSAN job, a 32-bit-m32job, and a valgrind job. That is more than most small C libraries do.
Correctness, hands on #
I compiled src/*.c with GCC and Clang at -Wall -Wextra -Wpedantic -Wconversion -Wshadow with no warnings, ran every non-crashing test and the
fails/ tests, and re-ran the alloca/coverage/reallocarray/memset tests under
-fsanitize=address,undefined -fno-sanitize-recover=all. All passed, no UB
reported. The only ASAN "leaks" came from tests that deliberately use
mulle_stdlib_nofree_allocator. The core allocation logic looks correct.
What is bad / questionable #
Abort-on-OOM is the whole personality, and it is a heavy constraint #
mulle_allocation_fail prints and calls abort() (src/mulle-allocator.c:44).
The library's premise (README.md:106) is that malloc's portable OOM
behaviour is unreliable (FreeBSD hangs, macOS crawls), so you should not pretend
to recover. That argument is sound for a process-owning application.
It is not sound for a library. Any library that links this cannot propagate
ENOMEM to its caller, cannot fail one request and keep serving others, and
cannot be used by a host that installs its own allocator. The README's advice —
"for one optional buffer, just call stdlib malloc and check NULL"
(README.md:385) — is reasonable, but it means the "one allocation API"
promise leaks: you now have two allocation policies and two free paths in the
same codebase.
fail being terminal (MULLE_C_NO_RETURN) makes this worse: you cannot even
install a handler that logs and continues. It was deliberately made so (the
RELEASENOTES.md describes a retry branch that was tried and dropped). That is
a defensible engineering call, but it removes the only escape hatch short of
longjmp — which the docs warn is running in an arbitrary context.
"The library trusts the vectors you install" is a fragile contract #
The funnel never validates block/n/size; each vector owns zero-checking
and overflow-checking (src/mulle-allocator-struct.h:87). The built-ins do it,
and the test trust-vector.c asserts the library does not pre-validate.
This is great for zero-overhead and for keeping policy out of the wrapper. It is
also a footgun: a custom arena vector that forgets the n * size check silently
proceeds with a wrapped size, and the wrapper gives no hint. The safety story
now lives in documentation rather than in the type system or a debug check.
Global mutable allocator, single-threaded setup only #
mulle_allocator_default is a process-wide mutable global patched by the
"foundation" (src/mulle-allocator.c:287). Configuration is only safe before
threads start (documented at README.md:407, mulle-allocator-struct.h:83).
That is a known and accepted C pattern, but it means you cannot have two
independent subsystems with different allocators without explicitly threading
the pointer through every call — which is the actual value proposition, so you
pay for it everywhere.
Integration: consumer and producer are both plain CMake #
The build installs a proper CMake package via
cmake/share/FindPackageSupport.cmake: namespaced exported targets plus
<pkg>-config.cmake and <pkg>-config-version.cmake in
lib/cmake/mulle-allocator/. A downstream project only needs
1find_package( mulle-allocator REQUIRED)
2target_link_libraries( app PRIVATE mulle-allocator::mulle-allocator)
I built and ran that against an installed prefix.
On the producer side, stock cmake -S ... -B ..., cmake --build and
cmake --install all succeed. No mulle-sde binary is invoked; the generated
cmake/share and cmake/reflect files are checked into git and consumed
directly. mulle-sde is only the maintenance tool for regenerating them. The
one build requirement is that mulle-c11 headers are reachable (the README's
-DCMAKE_C_FLAGS="-I<prefix>/include"), which is just the declared dependency.
The real residuals are narrower than the original text:
- No
pkg-configfile. The installed*-config.cmakeserves CMake consumers fully, but it is a CMake script that meson/autotools/plain Makefiles cannot use; those build systems discover installed dependencies through pkg-config. So non-CMake consumers have no automated discovery and must hardcode-I<prefix>/include -L<prefix>/lib -lmulle-allocator. - The
mulle-c11dependency is exposed only through the allocator target's<prefix>/include. Ifmulle-c11is installed to a different prefix, a consumer fails even withCMAKE_PREFIX_PATHpointing at it, becausemulle-c11::mulle-c11is not linked onto the exported target. Co-installation under one prefix works; split-prefix does not. Reproduced both ways. - Install destinations are hardcoded (
lib/cmake,include) instead ofGNUInstallDirs; the code comments call this deliberate, but it can mismatch lib64-style distributions.
Per RELEASENOTES.md:20, 9.0.0 changed the allocator struct layout, so the ABI
is not stable across majors and all consumers must recompile.
Layering oddities #
mulle_memset_uint32(src/mulle-memset.c) has nothing to do with allocation. The rationale is documented (dependency-graph reasons) but it is still a utility living in the wrong library; it makesmulle-allocatorthe hub for unrelated functionality.mulle-alloca.his included from the bottom ofmulle-allocator.h(src/mulle-allocator.h:925) whilemulle-memset.hincludesmulle-allocator.h, giving a circular include graph (guarded, but confusing).
The alloca macros are clever and slightly scary #
mulle_alloca_dostores an element count in atype *variable and casts it back viauintptr_t(src/mulle-alloca.h:286). This is documented as assuming a flat address space, but it is formally an integer↔pointer round trip and breaks on capability architectures (CHERI) and is the kind of thing that makes a security reviewer twitch.returnfrom inside amulle_alloca_doblock leaks unless you use_mulle_alloca_do_return; the compiler-side protection is a customMULLE_C_CONFINED_LOOPattribute (mulle-c11-feature.h:186) that is empty on ordinary GCC/Clang installs. So the leak protection is either "read the docs" or "install the author's clang plugin".
Portability is clang-shaped by design #
The code uses __typeof__ (MULLE_C_TYPE_OF, mulle-c11-feature.h:164), used
by mulle_malloc_for (src/mulle-alloca.h:418), and GCC/Clang attributes
throughout. That looks like a portability defect until you read the scope: the
project cross-compiles with mulle-clang (there is a
toolchain--linux-windows--x86_64-w64-mingw32--mulle-clang.cmake in
cmake/share), and native MSVC and macOS/Windows packages are explicitly out of
scope. So "not MSVC-clean" is true but irrelevant to the stated targets, and I
withdrew that framing.
The narrower observation that survives: no CI job runs the test suite on a cross-compiled Windows/macOS target, so the README's "behaves the same on every OS" line is asserted rather than demonstrated. That is a verification/documentation gap, not a portability defect.
Minor / nitpicky #
mulle_allocator_is_stdlib_allocatoronly compares thecallocpointer, so it cannot distinguishstdlibfromstdlib_nofree(src/mulle-allocator.c:309). Documented, but a function that returns "is stdlib" and is wrong about the no-free variant is easy to misuse.- Hundreds of externally visible identifiers beginning with
_followed by a lowercase letter (_mulle_allocator_*) are formally reserved to the implementation at file scope; Clang's-Wreserved-identifierflags all of them. Not breakage, but it is an avoidable namespace smell for a public header.
Resolved in a follow-up commit (
465dd07): theset_failnoreturn-on-a- parameter and thedouble[8]/double[16]mismatch were both fixed, and742ab5bwidenedcoverage.jsonto include the header andmulle-memset.c(badge now 98%, not 100%). I re-ran the suite: 30/30 expected outputs pass, andmulle_allocator_set_failnow takes a cast-freemulle_allocator_fail_tand compiles under-Werror -Wpedantic.
Would I use it in my own C projects? #
Short answer: only for an application where abort-on-OOM is already my policy, and only if I were already inside the mulle ecosystem. Otherwise, no.
Concretely:
- A command-line tool, daemon, or game that owns its process and treats OOM as
fatal: yes, this is a pleasant, low-overhead way to thread allocators through
data structures. The
mulle_stdlib_nofree"free or don't" idiom is particularly nice. - A reusable library, a plugin, an embedded system with a panic handler, or any
service that must return an error and keep running: no. The terminal
failand the global default allocator make correct integration hard. - A small standalone project: no, purely on dependency/integration weight. Adding
a
mulle-c11dependency and a CMake package for what is essentially anxmallocwrapper is not a trade I would make.
For comparison, what I actually reach for in C: a tiny xmalloc-style wrapper
when abort-on-OOM is fine, or an explicit allocator struct that returns errors
when it is not. mulle-allocator sits in the first camp and is better engineered
than my throwaway wrappers, but it is not a middle ground.
Summary #
| Area | Rating | Notes |
|---|---|---|
| API design | Good | Clean vector struct; one reallocarray funnel |
| Correctness | Good | Overflow checked; tests + ASAN/UBSAN clean |
| Memory-safety posture | Mixed | Safe by construction, but abort-on-OOM is absolute |
| Docs | Excellent | Candid, rationale-rich, REVIEW-GUIDE.md |
| Tests/CI | Excellent | Coverage, crash tests, sanitizers, valgrind, 32-bit |
| Portability | Acceptable | clang-shaped by design; cross-compiles to Windows; MSVC/macOS packaging out of scope |
| Integration (consumer) | Good | find_package works; namespaced target, deps resolved (verified) |
| Integration (producer) | Good | Plain cmake build/install works; no mulle-sde needed |
| ABI stability | Poor | Struct layout changed in 9.0.0; source-only in practice |
Bottom line: a well-built, honest implementation of a deliberately narrow idea. The craft is better than the market fit — it solves the author's problem very well, and does not try to solve anyone else's.