Skip to content

[Feature] dotnet-symbol: symbolicate managed Android/iOS crash traces (IL offsets -> file:line), like ndk-stack #5900

Description

@jonathanpeppers

Summary

Add a "symbolicate a stack trace" mode to dotnet-symbol (or a sibling tool) that takes a managed mobile crash trace whose frames carry only Type.Method + 0x<ILoffset>(Unknown Source) and rewrites them with real in File.cs:line N locations.

Think of it as ndk-stack for managed .NET (Android AndroidRuntime/JavaProxyThrowable traces today, Apple .ips/NSException traces too). The user points the tool at:

  • a folder containing their own *.dll + *.pdb (their app/library code), and
  • lets the tool auto-download any missing .NET / Microsoft symbols (exactly what dotnet-symbol already does for dumps/modules),

and it prints the symbolicated trace.

This is essentially mono-symbolicate reimagined for CoreCLR, with automatic framework-symbol download.

Motivation

On .NET-for-Android (both Mono and the new CoreCLR-on-Android), an unhandled managed exception that crosses into Java is logged by AndroidRuntime with frames like:

at Maui.Diagnostics.Playground.Features.Scenarios.ManagedCrashScenarioRunner.RunAsync + 0x2db(Unknown Source)

The + 0x2db is the method-relative IL offset (hex). (Unknown Source) means no PDB was consulted to translate it. In default Release builds (and many Debug configs), no managed file:line is emitted, so production crash logs are very hard to read - but the information needed to resolve them is fully present in the assembly + portable PDB.

I verified this end-to-end by hand (details below): given the exact shipped assembly and its portable PDB, each + 0xNNN resolves deterministically to a sequence point -> file:line. A tool that automates this - and downloads framework PDBs on demand - would make managed mobile crash logs as usable as native ones are with ndk-stack.

Worked examples (how the offsets map)

All from a real .NET MAUI app crash on a physical device. The crash is a simulated unhandled InvalidOperationException on the UI thread. Repro app (usable as a test corpus): https://github.com/Redth/Maui.Diagnostics.Playground

Example 1 - Release, CoreCLR, android-arm64

Crash frames:

at ...ManagedCrashScenarioRunner.ThrowUnhandledOnCurrentThread + 0xa(Unknown Source)
at ...ManagedCrashScenarioRunner.RunAsync + 0x2db(Unknown Source)
at ...ScenarioDetailPage+<RunScenarioAsync>d__31.MoveNext + 0xb3(Unknown Source)

Decompiling the shipped assembly (obj/Release/net*-android/android-arm64/linked/MyApp.dll) with ilspycmd -il:

  • RunAsync IL code size = 941 (0x3ad), so offset 0x2db is in range. The instruction at exactly IL_02db is:
    IL_02d6: br       IL_0390
    IL_02db: call     Task ManagedCrashScenarioRunner::ThrowUnhandledOnCurrentThread()   <-- + 0x2db
    IL_02e0: stloc.0
    
    -> maps to the switch-arm "managed-ui-unhandled" => ThrowUnhandledOnCurrentThread() at ManagedCrashScenarioRunner.cs:line 38.
  • ThrowUnhandledOnCurrentThread + 0xa -> that method is ldstr ... / newobj InvalidOperationException::.ctor / throw, so 0xa is the throw at ManagedCrashScenarioRunner.cs:line 63.

Example 2 - same crash, Debug build

Same logical crash, but the offset differs because Debug IL is unoptimized:

at ...ManagedCrashScenarioRunner.RunAsync + 0x30b(Unknown Source)
  • RunAsync Debug IL code size = 997 (0x3e5); instruction at IL_030b is again call ThrowUnhandledOnCurrentThread() (Debug keeps nops and a string-equality chain, so the same call sits at a higher offset).

Key takeaway for the tool: IL offsets are build-specific. The same crash is +0x30b (Debug) vs +0x2db (Release), and the Release frame only resolves against the trimmed/linked assembly that actually shipped (the android-arm64/linked copy), not the pre-link compile output. So matching must be against the exact build, validated by PDB id / MVID.

