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 b49aa2d0835..e4b7de7d63a 100644
--- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md
+++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md
@@ -128,6 +128,7 @@
### Added
+* Added internal synthesized-name replay infrastructure for compiler-generated names, preserving normal compilation output while enabling future hot reload name stability work.
* Added `FSharpMemberOrFunctionOrValue.IsPropertyAccessor` convenience property that returns true for compiler-generated property accessors (`get_X` / `set_X`). ([Issue #18157](https://github.com/dotnet/fsharp/issues/18157), [PR #19883](https://github.com/dotnet/fsharp/pull/19883))
* Added warning FS3884 when a function or delegate value is used as an interpolated string argument. ([PR #19289](https://github.com/dotnet/fsharp/pull/19289))
* Symbols: add ObsoleteDiagnosticInfo ([PR #19359](https://github.com/dotnet/fsharp/pull/19359))
@@ -151,4 +152,4 @@
* Exception field serialization (`GetObjectData` and field-restoring constructor) is now gated behind `langversion:11` (`LanguageFeature.ExceptionFieldSerializationSupport`). With langversion ≤10, exception codegen is unchanged from pre-#19342 behavior. ([PR #19746](https://github.com/dotnet/fsharp/pull/19746))
### Breaking Changes
-* Optimizer: don't inline named functions in debug builds ([PR #19548](https://github.com/dotnet/fsharp/pull/19548)
\ No newline at end of file
+* Optimizer: don't inline named functions in debug builds ([PR #19548](https://github.com/dotnet/fsharp/pull/19548)
diff --git a/src/Compiler/CodeGen/IlxGen.fs b/src/Compiler/CodeGen/IlxGen.fs
index 986acc7bba6..6e6f252606c 100644
--- a/src/Compiler/CodeGen/IlxGen.fs
+++ b/src/Compiler/CodeGen/IlxGen.fs
@@ -45,6 +45,13 @@ open FSharp.Compiler.TypedTreeOps.DebugPrint
open FSharp.Compiler.TypeHierarchy
open FSharp.Compiler.TypeRelations
+// Naming wrappers routed through here so synthesized-name replay stays enforceable.
+let private freshIlxName (g: TcGlobals) name m =
+ g.CompilerGlobalState.Value.IlxGenNiceNameGenerator.FreshCompilerGeneratedName(name, m)
+
+let private freshCoreName (g: TcGlobals) name m =
+ g.CompilerGlobalState.Value.NiceNameGenerator.FreshCompilerGeneratedName(name, m)
+
let getEmptyStackGuard () = StackGuard("IlxAssemblyGenerator")
let IsNonErasedTypar (tp: Typar) = not tp.IsErased
@@ -876,16 +883,12 @@ let GenFieldSpecForStaticField (isInteractive, g: TcGlobals, ilContainerTy, vspe
elif g.realsig then
assert (g.CompilerGlobalState |> Option.isSome)
- mkILFieldSpecInTy (
- ilContainerTy,
- CompilerGeneratedName(g.CompilerGlobalState.Value.IlxGenNiceNameGenerator.FreshCompilerGeneratedName(nm, m)),
- ilTy
- )
+ mkILFieldSpecInTy (ilContainerTy, CompilerGeneratedName(freshIlxName g nm m), ilTy)
else
let fieldName =
// Ensure that we have an g.CompilerGlobalState
assert (g.CompilerGlobalState |> Option.isSome)
- g.CompilerGlobalState.Value.IlxGenNiceNameGenerator.FreshCompilerGeneratedName(nm, m)
+ freshIlxName g nm m
let ilFieldContainerTy = mkILTyForCompLoc (CompLocForInitClass cloc)
mkILFieldSpecInTy (ilFieldContainerTy, fieldName, ilTy)
@@ -4693,7 +4696,7 @@ and GenApp (cenv: cenv) cgbuf eenv (f, fty, tyargs, curriedArgs, m) sequel =
let locName =
// Ensure that we have an g.CompilerGlobalState
assert (g.CompilerGlobalState |> Option.isSome)
- g.CompilerGlobalState.Value.IlxGenNiceNameGenerator.FreshCompilerGeneratedName("arg", m), ilTy, false
+ freshIlxName g "arg" m, ilTy, false
let loc, _realloc, eenv = AllocLocal cenv cgbuf eenv true locName scopeMarks
GenExpr cenv cgbuf eenv laterArg Continue
@@ -5030,13 +5033,7 @@ and GenTry cenv cgbuf eenv scopeMarks (e1, m, resultTy, spTry) =
assert (cenv.g.CompilerGlobalState |> Option.isSome)
let whereToSave, _realloc, eenvinner =
- AllocLocal
- cenv
- cgbuf
- eenvinner
- true
- (cenv.g.CompilerGlobalState.Value.IlxGenNiceNameGenerator.FreshCompilerGeneratedName("tryres", m), ilResultTy, false)
- (startTryMark, endTryMark)
+ AllocLocal cenv cgbuf eenvinner true (freshIlxName cenv.g "tryres" m, ilResultTy, false) (startTryMark, endTryMark)
Some(whereToSave, ilResultTy), eenvinner
@@ -5311,8 +5308,7 @@ and GenIntegerForLoop cenv cgbuf eenv (spFor, spTo, v, e1, dir, e2, loopBody, m)
// Ensure that we have an g.CompilerGlobalState
assert (g.CompilerGlobalState |> Option.isSome)
- let vName =
- g.CompilerGlobalState.Value.IlxGenNiceNameGenerator.FreshCompilerGeneratedName("endLoop", m)
+ let vName = freshIlxName g "endLoop" m
let v, _realloc, eenvinner =
AllocLocal cenv cgbuf eenvinner true (vName, g.ilg.typ_Int32, false) (start, finish)
@@ -5940,13 +5936,7 @@ and GenDefaultValue cenv cgbuf eenv (ty, m) =
// Ensure that we have an g.CompilerGlobalState
assert (g.CompilerGlobalState |> Option.isSome)
- AllocLocal
- cenv
- cgbuf
- eenv
- true
- (g.CompilerGlobalState.Value.IlxGenNiceNameGenerator.FreshCompilerGeneratedName("default", m), ilTy, false)
- scopeMarks
+ AllocLocal cenv cgbuf eenv true (freshIlxName g "default" m, ilTy, false) scopeMarks
// We can normally rely on .NET IL zero-initialization of the temporaries
// we create to get zero values for struct types.
//
@@ -6625,25 +6615,11 @@ and GenStructStateMachine cenv cgbuf eenvouter (res: LoweredStateMachine) sequel
// The local for the state machine
let locIdx, realloc, _ =
- AllocLocal
- cenv
- cgbuf
- eenvouter
- true
- (g.CompilerGlobalState.Value.IlxGenNiceNameGenerator.FreshCompilerGeneratedName("machine", m), ilCloTy, false)
- scopeMarks
+ AllocLocal cenv cgbuf eenvouter true (freshIlxName g "machine" m, ilCloTy, false) scopeMarks
// The local for the state machine address
let locIdx2, _realloc2, _ =
- AllocLocal
- cenv
- cgbuf
- eenvouter
- true
- (g.CompilerGlobalState.Value.IlxGenNiceNameGenerator.FreshCompilerGeneratedName(afterCodeThisVar.DisplayName, m),
- ilMachineAddrTy,
- false)
- scopeMarks
+ AllocLocal cenv cgbuf eenvouter true (freshIlxName g afterCodeThisVar.DisplayName m, ilMachineAddrTy, false) scopeMarks
let eenvouter =
eenvouter
@@ -9412,7 +9388,7 @@ and GenParams
if takenNames.Contains(id.idText) then
// Ensure that we have an g.CompilerGlobalState
assert (g.CompilerGlobalState |> Option.isSome)
- g.CompilerGlobalState.Value.NiceNameGenerator.FreshCompilerGeneratedName(id.idText, id.idRange)
+ freshCoreName g id.idText id.idRange
else
id.idText
@@ -10481,13 +10457,7 @@ and EmitSaveStack cenv cgbuf eenv m scopeMarks =
// Ensure that we have an g.CompilerGlobalState
assert (cenv.g.CompilerGlobalState |> Option.isSome)
- AllocLocal
- cenv
- cgbuf
- eenv
- true
- (cenv.g.CompilerGlobalState.Value.IlxGenNiceNameGenerator.FreshCompilerGeneratedName("spill", m), ty, false)
- scopeMarks
+ AllocLocal cenv cgbuf eenv true (freshIlxName cenv.g "spill" m, ty, false) scopeMarks
idx, eenv)
diff --git a/src/Compiler/FSharp.Compiler.Service.fsproj b/src/Compiler/FSharp.Compiler.Service.fsproj
index 5510af6b3f6..2d00dbc1a56 100644
--- a/src/Compiler/FSharp.Compiler.Service.fsproj
+++ b/src/Compiler/FSharp.Compiler.Service.fsproj
@@ -306,6 +306,8 @@
SyntaxTree\LexHelpers.fs
+
+
SyntaxTree\FsLexOutput\pplex.fsi
@@ -326,6 +328,7 @@
+
diff --git a/src/Compiler/TypedTree/CompilerGeneratedNameMapState.fs b/src/Compiler/TypedTree/CompilerGeneratedNameMapState.fs
new file mode 100644
index 00000000000..ff81e9b4eca
--- /dev/null
+++ b/src/Compiler/TypedTree/CompilerGeneratedNameMapState.fs
@@ -0,0 +1,59 @@
+module internal FSharp.Compiler.CompilerGeneratedNameMapState
+
+open System.Runtime.CompilerServices
+
+/// Minimal abstraction for compiler-generated name replay/state.
+/// Implementations can be hot-reload aware without coupling core compiler paths
+/// to a concrete synthesized-name map type.
+type ICompilerGeneratedNameMap =
+ abstract BeginSession: unit -> unit
+ abstract GetOrAddName: basicName: string -> string
+ abstract Snapshot: seq
+ abstract LoadSnapshot: snapshot: seq -> unit
+
+// Keep optional name-map state external to CompilerGlobalState so core signatures can remain stable.
+type private NameMapHolder() =
+ // Reads vastly outnumber writes. Installs happen at most a handful of times per
+ // compile, so the slot is a single volatile field rather than a lock-guarded one.
+ // Reference reads and writes are atomic, and the volatile semantics preserve the
+ // visibility ordering the lock provided.
+ []
+ let mutable current: ICompilerGeneratedNameMap option = None
+
+ member _.TryGet() = current
+ member _.Set(value: ICompilerGeneratedNameMap option) = current <- value
+
+let private holders = ConditionalWeakTable()
+
+let private getOrCreateHolder (owner: obj) =
+ holders.GetValue(owner, fun _ -> NameMapHolder())
+
+/// Pure read: never inserts, so a compile that never installs a map pays a single
+/// failed weak-table lookup.
+let private tryGetHolder (owner: obj) =
+ match holders.TryGetValue owner with
+ | true, holder -> Some holder
+ | _ -> None
+
+let tryGetCompilerGeneratedNameMap (owner: obj) =
+ match tryGetHolder owner with
+ | Some holder -> holder.TryGet()
+ | None -> None
+
+/// A reader for the owner's name-map slot. The holder is resolved exactly once here
+/// and captured by the returned closure, so each generated name costs a single
+/// volatile field read rather than a ConditionalWeakTable probe and lock.
+///
+/// The holder is created eagerly on purpose: the emit hook can install the map later
+/// in the compile, after CompilerGlobalState and therefore this accessor have been
+/// constructed, and it installs through the same owner. Pre-creating the holder means
+/// that later install mutates the object this closure captured, so the map is observed.
+let getCompilerGeneratedNameMapAccessor (owner: obj) : unit -> ICompilerGeneratedNameMap option =
+ let holder = getOrCreateHolder owner
+ fun () -> holder.TryGet()
+
+let setCompilerGeneratedNameMap (owner: obj) (map: ICompilerGeneratedNameMap) = (getOrCreateHolder owner).Set(Some map)
+
+let setCompilerGeneratedNameMapOpt (owner: obj) (map: ICompilerGeneratedNameMap option) = (getOrCreateHolder owner).Set(map)
+
+let clearCompilerGeneratedNameMap (owner: obj) = (getOrCreateHolder owner).Set(None)
diff --git a/src/Compiler/TypedTree/CompilerGlobalState.fs b/src/Compiler/TypedTree/CompilerGlobalState.fs
index 1f46a53ad6c..cd7ccb9a60a 100644
--- a/src/Compiler/TypedTree/CompilerGlobalState.fs
+++ b/src/Compiler/TypedTree/CompilerGlobalState.fs
@@ -8,6 +8,7 @@ open System
open System.Collections.Concurrent
open System.Threading
open Internal.Utilities.Library
+open FSharp.Compiler.CompilerGeneratedNameMapState
open FSharp.Compiler.Syntax.PrettyNaming
open FSharp.Compiler.Text
@@ -18,7 +19,7 @@ open FSharp.Compiler.Text
/// It is made concurrency-safe since a global instance of the type is allocated in tast.fs, and it is good
/// policy to make all globally-allocated objects concurrency safe in case future versions of the compiler
/// are used to host multiple concurrent instances of compilation.
-type NiceNameGenerator() =
+type NiceNameGenerator(getCompilerGeneratedNameMap: unit -> ICompilerGeneratedNameMap option) =
let basicNameCounts = ConcurrentDictionary(max Environment.ProcessorCount 1, 127)
// Cache this as a delegate.
let basicNameCountsAddDelegate = Func(fun _ -> ref 0)
@@ -34,16 +35,32 @@ type NiceNameGenerator() =
CompilerGeneratedNameSuffix basicName (string m.StartLine + (match (count - 1) with 0 -> "" | n -> "-" + string n))
member _.FreshCompilerGeneratedNameOfBasicName (basicName, m: range) =
- let count = increment basicName m
- mkName basicName m count
+ match getCompilerGeneratedNameMap() with
+ | Some map -> map.GetOrAddName basicName
+ | None ->
+ let count = increment basicName m
+ mkName basicName m count
member this.FreshCompilerGeneratedName (name, m: range) =
this.FreshCompilerGeneratedNameOfBasicName (GetBasicNameOfPossibleCompilerGeneratedName name, m)
member _.FreshCompilerGeneratedNameInScope (scopeFileIndex: int, name: string, m: range) =
let basicName = GetBasicNameOfPossibleCompilerGeneratedName name
- let count = incrementBucket basicName scopeFileIndex
- mkName basicName m count
+
+ // The replay map must win over per-file occurrence buckets, exactly as it
+ // does in FreshCompilerGeneratedNameOfBasicName. When a session installs the
+ // map, every allocation path replays the baseline's stable names. Otherwise,
+ // line-based per-file names would drift under edits. The map is only ever
+ // installed by the hot reload emit hook or by an in-process compile, so the
+ // deterministic per-file bucketing from https://github.com/dotnet/fsharp/issues/19732
+ // is untouched in normal compilation.
+ match getCompilerGeneratedNameMap() with
+ | Some map -> map.GetOrAddName basicName
+ | None ->
+ let count = incrementBucket basicName scopeFileIndex
+ mkName basicName m count
+
+ new () = NiceNameGenerator(fun () -> None)
/// Reset the per-(basicName, file) occurrence counters so a subsequent codegen run assigns the
/// same compiler-generated occurrence names a fresh process would. Callers must ensure no
@@ -56,16 +73,18 @@ type NiceNameGenerator() =
///
/// This type may be accessed concurrently, though in practice it is only used from the compilation thread.
/// It is made concurrency-safe since a global instance of the type is allocated in tast.fs.
-type StableNiceNameGenerator() =
+type StableNiceNameGenerator(getCompilerGeneratedNameMap: unit -> ICompilerGeneratedNameMap option) =
let niceNames = ConcurrentDictionary>(max Environment.ProcessorCount 1, 127)
- let innerGenerator = NiceNameGenerator()
+ let innerGenerator = NiceNameGenerator(getCompilerGeneratedNameMap)
member x.GetUniqueCompilerGeneratedName (name, m: range, uniq) =
let basicName = GetBasicNameOfPossibleCompilerGeneratedName name
let key = basicName, uniq
niceNames.GetOrAddLazy(key, fun (basicName, _) -> innerGenerator.FreshCompilerGeneratedNameOfBasicName(basicName, m))
+ new () = StableNiceNameGenerator(fun () -> None)
+
/// Reset the stable-name cache and inner occurrence counters, so both the cached stable names and
/// the underlying occurrence counters are cleared. See NiceNameGenerator.ResetCompilerGeneratedNameState.
member _.ResetCompilerGeneratedNameState() =
@@ -78,15 +97,20 @@ type PerFileNamingScope internal (nng: NiceNameGenerator, fileIndex: int) =
member _.Fresh (name: string, m: range) =
nng.FreshCompilerGeneratedNameInScope(fileIndex, name, m)
-type internal CompilerGlobalState () =
+type internal CompilerGlobalState () as this =
+ /// Reader for the optional synthesized-name map attached to this instance. The
+ /// accessor resolves the side-channel slot once, so each generated name costs a
+ /// single None check, not a weak-table probe and lock, when no map is installed.
+ let getCompilerGeneratedNameMap = getCompilerGeneratedNameMapAccessor (this :> obj)
+
/// A global generator of compiler generated names
- let globalNng = NiceNameGenerator()
+ let globalNng = NiceNameGenerator(getCompilerGeneratedNameMap)
/// A global generator of stable compiler generated names
- let globalStableNameGenerator = StableNiceNameGenerator ()
+ let globalStableNameGenerator = StableNiceNameGenerator(getCompilerGeneratedNameMap)
/// A name generator used by IlxGen for static fields, some generated arguments and other things.
- let ilxgenGlobalNng = NiceNameGenerator ()
+ let ilxgenGlobalNng = NiceNameGenerator(getCompilerGeneratedNameMap)
member _.NiceNameGenerator = globalNng
@@ -118,4 +142,4 @@ let newUnique() = Interlocked.Increment &uniqueCount
let mutable private stampCount = 0L
let newStamp() =
let stamp = Interlocked.Increment &stampCount
- stamp
\ No newline at end of file
+ stamp
diff --git a/src/Compiler/TypedTree/GeneratedNames.fs b/src/Compiler/TypedTree/GeneratedNames.fs
new file mode 100644
index 00000000000..a172cd82904
--- /dev/null
+++ b/src/Compiler/TypedTree/GeneratedNames.fs
@@ -0,0 +1,258 @@
+module internal FSharp.Compiler.GeneratedNames
+
+open System
+open System.Text.RegularExpressions
+
+/// Marker of occurrence-keyed closure class names produced by hot reload closure
+/// name allocation:
+/// `{base}@hotreload#g{generation}_o{occurrenceChain}`. Generation 0 names are minted
+/// by flag-on baseline compiles. Generation N >= 1 names are minted for occurrences
+/// first allocated by a delta compile of session generation N. The `#g..._o...`
+/// suffix space is disjoint from the replayable `-{ordinal}` suffix space of
+/// FSharpSynthesizedTypeMaps, so these names never parse as replay ordinals and are
+/// never produced by sequence replay.
+[]
+let HotReloadGenerationSuffixedNameInfix = "@hotreload#g"
+
+/// Recognizes occurrence-keyed generation-suffixed closure class names:
+/// `{base}@hotreload#g{N}_o{chain}`, any generation.
+let IsHotReloadGenerationSuffixedName (name: string) =
+ not (String.IsNullOrEmpty name)
+ && name.IndexOf(HotReloadGenerationSuffixedNameInfix, StringComparison.Ordinal)
+ >= 0
+
+/// Parses the generation of an occurrence-keyed closure class name:
+/// `f@hotreload#g2_o3` -> Some 2. None when the name is not generation-suffixed
+/// or malformed.
+let TryGetHotReloadNameGeneration (name: string) : int option =
+ if String.IsNullOrEmpty name then
+ None
+ else
+ match name.IndexOf(HotReloadGenerationSuffixedNameInfix, StringComparison.Ordinal) with
+ | -1 -> None
+ | markerIndex ->
+ let digitsStart = markerIndex + HotReloadGenerationSuffixedNameInfix.Length
+ let digitsEnd = name.IndexOf("_o", digitsStart, StringComparison.Ordinal)
+
+ if digitsEnd <= digitsStart then
+ None
+ else
+ match Int32.TryParse(name.Substring(digitsStart, digitsEnd - digitsStart)) with
+ | true, generation when generation >= 0 -> Some generation
+ | _ -> None
+
+type SynthesizedPositionalName =
+ {
+ NormalizedBasicName: string
+ Ordinal: int list
+ }
+
+type HotReloadReplayName =
+ {
+ NormalizedBasicName: string
+ ReplayOrdinal: int
+ }
+
+type HotReloadGenerationName =
+ {
+ NormalizedBasicName: string
+ Generation: int
+ OccurrenceOrdinal: int list
+ }
+
+let private debugPipeNameRegex =
+ lazy Regex(@"^Pipe #[1-9][0-9]* (?:input|stage #[1-9][0-9]*) at line ([1-9][0-9]*)$", RegexOptions.CultureInvariant)
+
+let private tryParseNonNegativeInt (text: string) =
+ match Int32.TryParse text with
+ | true, value when value >= 0 -> Some value
+ | _ -> None
+
+let private tryParsePositiveInt (text: string) =
+ match Int32.TryParse text with
+ | true, value when value > 0 -> Some value
+ | _ -> None
+
+let private tryParseLineOrdinalSuffix (suffix: string) =
+ let dashIndex = suffix.IndexOf('-')
+
+ if dashIndex < 0 then
+ tryParsePositiveInt suffix |> Option.map (fun line -> line, 0)
+ elif dashIndex > 0 && dashIndex < suffix.Length - 1 then
+ match tryParsePositiveInt (suffix.Substring(0, dashIndex)), tryParseNonNegativeInt (suffix.Substring(dashIndex + 1)) with
+ | Some line, Some ordinal -> Some(line, ordinal)
+ | _ -> None
+ else
+ None
+
+let private tryNormalizeDebugPipeBasicName (name: string) =
+ let matchResult = debugPipeNameRegex.Value.Match name
+
+ if matchResult.Success then
+ let line = Int32.Parse matchResult.Groups[1].Value
+ let marker = " at line "
+ let markerIndex = name.LastIndexOf(marker, StringComparison.Ordinal)
+
+ if markerIndex > 0 then
+ Some(name.Substring(0, markerIndex), line)
+ else
+ None
+ else
+ None
+
+let private tryParseOccurrenceOrdinal (text: string) =
+ if String.IsNullOrWhiteSpace text then
+ None
+ else
+ let parts = text.Split([| '_' |], StringSplitOptions.None)
+
+ if parts |> Array.exists String.IsNullOrWhiteSpace then
+ None
+ else
+ let parsed = parts |> Array.map tryParseNonNegativeInt
+
+ if parsed |> Array.forall Option.isSome then
+ Some(parsed |> Array.map Option.get |> Array.toList)
+ else
+ None
+
+let private positionalName normalizedBasicName ordinal =
+ {
+ NormalizedBasicName = normalizedBasicName
+ Ordinal = ordinal
+ }
+
+let private tryNormalizeDebugPipeName (name: string) =
+ tryNormalizeDebugPipeBasicName name
+ |> Option.map (fun (normalizedBasicName, line) -> positionalName normalizedBasicName [ line; 0 ])
+
+let TryNormalizeHotReloadGenerationName (name: string) =
+ let markerIndex =
+ name.IndexOf(HotReloadGenerationSuffixedNameInfix, StringComparison.Ordinal)
+
+ if markerIndex <= 0 then
+ None
+ else
+ let baseName = name.Substring(0, markerIndex)
+ let generationStart = markerIndex + HotReloadGenerationSuffixedNameInfix.Length
+ let ordinalMarker = "_o"
+
+ let ordinalMarkerIndex =
+ name.IndexOf(ordinalMarker, generationStart, StringComparison.Ordinal)
+
+ if
+ ordinalMarkerIndex <= generationStart
+ || ordinalMarkerIndex + ordinalMarker.Length >= name.Length
+ || String.IsNullOrWhiteSpace baseName
+ || baseName.IndexOf("@", StringComparison.Ordinal) >= 0
+ then
+ None
+ else
+ match
+ tryParseNonNegativeInt (name.Substring(generationStart, ordinalMarkerIndex - generationStart)),
+ tryParseOccurrenceOrdinal (name.Substring(ordinalMarkerIndex + ordinalMarker.Length))
+ with
+ | Some generation, Some occurrenceOrdinal ->
+ let normalizedBasicName =
+ match tryNormalizeDebugPipeBasicName baseName with
+ | Some(normalizedPipeName, _) -> normalizedPipeName
+ | None -> baseName
+
+ Some
+ {
+ NormalizedBasicName = normalizedBasicName
+ Generation = generation
+ OccurrenceOrdinal = occurrenceOrdinal
+ }
+ | _ -> None
+
+let TryNormalizeHotReloadReplayName (name: string) =
+ let marker = "@hotreload"
+ let markerIndex = name.LastIndexOf(marker, StringComparison.Ordinal)
+
+ if markerIndex <= 0 then
+ None
+ else
+ let suffixStart = markerIndex + marker.Length
+ let suffix = name.Substring suffixStart
+ let baseName = name.Substring(0, markerIndex)
+
+ if
+ String.IsNullOrWhiteSpace baseName
+ || baseName.IndexOf("@", StringComparison.Ordinal) >= 0
+ then
+ None
+ else
+ let ordinalOpt =
+ if suffix = "" then
+ Some 0
+ elif suffix.StartsWith("-", StringComparison.Ordinal) then
+ tryParsePositiveInt (suffix.Substring 1)
+ else
+ None
+
+ ordinalOpt
+ |> Option.map (fun ordinal ->
+ let normalizedBasicName =
+ match tryNormalizeDebugPipeBasicName baseName with
+ | Some(normalizedPipeName, _) -> normalizedPipeName
+ | None -> baseName
+
+ {
+ NormalizedBasicName = normalizedBasicName
+ ReplayOrdinal = ordinal
+ })
+
+let private tryNormalizeHotReloadOrdinalName (name: string) =
+ TryNormalizeHotReloadReplayName name
+ |> Option.map (fun replayName ->
+ let ordinal =
+ let markerIndex = name.LastIndexOf("@hotreload", StringComparison.Ordinal)
+ let baseName = name.Substring(0, markerIndex)
+
+ match tryNormalizeDebugPipeBasicName baseName with
+ | Some(_, line) -> [ line; replayName.ReplayOrdinal ]
+ | None -> [ replayName.ReplayOrdinal ]
+
+ positionalName replayName.NormalizedBasicName ordinal)
+
+let private tryNormalizeLineOrdinalName (name: string) =
+ let atIndex = name.LastIndexOf('@')
+
+ if atIndex <= 0 || atIndex = name.Length - 1 then
+ None
+ else
+ let baseName = name.Substring(0, atIndex)
+ let suffix = name.Substring(atIndex + 1)
+
+ match tryParseLineOrdinalSuffix suffix with
+ | None -> None
+ | Some(line, ordinal) ->
+ match tryNormalizeDebugPipeBasicName baseName with
+ | Some(normalizedPipeName, pipeLine) when pipeLine = line -> Some(positionalName normalizedPipeName [ line; ordinal ])
+ | Some _ -> None
+ | None ->
+ if
+ String.IsNullOrWhiteSpace baseName
+ || baseName.IndexOf("@", StringComparison.Ordinal) >= 0
+ || baseName.StartsWith("Pipe #", StringComparison.Ordinal)
+ then
+ None
+ else
+ Some(positionalName baseName [ line; ordinal ])
+
+let tryNormalizeSynthesizedTypeNameForPositionalPairing (name: string) =
+ if String.IsNullOrWhiteSpace name then
+ None
+ else
+ match tryNormalizeHotReloadOrdinalName name with
+ | Some normalized -> Some normalized
+ | None ->
+ match tryNormalizeLineOrdinalName name with
+ | Some normalized -> Some normalized
+ | None -> tryNormalizeDebugPipeName name
+
+let SynthesizedNameMapKey (basicName: string) =
+ match tryNormalizeSynthesizedTypeNameForPositionalPairing basicName with
+ | Some normalized -> normalized.NormalizedBasicName
+ | None -> basicName
diff --git a/src/Compiler/TypedTree/SynthesizedTypeMaps.fs b/src/Compiler/TypedTree/SynthesizedTypeMaps.fs
new file mode 100644
index 00000000000..fa6462d36ab
--- /dev/null
+++ b/src/Compiler/TypedTree/SynthesizedTypeMaps.fs
@@ -0,0 +1,258 @@
+module internal FSharp.Compiler.SynthesizedTypeMaps
+
+open System
+open System.Collections.Concurrent
+open System.Collections.Generic
+
+open FSharp.Compiler.CompilerGeneratedNameMapState
+open FSharp.Compiler.GeneratedNames
+open FSharp.Compiler.Syntax.PrettyNaming
+
+///
+/// Provides stable compiler-generated names across hot reload sessions.
+///
+/// Replay buckets are keyed by line-normalized basic name. Bucket values remain the
+/// original generation-0 full names, so a matched closure whose code moves from line
+/// 28 to line 30 still gets its line-28 birth name back. That mirrors Roslyn EnC:
+/// identity is established at first allocation and replayed exactly.
+///
+type FSharpSynthesizedTypeMaps() =
+ let syncLock = obj ()
+ let buckets = ConcurrentDictionary>()
+ let ordinals = ConcurrentDictionary()
+ let mutable usesRecordedSnapshot = false
+
+ let makeHotReloadName (baseName: string) ordinal =
+ let suffix = if ordinal <= 0 then "hotreload" else $"hotreload-{ordinal}"
+
+ CompilerGeneratedNameSuffix baseName suffix
+
+ let createBucket (names: string[]) =
+ let bucket = ResizeArray()
+
+ for name in names do
+ bucket.Add(name)
+
+ bucket
+
+ let computeName basicName index = makeHotReloadName basicName index
+
+ let tryGetHotReloadOrdinal (mapKey: string) (name: string) =
+ match TryNormalizeHotReloadReplayName name with
+ | Some replayName when replayName.NormalizedBasicName = mapKey -> Some replayName.ReplayOrdinal
+ | _ -> None
+
+ let tryGetStableOrdinal (mapKey: string) (name: string) =
+ match TryNormalizeHotReloadReplayName name with
+ | Some replayName when replayName.NormalizedBasicName = mapKey -> Some [ replayName.ReplayOrdinal ]
+ | _ ->
+ match TryNormalizeHotReloadGenerationName name with
+ | Some generationName when generationName.NormalizedBasicName = mapKey -> Some generationName.OccurrenceOrdinal
+ | _ -> None
+
+ let canonicalizeSnapshotNames mapKey (names: string[]) =
+ let parsed =
+ names
+ |> Array.mapi (fun index name -> index, name, tryGetHotReloadOrdinal mapKey name)
+
+ if parsed |> Array.forall (fun (_, _, ordinalOpt) -> ordinalOpt.IsSome) then
+ // IL metadata can enumerate synthesized helpers in a different order than allocation.
+ // Normalize pure hot-reload buckets so replay always starts at ordinal 0, then 1, etc.
+ let sorted =
+ parsed
+ |> Array.sortBy (fun (index, _, ordinalOpt) -> struct (ordinalOpt.Value, index))
+
+ let ordinalsAreDistinct =
+ let ordinals = sorted |> Array.map (fun (_, _, ordinalOpt) -> ordinalOpt.Value)
+ (Array.distinct ordinals).Length = ordinals.Length
+
+ if ordinalsAreDistinct && sorted.Length > 0 then
+ // Place every name at the slot index its ordinal records, filling holes
+ // with the computed name for that slot. Holes arise exactly where an
+ // allocation's replay name never surfaced in IL. The filler equals what
+ // GetOrAddName produced for that slot originally, so replay positions
+ // are exact.
+ let maxOrdinal =
+ sorted |> Array.map (fun (_, _, ordinalOpt) -> ordinalOpt.Value) |> Array.max
+
+ let namesByOrdinal =
+ sorted
+ |> Array.map (fun (_, name, ordinalOpt) -> ordinalOpt.Value, name)
+ |> Map.ofArray
+
+ let replayFillBasicName =
+ let rawBasicNames =
+ sorted
+ |> Array.choose (fun (_, name, _) ->
+ let rawBasicName = GetBasicNameOfPossibleCompilerGeneratedName name
+
+ if String.Equals(SynthesizedNameMapKey rawBasicName, mapKey, StringComparison.Ordinal) then
+ Some rawBasicName
+ else
+ None)
+ |> Array.distinct
+
+ match rawBasicNames with
+ | [| rawBasicName |] -> rawBasicName
+ | _ -> mapKey
+
+ Array.init (maxOrdinal + 1) (fun slot ->
+ match Map.tryFind slot namesByOrdinal with
+ | Some name -> name
+ | None -> makeHotReloadName replayFillBasicName slot)
+ else
+ sorted |> Array.map (fun (_, name, _) -> name)
+ else
+ let parsed =
+ names
+ |> Array.mapi (fun index name -> index, name, tryGetStableOrdinal mapKey name)
+
+ if parsed |> Array.forall (fun (_, _, ordinalOpt) -> ordinalOpt.IsSome) then
+ let sorted =
+ parsed
+ |> Array.sortBy (fun (index, _, ordinalOpt) -> struct (ordinalOpt.Value, index))
+
+ let ordinalsAreDistinct =
+ let ordinals = sorted |> Array.map (fun (_, _, ordinalOpt) -> ordinalOpt.Value)
+ (Array.distinct ordinals).Length = ordinals.Length
+
+ if ordinalsAreDistinct then
+ sorted |> Array.map (fun (_, name, _) -> name)
+ else
+ names
+ else
+ names
+
+ let nameMapKeyFromSnapshotName (name: string) =
+ GetBasicNameOfPossibleCompilerGeneratedName name |> SynthesizedNameMapKey
+
+ /// Validates that a generated name belongs to the normalized map key.
+ let validateName mapKey (name: string) index =
+ // Snapshots can contain legacy/basic synthesized names, for example
+ // "@_instance", alongside hot-reload-managed names. Accept both forms so
+ // existing sessions restore.
+ let actualKey = nameMapKeyFromSnapshotName name
+
+ if not (String.Equals(actualKey, mapKey, StringComparison.Ordinal)) then
+ invalidArg "snapshot" $"Name '{name}' at index {index} belongs to normalized key '{actualKey}', not snapshot key '{mapKey}'"
+
+ let loadSnapshotCore canonicalize (snapshot: seq) =
+ lock syncLock (fun () ->
+ buckets.Clear()
+ ordinals.Clear()
+ usesRecordedSnapshot <- not canonicalize
+
+ let normalizedBuckets =
+ Dictionary>(StringComparer.Ordinal)
+
+ for struct (basicName, names) in snapshot do
+ let mapKey = SynthesizedNameMapKey basicName
+
+ if canonicalize then
+ // Validate each name matches the normalized key. Loading normalizes
+ // old raw-key snapshots, so on-disk baselines captured before this
+ // change replay through the same line-stable buckets.
+ names |> Array.iteri (fun i name -> validateName mapKey name i)
+ else
+ // Recorded snapshots are allocation-key to final-emitted-name slots.
+ // Occurrence-keyed closure overrides can intentionally move a final
+ // name into a bucket whose allocation key differs from the name's
+ // derived key, so only null validation applies here.
+ names
+ |> Array.iteri (fun i name ->
+ if isNull (box name) then
+ invalidArg "snapshot" $"Name at index {i} in snapshot key '{mapKey}' is null")
+
+ let namesToLoad =
+ if canonicalize then
+ canonicalizeSnapshotNames mapKey names
+ else
+ // Recorded snapshots are already in allocation order. Keep them
+ // identity-preserving after validation. Old reconstructed
+ // snapshots continue through canonicalization.
+ Array.copy names
+
+ let bucket =
+ match normalizedBuckets.TryGetValue mapKey with
+ | true, existing -> existing
+ | _ ->
+ let created = ResizeArray()
+ normalizedBuckets[mapKey] <- created
+ created
+
+ for name in namesToLoad do
+ if canonicalize then
+ if not (bucket.Contains name) then
+ bucket.Add name
+ else
+ bucket.Add name
+
+ for KeyValue(mapKey, bucket) in normalizedBuckets do
+ buckets[mapKey] <- createBucket (bucket.ToArray())
+ ordinals[mapKey] <- 0)
+
+ member _.GetOrAddName(basicName: string) =
+ lock syncLock (fun () ->
+ let mapKey = SynthesizedNameMapKey basicName
+ let bucket = buckets.GetOrAdd(mapKey, fun _ -> ResizeArray())
+
+ // Keep ordinal reservation and bucket mutation in one critical section so
+ // concurrent callers cannot observe or produce out-of-order allocations.
+ // The ordinal is intentionally the encounter order within the normalized
+ // bucket. If same-bucket closures are reordered, the downstream
+ // positional-pairing shape guard owns that concern. This allocator only
+ // replays generation-0 names for matching allocation slots.
+ let index =
+ match ordinals.TryGetValue mapKey with
+ | true, current ->
+ ordinals[mapKey] <- current + 1
+ current
+ | _ ->
+ ordinals[mapKey] <- 1
+ 0
+
+ if index < bucket.Count then
+ bucket[index]
+ else
+ let name = computeName basicName index
+ bucket.Add name
+ name)
+
+ /// Resets allocation state so subsequent edits reuse the original name ordering.
+ member _.BeginSession() =
+ lock syncLock (fun () ->
+ for KeyValue(key, _) in buckets do
+ ordinals[key] <- 0)
+
+ /// Captures the current stable names grouped by compiler-generated base name.
+ member _.Snapshot: seq =
+ lock syncLock (fun () ->
+ [|
+ for KeyValue(key, bucket) in buckets do
+ yield struct (key, bucket.ToArray())
+ |]
+ :> seq)
+
+ member _.UsesRecordedSnapshot = lock syncLock (fun () -> usesRecordedSnapshot)
+
+ /// Loads a previously captured snapshot, replacing any existing allocation state.
+ member _.LoadSnapshot(snapshot: seq) = loadSnapshotCore true snapshot
+
+ ///
+ /// Loads a snapshot that was recorded from this allocator's own allocation slots.
+ /// The bucket arrays are ground truth, so this intentionally skips IL-order
+ /// reconstruction canonicalization and key-derived name validation.
+ ///
+ member _.LoadRecordedSnapshot(snapshot: seq) = loadSnapshotCore false snapshot
+
+ interface ICompilerGeneratedNameMap with
+ member this.BeginSession() = this.BeginSession()
+ member this.GetOrAddName(basicName) = this.GetOrAddName(basicName)
+ member this.Snapshot = this.Snapshot
+ member this.LoadSnapshot(snapshot) = this.LoadSnapshot(snapshot)
+
+/// Retrieves a stable compiler-generated name or falls back to the provided generator.
+let nextName (mapOpt: ICompilerGeneratedNameMap option) basicName generate =
+ match mapOpt with
+ | Some map -> map.GetOrAddName basicName
+ | None -> generate ()
diff --git a/tests/FSharp.Compiler.ComponentTests/EmittedIL/CompilerGeneratedNameDeterminism.fs b/tests/FSharp.Compiler.ComponentTests/EmittedIL/CompilerGeneratedNameDeterminism.fs
new file mode 100644
index 00000000000..ed66a783e46
--- /dev/null
+++ b/tests/FSharp.Compiler.ComponentTests/EmittedIL/CompilerGeneratedNameDeterminism.fs
@@ -0,0 +1,136 @@
+namespace EmittedIL
+
+open System
+open System.IO
+open System.Reflection
+open System.Reflection.Metadata
+open System.Reflection.PortableExecutable
+open Xunit
+
+open FSharp.Test.Compiler
+
+module CompilerGeneratedNameDeterminismTests =
+
+ let private source =
+ """
+module GeneratedNameDeterminismSample
+
+open System.Threading.Tasks
+
+let makeAdder x =
+ let inner y = x + y
+ inner
+
+let asyncValue () =
+ async {
+ let! value = async { return 1 }
+ return value + 1
+ }
+
+let taskValue () =
+ task {
+ let! value = Task.FromResult 1
+ return value + 1
+ }
+
+type Builder() =
+ member _.Bind(x, f) = f x
+ member _.Return(x) = x
+
+let builder = Builder()
+
+let computed value =
+ builder {
+ let! x = value
+ return x + 1
+ }
+"""
+
+ let private getOutputPath = function
+ | CompilationResult.Success success ->
+ match success.OutputPath with
+ | Some path -> path
+ | None -> failwith "Compilation did not produce an output path."
+ | CompilationResult.Failure failure ->
+ failwithf "Compilation was expected to succeed, but failed with: %A" failure.Diagnostics
+
+ let private compileLibrary outputDirectory =
+ FSharp source
+ |> withOutputDirectory (Some(DirectoryInfo outputDirectory))
+ |> withOptions [ "--debug:portable"; "--deterministic"; "--optimize-" ]
+ |> asLibrary
+ |> compile
+ |> shouldSucceed
+ |> getOutputPath
+
+ let private typeName (reader: MetadataReader) (handle: TypeDefinitionHandle) =
+ let rec buildName (handle: TypeDefinitionHandle) =
+ let typeDef = reader.GetTypeDefinition handle
+ let name = reader.GetString typeDef.Name
+
+ let visibility = typeDef.Attributes &&& TypeAttributes.VisibilityMask
+
+ let isNested =
+ match visibility with
+ | TypeAttributes.NestedPublic
+ | TypeAttributes.NestedPrivate
+ | TypeAttributes.NestedFamily
+ | TypeAttributes.NestedAssembly
+ | TypeAttributes.NestedFamORAssem
+ | TypeAttributes.NestedFamANDAssem -> true
+ | _ -> false
+
+ if isNested then
+ let declaringTypeHandle = typeDef.GetDeclaringType()
+ $"{buildName declaringTypeHandle}+{name}"
+ else
+ let namespaceName =
+ if typeDef.Namespace.IsNil then
+ ""
+ else
+ reader.GetString typeDef.Namespace
+
+ if String.IsNullOrEmpty namespaceName then
+ name
+ else
+ $"{namespaceName}.{name}"
+
+ buildName handle
+
+ let private emittedGeneratedNames assemblyPath =
+ use stream = File.OpenRead assemblyPath
+ use peReader = new PEReader(stream)
+ let reader = peReader.GetMetadataReader()
+
+ let names =
+ [ for typeHandle in reader.TypeDefinitions do
+ yield typeName reader typeHandle
+
+ let typeDef = reader.GetTypeDefinition typeHandle
+
+ for methodHandle in typeDef.GetMethods() do
+ let methodDef = reader.GetMethodDefinition methodHandle
+ yield reader.GetString methodDef.Name ]
+
+ names
+ |> List.filter (fun name -> name.IndexOf('@') >= 0)
+ |> List.sort
+
+ []
+ let ``normal compilation emits identical generated names across two compiles`` () =
+ let tempRoot =
+ Path.Combine(Path.GetTempPath(), "fsharp-generated-name-determinism-" + Guid.NewGuid().ToString("N"))
+
+ try
+ let firstOutput = Path.Combine(tempRoot, "first")
+ let secondOutput = Path.Combine(tempRoot, "second")
+
+ let firstNames = compileLibrary firstOutput |> emittedGeneratedNames
+ let secondNames = compileLibrary secondOutput |> emittedGeneratedNames
+
+ Assert.True(not firstNames.IsEmpty, "Expected at least one compiler-generated name in emitted metadata.")
+ Assert.DoesNotContain(firstNames, fun name -> name.Contains("@hotreload"))
+ Assert.Equal(firstNames, secondNames)
+ finally
+ if Directory.Exists tempRoot then
+ Directory.Delete(tempRoot, true)
diff --git a/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj b/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj
index 5070905529f..49669e8fe26 100644
--- a/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj
+++ b/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj
@@ -256,6 +256,7 @@
+
diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj
index 6193e4f73a4..a192f86d4f6 100644
--- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj
+++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj
@@ -29,6 +29,8 @@
+
+
diff --git a/tests/FSharp.Compiler.Service.Tests/HotReload/GeneratedNamesTests.fs b/tests/FSharp.Compiler.Service.Tests/HotReload/GeneratedNamesTests.fs
new file mode 100644
index 00000000000..78fd1de31a0
--- /dev/null
+++ b/tests/FSharp.Compiler.Service.Tests/HotReload/GeneratedNamesTests.fs
@@ -0,0 +1,181 @@
+namespace FSharp.Compiler.Service.Tests.HotReload
+
+open Xunit
+
+open FSharp.Compiler.CompilerGeneratedNameMapState
+open FSharp.Compiler.CompilerGlobalState
+open FSharp.Compiler.GeneratedNames
+open FSharp.Compiler.SynthesizedTypeMaps
+open FSharp.Compiler.Text
+
+module GeneratedNamesTests =
+
+ let zeroRange = Range.range0
+
+ let private expectPositionalName input expectedName expectedOrdinal =
+ match tryNormalizeSynthesizedTypeNameForPositionalPairing input with
+ | Some actual ->
+ Assert.Equal(expectedName, actual.NormalizedBasicName)
+ Assert.Equal(expectedOrdinal, actual.Ordinal)
+ | None -> failwithf "Expected '%s' to normalize for positional pairing." input
+
+ let private expectNoPositionalName input =
+ Assert.True(
+ Option.isNone (tryNormalizeSynthesizedTypeNameForPositionalPairing input),
+ sprintf "Expected '%s' not to normalize for positional pairing." input
+ )
+
+ []
+ let ``NiceNameGenerator without map uses legacy suffix`` () =
+ let compilerState = CompilerGlobalState()
+ clearCompilerGeneratedNameMap (compilerState :> obj)
+ let generator = compilerState.NiceNameGenerator
+
+ let first = generator.FreshCompilerGeneratedName("lambda", zeroRange)
+ let second = generator.FreshCompilerGeneratedName("lambda", zeroRange)
+
+ Assert.Equal("lambda@1", first)
+ Assert.Equal("lambda@1-1", second)
+
+ []
+ let ``NiceNameGenerator with synthesized map replays snapshot`` () =
+ let compilerState = CompilerGlobalState()
+ let map = FSharpSynthesizedTypeMaps()
+ map.BeginSession()
+ setCompilerGeneratedNameMap (compilerState :> obj) (map :> ICompilerGeneratedNameMap)
+
+ let generator = compilerState.NiceNameGenerator
+
+ let first = generator.FreshCompilerGeneratedName("closure", zeroRange)
+ let second = generator.FreshCompilerGeneratedName("closure", zeroRange)
+
+ let snapshot =
+ map.Snapshot
+ |> Seq.find (fun struct (key, _) -> key = "closure")
+ |> fun struct (_, names) -> names
+
+ map.BeginSession()
+
+ let replayFirst = generator.FreshCompilerGeneratedName("closure", zeroRange)
+ let replaySecond = generator.FreshCompilerGeneratedName("closure", zeroRange)
+
+ Assert.Equal("closure@hotreload", first)
+ Assert.Equal("closure@hotreload-1", second)
+ Assert.Equal(snapshot, [| first; second |])
+ Assert.Equal(snapshot, [| replayFirst; replaySecond |])
+
+ []
+ let ``NiceNameGenerator counters not incremented during replay mode`` () =
+ let compilerState = CompilerGlobalState()
+ let map = FSharpSynthesizedTypeMaps()
+ map.BeginSession()
+ setCompilerGeneratedNameMap (compilerState :> obj) (map :> ICompilerGeneratedNameMap)
+
+ let generator = compilerState.NiceNameGenerator
+
+ generator.FreshCompilerGeneratedName("test", zeroRange) |> ignore
+ generator.FreshCompilerGeneratedName("test", zeroRange) |> ignore
+
+ clearCompilerGeneratedNameMap (compilerState :> obj)
+
+ let first = generator.FreshCompilerGeneratedName("test", zeroRange)
+ let second = generator.FreshCompilerGeneratedName("test", zeroRange)
+
+ Assert.Equal("test@1", first)
+ Assert.Equal("test@1-1", second)
+
+ []
+ let ``NiceNameGenerator without map keys ordinals by file index`` () =
+ let compilerState = CompilerGlobalState()
+ clearCompilerGeneratedNameMap (compilerState :> obj)
+ let generator = compilerState.NiceNameGenerator
+ let start = Position.mkPos 42 0
+ let fileOneRange = Range.mkRange "/tmp/generated-names-file-one.fs" start start
+ let fileTwoRange = Range.mkRange "/tmp/generated-names-file-two.fs" start start
+
+ let fileOneFirst = generator.FreshCompilerGeneratedName("closure", fileOneRange)
+ let fileOneSecond = generator.FreshCompilerGeneratedName("closure", fileOneRange)
+ let fileTwoFirst = generator.FreshCompilerGeneratedName("closure", fileTwoRange)
+ let fileOneThird = generator.FreshCompilerGeneratedName("closure", fileOneRange)
+
+ Assert.Equal("closure@42", fileOneFirst)
+ Assert.Equal("closure@42-1", fileOneSecond)
+ Assert.Equal("closure@42", fileTwoFirst)
+ Assert.Equal("closure@42-2", fileOneThird)
+
+ []
+ let ``PerFileNamingScope uses map before per-file buckets`` () =
+ let compilerState = CompilerGlobalState()
+ let map = FSharpSynthesizedTypeMaps()
+ map.BeginSession()
+ setCompilerGeneratedNameMap (compilerState :> obj) (map :> ICompilerGeneratedNameMap)
+
+ let start = Position.mkPos 42 0
+ let fileRange = Range.mkRange "/tmp/generated-names-file-scope.fs" start start
+ let scope = compilerState.NewFileScope fileRange
+
+ let first = scope.Fresh("closure", fileRange)
+ let second = scope.Fresh("closure", fileRange)
+
+ clearCompilerGeneratedNameMap (compilerState :> obj)
+ let fallback = scope.Fresh("closure", fileRange)
+
+ Assert.Equal("closure@hotreload", first)
+ Assert.Equal("closure@hotreload-1", second)
+ Assert.Equal("closure@42", fallback)
+
+ []
+ let ``Per-file naming scope remains one-based and file-index scoped`` () =
+ let compilerState = CompilerGlobalState()
+ clearCompilerGeneratedNameMap (compilerState :> obj)
+ let start = Position.mkPos 7 0
+ let fileOneRange = Range.mkRange "/tmp/per-file-scope-one.fs" start start
+ let fileTwoRange = Range.mkRange "/tmp/per-file-scope-two.fs" start start
+
+ let fileOneScope = compilerState.NewFileScope(fileOneRange)
+ let fileTwoScope = compilerState.NewFileScope(fileTwoRange)
+
+ let first = fileOneScope.Fresh("closure", fileOneRange)
+ let second = fileOneScope.Fresh("closure", fileTwoRange)
+ let third = fileTwoScope.Fresh("closure", fileOneRange)
+
+ Assert.Equal("closure@7", first)
+ Assert.Equal("closure@7-1", second)
+ Assert.Equal("closure@7", third)
+
+ []
+ let ``positional synthesized name normalization recognizes pipe and ordinal labels`` () =
+ expectPositionalName "Pipe #1 input at line 28@28" "Pipe #1 input" [ 28; 0 ]
+ expectPositionalName "Pipe #1 stage #2 at line 28@28" "Pipe #1 stage #2" [ 28; 0 ]
+ expectPositionalName "Pipe #1 stage #2 at line 28" "Pipe #1 stage #2" [ 28; 0 ]
+ expectPositionalName "Pipe #1 stage #2 at line 28@hotreload-1" "Pipe #1 stage #2" [ 28; 1 ]
+ expectPositionalName "endpoints@hotreload" "endpoints" [ 0 ]
+ expectPositionalName "endpoints@hotreload-2" "endpoints" [ 2 ]
+ expectPositionalName "endpoints@42-1" "endpoints" [ 42; 1 ]
+
+ []
+ let ``positional synthesized name normalization rejects unrelated generated-looking names`` () =
+ expectNoPositionalName ""
+ expectNoPositionalName "not generated"
+ expectNoPositionalName "Pipe #1 stage #2 line 28@28"
+ expectNoPositionalName "Pipe #1 stage #2 at line 28@29"
+ expectNoPositionalName "endpoints@hotreload#g0_o0"
+
+ []
+ let ``generation-suffixed name parsing recognizes generation and occurrence`` () =
+ Assert.True(IsHotReloadGenerationSuffixedName "f@hotreload#g2_o3_4")
+ Assert.Equal(Some 2, TryGetHotReloadNameGeneration "f@hotreload#g2_o3_4")
+
+ match TryNormalizeHotReloadGenerationName "Pipe #1 stage #2 at line 28@hotreload#g0_o1_2" with
+ | Some actual ->
+ Assert.Equal("Pipe #1 stage #2", actual.NormalizedBasicName)
+ Assert.Equal(0, actual.Generation)
+ Assert.Equal([ 1; 2 ], actual.OccurrenceOrdinal)
+ | None -> failwith "Expected generation-suffixed name to normalize."
+
+ []
+ let ``generation-suffixed name parsing rejects malformed names`` () =
+ Assert.Equal(None, TryGetHotReloadNameGeneration "")
+ Assert.Equal(None, TryGetHotReloadNameGeneration "f@hotreload#g_o3")
+ Assert.Equal(None, TryNormalizeHotReloadGenerationName "f@hotreload#g1_o")
+ Assert.Equal(None, TryNormalizeHotReloadGenerationName "f@bad@hotreload#g1_o0")
diff --git a/tests/FSharp.Compiler.Service.Tests/HotReload/NameMapTests.fs b/tests/FSharp.Compiler.Service.Tests/HotReload/NameMapTests.fs
new file mode 100644
index 00000000000..6079a91c292
--- /dev/null
+++ b/tests/FSharp.Compiler.Service.Tests/HotReload/NameMapTests.fs
@@ -0,0 +1,202 @@
+namespace FSharp.Compiler.Service.Tests.HotReload
+
+open System
+open Xunit
+
+open FSharp.Compiler.SynthesizedTypeMaps
+
+module NameMapTests =
+
+ []
+ let ``name map replays recorded sequence`` () =
+ let map = FSharpSynthesizedTypeMaps()
+ map.BeginSession()
+
+ let first = map.GetOrAddName "lambda"
+ let second = map.GetOrAddName "lambda"
+
+ map.BeginSession()
+
+ let replayFirst = map.GetOrAddName "lambda"
+ let replaySecond = map.GetOrAddName "lambda"
+
+ Assert.Equal(first, replayFirst)
+ Assert.Equal(second, replaySecond)
+
+ let private hasLineNumberSuffix (name: string) =
+ let atIndex = name.IndexOf('@')
+ atIndex >= 0 && atIndex + 1 < name.Length && Char.IsDigit name[atIndex + 1]
+
+ []
+ let ``generated names avoid source line suffixes`` () =
+ let map = FSharpSynthesizedTypeMaps()
+ map.BeginSession()
+
+ let name = map.GetOrAddName "closure"
+ let another = map.GetOrAddName "closure"
+
+ Assert.False(hasLineNumberSuffix name, $"Expected '{name}' to avoid line-number suffixes.")
+ Assert.False(hasLineNumberSuffix another, $"Expected '{another}' to avoid line-number suffixes.")
+
+ []
+ let ``snapshot reload restores recorded names`` () =
+ let map = FSharpSynthesizedTypeMaps()
+ map.BeginSession()
+
+ let first = map.GetOrAddName "anon"
+ let second = map.GetOrAddName "anon"
+
+ let snapshot = map.Snapshot |> Seq.toArray
+
+ let replay = FSharpSynthesizedTypeMaps()
+ replay.LoadSnapshot snapshot
+ replay.BeginSession()
+
+ let replayFirst = replay.GetOrAddName "anon"
+ let replaySecond = replay.GetOrAddName "anon"
+
+ Assert.Equal(first, replayFirst)
+ Assert.Equal(second, replaySecond)
+
+ []
+ let ``line-normalized replay preserves generation-zero pipe name`` () =
+ let map = FSharpSynthesizedTypeMaps()
+ map.BeginSession()
+
+ let baselineName = map.GetOrAddName "Pipe #1 stage #2 at line 28"
+
+ map.BeginSession()
+
+ let replayedName = map.GetOrAddName "Pipe #1 stage #2 at line 30"
+
+ Assert.Equal("Pipe #1 stage #2 at line 28@hotreload", baselineName)
+ Assert.Equal(baselineName, replayedName)
+ Assert.Contains("line 28", replayedName)
+ Assert.DoesNotContain("line 30", replayedName)
+
+ []
+ let ``LoadSnapshot normalizes old raw pipe keys`` () =
+ let map = FSharpSynthesizedTypeMaps()
+
+ let oldSnapshot =
+ [| struct ("Pipe #1 stage #2 at line 28", [| "Pipe #1 stage #2 at line 28@hotreload" |]) |]
+
+ map.LoadSnapshot oldSnapshot
+ map.BeginSession()
+
+ let replayedName = map.GetOrAddName "Pipe #1 stage #2 at line 30"
+ let snapshot = map.Snapshot |> Seq.toArray
+ let struct (snapshotKey, snapshotNames) = Assert.Single snapshot
+
+ Assert.Equal("Pipe #1 stage #2 at line 28@hotreload", replayedName)
+ Assert.Equal("Pipe #1 stage #2", snapshotKey)
+ Assert.Equal([| "Pipe #1 stage #2 at line 28@hotreload" |], snapshotNames)
+
+ []
+ let ``LoadSnapshot fills normalized pipe replay holes with birth-line names`` () =
+ let map = FSharpSynthesizedTypeMaps()
+
+ let gappedSnapshot =
+ [| struct (
+ "Pipe #1 stage #2",
+ [| "Pipe #1 stage #2 at line 28@hotreload-2"; "Pipe #1 stage #2 at line 28@hotreload" |]
+ ) |]
+
+ map.LoadSnapshot gappedSnapshot
+ map.BeginSession()
+
+ let replayed = [| for _ in 0 .. 2 -> map.GetOrAddName "Pipe #1 stage #2 at line 30" |]
+
+ Assert.Equal(
+ [| "Pipe #1 stage #2 at line 28@hotreload"
+ "Pipe #1 stage #2 at line 28@hotreload-1"
+ "Pipe #1 stage #2 at line 28@hotreload-2" |],
+ replayed
+ )
+
+ []
+ let ``LoadSnapshot canonicalizes hot reload ordinals for replay`` () =
+ let map = FSharpSynthesizedTypeMaps()
+
+ let outOfOrderSnapshot =
+ [| struct ("closure", [| "closure@hotreload-10"; "closure@hotreload"; "closure@hotreload-2"; "closure@hotreload-1" |]) |]
+
+ map.LoadSnapshot outOfOrderSnapshot
+ map.BeginSession()
+
+ // Replay is ordinal-positioned. A gapped bucket keeps every surviving name
+ // at its exact allocation slot and re-computes the missing slots' names.
+ let replayed = [| for _ in 0 .. 10 -> map.GetOrAddName "closure" |]
+
+ let expected =
+ [| "closure@hotreload"
+ yield! [| for i in 1 .. 10 -> $"closure@hotreload-{i}" |] |]
+
+ Assert.Equal(expected, replayed)
+ Assert.Equal("closure@hotreload-10", replayed[10])
+
+ []
+ let ``LoadSnapshot preserves occurrence-keyed generation-zero names`` () =
+ let map = FSharpSynthesizedTypeMaps()
+
+ let snapshot =
+ [| struct ("f", [| "f@hotreload-2"; "f@hotreload#g0_o0"; "f@hotreload-1" |]) |]
+
+ map.LoadSnapshot snapshot
+ map.BeginSession()
+
+ let replayed = [| for _ in 0 .. 2 -> map.GetOrAddName "f" |]
+ Assert.Equal([| "f@hotreload#g0_o0"; "f@hotreload-1"; "f@hotreload-2" |], replayed)
+
+ []
+ let ``LoadSnapshot validates name prefix`` () =
+ let map = FSharpSynthesizedTypeMaps()
+
+ let validSnapshot =
+ [| struct ("test", [| "test@hotreload"; "test@hotreload-1" |])
+ struct ("Name", [| "Name@" |])
+ struct ("Circle", [| "Circle@DebugTypeProxy" |]) |]
+
+ map.LoadSnapshot validSnapshot
+
+ []
+ let ``LoadSnapshot accepts legacy basic names`` () =
+ let map = FSharpSynthesizedTypeMaps()
+
+ let legacySnapshot =
+ [| struct ("@_instance", [| "@_instance" |])
+ struct ("cached", [| "cached"; "cached@hotreload" |]) |]
+
+ map.LoadSnapshot legacySnapshot
+
+ []
+ let ``LoadSnapshot rejects basicName mismatch`` () =
+ let map = FSharpSynthesizedTypeMaps()
+
+ let mismatchedSnapshot = [| struct ("foo", [| "bar@hotreload" |]) |]
+ let ex = Assert.Throws(fun () -> map.LoadSnapshot mismatchedSnapshot)
+ Assert.Contains("snapshot key 'foo'", ex.Message)
+ Assert.Contains("bar@hotreload", ex.Message)
+
+ []
+ let ``LoadSnapshot rejects name without marker`` () =
+ let map = FSharpSynthesizedTypeMaps()
+
+ let invalidSnapshot = [| struct ("test", [| "testhotreload" |]) |]
+ let ex = Assert.Throws(fun () -> map.LoadSnapshot invalidSnapshot)
+ Assert.Contains("snapshot key 'test'", ex.Message)
+ Assert.Contains("testhotreload", ex.Message)
+
+ []
+ let ``LoadRecordedSnapshot preserves allocation-key slots`` () =
+ let map = FSharpSynthesizedTypeMaps()
+
+ let recordedSnapshot =
+ [| struct ("allocation", [| "final@hotreload#g0_o0"; "allocation@hotreload-1" |]) |]
+
+ map.LoadRecordedSnapshot recordedSnapshot
+ map.BeginSession()
+
+ Assert.True(map.UsesRecordedSnapshot)
+ Assert.Equal("final@hotreload#g0_o0", map.GetOrAddName "allocation")
+ Assert.Equal("allocation@hotreload-1", map.GetOrAddName "allocation")