dotnet / dotnet/diagnostics

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

Open
#5,900 2 comments 0 reactions 0 assignees View on GitHub
enhancement
Dominant language
C++
Stars
1.3k
Forks
404
Avg merge
2d 3h
Merged PRs (30d)
38

Description

## 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(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+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 `nop`s 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
[--assemblies ] # folder(s) with your shipped *.dll (+ sidecar/embedded *.pdb)
[--symbols ...] # extra local symbol dirs
[--no-download] # offline / my-code only
[--strict] # fail (don't guess) on PDB-id mismatch or ambiguous overloads
[--output ] [--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////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`; handle nested types (`Outer+d__31`), compiler-generated names (`<>c.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.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start by examining the existing dotnet-symbol CLI and its Microsoft.SymbolStore download path, then review the System.Reflection.Metadata resolution algorithm described here. Use the Maui.Diagnostics.Playground crash traces as fixtures and cover Android/Apple parsing, PDB matching, and IL-offset-to-sequence-point mapping; done means a trace is symbolicated without guessing or mismatches.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, csharp, ios
Domain
cli, mobile-dev, tooling
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.