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 _ -> ()