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:19 — type Hash = int
src/Compiler/Utilities/TypeHashing.fs:33 — let inline combineHash acc y : Hash = (acc <<< 1) + y + 631
src/Compiler/Checking/SignatureHash.fs:198-203 — calculateSignatureHashOfFiles folds the per-file hashes with hashListOrderMatters, i.e. combineHash
src/Compiler/Driver/fsc.fs:870-899 — refAssemblySignatureHash = 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):
dotnet build
- Note the MVID of
obj/Debug/net10.0/refint/RefMvidRepro.dll
- In
File01.fs, rename value01 to renamedValue01
dotnet build
- 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
The signature hash that gives a reference assembly its MVID is accumulated in a 32-bit
intwith a combiner that shifts left once per source file, so the contribution of file i of N is multiplied by2^(N-i)and is exactly zero onceN - 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'sCopyRefAssemblycompares MVIDs, concludesobj/refis 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).refAssemblySignatureHashalso addsoptDataHash, 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:19—type Hash = intsrc/Compiler/Utilities/TypeHashing.fs:33—let inline combineHash acc y : Hash = (acc <<< 1) + y + 631src/Compiler/Checking/SignatureHash.fs:198-203—calculateSignatureHashOfFilesfolds the per-file hashes withhashListOrderMatters, i.e.combineHashsrc/Compiler/Driver/fsc.fs:870-899—refAssemblySignatureHash = calculateSignatureHashOfFiles + calculateHashOfAssemblyTopAttributes + optDataHashsrc/Compiler/AbstractIL/ilwrite.fs:4209-4219— for a reference assembly the metadata SHA is replaced byBitConverter.GetBytes(impliedSigHash), 4 bytes; the first 16 bytes of the resulting SHA become the MVID (written to the.mvidsection at:4427)Unrolled, the accumulator is
acc = Σ (yᵢ + 631) · 2^(N−i). Mod 2³², distanced = N − iretains only the low32 − dbits of a file's hash, and nothing at all onced >= 32.Latent since #15325 (which introduced both the signature hash and this combiner), but only observable since #19751 replaced the randomized
String.GetHashCodewith FNV-1a. Before that fix every fsc process produced a different MVID, soCopyRefAssemblyalways 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 intests/FSharp.Compiler.ComponentTests/CompilerOptions/fsc/determinism/determinism.fs, next to the #19751 one....renamed in an early filefails;...renamed in the last fileis 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, eachlet valueNN (x: int) : int = x + N):dotnet buildobj/Debug/net10.0/refint/RefMvidRepro.dllFile01.fs, renamevalue01torenamedValue01dotnet buildrefint/RefMvidRepro.dll, and check whetherobj/Debug/net10.0/ref/RefMvidRepro.dllexports the new nameRenaming in
File40.fsinstead 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/refis refreshed with the new surface.Actual behavior
The MVID is unchanged:
The two reference assemblies differ in content and in SHA-256 and share an MVID.
obj/refis never refreshed, so consumers fail withFS0039for 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:
File01.fsFile05.fsFile08.fsFile11.fsFile20.fsFile40.fsDistances 30 and 31 are the fuzzy edge rather than a clean cliff: at distance
da file keeps only the low32 - dbits of its hash, so whether a particular rename shows up there depends on the names. Distance >= 32 is always zero.The test-suite failure:
Known workarounds
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.--optimize+(Release), whereoptDataHashmasks the truncation.@(IntermediateRefAssembly)against$(TargetRefPath)afterCopyFilesToOutputDirectoryand copies when the bytes differ.Suggested fix
combineHash's<<< 1is 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: widenHashtoint64, or feed the components into an incremental SHA-256 as the non---refoutpath 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
mainat74ec4f7df(2026-08-27)