Skip to content

Patterns weavers produce non-deterministic output even with Deterministic=true #35

@gfraiteur

Description

@gfraiteur

Summary

PostSharp''s compiler honors the MSBuild Deterministic property (PostSharp.properties maps $(Deterministic) to PostSharpDeterministic, passed to the PostSharp task), and Tests/Core/TestDeterministicBuild verifies that two builds with Deterministic=True produce byte-identical PE and PDB — it passes, including the .NET Framework / Windows-PDB case.

However, that test only covers the plain weaver. When Patterns weavers run (Common / Model / Threading), the output is not reproducible: two rebuilds with identical inputs and Deterministic=true produce different PE and PDB files.

Repro (PostSharp 2024.0, branch topic/2024.0/test-reorganization)

Build UserInterface\PostSharp.Settings.UI\PostSharp.Settings.UI.csproj twice and compare outputs:

pwsh Build/Scripts/msbuild.ps1 -VisualStudioVersion <installed> UserInterface\PostSharp.Settings.UI\PostSharp.Settings.UI.csproj /t:Rebuild /p:Configuration=vsix
# copy bin\vsix\PostSharp.Settings.UI.exe + .pdb aside, rebuild, compare hashes
  • The project is woven by the Patterns weavers (build emits COM014 / THR027 warnings).
  • Verified via binlog that both csc /deterministic+ and the PostSharp task parameter Deterministic=True were in effect in both builds.
  • PostSharp.Settings.exe and PostSharp.VisualStudio.Debugging.Server.dll from the same solution (not aspect-woven, plain csc) are byte-identical across rebuilds — the difference is specific to the woven assemblies (PostSharp.Settings.UI.exe, PostSharp.VisualStudio.Package.VS16.0/VS17.0.dll).

Observed difference

The PE differs only in hash-derived/build-id fields — everything else is byte-identical:

  • COFF TimeDateStamp (offset 0x88 in the repro binary)
  • PE checksum region
  • debug directory data (~128 bytes: CodeView GUID/age and the PdbChecksum entry)
  • MVID
  • the Windows (full) PDB differs as well

The pattern suggests the deterministic output-hashing path is bypassed or salted somewhere in the Patterns weaving pipeline — plausibly the Windows PDB writing in that path produces a fresh signature, which then cascades into the PE''s derived fields via PdbChecksum.

Impact

This blocks strict content comparison of the rebuilt UserInterface assemblies in the signed/unsigned distribution equivalence gate (Build/Distribution/Compare-SignedDistribution.ps1): the UserInterface solution is rebuilt in every distribution build (TouchArtifacts forces it), so the woven assemblies + their PDBs + the VSIX catalog.json/manifest.json (which embed their hashes) are currently exempted to presence-only comparison (-RebuiltComponentNames; see Documentation/Distribution.md, section "Rebuilt components"). More generally, any customer relying on deterministic builds does not get reproducible output for aspect-woven projects.

Suggested fix path

  1. Find where determinism is lost in the weave pipeline when Patterns weavers / Windows PDB writing are involved.
  2. Extend Tests/Core/TestDeterministicBuild with a Patterns-aspect test case (it currently only covers the plain weaver, which is why this regressed silently).

Root cause (resolved)

Found and fixed in commit 1dadc2f95c on topic/2024.0/test-reorganization. The Patterns weavers turned out to be incidental - the defect is in the compiler's Windows-PDB writing path and reproduces with a plain MethodInterceptionAspect as well.

MetaDataEmitImpl.GetTypeDefProps / GetMethodProps (the IMetaDataImport shim that the unmanaged symbol writer, diasymreader, queries for type/method names) reported name lengths in bytes instead of wide characters. For woven types whose fully qualified name exceeds 127 characters, diasymreader then falls back to naming the PDB module after a heap address, which is different on every build. The nondeterministic PDB content cascades exactly as suspected in the summary: PDB content hash (pdbId) feeds the CodeView GUID and debug-directory timestamp, which feed the PE image hash, which produces the MVID, COFF TimeDateStamp, PE checksum, and strong-name signature.

The trigger in PostSharp.Settings.UI is the binding type generated for the [Dispatched] explicit interface implementation IProgressTaskObserver.SetStatusText in ProgressPage: <PostSharp.Settings.Wizard.Progress.IProgressTaskObserver.SetStatusText>z__Binding, whose fully qualified name is 133 characters. A second cliff exists at diasymreader's own internal buffer (empirically between 251 and 271 characters), where it takes the same fallback regardless of the reported length - names handed to the symbol writer are therefore now deterministically clamped to 250 characters.

Verification: PostSharp.Settings.UI rebuilds are byte-identical (PE and PDB); Tests/Core/TestDeterministicBuild was extended with a long-name explicit-interface-implementation scenario (plain aspect, no Patterns dependency) that fails on the previous compiler and passes now. The signed/unsigned gate's presence-only exemption (-RebuiltComponentNames) stays in place until a signed/unsigned chain run on a fixed compiler confirms the rebuilt UI assemblies are byte-identical end-to-end.

The same defect also corrupts PDB module names in non-deterministic Windows-PDB builds (cosmetic, no debugging impact) - tracked separately as #36.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions