Skip to content

Instantly share code, notes, and snippets.

@0xced
Last active March 3, 2021 07:40
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save 0xced/505206d19ce96319814a92a0b828b14a to your computer and use it in GitHub Desktop.
Save 0xced/505206d19ce96319814a92a0b828b14a to your computer and use it in GitHub Desktop.
Minimal project to test producing a single executable which embeds its dll files and its pdb

EmbeddedPdbCostura

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 to Costura.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:

Symptoms

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.

Cause

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.

Workarounds

  • 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.

Resolution

A fix is anticipated in .NET Framework 4.7.2 in the near future that restores Portable PDB functionality with greatly improved performance.

Test procedure

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

💣 Complication

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.

🔍 Investigation

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") returns true.

  • ❌ When compiling with <Costura CreateTemporaryAssemblies="true" />RuntimeFeature.IsSupported("PortablePdb") returns false.

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>()

💡 Eureka

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).

Workaround #1 (better)

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.

Workaround #2 (not so good)

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.

Solution

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.

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net472</TargetFramework>
<DebugType>embedded</DebugType>
<AutoGenerateBindingRedirects>false</AutoGenerateBindingRedirects>
<GenerateSupportedRuntime>false</GenerateSupportedRuntime>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Ben.Demystifier" Version="0.3.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Costura.Fody" Version="4.1.0" PrivateAssets="All" />
<PackageReference Include="Fody" Version="6.3.0" PrivateAssets="All" />
</ItemGroup>
</Project>
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Costura CreateTemporaryAssemblies="true" LoadAtModuleInit="false" />
</Weavers>
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
<xs:element name="Weavers">
<xs:complexType>
<xs:all>
<xs:element name="Costura" minOccurs="0" maxOccurs="1">
<xs:complexType>
<xs:all>
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="IncludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged32Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged 32 bit assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged 64 bit assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="PreloadOrder" type="xs:string">
<xs:annotation>
<xs:documentation>The order of preloaded assemblies, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
</xs:all>
<xs:attribute name="CreateTemporaryAssemblies" type="xs:boolean">
<xs:annotation>
<xs:documentation>This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeDebugSymbols" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls if .pdbs for reference assemblies are also embedded.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableCompression" type="xs:boolean">
<xs:annotation>
<xs:documentation>Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableCleanup" type="xs:boolean">
<xs:annotation>
<xs:documentation>As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="LoadAtModuleInit" type="xs:boolean">
<xs:annotation>
<xs:documentation>Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IgnoreSatelliteAssemblies" type="xs:boolean">
<xs:annotation>
<xs:documentation>Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="ExcludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="Unmanaged32Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged 32 bit assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="Unmanaged64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged 64 bit assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="PreloadOrder" type="xs:string">
<xs:annotation>
<xs:documentation>The order of preloaded assemblies, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:all>
<xs:attribute name="VerifyAssembly" type="xs:boolean">
<xs:annotation>
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
<xs:annotation>
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="GenerateXsd" type="xs:boolean">
<xs:annotation>
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:schema>
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
namespace EmbeddedPdbCostura
{
class Program
{
static Program()
{
CosturaUtility.Initialize();
}
private static async Task<int> Main()
{
try
{
//PrintSwitches();
//AppContext.SetSwitch("Switch.System.Diagnostics.IgnorePortablePDBsInStackTraces", false);
Console.WriteLine($"RuntimeFeature.IsSupported(RuntimeFeature.PortablePdb) => {RuntimeFeature.IsSupported(RuntimeFeature.PortablePdb)}");
await Task.Delay(-1, new CancellationToken(true));
return 0;
}
catch (Exception exception)
{
var demystifiedException = exception.ToStringDemystified();
await Console.Error.WriteLineAsync(demystifiedException);
return demystifiedException.Contains("Program.cs") ? 0 : 1;
}
}
private static void PrintSwitches()
{
// Copied from System.AppContextDefaultValues
var names = new[]
{
"Switch.System.Globalization.NoAsyncCurrentCulture",
"Switch.System.Globalization.EnforceJapaneseEraYearRanges",
"Switch.System.Globalization.FormatJapaneseFirstYearAsANumber",
"Switch.System.Globalization.EnforceLegacyJapaneseDateParsing",
"Switch.System.Threading.ThrowExceptionIfDisposedCancellationTokenSource",
"Switch.System.Diagnostics.EventSource.PreserveEventListnerObjectIdentity",
"Switch.System.IO.UseLegacyPathHandling",
"Switch.System.IO.BlockLongPaths",
"Switch.System.Security.Cryptography.DoNotAddrOfCspParentWindowHandle",
"Switch.System.Security.ClaimsIdentity.SetActorAsReferenceWhenCopyingClaimsIdentity",
"Switch.System.Diagnostics.IgnorePortablePDBsInStackTraces",
"Switch.System.Runtime.Serialization.UseNewMaxArraySize",
"Switch.System.Runtime.Serialization.UseConcurrentFormatterTypeCache",
"Switch.System.Threading.UseLegacyExecutionContextBehaviorUponUndoFailure",
"Switch.System.Security.Cryptography.UseLegacyFipsThrow",
"Switch.System.Runtime.InteropServices.DoNotMarshalOutByrefSafeArrayOnInvoke",
"Switch.System.Threading.UseNetCoreTimer",
};
foreach (var switchName in names)
{
var found = AppContext.TryGetSwitch(switchName, out var isEnabled);
Console.WriteLine($"{switchName} = {(found ? isEnabled.ToString() : "?")}");
}
}
}
}
@0xced
Copy link
Author

0xced commented Jan 13, 2021

Reported as Fody/Costura#633

@0xced
Copy link
Author

0xced commented Jan 26, 2021

Fixed by Fody/Costura#638 and Fody/Costura#642. It means this issue will no longer exist in Costura 5.0.0 and later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment