Skip to content

Reference assembly MVID does not change when a public member is renamed in an early file (--optimize-) #20389

Description

@marklam

The signature hash that gives a reference assembly its MVID is accumulated in a 32-bit int with a combiner that shifts left once per source file, so the contribution of file i of N is multiplied by 2^(N-i) and is exactly zero once N - i >= 32. In a project with more than ~32 files, renaming a public member in any earlier file produces a reference assembly with different content but an identical MVID. MSBuild's CopyRefAssembly compares MVIDs, concludes obj/ref is current, and skips the copy — so every downstream project keeps compiling against a public surface that no longer exists, and no amount of rebuilding clears it.

The MVID is a 128-bit GUID, but its only API-sensitive input here is that single 32-bit int — and the fold discards all but the last ~32 files of it.

This is only visible with --optimize- (i.e. Debug). refAssemblySignatureHash also adds optDataHash, a SHA over the embedded optimization data which is not truncated, so under --optimize+ a rename moves the MVID through that term instead and masks the defect.

Where it comes from

  • src/Compiler/Utilities/TypeHashing.fs:19type Hash = int
  • src/Compiler/Utilities/TypeHashing.fs:33let inline combineHash acc y : Hash = (acc <<< 1) + y + 631
  • src/Compiler/Checking/SignatureHash.fs:198-203calculateSignatureHashOfFiles folds the per-file hashes with hashListOrderMatters, i.e. combineHash
  • src/Compiler/Driver/fsc.fs:870-899refAssemblySignatureHash = calculateSignatureHashOfFiles + calculateHashOfAssemblyTopAttributes + optDataHash
  • src/Compiler/AbstractIL/ilwrite.fs:4209-4219 — for a reference assembly the metadata SHA is replaced by BitConverter.GetBytes(impliedSigHash), 4 bytes; the first 16 bytes of the resulting SHA become the MVID (written to the .mvid section at :4427)

Unrolled, the accumulator is acc = Σ (yᵢ + 631) · 2^(N−i). Mod 2³², distance d = N − i retains only the low 32 − d bits of a file's hash, and nothing at all once d >= 32.

Latent since #15325 (which introduced both the signature hash and this combiner), but only observable since #19751 replaced the randomized String.GetHashCode with FNV-1a. Before that fix every fsc process produced a different MVID, so CopyRefAssembly always copied and the truncation never surfaced. The test added with #19751 asserts stability (same source ⇒ same MVID) but not discrimination (different API ⇒ different MVID), which is the gap this hits.

Repro steps

Failing tests, on a branch off main: https://github.com/marklam/fsharp/tree/mvid-collisions — two tests in tests/FSharp.Compiler.ComponentTests/CompilerOptions/fsc/determinism/determinism.fs, next to the #19751 one. ...renamed in an early file fails; ...renamed in the last file is the control and passes, which is what makes the failure positional rather than "renames are ignored".

Standalone (attached zip — 40 single-module files, File01.fs...File40.fs, each let valueNN (x: int) : int = x + N):

  1. dotnet build
  2. Note the MVID of obj/Debug/net10.0/refint/RefMvidRepro.dll
  3. In File01.fs, rename value01 to renamedValue01
  4. dotnet build
  5. Compare the MVID of refint/RefMvidRepro.dll, and check whether obj/Debug/net10.0/ref/RefMvidRepro.dll exports the new name

Renaming in File40.fs instead does update the MVID.

Expected behavior

Step 4 produces a reference assembly whose MVID differs from step 2, since its public surface changed, and obj/ref is refreshed with the new surface.

Actual behavior

The MVID is unchanged:

after step 1:  refint mvid=93689f44-88a3-f67e-0d9e-5f7d1141a66b   ref mvid=93689f44-...
after step 4:  refint mvid=93689f44-88a3-f67e-0d9e-5f7d1141a66b   ref mvid=93689f44-...

refint exports renamedValue01 : yes
ref    exports renamedValue01 : no   (still exports value01)

The two reference assemblies differ in content and in SHA-256 and share an MVID. obj/ref is never refreshed, so consumers fail with FS0039 for a member that is right there in the source — often with "Maybe you want one of the following:" naming the symbol you just renamed away. Rebuilding does not clear it.

Positional scan, one member renamed at a time over the same 40 files:

Renamed member in Distance from end of compile order Reference assembly MVID
File01.fs 39 unchanged
File05.fs 35 unchanged
File08.fs 32 unchanged
File11.fs 29 changed
File20.fs 20 changed
File40.fs 0 changed

Distances 30 and 31 are the fuzzy edge rather than a clean cliff: at distance d a file keeps only the low 32 - d bits of its hash, so whether a particular rename shows up there depends on the names. Distance >= 32 is always zero.

The test-suite failure:

failed CompilerOptions.Fsc.determinism.Reference assembly MVID changes when a public member is renamed in an early file
  Assert.NotEqual() Failure: Values are equal
  Expected: Not dbfafb24-f18c-aca9-8a31-a497f09cd434
  Actual:       dbfafb24-f18c-aca9-8a31-a497f09cd434

Known workarounds

  • Delete obj/<Config>/<tfm>/ref/*.dll — the copy then happens because the destination is missing. This is the only reliable cure once a project is in the stale state.
  • <ProduceReferenceAssembly>false</ProduceReferenceAssembly> on affected projects, at the cost of the reference-assembly optimisation.
  • Build with --optimize+ (Release), where optDataHash masks the truncation.
  • An MSBuild target that hashes @(IntermediateRefAssembly) against $(TargetRefPath) after CopyFilesToOutputDirectory and copies when the bytes differ.

Suggested fix

combineHash's <<< 1 is lossy — a multiply-based mix such as the FNV-1a step already used for strings (acc = (acc ^^^ y) * 16777619) is invertible mod 2^32 and would lose nothing positionally. Better still, stop funnelling a 128-bit MVID through a 32-bit intermediate: widen Hash to int64, or feed the components into an incremental SHA-256 as the non---refout path already does. Even without the shift, 32 bits gives roughly 1-in-65k birthday collisions across a large public surface. A discrimination test (different API ⇒ different MVID) alongside the existing stability test would catch regressions here.

Related information

  • Windows 11 Pro 24H2 (10.0.26200), x64
  • .NET SDK 10.0.400, FSharp.Compiler.Service 43.12.400-servicing (43.1204.26.38015)
  • Also reproduces on a build of main at 74ec4f7df (2026-08-27)
  • Command line and MSBuild builds; no IDE involved

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    Status
    New

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions