When grep crashes: tracing ripgrep + MUSL mallocng segfaults in huge directory walks
A SIGSEGV inside calloc sounds like the wrong kind of bug
The first time this kind of crash shows up, it feels personal. ripgrep is supposed to be boring: walk a directory tree, open files, and search for a string. So when a build of ripgrep targeting x86_64-unknown-linux-musl suddenly dies with a SIGSEGV (that’s Linux’s “segmentation fault” signal: the process tried to touch invalid memory), the backtrace points somewhere that doesn’t look like text-search code at all. (github.com)
In the reported case, the crash is an “integrity assertion” deep inside musl libc’s mallocng (musl is an alternative C standard library; mallocng is its newer heap allocator). The failing line shows up in get_meta() in meta.h, called from __malloc_allzerop(), reached through calloc(), which is being invoked by opendir() while directory entries are being read. ()
Why does a grep-style tool crash only with MUSL binaries? Because the directory-walk path crosses multiple layers: Rust’s directory iteration, a filesystem call that allocates small C structs, and a C heap allocator that tries hard to detect corruption. If any assumption gets violated under extreme conditions, mallocng can pull the emergency brake. ()
What ripgrep is doing under the hood (and where concurrency enters)
To understand the shape of the problem, it helps to map “searching” to “walking.” ripgrep is a line-oriented search tool, but the heavy part for most repos is building the file list: recursively traversing directories and consulting ignore rules. (github.com)
ripgrep runs this work in parallel by default. There’s a command-line flag -j, --threads=NUM, where 0 means “choose automatically using heuristics.” (manpages.debian.org)
That parallelism is implemented using the ignore crate’s directory-walk workers: multiple worker threads process “work units,” and each unit can call read_dir(). (docs.rs)
So in the crash backtrace, this line is the key story beat:
std::fs::read_dir→ignore::walk→readdiron Unix →opendir()→calloc()→ musl mallocng integrity check. ()
That’s a lot of moving parts for something that seems like “just reading a directory.” And when you multiply that by a very large tree and high concurrency, you increase pressure on both OS resources (lots of directory opens) and the allocator (lots of tiny allocations, fast reuse, allocator metadata churn).
Why musl’s mallocng asserts instead of quietly continuing
The crash isn’t “random.” It’s an assertion meant to catch corrupted heap metadata. musl’s mallocng uses out-of-band metadata (metadata stored separately from the user’s payload bytes). It also uses a random secret per allocation context so that fake or corrupted metadata becomes much harder to construct without being detected. (openwall.com)
In the mallocng overview discussion, the allocator design is explained like this:
struct groupdescribes a chunk of memory that holds a number of allocation slots (think: a slab of space).struct metais the out-of-band metadata for those slots, including validation that links back to the owning group.- Validation uses a secret-derived value so allocator state can detect inconsistencies that often come from memory misuse (like use-after-free or double-free). ()
So when get_meta() runs during calloc(), it does a “does this pointer really look like it belongs to the heap structure I expect?” check. In the mailing-list reports describing similar assertion failures, the assertion is along the lines of “meta doesn’t match expected base address.” (openwall.com)
If the heap state becomes inconsistent, mallocng triggers an integrity assertion, which often ends in a SIGSEGV when the code touches invalid metadata pointers.
The reproduction pattern: huge directory trees + repeated runs
The issue report describes a reproduction that feels almost like a stress test: generate a directory tree with roughly 20 GiB across 1.8 million files, then repeatedly run ripgrep searching for a literal string that is not present. On a 24-core system with enough RAM that the tree stays cached in the kernel’s block cache, the crash appears “about a minute” into a loop that runs rg continuously. ()
Two knobs are implicitly doing work here:
- Search tree size. More entries means more directory reads over time.
- Concurrency. More workers means more simultaneous directory reads and allocations.
Because opendir() is being reached via read_dir(), this failure isn’t just “a malloc bug somewhere.” It’s a mallocng integrity check being hit in the directory-walk hot path. ()
Turning the incident into a debugging checklist
This is the part where engineers tend to zoom out: “the allocator asserted.” Great, but what do you do next?
A practical approach is to reproduce deterministically and then reduce variables:
1) Make concurrency explicit
Even though ripgrep’s default -j 0 picks a thread count automatically, that automatic choice can move around between machines and environments. The safest first step is to cap threads.
You’d run something like:
rg -j 1 'some-literal-that-does-not-exist'.
If the crash disappears or becomes rare, that points back toward allocator pressure and race-y timing windows in the interaction between filesystem iteration and heap metadata.
ripgrep’s thread flag behavior is documented in its man page. (manpages.debian.org)
2) Capture the allocator context with symbols
The report already includes a detailed backtrace showing symbols in both musl and Rust standard library frames. (github.com)
The general workflow is to build a musl-linked debug binary (or otherwise get line numbers), run under a debugger, and confirm which operation triggers the calloc() path leading to opendir().
3) Reduce “surface area” while keeping the failure shape
A huge tree can hide the real trigger behind sheer volume. Reduce depth and file count while keeping the concurrency and directory-open patterns. The goal is to find the smallest tree that still tickles mallocng’s integrity check.
That’s usually how allocator issues become tractable: you stop thinking in terms of the whole repository and start thinking in terms of allocation patterns.
Workarounds that don’t require understanding the allocator’s internals
Sometimes the goal is not to fully explain the root cause right away, but to keep systems running while investigation happens. Given this failure is in the heap allocator during directory walking, the most plausible mitigations are the ones that reduce allocator stress:
- Use fewer ripgrep worker threads (e.g.,
-j 1or-j 2) to reduce simultaneousread_diractivity. () - Prefer a different C library build (for example, a glibc-linked build) if that’s available in your deployment pipeline. The symptom is specifically in musl mallocng’s integrity checking path. ()
Those are blunt instruments, but they’re aligned with the crash’s location: calloc() and opendir() during parallel directory traversal. ()
The bigger lesson: “memory safety” depends on the weakest link
Rust gives strong guarantees in Rust code, but a ripgrep binary that’s linked against musl is still relying on C code for the allocator, directory handling, and a chunk of the standard library’s FFI plumbing. In this case, the weakest link isn’t a Rust pointer bug in isolation; it’s the combined runtime behavior across Rust threading, filesystem iteration, and allocator invariants.
mallocng’s design goal is to detect inconsistent heap metadata using a secret-backed validation scheme, so it tends to fail loudly rather than corrupt silently. That loudness can feel like a “random crash,” but it’s actually a deliberate signal: the allocator believes its heap state became impossible. (openwall.com)
And this report is a reminder that scaling from “a normal repo” to “millions of entries with many workers” can push systems into timing and allocation regimes that rarely show up during ordinary development.
Conclusion
The ripgrep + musl segfault story isn’t about regex engines or file content parsing at all. It’s about the directory-walk path: parallel workers calling read_dir(), hitting opendir(), and triggering musl mallocng’s calloc() integrity checks when the allocator observes heap metadata it can’t reconcile. ()
When things crash like this, the shortest path to understanding is to follow the stack exactly as presented: find the boundary crossing, then reason about what each layer assumes. At huge scale, those assumptions get stressed—not by bad inputs, but by sheer volume and concurrency.
Comments (0)
No comments yet. Be the first to respond!
Leave a Comment
Your comment will be visible after review.