Notes that fell out of the investigation

  • DebugType=embedded makes CoreCLR-on-Android emit managed file:line for the app's own frames at runtime (PDB embedded in the assembly), but framework frames still lack lines. Offline symbolication is the general solution and covers framework frames too once their PDBs are downloaded.
  • Even R2R Release frames report the managed IL offset in the Java-side trace (not a native PC), so IL-offset symbolication is the right primitive across configs.

Proposed CLI

dotnet-symbol --symbolicate <trace.txt>
    [--assemblies <dir>]   # folder(s) with your shipped *.dll (+ sidecar/embedded *.pdb)
    [--symbols <dir>...]   # extra local symbol dirs
    [--no-download]        # offline / my-code only
    [--strict]             # fail (don't guess) on PDB-id mismatch or ambiguous overloads
    [--output <file>] [--format text|json]

Pipe-friendly, like ndk-stack:

adb logcat -d | dotnet-symbol --symbolicate - --assemblies ./bin/Release/net*-android

"Point it at your build output" (no APK parsing required for v1)

APK/AAB extraction would be ideal (binds to exactly what shipped), but it is not required to be useful. For a first version, let users point --assemblies at their bin//obj/ build output directory and have the tool recurse to find the matching assembly + PDB. For trimmed Release apps the relevant copy lives under obj/<Config>/<tfm>/<rid>/linked/; the tool can prefer that, and use the PDB-id/MVID check to confirm it picked the right one (and warn loudly if only a mismatched/pre-trim assembly is available). APK/AAB and Apple .app/.xcarchive extraction can be a later enhancement.

Resolution algorithm (per managed frame)

  1. Parse Type.Method + 0x<hex>; handle nested types (Outer+<Async>d__31), compiler-generated names (<>c.<X>b__N_M), and pass through pure-Java/native frames unchanged.
  2. Find the defining assembly - the trace does not include the assembly name, MethodDef token, or MVID, so build a fullTypeName -> (assembly, TypeDefinitionHandle) index over the provided dirs (+ runtime/framework packs for BCL types).
  3. Find the method - match by simple name; disambiguate overloads using the IL offset itself (a candidate is only valid if offset < method IL size and lands at/after a real sequence point).
  4. Map offset -> line via the portable PDB: MethodDebugInformation -> largest sequence point with IL offset <= NNN that isn't hidden (0xFEEFEE) -> {document, startLine}.
  5. Validate the PDB matches the PE (PDB id / debug-directory GUID+age) and warn / fail-in---strict on mismatch.

Most of this is System.Reflection.Metadata (MetadataReader, MethodDebugInformation, SequencePointCollection), and the download half is the existing dotnet-symbol / Microsoft.SymbolStore path.

iOS / Apple

The same approach applies to Apple platforms: managed frames in NSException/.ips crash reports carry the managed IL offset and resolve the same way against the app assemblies + portable PDBs. The only platform-specific work is the front-end parser for Apple crash-report format vs Android AndroidRuntime format; the metadata/PDB core is shared.

Hard cases / mitigations

Problem Mitigation
No signature/token in trace -> overload ambiguity Disambiguate by IL-range + sequence-point validity; expose confidence; --strict refuses to guess
Wrong build supplied -> silent mis-resolution Validate PDB-id/MVID against the PE; prefer the trimmed/linked assembly
Trimming rewrites IL & MVID Use the post-trim (linked) assembly + its PDB
Inlining (Release/R2R) drops callee frames Inherent; optionally annotate using PDB inline data if present
Embedded PDBs Detect EmbeddedPortablePdb debug-dir entry; read in place, skip download

Prior art

  • ndk-stack (Android NDK) - the UX model.
  • mono-symbolicate - same idea, Mono/.msym-only, no auto-download.
  • Sentry's managed-frame symbolication from {MVID, MethodDef token, IL offset} - the robust server-side variant; this tool would do the same lookup but reconstruct the token from (type, method, IL range) since the device trace omits it.

Test corpus

https://github.com/Redth/Maui.Diagnostics.Playground contains a gallery of managed/native crash scenarios and captured logcat traces (Mono/CoreCLR x Debug/Release) that can serve as fixtures for this feature.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions