wpf: Building WPF projects is not deterministic with dotnet.exe
- .NET Core version: 5.0.100
- Windows version: 19042.630
- MSBuild .NET Framework version: 16.8.2.56705
Problem description:
Building a WPF .NET Core project with dotnet.exe
is not deterministic, but building it with msbuild.exe
is deterministic as expected.
This issue also breaks incremental builds.
Actual behavior:
Each execution of dotnet.exe build
produces assemblies that are binary different.
Expected behavior:
Building a .NET Core project should result in identical output as long as the input did not change.
SDK style projects define <Deterministic>true</Deterministic>
by default, which is meant
to produce identical output each build.
Minimal repro:
dotnet new wpf --name deterministic_bug
cd deterministic_bug
dotnet build -p:OutputPath=bin\dotnet_1\ -p:IntermediateOutputPath=obj\dotnet_1\
dotnet build -p:OutputPath=bin\dotnet_2\ -p:IntermediateOutputPath=obj\dotnet_2\
msbuild /restore -p:OutputPath=bin\msbuild_1\ -p:IntermediateOutputPath=obj\msbuild_1\
msbuild /restore -p:OutputPath=bin\msbuild_2\ -p:IntermediateOutputPath=obj\msbuild_2\
Compare bin\dotnet_1\deterministic_bug.dll
to bin\dotnet_2\deterministic_bug.dll
both are different
Compare bin\msbuild_1\deterministic_bug.dll
to bin\msbuild_2\deterministic_bug.dll
both are identical
Deeper look
It seems to be a problem with the XAML markup compiler.
If you check obj\dotnet_1\deterministic_bug_MarkupCompile.cache
and obj\dotnet_2\deterministic_bug_MarkupCompile.cache
, you can see that they differ from line 12 to 15. Those lines represent hashes over <Page>
-items, references, <Content>
-files and cs
-files. Check out GenerateCacheForFileList
from CompilerState.cs.
The hash generation seems to be straightforward, but for some reason the result is not stable and differs everytime in .NET Core.
The same does not reproduce for msbuild.exe
. Comparing the content of obj\msbuild_1\
and obj\msbuild_2\
result in identical files.
About this issue
- Original URL
- State: closed
- Created 4 years ago
- Reactions: 3
- Comments: 15 (14 by maintainers)
So, today I had some time to look into it again and I might have finally found the cause of the different output.
It seemed that my first intuition was not that wrong. While it is correct that the
Dictionary
class keeps track of the insert order, the WPF code often uses its non-generic counterpartHashTable
(andHybridDictionary
), which do not keep track of the insert order, which results in different entry order between multiple runs when iterating over it.After doing some debugging I came to the
XmlnsCache
class, which is more or less responsible for mapping XAML namespaces to assemblies.The problematic code is where the reference assemblies are being scanned:
(_assemblyPathTable is here a
HybridDictionary
)After patching the
foreach
loop using a simpleOrderBy
, I was no longer able to reproduce the issue.I hope I made no mistake. Maybe someone could verify this by cherry picking this small commit: https://github.com/MichaeIDietrich/wpf/commit/cff984aaf851ba126a822190bf815d0bf5c4d40e
Here again the steps to reproduce, since the steps from my first comment used different output paths which seem to have also an effect on the output for some reason:
dotnet new wpf --name deterministic_bug
cd deterministic_bug
dotnet build
obj
andbin
folder to some location outside the project (project is now clean again)dotnet build
Compare the output of both builds.
To test against a local build of the
PresentationBuildTasks.dll
, one can simply add this line to the csproj file:<_PresentationBuildTasksAssembly>path\to\PresentationBuildTasks.dll</_PresentationBuildTasksAssembly>
The
AssemblyReference.cache
file is still different, but it does not seem to have a negative effect here.@MichaeIDietrich: Thank you for this. I’ve confirmed your findings. The hash produced is indeed different, and your solution fixes the incremental build issue on WPF for .NET Core. As is, the first comparison against the hashed Reference list is failing every time, causing WPF to always compile.
https://github.com/dotnet/wpf/blob/master/src/Microsoft.DotNet.Wpf/src/PresentationBuildTasks/MS/Internal/Tasks/IncrementalCompileAnalyzer.cs#L98
https://github.com/dotnet/wpf/blob/master/src/Microsoft.DotNet.Wpf/src/PresentationBuildTasks/Microsoft/Build/Tasks/Windows/MarkupCompilePass1.cs#L1094
https://github.com/dotnet/wpf/blob/master/src/Microsoft.DotNet.Wpf/src/PresentationBuildTasks/MS/Internal/Tasks/CompilerState.cs#L218
Two possible options for next steps are:
Thanks again for the analysis and fix. This is an extremely impactful contribution.
(This will go in to .NET 6.0, but we also want to include this in the next servicing releases for 5.0 and 3.1.)
/cc @dotnet/wpf-developers
Dictionary order in .NET Framework (and I believe .NET Core too), if memory serves, is based on insertion order rather than a function of the key.
OK, the bug still reproduces with .NET 5.0.102.
After digging deeper into the issue with the WPF compiler cache, I realised that the reason for this issue is most probably a difference in the behavior of .NET Core compared to .NET Framework. As I stated in my initial comment, the cache uses
GetHashCode()
on strings to calculated cache values. What I was not aware of until recently is thatstring.GetHashCode()
is in fact not stable on .NET Core for subsequent program runs and in that way is not meant to be persisted.source: documentation (see Remarks)
This behavior is intentional for security reasons, so we might simply switch to a custom implementation to generate the hashes for the cache. The easiest approach might probably to just copy the hash code generation of .NET Framework.
Please have a look at this. Thank you in advance.
OK, I have good news and bad news.
Good news first, changing the hashing algorithm fixes indeed the incremental build for WPF projects. I will make a PR for this change.
The bad news are that as expected this does not fix the issue with the non-deterministic output DLLs.
Comparing the obj-folder including the fix still leaves some files with different content:
A difference in the order of assembly references can be found in all the 4 files:
(diff of both .resources files)
If I could make a guess, I could imagine that this also might come from string hashing and that there is some code that for-eaches over the values of a dictionary that contains assembly references where a string is used as key, so that the order varies depending on the hash value of the string. But I might be wrong, maybe someone with knowledge about these files could have a look at this.
Hi @ryalanms
I cloned the wpf repo yesterday, but I had trouble making the whole solution to compile, to test the fix locally.
While this might fix the incremental build for wpf projects, I’m currently not quite sure, whether this also fixes my initial problem with the non-deterministic output DLLs, if
dotnet.exe
is used instead ofmsbuild.exe
, since the repro steps I posted should usually be independent from incremental build.But I will give it another try tomorrow and will report back about the result.