Minimal project to test producing a single executable which embeds its dll files and its pdb.
The goal is to ensure that source code information is still present in stack traces of exceptions.
- The dll files are embedded with Costura by adding a
PackageReference
toCostura.Fody
in the project file. - The pdb is embedded by specifying
<DebugType>embedded</DebugType>
in the project file.
Note that .NET Framework 4.7.2 is required, else stack traces are missing source information for frames with debug information in the Portable PDB format when running on .NET Framework 4.7.1:
An application that formats stack traces is missing source information for some or all frames. This includes stack traces formatted via System.Exception.ToString(), System.Exception.StackTrace and System.Diagnostics.StackTrace.ToString(). The frames missing source information reside in assemblies that have pdbs in the Portable PDB format present on disk.
The .NET Framework 4.7.1 added support for detecting and parsing the Portable PDB file format to show file and line number information in stack traces. However, due to an implementation issue, the feature had an unacceptable performance impact and Microsoft intentionally disabled it.
- If you control the build process for the problematic assemblies you may be able to configure it to generate the classic Windows PDB format instead.
- You can use the PDB conversion tool to convert the Portable PDBs into the classic Windows PDB format and deploy those with the application instead.
A fix is anticipated in .NET Framework 4.7.2 in the near future that restores Portable PDB functionality with greatly improved performance.
msbuild /restore EmbeddedPdbCostura.csproj && bin\Debug\net472\EmbeddedPdbCostura.exe
Note: don't use dotnet msbuild
. It is important to use msbuild
(requires a developer command prompt) else Costura gets confused and generates an executable referencing System.Private.CoreLib
which crashes before hitting the Main
method with a System.TypeInitializationException: The type initializer for '<Module>' threw an exception
because it can't find System.Private.CoreLib
which is a .NET Core library.
✅ Success, the output includes reference to Program.cs
with line numbers:
System.Threading.Tasks.TaskCanceledException: A task was canceled.
at async Task<int> EmbeddedPdbCostura.Program.Main() in C:/Projects/Experiments/EmbeddedPdbCostura/Program.cs:line 18
Some NuGet packages require an assembly to be physically on disk to operate properly. Costura supports this scenario; it can be configured to create temporary assemblies at startup with the following FodyWeavers.xml
file:
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Costura CreateTemporaryAssemblies="true" />
</Weavers>
Unfortunately, and for some unknown reason yet, creating temporary assemblies make the source code information disappear from the stack traces.
rm -rf bin obj && msbuild /restore EmbeddedPdbCostura.csproj && bin\Debug\net472\EmbeddedPdbCostura.exe
❌ Failure: the output does not include reference to Program.cs
with line numbers anymore:
System.Threading.Tasks.TaskCanceledException: A task was canceled.
at async Task<int> EmbeddedPdbCostura.Program.Main()
Ideally, creating temporary assemblies should also retain source code information in stack traces.
After digging step by step into Exception.StackTrace
I found the InitializeSourceInfo
method
Stack trace:
StackFrameHelper.InitializeSourceInfo() at StackFrameHelper.cs:line 67
StackTrace.CaptureStackTrace() at StackTrace.cs:line 199
new StackTrace() at StackTrace.cs:line 97
Environment.GetStackTrace()
Exception.GetStackTrace() at Exception.cs:line 260
Exception.get_StackTrace() at Exception.cs:line 244
Decompiled source code:
internal void InitializeSourceInfo(int iSkip, bool fNeedFileInfo, Exception exception)
{
StackTrace.GetStackFramesInternal(this, iSkip, fNeedFileInfo, exception);
if (!fNeedFileInfo || !RuntimeFeature.IsSupported("PortablePdb") || StackFrameHelper.t_reentrancy > 0)
return;
[...]
The missing source code information probably comes from this difference:
-
✅ When compiling with
<Costura CreateTemporaryAssemblies="false" />
⇒RuntimeFeature.IsSupported("PortablePdb")
returnstrue
. -
❌ When compiling with
<Costura CreateTemporaryAssemblies="true" />
⇒RuntimeFeature.IsSupported("PortablePdb")
returnsfalse
.
When RuntimeFeature.IsSupported("PortablePdb")
returns false
, the InitializeSourceInfo
method returns early and no source code information is available in the stack trace of exceptions.
Now, let's figure out what makes the PortablePdb
runtime feature supported or not.
First impression: using CreateTemporaryAssemblies="true"
somehow interferes with the target framework.
In AppContextDefaultValues.cs
:
public static void PopulateDefaultValues()
{
AppContextDefaultValues.ParseTargetFrameworkName(out string identifier, out string profile, out int version);
// identifier = ".NETFramework"
// profile = ""
// version = 40000
AppContextDefaultValues.PopulateDefaultValuesPartial(identifier, profile, version);
}
Then in PopulateDefaultValuesPartial, the app context switch is set to the wrong value because the version is 40000 instead of 40702. When using CreateTemporaryAssemblies="false"
the version is 40702 and the portable pdb switch has the correct value.
if (version <= 40701)
{
AppContext.DefineSwitchDefault(SwitchIgnorePortablePDBsInStackTraces, true);
}
Now, let's go into AppContextDefaultValues.ParseTargetFrameworkName
to figure out why we get version 40000 instead 40702.
private static void ParseTargetFrameworkName(out string identifier, out string profile, out int version)
{
if (AppContextDefaultValues.TryParseFrameworkName(AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName, out identifier, out version, out profile))
return;
identifier = ".NETFramework";
version = 40000;
profile = string.Empty;
}
It looks like 40000 is the default value when AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName
can't be parsed.
✅ When compiling with <Costura CreateTemporaryAssemblies="false" />
⇒ AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName
returns ".NETFramework,Version=v4.7.2"
❌ When compiling with <Costura CreateTemporaryAssemblies="true" />
⇒ AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName
returns null
Let's put a breakpoint on the SetupInformation.TargetFrameworkName
setter to understand where it comes from.
✅ Breakpoint hit before anything else.
AppDomainSetup.set_TargetFrameworkName() at AppDomainSetup.cs:line 396
AppDomain.SetTargetFrameworkName()
❌ Breakpoint in ParseTargetFrameworkName
hit before AppDomainSetup.set_TargetFrameworkName
, stack trace:
AppContextDefaultValues.ParseTargetFrameworkName() at AppContextDefaultValues.cs:line 50
AppContextDefaultValues.PopulateDefaultValues() at AppContextDefaultValues.cs:line 41
AppContext.InitializeDefaultSwitchValues()
AppContext.TryGetSwitch()
static AppContextSwitches()
AppContextSwitches.get_BlockLongPaths()
Path.NormalizePath()
Path.GetFullPathInternal()
Path.GetTempPath()
AssemblyLoader.Attach() at C:\Projects\Experiments\EmbeddedPdbCostura\obj\Debug\net472\ILTemplateWithTempAssembly.cs:line 31
static <Module>()
When using <Costura CreateTemporaryAssemblies="true" />
, Costura uses ILTemplateWithTempAssembly.cs instead of ILTemplate.cs. At the beginning of the Attach()
method, Path.GetTempPath()
is called. This starts the AppContext switches initialization code that populate the default switch values (as seen in above stack trace). But remember that the Attach()
method is called very early, even before Main
. And at that point, AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName
has not yet been set by the framework. So the AppContext switches initialization runs with a wrong target framework (.NET Framework 4.0.0) instead of the real one (.NET Framework 4.7.2).
Set LoadAtModuleInit to false
in the FodyWeavers.xml
configuration file: <Costura CreateTemporaryAssemblies="true" LoadAtModuleInit="false" />
and manually call CosturaUtility.Initialize()
. Note that Costura fails with the following error when calling CosturaUtility.Initialize()
from the Main
method (not sure why):
Fody: Costura was not initialized. Make sure LoadAtModuleInit=true or call CosturaUtility.Initialize().
Instead, call CosturaUtility.Initialize()
from the static initializer of your Program
class:
static Program()
{
CosturaUtility.Initialize();
}
This workaround is better than the next one because it addresses the root cause of the issue. Unlike workaround #2, all AppContext switches will be properly initialized.
In order to get source code information in stack trace, even when using <Costura CreateTemporaryAssemblies="true" />
, add this at the very beginning of the Main
method:
AppContext.SetSwitch("Switch.System.Diagnostics.IgnorePortablePDBsInStackTraces", false);
Alternatively, add the app context switch override in the app.config file as documented in Retargeting Changes for Migration from .NET Framework 4.7.1 to 4.7.2.
Costura should not mess with the AppContext switch default values when using the IL template with temporary assemblies (ILTemplateWithTempAssembly). How to best achieve this is not yet clear to me. Maybe the code that calls Path.GetTempPath()
should be executed the first time ResolveAssembly
is called? Maybe at the point AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName
is properly set?
Update: this has been fixed in Costura itself in Fody/Costura#638 and Fody/Costura#642 by manually setting the target framework name before doing anything else. The fix is available in Costura 5.0.0 and later.
Reported as Fody/Costura#633