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)
- 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.
- 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).
- 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).
- Map offset -> line via the portable PDB:
MethodDebugInformation -> largest sequence point with IL offset <= NNN that isn't hidden (0xFEEFEE) -> {document, startLine}.
- 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.
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 onlyType.Method + 0x<ILoffset>(Unknown Source)and rewrites them with realin File.cs:line Nlocations.Think of it as
ndk-stackfor managed .NET (AndroidAndroidRuntime/JavaProxyThrowabletraces today, Apple.ips/NSExceptiontraces too). The user points the tool at:*.dll+*.pdb(their app/library code), anddotnet-symbolalready does for dumps/modules),and it prints the symbolicated trace.
This is essentially
mono-symbolicatereimagined 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
AndroidRuntimewith frames like:The
+ 0x2dbis 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 managedfile:lineis 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
+ 0xNNNresolves 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 withndk-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
InvalidOperationExceptionon the UI thread. Repro app (usable as a test corpus): https://github.com/Redth/Maui.Diagnostics.PlaygroundExample 1 - Release, CoreCLR, android-arm64
Crash frames:
Decompiling the shipped assembly (
obj/Release/net*-android/android-arm64/linked/MyApp.dll) withilspycmd -il:RunAsyncIL code size =941(0x3ad), so offset0x2dbis in range. The instruction at exactlyIL_02dbis:switch-arm"managed-ui-unhandled" => ThrowUnhandledOnCurrentThread()atManagedCrashScenarioRunner.cs:line 38.ThrowUnhandledOnCurrentThread + 0xa-> that method isldstr ... / newobj InvalidOperationException::.ctor / throw, so0xais thethrowatManagedCrashScenarioRunner.cs:line 63.Example 2 - same crash, Debug build
Same logical crash, but the offset differs because Debug IL is unoptimized:
RunAsyncDebug IL code size =997(0x3e5); instruction atIL_030bis againcall ThrowUnhandledOnCurrentThread()(Debug keepsnops 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 (theandroid-arm64/linkedcopy), 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=embeddedmakes CoreCLR-on-Android emit managedfile:linefor 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.Proposed CLI
Pipe-friendly, like
ndk-stack:"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
--assembliesat theirbin//obj/build output directory and have the tool recurse to find the matching assembly + PDB. For trimmed Release apps the relevant copy lives underobj/<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/.xcarchiveextraction can be a later enhancement.Resolution algorithm (per managed frame)
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.fullTypeName -> (assembly, TypeDefinitionHandle)index over the provided dirs (+ runtime/framework packs for BCL types).offset < method IL sizeand lands at/after a real sequence point).MethodDebugInformation-> largest sequence point with IL offset<= NNNthat isn'thidden (0xFEEFEE)->{document, startLine}.--stricton mismatch.Most of this is
System.Reflection.Metadata(MetadataReader,MethodDebugInformation,SequencePointCollection), and the download half is the existingdotnet-symbol/Microsoft.SymbolStorepath.iOS / Apple
The same approach applies to Apple platforms: managed frames in
NSException/.ipscrash 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 AndroidAndroidRuntimeformat; the metadata/PDB core is shared.Hard cases / mitigations
confidence;--strictrefuses to guesslinked) assembly + its PDBEmbeddedPortablePdbdebug-dir entry; read in place, skip downloadPrior art
ndk-stack(Android NDK) - the UX model.mono-symbolicate- same idea, Mono/.msym-only, no auto-download.{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.