Adding a heap allocator
A heap allocator plugin teaches MemDiver to recognise one allocator in a dump
and walk its heap. Once registered, an allocator works everywhere at the same
time: memdiver heap …, /api/heap/*, the heap_* MCP tools, consensus heap
roles, locate-key hit annotation and the web heap mode. All of these call
analyze_heap, and analyze_heap asks the registry.
Read the heap architecture first. The data model and the coordinate rules there are the contract.
1. Pick a kind
The registry is keyed by memdiver.heap.AllocatorKind, a closed str enum
(ptmalloc2, dlmalloc, scudo, mallocng, jemalloc, generic, unknown).
Replacing a built-in. An out-of-tree package can do this. Reuse its kind, because registering an existing kind replaces the descriptor. A typical case is a full jemalloc parser that walks the rtree.
A new allocator family. This needs a new
AllocatorKindmember inheap/model.py, and so an in-tree change.AllocatorKind("foo")raises for an unknown value. Add the member, itsheap.jsonlabel infrontend/src/i18n/locales/en/heap.json(allocators.<kind>), and the CLI choice inHEAP_ALLOCATOR_CHOICES(cli/heap.py).
The examples below use a fictional FOO member. Substitute your kind.
2. Write a detector
A detector gets a DetectContext and returns a DetectScore(score, evidence)
with score in [0, 1]. The context computes every field lazily, so use the
cheapest signal that answers the question:
Field / helper |
What it gives you |
|---|---|
|
|
|
The memory map. Pattern matching is fnmatch with a literal |
|
Mapped module paths / basenames |
|
A |
from memdiver.heap import DetectScore, Evidence
def detect_foo(ctx):
evidence = [Evidence("module", f"libfoo mapped: {m}", 0.7)
for m in ctx.matching_modules("libfoo*.so*")[:1]]
if ctx.has_region("[anon:foo:*"):
evidence.append(Evidence("region", "[anon:foo:*] regions present", 0.6))
return DetectScore(min(1.0, sum(e.weight for e in evidence)), evidence)
Rules:
Be cheap. Detection runs for every registered allocator on every analysis. Look at names first, and read bytes only to break ties.
Explain yourself. Every point of score should come from an
Evidenceline.memdiver heap detectprints these lines, and they are the only way a user can tell why your allocator won.Never raise on purpose.
run_detectorcatches exceptions and scores them 0 with adetector_errorline. A crash costs your allocator the detection but does not break the others.
3. Write a parser
from memdiver.heap import (AllocatorKind, ChunkState, HeapChunk, HeapLayout,
HeapRegion, HeapRole)
def parse_foo(memory, ctx, options):
chunks, regions, notes = [], [], []
for region in ctx.regions:
if not region.name.startswith("[anon:foo:") or not options.selects(region):
continue
regions.append(HeapRegion(region.va, region.size, region.name, "foo-arena"))
va = region.va
while va + 16 <= region.end and len(chunks) < options.max_chunks:
size = memory.u64(va) # None = page not captured
if size is None or size < 16 or va + size > region.end:
notes.append(f"foo: stopped at {va:#x}")
break
in_use = bool(size & 1)
size &= ~0xF
chunks.append(HeapChunk(
va=va, size=size, header_len=16, user_va=va + 16,
user_len=size - 16,
state=ChunkState.IN_USE if in_use else ChunkState.FREE,
# optional: paint a free-list pointer at user+0
roles=None if in_use else ((16, 8, HeapRole.FREE_LINK),),
))
va += size
return HeapLayout(AllocatorKind.FOO, platform=ctx.platform,
regions=tuple(regions), chunks=tuple(chunks),
confidence=0.8 if chunks else 0.0,
diagnostics=tuple(notes),
coverage={"regions_walked": len(regions)})
The contract every parser must honour:
Think in virtual addresses. Read only through
memory(VaMemory). Never compute file offsets yourself. A read returnsNonefor bytes the dump never captured, so treat that as a gap, not as zero.Strip pointer tags. Pass every pointer you follow through
memdiver.heap.strip_tag(ptr, memory.platform)(arm64 TBI). Usememory.platform.pointer_sizeandendiannessinstead of hard-coding 64-bit LE.Chunks never overlap.
HeapLayoutsorts them, but overlap is a bug, and the real-dump test asserts against it.Roles. The defaults (header, user / free data, slack) come from
header_len,user_va,user_lenandstate. SetHeapChunk.rolesonly for sub-ranges you know better, as(offset_from_va, length, HeapRole). Mark a whole chunk as metadata withextra={"meta": True}, or as the top chunk withbin="top".Honour
ParseOptions. Stop atoptions.max_chunks, and skip regions for whichoptions.selects(region)is false.Diagnose instead of guessing. If you cannot decode a structure, add a line to
diagnosticsand leave it out. Do not invent chunks.Raising is allowed but costly.
analyze_heapcatches the exception, recordsparser raised …and falls back to the next candidate.
4. Register a descriptor
from memdiver.heap import AllocatorDescriptor, AllocatorKind, register_allocator
register_allocator(AllocatorDescriptor(
kind=AllocatorKind.FOO,
detect=detect_foo,
parse=parse_foo, # None = detect-only
priority=45, # dispatch order among equal scores
levels=frozenset({"L0", "L1", "L2"}),
partial=False, # True: only part of the allocator is parsed
heuristic=False, # True: structure is guessed, not read
))
Be honest with levels / partial / heuristic. They are shown to the user in
every summary and as badges in the web UI.
In-tree allocators use a different path: add a module under
heap/allocators/ that exports DESCRIPTOR_OVERRIDES = {AllocatorKind.FOO: (probe_foo, parse_foo)}, and a BuiltinSpec row in heap/builtin.py.
name_detector then combines your probe with the name signals you add to
NAME_SIGNALS in heap/detect.py.
5. (Optional) out-of-tree via entry point
An installed package registers through the memdiver.allocators entry-point
group. The registry loads it once per process, right after the built-ins:
# pyproject.toml of your out-of-tree package
[project.entry-points."memdiver.allocators"]
# a module whose import calls register_allocator(...)
foo = "my_pkg.memdiver_allocator"
# ...or a callable that self-registers when invoked
# foo = "my_pkg.memdiver_allocator:register"
Discovery is additive and failure-isolated. A broken entry point is logged and
skipped. Because built-ins register before plugins load, and a plugin’s
register_allocator call replaces by default, a plugin that reuses a built-in
kind replaces that built-in. Out-of-tree packages can only reuse existing kinds
(see step 1).
6. Test
Build the expected answer by hand, then check that the parser recovers it.
tests/fixtures/synth_heap.py is the pattern to follow:
A builder writes the allocator’s structures field by field from the upstream struct definitions and returns a
SynthHeap: the region bytes plus the list ofExpectedChunk(va, size, state, header_len, user_len, bin)a correct parser must report. There is no allocation simulation, and the result is deterministic.as_regioned_raw(tmp_path, [heap], modules=[...])writes it as a.gdb_raw.bin+.mapspair (add module mappings to drive name detection), andopen_regioned(path)opens it throughopen_dump.
from memdiver.heap import analyze_heap
from tests.fixtures.synth_heap import as_regioned_raw, open_regioned
def test_foo_recovers_every_chunk(tmp_path):
heap = foo_heap(base_va=0x7F00_0010_0000) # your builder
source = open_regioned(as_regioned_raw(tmp_path, [heap]))
try:
layout = analyze_heap(source, "foo")
finally:
source.close()
got = [(c.va, c.size, c.state) for c in layout.chunks]
assert got == [(c.va, c.size, c.state) for c in heap.chunks]
Cover at least:
exact chunk boundaries, states and role runs;
32-bit and big-endian, if the allocator supports them;
a corrupt header (the parser resyncs and reports a diagnostic);
a negative control:
random_region()must not be detected as your allocator, with a false-positive rate under 1 % for content probes;registry isolation (see
tests/test_heap_registry.pyfor therestore_registryfixture that snapshots and restores the registry).
Then run the surface parity tests (tests/test_heap_surfaces.py,
tests/test_tools_heap.py) and, if you have real captures, the opt-in
MEMDIVER_REAL_HEAP_DUMPS suite described in the
user guide.