Skip to content

v0.9.2 — Symbolication for Reports

Pre-release
Pre-release

Choose a tag to compare

@jamesgober jamesgober released this 18 May 07:47
· 5 commits to main since this release

mod-alloc v0.9.2 — Symbolication for Reports

Date: 2026-05-14
Compare: v0.9.1...v0.9.2

Headline

Tier 2 backtraces are now human-readable. With
--features symbolicate (default OFF), the per-call-site report
resolves each raw return address against the running binary's
own debug info, returning function names and (on Linux/macOS)
source file + line. The allocation hot path is untouched; this is
a pure report-generation upgrade.

What's new

Public API additions

  • ModAlloc::symbolicated_report() -> Vec<SymbolicatedCallSite>
    drains the call-site table and resolves each frame. Cached
    per-address across calls. Allocates — call from outside the
    allocator hook only.
  • SymbolicatedCallSite carries count, total_bytes, and
    a Vec<SymbolicatedFrame>.
  • SymbolicatedFrame carries address, function: Option<String>,
    file: Option<PathBuf>, line: Option<u32>, and
    inlined: bool to flag expansions from a single physical
    return address.

Resolution backends

Platform Backend Resolves
Linux addr2line + object (DWARF) function, file, line, inlined frames
macOS / *BSD addr2line + object (DWARF) function, file, line, inlined frames
Windows pdb function name only (file/line deferred)

All backends share the same return type. Names are demangled via
rustc-demangle. Self-binary path is discovered via
std::env::current_exe.

Storage architecture

  • Unix path reads the binary once and Box::leaks the bytes
    so addr2line::Context (which borrows from the parsed
    object::File, which borrows from the bytes) can sit in a
    OnceLock<Option<UnixSymbolicator>> with 'static lifetime.
    Memory cost: roughly the binary's on-disk size, one-time.
  • Windows path opens the PDB beside the .exe, walks every
    public symbol once, builds a sorted Vec<(rva, name)> index,
    then drops the PDB. Per-resolve work is a binary search.
    Sidesteps PDB's non-Send internal types and self-referential
    borrows entirely.
  • Per-process address cache in symbolicate::report keyed by
    u64. Cache miss runs the platform symbolicator once;
    subsequent calls reuse the cached Vec<SymbolicatedFrame>.
    Mutex-protected HashMap.

Dependency policy

The zero-runtime-deps promise applies to the allocation hot path
and the default build. Symbolication is an explicitly out-of-band
report-generation activity, so the symbolicate feature is the
approved exception. New optional deps:

Crate Version Why
addr2line 0.21 DWARF address → name/file/line
object 0.32 ELF / Mach-O parser, addr2line backend
rustc-demangle 0.1 Rust symbol demangler
pdb 0.8 Windows PDB reader
uuid =1.10.0 Pinned to hold MSRV 1.75 (transitive)

All are pure-Rust, MSRV 1.75-compatible. No FFI. Documented in
.dev/DIRECTIVES.md section 2.2.

The uuid pin is load-bearing: pdb 0.8 pulls uuid in
transitively, and the latest uuid 1.x requires Rust 1.85. The
pinned 1.10.0 supports MSRV 1.63.

Test additions

  • tests/symbolicate_self.rs — symbolicates the test binary's
    own allocations and looks for a known function name in the
    resolved frames. Downgrades to a shape-only check when the
    test binary was built without debug info.
  • tests/symbolicate_concurrent.rs — 8 threads call
    symbolicated_report() simultaneously after a primer call
    flushes the main thread's arena. Asserts no deadlock and
    consistent row counts across reports.
  • src/symbolicate/self_binary.rs unit tests cover path
    resolution and caching.

Example

  • examples/symbolicate.rs — installs ModAlloc, exercises a
    few #[inline(never)] call paths, prints the top 10 sites
    sorted by total bytes with resolved function names plus any
    inlined-frame expansions.

CI

  • New CI steps in .github/workflows/ci.yml:
    cargo build --features symbolicate --verbose and
    cargo test --features symbolicate --verbose, run on
    ubuntu / macos / windows with the FP flag set.

Design notes

Why Box::leak on Unix instead of a self-referential struct

addr2line::Context<R> holds references into object::File,
which holds references into the binary bytes. Storing all three
in a single struct requires self-referential lifetimes that don't
work without ouroboros / yoke or unsafe Pin machinery. For
a profiler that opens its own binary once per process, leaking
the bytes is the simplest correct approach and incurs a one-time
megabyte-scale allocation rather than perpetual transitive crate
deps.

Why a sorted snapshot on Windows instead of holding the PDB

