From 832506243ad67bc54012d2285cf7395b543bbf2e Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 25 May 2026 09:46:53 +0200 Subject: [PATCH] Fix non-deterministic reference assembly MVIDs (#19751) Replace randomized String.GetHashCode with deterministic FNV-1a 32-bit hash in TypeHashing.hashText and hashILTypeRef. The per-process hash seed in .NET 6+ caused --refout / ProduceReferenceAssembly to emit a different MVID on every build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../.FSharp.Compiler.Service/11.0.100.md | 1 + src/Compiler/Driver/fsc.fs | 2 + src/Compiler/Utilities/TypeHashing.fs | 15 ++++++- .../fsc/determinism/determinism.fs | 45 +++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index 7d4162f804e..f3632218a39 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -57,6 +57,7 @@ * Fix parser recovery, name resolution, and code completion for unfinished enum patterns ([PR #19708](https://github.com/dotnet/fsharp/pull/19708)) * Parser: fix unexpected diagnostics in debug builds, improve error messages ([PR #19730](https://github.com/dotnet/fsharp/pull/19730)) * Fix signature conformance: overloaded member with unit parameter `M(())` now matches sig `member M: unit -> unit`. ([Issue #19596](https://github.com/dotnet/fsharp/issues/19596), [PR #19615](https://github.com/dotnet/fsharp/pull/19615)) +* Reference assembly MVIDs are now deterministic across compiler invocations. Previously, `--refout` / `true` produced a different MVID every build because the implied signature hash used .NET's randomized `String.GetHashCode()`. ([Issue #19751](https://github.com/dotnet/fsharp/issues/19751), [PR #19801](https://github.com/dotnet/fsharp/pull/19801)) ### Added diff --git a/src/Compiler/Driver/fsc.fs b/src/Compiler/Driver/fsc.fs index 1912f13ff71..43157660212 100644 --- a/src/Compiler/Driver/fsc.fs +++ b/src/Compiler/Driver/fsc.fs @@ -873,6 +873,8 @@ let main3 let observer = if hasIvt then PublicAndInternal else PublicOnly + // `hash` here is on byte[] / int64, neither of which depends on + // String.GetHashCode; safe for deterministic output. See issue #19751. let optDataHash = optDataResources |> List.map (fun ilResource -> diff --git a/src/Compiler/Utilities/TypeHashing.fs b/src/Compiler/Utilities/TypeHashing.fs index 5ada7732fe4..0575381fd4b 100644 --- a/src/Compiler/Utilities/TypeHashing.fs +++ b/src/Compiler/Utilities/TypeHashing.fs @@ -18,7 +18,18 @@ module internal HashingPrimitives = type Hash = int - let inline hashText (s: string) : Hash = hash s + /// FNV-1a 32-bit over UTF-16 code units – deterministic across processes + /// (unlike String.GetHashCode which is randomized in .NET 6+). + let hashStableString (s: string) : Hash = + let mutable h = 2166136261u + + for c in s do + h <- (h ^^^ uint32 c) * 16777619u + + int h + + let hashText (s: string) : Hash = hashStableString s + let inline combineHash acc y : Hash = (acc <<< 1) + y + 631 let inline pipeToHash (value: Hash) (acc: Hash) = combineHash acc value let inline addFullStructuralHash value (acc: Hash) = combineHash acc (hash value) @@ -91,7 +102,7 @@ module HashIL = let hashILTypeRef (tref: ILTypeRef) = tref.Enclosing |> hashListOrderMatters hashText - |> addFullStructuralHash tref.Name + |> pipeToHash (hashText tref.Name) let private hashILArrayShape (sh: ILArrayShape) = sh.Rank diff --git a/tests/FSharp.Compiler.ComponentTests/CompilerOptions/fsc/determinism/determinism.fs b/tests/FSharp.Compiler.ComponentTests/CompilerOptions/fsc/determinism/determinism.fs index 54915e18518..be9006bc946 100644 --- a/tests/FSharp.Compiler.ComponentTests/CompilerOptions/fsc/determinism/determinism.fs +++ b/tests/FSharp.Compiler.ComponentTests/CompilerOptions/fsc/determinism/determinism.fs @@ -4,8 +4,11 @@ namespace CompilerOptions.Fsc open Xunit open FSharp.Test open FSharp.Test.Compiler +open FSharp.Test.Utilities open System open System.IO +open System.Reflection.Metadata +open System.Reflection.PortableExecutable module determinism = @@ -139,3 +142,45 @@ module Determinism areSame (Path.ChangeExtension(exename1, "pdb")) (Path.ChangeExtension(exename2, "pdb")) | _ -> raise (new Exception "Pathmap1 and PathMap2 do not match") + /// Compile to ref assembly out-of-process via runFscProcess. + /// Separate processes needed because String.GetHashCode is seeded once per process. + let private compileRefAssembly (workDir: string) (sourceFile: string) : string * string = + Directory.CreateDirectory workDir |> ignore + let outDll = Path.Combine(workDir, "Out.dll") + let outRef = Path.Combine(workDir, "Out.ref.dll") + let defaultOpts = CompilerAssert.DefaultProjectOptions(TargetFramework.Current).OtherOptions + let result = runFscProcess [ + yield "--target:library" + yield "--deterministic+" + yield! (defaultOpts |> Array.toList) + yield $"--refout:{outRef}" + yield $"-o:{outDll}" + yield sourceFile + ] + if result.ExitCode <> 0 then + failwithf "fsc exit %d\nstdout:%s\nstderr:%s" result.ExitCode result.StdOut result.StdErr + outDll, outRef + + let private readMvid (dll: string) : Guid = + use peReader = new PEReader(File.OpenRead dll) + let reader = peReader.GetMetadataReader() + reader.GetGuid(reader.GetModuleDefinition().Mvid) + + // Regression test for https://github.com/dotnet/fsharp/issues/19751 + // Two separate fsc processes needed to detect randomized String.GetHashCode seeds. + [] + let ``Reference assembly MVID is deterministic across separate fsc invocations`` () = + let tempRoot = + Path.Combine(Path.GetTempPath(), "fsharp-ref-mvid-test-" + Guid.NewGuid().ToString("N")) + try + Directory.CreateDirectory tempRoot |> ignore + let src = Path.Combine(tempRoot, "Foo.fs") + File.WriteAllText(src, "module Foo.Core\n\nlet foo (x: int) : int = x + 1\n") + + let dll1, ref1 = compileRefAssembly (Path.Combine(tempRoot, "out1")) src + let dll2, ref2 = compileRefAssembly (Path.Combine(tempRoot, "out2")) src + + Assert.Equal(readMvid ref1, readMvid ref2) + Assert.Equal(readMvid dll1, readMvid dll2) + finally + try Directory.Delete(tempRoot, true) with _ -> ()