Skip to content

Register anonymous executable mappings for pre-existing processes#295

Draft
brianrob wants to merge 1 commit into
microsoft:mainfrom
brianrob:brianrob/broken-stacks
Draft

Register anonymous executable mappings for pre-existing processes#295
brianrob wants to merge 1 commit into
microsoft:mainfrom
brianrob:brianrob/broken-stacks

Conversation

@brianrob

@brianrob brianrob commented Jun 17, 2026

Copy link
Copy Markdown
Member

Problem

Profiling already-running .NET (or other JIT) processes on Linux yields a large fraction of BROKEN stacks in PerfView/TraceEvent — single-frame stacks (just the sampled IP, no callers). A customer saw 44.9% and 75.1% BROKEN in two services.

Root cause

At capture start, PerfSession::write_environment_modules synthesizes MMAP2 records from /proc/<pid>/maps so an already-running process's mappings are known to the unwinder. It skipped every entry with no backing file path:

if module.path.is_none() { return; }   // drops ALL anonymous mappings

.NET JIT code heaps are anonymous executable mappings (no path) when W^X is disabled (DOTNET_EnableWriteXorExecute=0) or no usable memfd is available (e.g. container seccomp profiles blocking memfd_create). With W^X on (default), JIT lives in /memfd:doublemapper mappings, which do have a path in procfs and were already registered — which is why this often stayed hidden.

If a process's JIT code was created before tracing started, no live MMAP2 record is ever emitted for it. With the synthetic enumeration skipping it, the region is never registered, the unwinder can't start there, and every sample in JIT'd code becomes a single-frame BROKEN stack. This is not a PerfView/TraceEvent or version issue — the stacks are genuinely truncated.

Fix

Stop skipping path-less mappings in write_environment_modules; emit anonymous executable mappings with an empty filename so the existing add_mmap_exec handler classifies them as anonymous code (UnwindType::Prolog), exactly as it already does for live anonymous JIT mappings. Non-executable mappings are still skipped unless all-mmaps capture is requested (captures_all).

Notes:

  • /proc/<pid>/maps gives an empty path for plain anonymous mappings (the kernel uses //anon only in live MMAP2 records); add_mmap_exec already treats an empty filename as anonymous.
  • Dynamic paths need no change: the live MMAP2 hook filters only on PROT_EXEC (not path), and processes started during capture get their mappings via that same live path. This synthetic enumeration was the only /proc/maps reader feeding the unwinder.

Verification (record-trace, on-CPU, .NET 10; broken rate measured via TraceEvent)

Scenario Before After
W^X off (anonymous JIT — the bug) 99.7% broken 0.2%
W^X on (memfd JIT — regression check) 0.1% 0.1% (no regression)
App started during capture (live MMAP2 path) n/a 0.2%

Repro steps

  1. Start a .NET app with DOTNET_EnableWriteXorExecute=0 and let it warm up so its JIT code is created before tracing starts.
  2. Run record-trace --on-cpu system-wide.
  3. Open the trace in PerfView.

Before this change the process is dominated by a BROKEN node; after, stacks reach the thread base.

write_environment_modules() synthesizes MMAP2 records from
/proc/<pid>/maps at capture start so an already-running process's
mappings are known to the unwinder. It skipped every entry without a
backing file path, which discarded anonymous executable mappings such as
.NET JIT code heaps (used when W^X is disabled or no usable memfd is
available, e.g. in some container sandboxes).

For a long-running process that JIT code exists before capture starts and
is never reported via a live MMAP2 record, so it was left unregistered.
CPU samples landing in it then produced single-frame stacks that
PerfView/TraceEvent reports as BROKEN (observed in 44-99% of samples in
affected processes).

Stop skipping path-less mappings; emit anonymous executable mappings with
an empty filename so the existing MMAP2 handler registers them as
anonymous code (UnwindType::Prolog), matching how live anonymous JIT
mappings are handled. Non-executable mappings are still skipped unless
all-mmaps capture is enabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@brianrob brianrob changed the title Register anonymous executable mappings for pre-existing processes (fix BROKEN JIT stacks) Register anonymous executable mappings for pre-existing processes Jun 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant