From 40b666bc76c350e4984d216950fdf9ba09a5dde1 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Wed, 1 Jul 2026 16:17:20 -0400 Subject: [PATCH] Add ResetCompilerGeneratedNameState to compiler-generated name generators Compiler-generated occurrence names (name@line-N) are allocated from process-wide counters on CompilerGlobalState that accumulate across compilations. When a warm checker re-emits the same project in-process, an unchanged closure therefore gets a different occurrence suffix than the previous emit, so consumers that align generated names across compilations (Edit-and-Continue delta emission, dotnet/fsharp#19941) cannot match them. Add an internal ResetCompilerGeneratedNameState to NiceNameGenerator (clears the per-(name, file) occurrence counters), StableNiceNameGenerator (clears the cached stable names and the inner counters), and an aggregate on CompilerGlobalState that resets all three generators, restoring the fresh-process name layout. Callers must ensure no compilation is concurrently generating names. No in-tree caller yet; the consumer is the hot reload emit path in dotnet/fsharp#19941. Covered by unit tests proving drift without reset, exact replay after reset, and that the stable-name cache itself is cleared. --- .../.FSharp.Compiler.Service/11.0.100.md | 1 + src/Compiler/TypedTree/CompilerGlobalState.fs | 20 ++++ .../TypedTree/CompilerGlobalState.fsi | 15 +++ .../CompilerGlobalStateTests.fs | 96 +++++++++++++++++++ .../FSharp.Compiler.Service.Tests.fsproj | 1 + 5 files changed, 133 insertions(+) create mode 100644 tests/FSharp.Compiler.Service.Tests/CompilerGlobalStateTests.fs 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 c66e2dee74e..2e58de05312 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -117,6 +117,7 @@ * Debug: fix if and match condition sequence points ([PR #19932](https://github.com/dotnet/fsharp/pull/19932)) * Checker: recover on checking language version ([PR ##19970](https://github.com/dotnet/fsharp/pull/19970)) * Implied argument names for function-to-delegate coercions now fall back to the delegate's `Invoke` parameter names when the function has no recoverable names (e.g. a partial application like `System.Func((+) 1)`), instead of synthetic `delegateArg0`, `delegateArg1`, … names. ([PR #20001](https://github.com/dotnet/fsharp/pull/20001)) +* Add internal `ResetCompilerGeneratedNameState` to `CompilerGlobalState` name generators so warm-checker re-compilation can produce fresh-process-identical generated names. ([PR #20017](https://github.com/dotnet/fsharp/pull/20017)) ### Improved diff --git a/src/Compiler/TypedTree/CompilerGlobalState.fs b/src/Compiler/TypedTree/CompilerGlobalState.fs index 38af7ad9152..e5641d24eb4 100644 --- a/src/Compiler/TypedTree/CompilerGlobalState.fs +++ b/src/Compiler/TypedTree/CompilerGlobalState.fs @@ -46,6 +46,11 @@ type NiceNameGenerator() = let count = incrementBucket basicName scopeFileIndex mkName basicName m count + /// 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 + /// concurrent codegen is using this generator when resetting. + member _.ResetCompilerGeneratedNameState() = basicNameCounts.Clear() + /// Generates compiler-generated names marked up with a source code location, but if given the same unique value then /// return precisely the same name. Each name generated also includes the StartLine number of the range passed in /// at the point of first generation. @@ -65,6 +70,12 @@ type StableNiceNameGenerator() = lazy innerGenerator.FreshCompilerGeneratedNameOfBasicName(basicName, m)) lazyName.Value + /// 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() = + niceNames.Clear() + innerGenerator.ResetCompilerGeneratedNameState() + [] type PerFileNamingScope internal (nng: NiceNameGenerator, fileIndex: int) = @@ -90,6 +101,15 @@ type internal CompilerGlobalState () = member _.NewFileScope (fileRange: range) = PerFileNamingScope(globalNng, fileRange.FileIndex) + /// Reset all compiler-generated-name occurrence counters on this state, so successive in-process + /// codegen runs over the same source produce identical generated names (a fresh-process layout). + /// Callers must ensure no compilation is concurrently generating names (quiescence). Needed by + /// Edit-and-Continue style scenarios that re-emit from a warm checker. + member _.ResetCompilerGeneratedNameState() = + globalNng.ResetCompilerGeneratedNameState() + globalStableNameGenerator.ResetCompilerGeneratedNameState() + ilxgenGlobalNng.ResetCompilerGeneratedNameState() + /// Unique name generator for stamps attached to lambdas and object expressions type Unique = int64 diff --git a/src/Compiler/TypedTree/CompilerGlobalState.fsi b/src/Compiler/TypedTree/CompilerGlobalState.fsi index 91689196ade..aea209b6684 100644 --- a/src/Compiler/TypedTree/CompilerGlobalState.fsi +++ b/src/Compiler/TypedTree/CompilerGlobalState.fsi @@ -19,6 +19,11 @@ type NiceNameGenerator = member FreshCompilerGeneratedName: name: string * m: range -> string member IncrementOnly: name: string * m: range -> int + /// 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 + /// concurrent codegen is using this generator when resetting. + member ResetCompilerGeneratedNameState: unit -> unit + /// Generates compiler-generated names marked up with a source code location, but if given the same unique value then /// return precisely the same name. Each name generated also includes the StartLine number of the range passed in /// at the point of first generation. @@ -30,6 +35,10 @@ type StableNiceNameGenerator = new: unit -> StableNiceNameGenerator member GetUniqueCompilerGeneratedName: name: string * m: range * uniq: int64 -> string + /// 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: unit -> unit + /// A compiler-generated-name allocation scope bound to a single ImplFile being optimized. /// Instances can only be obtained from CompilerGlobalState.NewFileScope so a call site can't /// accidentally bucket names by the wrong (e.g. inlined-source) file and reintroduce the @@ -59,6 +68,12 @@ type internal CompilerGlobalState = /// under parallel optimization. See https://github.com/dotnet/fsharp/issues/19732. member NewFileScope: fileRange: range -> PerFileNamingScope + /// Reset all compiler-generated-name occurrence counters on this state, so successive in-process + /// codegen runs over the same source produce identical generated names (a fresh-process layout). + /// Callers must ensure no compilation is concurrently generating names (quiescence). Needed by + /// Edit-and-Continue style scenarios that re-emit from a warm checker. + member ResetCompilerGeneratedNameState: unit -> unit + type Unique = int64 /// Concurrency-safe diff --git a/tests/FSharp.Compiler.Service.Tests/CompilerGlobalStateTests.fs b/tests/FSharp.Compiler.Service.Tests/CompilerGlobalStateTests.fs new file mode 100644 index 00000000000..888e79ac50e --- /dev/null +++ b/tests/FSharp.Compiler.Service.Tests/CompilerGlobalStateTests.fs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +module FSharp.Compiler.Service.Tests.CompilerGlobalStateTests + +open FSharp.Compiler.CompilerGlobalState +open FSharp.Compiler.Text.Range +open FSharp.Test.Assert +open Xunit + +[] +let ``NiceNameGenerator drifts across calls and ResetCompilerGeneratedNameState restores a fresh-process layout`` () = + let nng = NiceNameGenerator() + let r = rangeN "niceNameGenerator.fs" 10 + + // First batch: occurrence counters start from zero, so names drift f@10, f@10-1, f@10-2. + let batch1 = [ for _ in 1 .. 3 -> nng.FreshCompilerGeneratedName("f", r) ] + batch1 |> shouldEqual [ "f@10"; "f@10-1"; "f@10-2" ] + + // Without a reset, further calls keep drifting from where the counters left off. + let keepsDriftingWithoutReset = [ for _ in 1 .. 2 -> nng.FreshCompilerGeneratedName("f", r) ] + keepsDriftingWithoutReset |> shouldEqual [ "f@10-3"; "f@10-4" ] + + // Resetting clears the occurrence counters, so a subsequent run reproduces the very first batch. + nng.ResetCompilerGeneratedNameState() + let batch2 = [ for _ in 1 .. 3 -> nng.FreshCompilerGeneratedName("f", r) ] + batch2 |> shouldEqual batch1 + +[] +let ``StableNiceNameGenerator caches by uniq and ResetCompilerGeneratedNameState clears both the cache and the counters`` () = + let gen = StableNiceNameGenerator() + let r = rangeN "stableNiceNameGenerator.fs" 20 + + // First occurrence of "h" for uniq 1. + let first = gen.GetUniqueCompilerGeneratedName("h", r, 1L) + first |> shouldEqual "h@20" + + // A different uniq for the same basic name advances the shared occurrence counter. + let second = gen.GetUniqueCompilerGeneratedName("h", r, 2L) + second |> shouldEqual "h@20-1" + + // Re-querying uniq 1 must return the cached name, not a recomputed (drifted) one, even though + // the shared occurrence counter for "h" has since advanced to produce `second`. + let cachedAgain = gen.GetUniqueCompilerGeneratedName("h", r, 1L) + cachedAgain |> shouldEqual first + + gen.ResetCompilerGeneratedNameState() + + // Replaying the exact same sequence of calls after a reset reproduces the exact same names + // ("h@20" then "h@20-1"), because both the stable-name cache and the shared occurrence + // counter were cleared: a fresh call for uniq 1 is once again the first-ever occurrence. + let afterResetForUniq1 = gen.GetUniqueCompilerGeneratedName("h", r, 1L) + afterResetForUniq1 |> shouldEqual first + + let afterResetForUniq2 = gen.GetUniqueCompilerGeneratedName("h", r, 2L) + afterResetForUniq2 |> shouldEqual second + + // The cache is fully functional again after reset: re-querying uniq 1 still returns the + // cached (post-reset) name rather than drifting further. + let cachedAgainAfterReset = gen.GetUniqueCompilerGeneratedName("h", r, 1L) + cachedAgainAfterReset |> shouldEqual afterResetForUniq1 + + // Prove the stable-name CACHE itself was cleared, not just the inner counters: after another + // reset, the same (name, uniq) key queried with a DIFFERENT range must be recomputed from the + // new range ("h@99"). A stale cache entry would instead return the pre-reset "h@20". + gen.ResetCompilerGeneratedNameState() + let differentRange = rangeN "stableNiceNameGenerator.fs" 99 + let recomputedForUniq1 = gen.GetUniqueCompilerGeneratedName("h", differentRange, 1L) + recomputedForUniq1 |> shouldEqual "h@99" + +[] +let ``CompilerGlobalState.ResetCompilerGeneratedNameState resets all three generators together`` () = + let state = CompilerGlobalState() + let r = rangeN "compilerGlobalState.fs" 30 + + let niceName1 = state.NiceNameGenerator.FreshCompilerGeneratedName("f", r) + let ilxName1 = state.IlxGenNiceNameGenerator.FreshCompilerGeneratedName("g", r) + let stableName1 = state.StableNameGenerator.GetUniqueCompilerGeneratedName("h", r, 1L) + + // Drift each generator away from its first-occurrence name before resetting. + state.NiceNameGenerator.FreshCompilerGeneratedName("f", r) |> ignore + state.IlxGenNiceNameGenerator.FreshCompilerGeneratedName("g", r) |> ignore + state.StableNameGenerator.GetUniqueCompilerGeneratedName("h", r, 2L) |> ignore + + state.ResetCompilerGeneratedNameState() + + let niceName2 = state.NiceNameGenerator.FreshCompilerGeneratedName("f", r) + let ilxName2 = state.IlxGenNiceNameGenerator.FreshCompilerGeneratedName("g", r) + // Replaying the same first call (uniq 1) after the aggregate reset reproduces the original + // stable name, confirming the reset reached the StableNameGenerator too. (StableNiceNameGenerator's + // own tests separately confirm that the reset actually clears its cache, rather than merely + // resetting the shared occurrence counter.) + let stableName2 = state.StableNameGenerator.GetUniqueCompilerGeneratedName("h", r, 1L) + + niceName2 |> shouldEqual niceName1 + ilxName2 |> shouldEqual ilxName1 + stableName2 |> shouldEqual stableName1 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 d29c8693d18..6193e4f73a4 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj @@ -53,6 +53,7 @@ +