Reference assembly MVID does not change when a public member is renamed in an early file (--optimize-)
- Dominant language
- F#
- Stars
- 4.3k
- Forks
- 876
- Avg merge
- 4d 22h
- Merged PRs (30d)
- 144
Description
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`):
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///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.
* `false` 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
Contributor guide
Research direction
Start with src/Compiler/Utilities/TypeHashing.fs and src/Compiler/Checking/SignatureHash.fs, then trace refAssemblySignatureHash in src/Compiler/Driver/fsc.fs and MVID generation in src/Compiler/AbstractIL/ilwrite.fs. Run the two determinism tests in tests/FSharp.Compiler.ComponentTests/CompilerOptions/fsc/determinism/determinism.fs. Done means an early public-member rename changes the reference assembly MVID and refreshes the copied reference assembly.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- fsharp
- Domain
- build-system, compilers
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100