pdb::PDB and its derived AddressMap / SymbolTable /
DebugInformation borrow from each other through trait objects
that aren't auto-marked Send. Storing them together in a static
hits cannot be sent between threads errors. The clean fix is to
do the parse once at first call, copy out the data we need
(Vec<(u32, String)> of (rva, demangled_name)), drop the PDB,
and binary-search on every resolve. PDB's source-line decoding
and S_INLINESITE expansion are deferred to a later release; the
current shipping output is function name plus the raw address.

Why current_exe instead of platform-specific syscalls

std::env::current_exe already wraps readlink /proc/self/exe
on Linux, _NSGetExecutablePath on macOS, and
GetModuleFileNameW on Windows. Reimplementing these adds code
to maintain without any practical benefit; using std directly
keeps the symbolicator module short.

Address-to-RVA approximation on Windows

The captured addresses are absolute virtual addresses. PDB
resolves via RVA (relative virtual address). Without the
module's load base we approximate by masking address to 32
bits and binary-searching the sorted RVA index. For non-ASLR
builds this is exact; for ASLR builds the result is usable for
relative comparison inside the same process run but addresses
across runs will differ.

Migration

Default builds are unchanged. The counters and backtraces
features behave identically. No code edits required for callers
on those paths.

Opting in to symbolicate:

[dependencies]
mod-alloc = { version = "0.9", features = ["symbolicate"] }
let report = GLOBAL.symbolicated_report();
for site in &report {
    let top = &site.frames[0];
    println!("{} allocs at {}",
        site.count,
        top.function.as_deref().unwrap_or("<unresolved>"));
}

backtraces is implied by symbolicate; activating
symbolicate alone is sufficient.

Verification

Full matrix run on Windows host (x86_64):

  • cargo build (default features)
  • cargo build --no-default-features
  • cargo build --features counters
  • cargo build --features backtraces
  • cargo build --features symbolicate
  • cargo build --features dhat-compat
  • cargo build --all-features
  • cargo +1.75 build --all-features (MSRV)
  • cargo fmt --all -- --check
  • cargo clippy --all-targets -- -D warnings
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo doc --no-deps
  • cargo doc --all-features --no-deps
  • cargo test --all-features: 60/60 pass (34 unit + 2 new
    v0.9.2 integration + 4 v0.9.1 integration + 4 v0.9.0
    integration + 5 smoke + 11 doctests)
  • Banned-word scan: 0 hits across shipping files
  • Em-dash scan: 0 hits across shipping files

CI matrix: ubuntu-latest, macos-latest, windows-latest plus the
ASAN nightly Linux job. New steps for the symbolicate feature.

Limitations

  • Windows produces function names only. Source file and line
    resolution from PDB is deferred. Inlined-frame expansion from
    S_INLINESITE is deferred.
  • Address-to-RVA approximation on Windows. Best-effort
    masking; exact for non-ASLR builds, approximate but
    intra-run-consistent for ASLR builds.
  • Shallow traces on stock builds. When std is compiled
    without frame pointers, the walker captures only the immediate
    caller of GlobalAlloc::alloc inside std's allocation
    infrastructure. Documented in v0.9.1; symbolication exposes
    the limitation more visibly (the resolved frame is often a CRT
    helper like __dyn_tls_init_callback rather than the user's
    function). For meaningful traces, build with
    cargo +nightly -Z build-std=std and
    RUSTFLAGS="-C force-frame-pointers=yes".
  • C++ frames remain mangled. Only Rust mangling is decoded
    via rustc-demangle. Adding cpp_demangle is a follow-up if
    there's demand.
  • fsys integration deferred. The ROADMAP's mention of
    fsys for reading the binary was not taken: fsys's public
    API does not currently support generic-path reads (precedent
    set by mod-tempdir's v0.9.3 audit). std::fs::read is used
    directly.

Follow-ups

  • Windows source-file / line resolution. Adds the PDB
    module.line_program() plumbing. Material work but
    achievable.
  • Windows inlined-frame expansion. Decode S_INLINESITE
    records inside per-module symbol streams.
  • C++ demangling behind the symbolicate feature if a real
    user needs it.
  • v0.9.1.1 Tier 2 perf pass (still open from v0.9.1) —
    separate milestone, unaffected by symbolication work.

Release ceremony

Standard pattern:

  1. git tag -a v0.9.2 -m "Release v0.9.2 - symbolication for reports"
  2. git push origin main
  3. git push origin v0.9.2
  4. cargo publish --dry-run --all-features
  5. cargo publish
  6. Confirm https://crates.io/crates/mod-alloc/0.9.2 is live.

GitHub release title: v0.9.2 — Symbolication for Reports.
Tag as pre-release; 1.0.0 waits for v0.9.4 (dev-bench
integration) to ship and bake.


Full Changelog: v0.9.1...v0.9.2