diff --git a/src/Compiler/CodeGen/IlxDeltaEmitter.fs b/src/Compiler/CodeGen/IlxDeltaEmitter.fs index f1b0e309a69..5a09bbe251e 100644 --- a/src/Compiler/CodeGen/IlxDeltaEmitter.fs +++ b/src/Compiler/CodeGen/IlxDeltaEmitter.fs @@ -21,6 +21,7 @@ open FSharp.Compiler.HotReloadBaseline open FSharp.Compiler.HotReloadPdb open FSharp.Compiler.IlxDeltaStreams open FSharp.Compiler.CodeGen.FSharpDefinitionIndex +open FSharp.Compiler.GeneratedNames open FSharp.Compiler.SynthesizedTypeMaps open FSharp.Compiler.Syntax.PrettyNaming open FSharp.Compiler.TypedTreeDiff @@ -3547,35 +3548,52 @@ let emitDeltaWithDebugData (freshDebugPdb: byte[] option) (request: IlxDeltaRequ | [||] -> None | [| single |] -> Some single | matches -> + let isBaselineMatchAvailable matchedName = + match newTypeNameByBaseline.TryGetValue matchedName with + | true, existingNewName -> String.Equals(existingNewName, newFullName, StringComparison.Ordinal) + | false, _ -> true + let exactMatch = matches - |> Array.tryFind (fun (matchedName, _) -> String.Equals(matchedName, newFullName, StringComparison.Ordinal)) + |> Array.tryFind (fun (matchedName, _) -> + String.Equals(matchedName, newFullName, StringComparison.Ordinal) + && isBaselineMatchAvailable matchedName) match exactMatch with | Some matchResult -> Some matchResult | None -> - let normalizeTypePath (name: string) = - name.Split([| '.'; '+' |], StringSplitOptions.RemoveEmptyEntries) - |> String.concat "." - - let normalizedTarget = normalizeTypePath newFullName + let normalizedTarget = normalizeTypePathName newFullName let normalizedMatches = matches |> Array.filter (fun (matchedName, _) -> - String.Equals(normalizeTypePath matchedName, normalizedTarget, StringComparison.Ordinal)) + String.Equals(normalizeTypePathName matchedName, normalizedTarget, StringComparison.Ordinal) + && isBaselineMatchAvailable matchedName) match normalizedMatches with | [| normalizedMatch |] -> Some normalizedMatch | _ -> - let matchedNames = matches |> Array.map fst |> String.concat "; " - let allCandidates = candidateNames |> String.concat "; " + let unassignedMatches = + matches + |> Array.filter (fun (matchedName, _) -> isBaselineMatchAvailable matchedName) + + if IsCompilerGeneratedName typeDef.Name && not (Array.isEmpty unassignedMatches) then + unassignedMatches + |> Array.sortBy (fun (matchedName, _) -> + TryGetHotReloadReplayNameOrdinal matchedName + |> Option.defaultValue Int32.MaxValue, + matchedName) + |> Array.head + |> Some + else + let matchedNames = matches |> Array.map fst |> String.concat "; " + let allCandidates = candidateNames |> String.concat "; " - raise ( - HotReloadUnsupportedEditException( - $"Ambiguous synthesized type mapping for '{newFullName}' (candidates=[{allCandidates}], baselineMatches=[{matchedNames}]); full rebuild required." + raise ( + HotReloadUnsupportedEditException( + $"Ambiguous synthesized type mapping for '{newFullName}' (candidates=[{allCandidates}], baselineMatches=[{matchedNames}]); full rebuild required." + ) ) - ) if traceSynthesizedMappings.Value then match baselineNameOpt with @@ -3692,19 +3710,17 @@ let emitDeltaWithDebugData (freshDebugPdb: byte[] option) (request: IlxDeltaRequ addedTypeDefs.Add(enclosing, typeDef, fullName) deltaToken | None when typeDef.Name.Contains "@hotreload" -> - // A legacy (non-generation-suffixed) hot-reload closure name with no - // baseline counterpart: closure-chain CE lowerings (async) number - // their classes `-N` by emission order, so a structural CE change - // shifts every later name off its baseline row. Tokens looked up - // through the baseline mappings would be garbage; fail closed. + // A non-generation-suffixed hot-reload helper with no baseline counterpart can be + // regeneration noise from unchanged code in a full in-process emit. Do not allocate + // it as an added type. If updated IL actually references it, the later token remap + // still fails closed rather than emitting a bogus row. let fullName = (mkRefForNestedILTypeDef ILScopeRef.Local (enclosing, typeDef)).FullName - raise ( - HotReloadUnsupportedEditException( - $"Computation-expression closure chain changed: synthesized type '{fullName}' has no baseline counterpart; the closure chain cannot be aligned with the baseline. Please rebuild." - ) - ) + if traceSynthesizedMappings.Value then + printfn "[fsharp-hotreload][synthesized-map] ignoring unmatched helper %s" fullName + + 0 | None -> request.Baseline.TokenMappings.TypeDefTokenMap(enclosing, typeDef) addMapping typeTokenMap newTypeToken baselineTypeToken diff --git a/src/Compiler/HotReload/FSharpHotReloadSession.fs b/src/Compiler/HotReload/FSharpHotReloadSession.fs index 3c5f7bdf584..d81d7264145 100644 --- a/src/Compiler/HotReload/FSharpHotReloadSession.fs +++ b/src/Compiler/HotReload/FSharpHotReloadSession.fs @@ -516,6 +516,7 @@ type FSharpHotReloadSession ( hotReloadService: FSharpHotReloadService, parseAndCheckSnapshot: FSharpProjectSnapshot -> string -> Async, + refreshOutputBeforeEmit: FSharpCheckProjectResults -> string option -> Async, tryGetOutputPath: FSharpProjectSnapshot -> string option, // Registers a successfully baselined project (resolved output path + project key) in // the owning checker's live-session registry, which FSharpChecker.Compile consults to @@ -645,6 +646,7 @@ type FSharpHotReloadSession |] let projectKey = projectKeyOfSnapshot projectSnapshot + let resolvedOutputPath = resolveOutputPath projectKey projectSnapshot let currentTrackedInputs = computeTrackedInputs projectSnapshot @@ -654,11 +656,18 @@ type FSharpHotReloadSession | true, committed -> committed <> currentTrackedInputs | false, _ -> false) + let parseCheckAndMaybeRefreshOutput () = + async { + let! projectResults = parseAndCheckSnapshot projectSnapshot opName + do! refreshOutputBeforeEmit projectResults resolvedOutputPath + return projectResults + } + let! result = hotReloadService.EmitHotReloadDelta projectKey - (fun () -> parseAndCheckSnapshot projectSnapshot opName) - (resolveOutputPath projectKey projectSnapshot) + parseCheckAndMaybeRefreshOutput + resolvedOutputPath trackedInputsChanged match result with diff --git a/src/Compiler/HotReload/HotReloadState.fs b/src/Compiler/HotReload/HotReloadState.fs index eb90c8f4a09..92890ffd108 100644 --- a/src/Compiler/HotReload/HotReloadState.fs +++ b/src/Compiler/HotReload/HotReloadState.fs @@ -1,6 +1,8 @@ module internal FSharp.Compiler.HotReloadState open System +open System.Collections.Generic +open System.IO open FSharp.Compiler.CodeAnalysis open FSharp.Compiler.EditAndContinue open FSharp.Compiler.HotReloadBaseline @@ -422,6 +424,82 @@ let setCurrentEmissionContext (context: HotReloadEmissionContext option) = let tryGetCurrentEmissionContext () = lock emissionContextLock (fun () -> currentEmissionContext) +let private replaceCurrentEmissionContext context = + lock emissionContextLock (fun () -> + let previous = currentEmissionContext + currentEmissionContext <- context + previous) + +/// Runs synchronous work while the fsc emit hook observes the supplied emission context. +let withCurrentEmissionContext context action = + let previous = replaceCurrentEmissionContext (Some context) + + try + action () + finally + lock emissionContextLock (fun () -> currentEmissionContext <- previous) + +/// Runs asynchronous work while the fsc emit hook observes the supplied emission context. +let withCurrentEmissionContextAsync context work = + async { + let previous = replaceCurrentEmissionContext (Some context) + + try + return! work + finally + lock emissionContextLock (fun () -> currentEmissionContext <- previous) + } + +/// Tracks the session-owned projects that can service an in-process compile by output path. +/// The owning checker keeps one registry and removes a session's entries when it is disposed. +type HotReloadEmissionTargetRegistry() = + let targets = ResizeArray() + let gate = obj () + + let normalizeOutputPath (path: string) = + try + Path.GetFullPath(path) + with _ -> + path + + let outputPathComparison = + if Path.DirectorySeparatorChar = '\\' then + StringComparison.OrdinalIgnoreCase + else + StringComparison.Ordinal + + member _.Register(store: HotReloadSessionStore, outputPath: string, projectKey: HotReloadProjectKey) = + let normalized = normalizeOutputPath outputPath + + lock gate (fun () -> + // A recapture of the same project in the same session replaces its entry. + targets.RemoveAll(fun (_, existingStore, existingKey) -> + obj.ReferenceEquals(existingStore, store) && existingKey = projectKey) + |> ignore + + // Most recent first: if two live sessions track the same output, the newest + // baseline is the one this checker should use for the next in-process compile. + targets.Insert(0, (normalized, store, projectKey))) + + member _.UnregisterStore(store: HotReloadSessionStore) = + lock gate (fun () -> + targets.RemoveAll(fun (_, existingStore, _) -> obj.ReferenceEquals(existingStore, store)) + |> ignore) + + member _.TryResolve(outputPath: string option) = + match outputPath with + | None -> None + | Some outputPath -> + let target = normalizeOutputPath outputPath + + lock gate (fun () -> + targets + |> Seq.tryPick (fun (registeredPath, store, projectKey) -> + if String.Equals(registeredPath, target, outputPathComparison) then + Some { Store = store; ProjectKey = projectKey } + else + None)) + let private activeSessionStore = HotReloadSessionStore() /// The process-local store identity-less callers operate on: the fsc emit hook when no scoped diff --git a/src/Compiler/Service/service.fs b/src/Compiler/Service/service.fs index 83368aa8f4a..55df77b123e 100644 --- a/src/Compiler/Service/service.fs +++ b/src/Compiler/Service/service.fs @@ -113,6 +113,11 @@ module CompileHelpers = diagnostics.ToArray(), result +[] +type private CheckedProjectCompileMode = + | BaselineLike + | HotReloadSessionRefresh of FSharp.Compiler.HotReloadState.HotReloadEmissionContext + [] // There is typically only one instance of this type in an IDE process. type FSharpChecker @@ -527,68 +532,7 @@ type FSharpChecker // entities are unaffected, owning private stores and reconstructing baselines from disk. do FSharp.Compiler.HotReloadState.clearSessionState () - // Projects tracked by LIVE session entities created via CreateHotReloadSession, keyed by - // the resolved output path each AddProject baselined (most recent first). Compile consults - // this to resolve the scoped emission context — which session, and which project inside - // it, a given in-process compile serves. Disposing a session removes its entries. - let liveHotReloadEmissionTargets = - ResizeArray() - - let liveHotReloadEmissionTargetsGate = obj () - - let normalizeOutputPathForEmissionTargets (path: string) = - try - Path.GetFullPath(path) - with _ -> - path - - let outputPathComparison = - if Path.DirectorySeparatorChar = '\\' then - StringComparison.OrdinalIgnoreCase - else - StringComparison.Ordinal - - let registerHotReloadEmissionTarget - (store: FSharp.Compiler.HotReloadState.HotReloadSessionStore) - (outputPath: string) - (projectKey: FSharp.Compiler.HotReloadState.HotReloadProjectKey) - = - let normalized = normalizeOutputPathForEmissionTargets outputPath - - lock liveHotReloadEmissionTargetsGate (fun () -> - // A recapture of the same project in the same session replaces its entry. - liveHotReloadEmissionTargets.RemoveAll(fun (_, existingStore, existingKey) -> - obj.ReferenceEquals(existingStore, store) && existingKey = projectKey) - |> ignore - - liveHotReloadEmissionTargets.Insert(0, (normalized, store, projectKey))) - - let unregisterHotReloadEmissionTargets (store: FSharp.Compiler.HotReloadState.HotReloadSessionStore) = - lock liveHotReloadEmissionTargetsGate (fun () -> - liveHotReloadEmissionTargets.RemoveAll(fun (_, existingStore, _) -> obj.ReferenceEquals(existingStore, store)) - |> ignore) - - // Resolves the session entity (and tracked project) an in-process compile belongs to by - // the compile's output path. The most recently baselined project wins when several live - // sessions track the same output. - let tryResolveHotReloadEmissionContext (outputPath: string option) = - match outputPath with - | None -> None - | Some outputPath -> - let target = normalizeOutputPathForEmissionTargets outputPath - - lock liveHotReloadEmissionTargetsGate (fun () -> - liveHotReloadEmissionTargets - |> Seq.tryPick (fun (registeredPath, store, projectKey) -> - if String.Equals(registeredPath, target, outputPathComparison) then - Some( - { - FSharp.Compiler.HotReloadState.HotReloadEmissionContext.Store = store - FSharp.Compiler.HotReloadState.HotReloadEmissionContext.ProjectKey = projectKey - } - ) - else - None)) + let hotReloadEmissionTargets = FSharp.Compiler.HotReloadState.HotReloadEmissionTargetRegistry() static member getParallelReferenceResolutionFromEnvironment() = getParallelReferenceResolutionFromEnvironment () @@ -680,12 +624,26 @@ type FSharpChecker let sessionService = createHotReloadService sessionStore sessionService.SetSessionCapabilities(FSharpChecker.ParseHotReloadCapabilities capabilities) + let refreshOutputBeforeEmit projectResults outputPath = + async { + match outputPath with + | Some outputPath when isEnvVarTruthy "FSHARP_HOTRELOAD_INPROCESS_COMPILE" -> + // dotnet-watch can skip the external per-edit build on this path. + // Refresh the obj output here so stale-output validation and the + // delta reader both observe the edited module. + let! _ = this.CompileFromCheckedProjectForHotReloadSession(projectResults, outputPath) + return () + | _ -> + return () + } + new FSharpHotReloadSession( sessionService, (fun projectSnapshot opName -> this.ParseAndCheckProject(projectSnapshot, userOpName = opName)), + refreshOutputBeforeEmit, tryGetOutputPathFromProjectSnapshot, - (fun outputPath projectKey -> registerHotReloadEmissionTarget sessionStore outputPath projectKey), - (fun () -> unregisterHotReloadEmissionTargets sessionStore) + (fun outputPath projectKey -> hotReloadEmissionTargets.Register(sessionStore, outputPath, projectKey)), + (fun () -> hotReloadEmissionTargets.UnregisterStore sessionStore) ) member _.HotReloadCapabilities = @@ -795,7 +753,7 @@ type FSharpChecker if hasTestArgument "HotReloadDeltas" argv then None else - tryResolveHotReloadEmissionContext (tryGetOutputPathFromCommandLineOptions "" argv) + hotReloadEmissionTargets.TryResolve(tryGetOutputPathFromCommandLineOptions "" argv) let ensureHotReloadSessionHookArgument (argv: string[]) = // Keep synthesized-name replay active for checker-owned hot reload sessions even when @@ -813,16 +771,16 @@ type FSharpChecker async { let ctok = CompilationThreadToken() + let compile = + async { + return CompileHelpers.compileFromArgs (ctok, argv, legacyReferenceResolver, None, None) + } match emissionContext with | Some context -> - FSharp.Compiler.HotReloadState.setCurrentEmissionContext (Some context) - - try - return CompileHelpers.compileFromArgs (ctok, argv, legacyReferenceResolver, None, None) - finally - FSharp.Compiler.HotReloadState.setCurrentEmissionContext None - | None -> return CompileHelpers.compileFromArgs (ctok, argv, legacyReferenceResolver, None, None) + return! FSharp.Compiler.HotReloadState.withCurrentEmissionContextAsync context compile + | None -> + return! compile } /// This function is called when the entire environment is known to have changed for reasons not encoded in the ProjectOptions of any project/compilation. @@ -1135,7 +1093,9 @@ type FSharpChecker /// Compile a DLL from cached typecheck results, skipping parse/typecheck/optimization. /// For dev-loop use only. Requires keepAssemblyContents=true. /// Returns the output file path on success. - member internal _.CompileFromCheckedProject(results: FSharpCheckProjectResults, outfile: string) = + member private _.CompileFromCheckedProjectCore + (results: FSharpCheckProjectResults, outfile: string, compileMode: CheckedProjectCompileMode) + = async { let tcConfig, tcGlobals, tcImports, unfinalizedCcu, ccuSig, topAttrsOpt, _ilAssemRef, typedImplFilesOpt = results.CompilationData @@ -1147,13 +1107,17 @@ type FSharpChecker // shared CompilerGlobalState can carry closure-name state from a prior in-process hot-reload // emit; clear it so this full-module emit is baseline-consistent and the delta emitter does // its own @->stable bridging. - tcGlobals.CompilerGlobalState - |> Option.iter (fun cgs -> - FSharp.Compiler.ClosureNameAllocationState.clearClosureNameState (cgs :> obj) - FSharp.Compiler.CompilerGeneratedNameMapState.clearCompilerGeneratedNameMap (cgs :> obj) - // Reset occurrence counters so closure names (name@line-N) match a fresh-process - // build instead of drifting as the reused CompilerGlobalState accumulates across edits. - cgs.ResetGeneratedNameCounters()) + match compileMode with + | CheckedProjectCompileMode.BaselineLike -> + tcGlobals.CompilerGlobalState + |> Option.iter (fun cgs -> + FSharp.Compiler.ClosureNameAllocationState.clearClosureNameState (cgs :> obj) + FSharp.Compiler.CompilerGeneratedNameMapState.clearCompilerGeneratedNameMap (cgs :> obj) + // Reset occurrence counters so closure names (name@line-N) match a fresh-process + // build instead of drifting as the reused CompilerGlobalState accumulates across edits. + cgs.ResetGeneratedNameCounters()) + | CheckedProjectCompileMode.HotReloadSessionRefresh _ -> + () // The CCU from TransparentCompiler has unfinalized Contents (empty ModuleOrNamespaceType). // Finalize it using ccuSig, matching what CheckClosedInputSetFinish does. @@ -1225,7 +1189,7 @@ type FSharpChecker // Dev-loop optimization: use minimal passes only (no extra loops, no detuple, // no TLR, no cross-assembly opt). This DLL is for local testing, not shipping. // OptimizeImplFile + LowerLocalMutables + LowerCalls are mandatory for correct IlxGen. - let optimizedImpls, optDataResources = + let optimizedImpls, optimizedImplsForCodegenHook, optDataResources = let minimalSettings = { tcConfig.optSettings with jitOptUser = Some false @@ -1254,21 +1218,50 @@ type FSharpChecker implFile ) - let file = LowerLocalMutables.TransformImplFile tcGlobals importMap file - let file = LowerCalls.LowerImplFile tcGlobals file - - { - ImplFile = file - OptimizeDuringCodeGen = optDuringCodeGen - }, + let preLoweredFile = file + let loweredFile = LowerLocalMutables.TransformImplFile tcGlobals importMap preLoweredFile + let loweredFile = LowerCalls.LowerImplFile tcGlobals loweredFile + + ({ + ImplFile = preLoweredFile + OptimizeDuringCodeGen = optDuringCodeGen + }, + { + ImplFile = loweredFile + OptimizeDuringCodeGen = optDuringCodeGen + }), (env', hidingInfo')) (optEnv0, SignatureHidingInfo.Empty) |> fst + + let preLoweredImpls = + impls + |> List.map fst + |> CheckedAssemblyAfterOptimization + + let loweredImpls = + impls + |> List.map snd |> CheckedAssemblyAfterOptimization - impls, [] + loweredImpls, preLoweredImpls, [] ReportTime tcConfig "CompileFromCheckedProject: TAST -> IL" + + match compileMode with + | CheckedProjectCompileMode.HotReloadSessionRefresh emissionContext -> + let compilerEmitHook = + FSharp.Compiler.CompilerEmitHookBootstrap.resolveCompilerEmitHookForCompile tcConfig + + let prepareForCodeGeneration () = + // The cached compile bypasses fsc.fs, so invoke the same hook preparation here: + // it installs session-scoped closure and synthesized-name replay before IlxGen. + compilerEmitHook.PrepareForCodeGeneration(false, tcGlobals, optimizedImplsForCodegenHook) + + FSharp.Compiler.HotReloadState.withCurrentEmissionContext emissionContext prepareForCodeGeneration + | CheckedProjectCompileMode.BaselineLike -> + () + let ilxGenerator = CreateIlxAssemblyGenerator(tcConfig, tcImports, tcGlobals, tcVal, generatedCcu) let codegenResults = @@ -1325,8 +1318,11 @@ type FSharpChecker // Strip native resources — default.win32manifest may not exist on all platforms. { m with NativeResources = [] } - // Hand the in-memory module to the hot-reload session so it skips the disk re-parse. - inMemoryEmitCache[outfile] <- ilxMainModule + if isEnvVarTruthy "FSHARP_HOTRELOAD_INPROCESS_CACHE_IL" then + // The cache avoids a disk parse, but the raw in-memory module is not yet shape-identical + // to the written obj assembly for all symbol-matching paths. Keep it opt-in until that + // representation mismatch is closed. + inMemoryEmitCache[outfile] <- ilxMainModule let normalizeAssemblyRefs (aref: ILAssemblyRef) = tcImports.NormalizeAssemblyRef(ctok, aref) @@ -1364,6 +1360,27 @@ type FSharpChecker return outfile } + /// Compile a DLL from cached typecheck results, skipping parse/typecheck/optimization. + /// For dev-loop use only. Requires keepAssemblyContents=true. + /// Returns the output file path on success. + member internal this.CompileFromCheckedProject(results: FSharpCheckProjectResults, outfile: string) = + this.CompileFromCheckedProjectCore(results, outfile, CheckedProjectCompileMode.BaselineLike) + + member private this.CompileFromCheckedProjectForHotReloadSession(results: FSharpCheckProjectResults, outfile: string) = + match hotReloadEmissionTargets.TryResolve(Some outfile) with + | Some emissionContext -> + this.CompileFromCheckedProjectCore( + results, + outfile, + CheckedProjectCompileMode.HotReloadSessionRefresh emissionContext + ) + | None -> + raise ( + InvalidOperationException( + $"CompileFromCheckedProjectForHotReloadSession could not find a live hot reload session for output '{outfile}'." + ) + ) + /// Tokenize a single line, returning token information and a tokenization state represented by an integer member _.TokenizeLine(line: string, state: FSharpTokenizerLexState) = let tokenizer = FSharpSourceTokenizer([], None, None, None) diff --git a/src/Compiler/TypedTree/GeneratedNames.fs b/src/Compiler/TypedTree/GeneratedNames.fs index 87937b1b5e7..5c055c17f65 100644 --- a/src/Compiler/TypedTree/GeneratedNames.fs +++ b/src/Compiler/TypedTree/GeneratedNames.fs @@ -22,6 +22,9 @@ type ICompilerGeneratedNameMap = [] let HotReloadGenerationSuffixedNameInfix = "@hotreload#g" +[] +let HotReloadReplayNameSuffix = "@hotreload" + /// Recognizes occurrence-keyed (generation-suffixed) closure class names /// (`{base}@hotreload#g{N}_o{chain}`), any generation. let IsHotReloadGenerationSuffixedName (name: string) = @@ -48,3 +51,39 @@ let TryGetHotReloadNameGeneration (name: string) : int option = match Int32.TryParse(name.Substring(digitsStart, digitsEnd - digitsStart)) with | true, generation when generation >= 0 -> Some generation | _ -> None + +let private leafName (name: string) = + let leafStart = + max (name.LastIndexOf('.')) (name.LastIndexOf('+')) + 1 + + if leafStart > 0 && leafStart < name.Length then + name.Substring leafStart + else + name + +/// Parses the replay ordinal of a synthesized hot-reload name produced by +/// FSharpSynthesizedTypeMaps: f@hotreload -> Some 0, +/// f@hotreload-2 -> Some 2. Accepts either a leaf name or a +/// metadata full name using ./+ separators. +let TryGetHotReloadReplayNameOrdinal (name: string) : int option = + if String.IsNullOrEmpty name then + None + else + let leaf = leafName name + let suffixStart = leaf.IndexOf(HotReloadReplayNameSuffix, StringComparison.Ordinal) + + if suffixStart < 0 then + None + else + let ordinalStart = suffixStart + HotReloadReplayNameSuffix.Length + + if ordinalStart = leaf.Length then + Some 0 + elif ordinalStart < leaf.Length && leaf[ordinalStart] = '-' then + let suffix = leaf.Substring(ordinalStart + 1) + + match Int32.TryParse suffix with + | true, ordinal when ordinal > 0 -> Some ordinal + | _ -> None + else + None diff --git a/src/Compiler/TypedTree/SynthesizedTypeMaps.fs b/src/Compiler/TypedTree/SynthesizedTypeMaps.fs index 5187589a409..8c9ece09163 100644 --- a/src/Compiler/TypedTree/SynthesizedTypeMaps.fs +++ b/src/Compiler/TypedTree/SynthesizedTypeMaps.fs @@ -28,20 +28,6 @@ type FSharpSynthesizedTypeMaps() = let computeName basicName index = makeHotReloadName basicName index - let tryGetHotReloadOrdinal (basicName: string) (name: string) = - let hotReloadPrefix = basicName + "@hotreload" - - if name.Equals(hotReloadPrefix, StringComparison.Ordinal) then - Some 0 - elif name.StartsWith(hotReloadPrefix + "-", StringComparison.Ordinal) then - let suffix = name.Substring(hotReloadPrefix.Length + 1) - - match Int32.TryParse suffix with - | true, ordinal when ordinal > 0 -> Some ordinal - | _ -> None - else - None - let canonicalizeSnapshotNames basicName (names: string[]) = // Occurrence-keyed closure names ({base}@hotreload#g{N}_o{chain}) // are managed by the closure name allocator's assigned-name table, never by @@ -54,7 +40,7 @@ type FSharpSynthesizedTypeMaps() = let parsed = names - |> Array.mapi (fun index name -> index, name, tryGetHotReloadOrdinal basicName name) + |> Array.mapi (fun index name -> index, name, TryGetHotReloadReplayNameOrdinal name) if parsed |> Array.forall (fun (_, _, ordinalOpt) -> ordinalOpt.IsSome) then // IL metadata can enumerate synthesized helpers in a different order than allocation. diff --git a/tests/FSharp.Compiler.Service.Tests/HotReload/HotReloadCheckerTests.fs b/tests/FSharp.Compiler.Service.Tests/HotReload/HotReloadCheckerTests.fs index 008be674e2a..637c65691da 100644 --- a/tests/FSharp.Compiler.Service.Tests/HotReload/HotReloadCheckerTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/HotReload/HotReloadCheckerTests.fs @@ -52,15 +52,24 @@ type Type = useTransparentCompiler = CompilerAssertHelpers.UseTransparentCompiler ) - let private prepareProjectOptions + let private withEnvironmentVariable name value = + let previous = Environment.GetEnvironmentVariable(name) + Environment.SetEnvironmentVariable(name, value) + + { new IDisposable with + member _.Dispose() = + Environment.SetEnvironmentVariable(name, previous) } + + let private prepareProjectOptionsWithSources (checker: FSharpChecker) - (fsPath: string) + (projectFsPath: string) (dllPath: string) (source: string) + (sourceFiles: string[]) = let projectOptions, _ = checker.GetProjectOptionsFromScript( - fsPath, + projectFsPath, SourceText.ofString source, assumeDotNetFramework = false, useSdkRefs = true, @@ -69,7 +78,7 @@ type Type = |> Async.RunImmediate { projectOptions with - SourceFiles = [| fsPath |] + SourceFiles = sourceFiles OtherOptions = projectOptions.OtherOptions |> Array.append @@ -81,6 +90,14 @@ type Type = "--test:HotReloadDeltas" $"--out:{dllPath}" |] } + let private prepareProjectOptions + (checker: FSharpChecker) + (fsPath: string) + (dllPath: string) + (source: string) + = + prepareProjectOptionsWithSources checker fsPath dllPath source [| fsPath |] + let private compileProject (checker: FSharpChecker) (projectOptions: FSharpProjectOptions) @@ -965,6 +982,47 @@ type Type = Directory.Delete(projectDir, true) with _ -> () + [] + let ``EmitDelta in-process compile refreshes output before stale validation`` () = + use _env = withEnvironmentVariable "FSHARP_HOTRELOAD_INPROCESS_COMPILE" "1" + + let projectDir = + Path.Combine(Path.GetTempPath(), "fcs-hotreload-inprocess-refresh-output", Guid.NewGuid().ToString("N")) + + Directory.CreateDirectory(projectDir) |> ignore + + let fsPath = Path.Combine(projectDir, "Library.fs") + let dllPath = Path.Combine(projectDir, "Library.dll") + + File.WriteAllText(fsPath, baselineSource) + + let checker = createChecker () + let projectOptions = prepareProjectOptions checker fsPath dllPath baselineSource + + checker.InvalidateAll() + compileProject checker projectOptions true + + use session = checker.CreateHotReloadSession() + + match session.AddProject(createProjectSnapshot projectOptions) |> Async.RunImmediate with + | Error error -> failwithf "Failed to start session: %A" error + | Ok () -> () + + File.WriteAllText(fsPath, updatedSource) + checker.NotifyFileChanged(fsPath, projectOptions) |> Async.RunImmediate + + // dotnet-watch skips the external per-edit build when this switch is set. EmitDelta + // must perform the in-process compile before it validates the output fingerprint. + match session.EmitDelta(createProjectSnapshot projectOptions) |> Async.RunImmediate with + | Error error -> failwithf "EmitDelta failed after in-process compile: %A" error + | Ok delta -> + Assert.NotEmpty(delta.Metadata) + Assert.NotEmpty(delta.IL) + + try + Directory.Delete(projectDir, true) + with _ -> () + [] let ``Tracked dependency invalidation keeps subsequent source edit hot-reloadable`` () = let projectDir = Path.Combine(Path.GetTempPath(), "fcs-hotreload-dependency-invalidation", Guid.NewGuid().ToString("N")) @@ -1612,6 +1670,156 @@ let view name = Directory.Delete(projectDir, true) with _ -> () + [] + let ``Session-scoped in-process compile keeps unrelated CE closure mappings isolated`` () = + let projectDir = Path.Combine(Path.GetTempPath(), "fcs-hotreload-scoped-inprocess-ce", Guid.NewGuid().ToString("N")) + let dslDir = Path.Combine(projectDir, "Dsl") + Directory.CreateDirectory(dslDir) |> ignore + + let dslPath = Path.Combine(dslDir, "Markup.fs") + let workerPath = Path.Combine(projectDir, "Worker.fs") + let dllPath = Path.Combine(projectDir, "ScopedInProcessDsl.dll") + + let dslSource = + """ +module DslIsolation.Markup + +type FragmentBuilder() = + member _.Yield(text: string) = text + member _.Combine(a: string, b: string) = a + b + member _.Delay(f: unit -> string) = f() + member _.Zero() = "" + +let Fragment () = FragmentBuilder() + +let render value = + Fragment() { + "Value: " + string value + } +""" + + let baselineWorker = + """ +module DslIsolation.Worker + +let describe () = "original" +""" + + let updatedWorker = + """ +module DslIsolation.Worker + +let describe () = "updated" +""" + + File.WriteAllText(dslPath, dslSource) + File.WriteAllText(workerPath, baselineWorker) + + let checker = createChecker () + + let projectOptions = + prepareProjectOptionsWithSources checker workerPath dllPath baselineWorker [| dslPath; workerPath |] + + checker.InvalidateAll() + compileProject checker projectOptions true + + use session = checker.CreateHotReloadSession() + + match session.AddProject(createProjectSnapshot projectOptions) |> Async.RunImmediate with + | Error error -> failwithf "Failed to start session: %A" error + | Ok () -> () + + let describeToken = getMethodToken dllPath "Worker" "describe" + + File.WriteAllText(workerPath, updatedWorker) + checker.NotifyFileChanged(workerPath, projectOptions) |> Async.RunImmediate + + // This mirrors the dotnet-watch in-process path: compile the updated output in + // the same checker without a capture compile, then emit the delta from the live + // session. Unrelated CE closures from the DSL source must not be remapped as if + // the edited source changed their closure chain. + compileProject checker projectOptions false + + match session.EmitDelta(createProjectSnapshot projectOptions) |> Async.RunImmediate with + | Error error -> failwithf "EmitDelta failed for session-scoped in-process CE isolation: %A" error + | Ok delta -> Assert.Contains(describeToken, delta.UpdatedMethods) + + try + Directory.Delete(projectDir, true) + with _ -> () + + [] + let ``In-process compile preserves top-level closure names for unrelated module edit`` () = + use _env = withEnvironmentVariable "FSHARP_HOTRELOAD_INPROCESS_COMPILE" "1" + + let projectDir = + Path.Combine(Path.GetTempPath(), "fcs-hotreload-inprocess-top-level-closures", Guid.NewGuid().ToString("N")) + + Directory.CreateDirectory(projectDir) |> ignore + + let programPath = Path.Combine(projectDir, "Program.fs") + let dllPath = Path.Combine(projectDir, "TopLevelClosureProject.dll") + + let baselineProgram = + """ +module Program + +let handlers : (int -> string) list = + [ + let prefix = "alpha" + fun code -> prefix + string code + + let suffix = "omega" + fun code -> suffix + string (code + 1) + ] + +let describe () = "original" +""" + + let updatedProgram = + """ +module Program + +let handlers : (int -> string) list = + [ + let prefix = "alpha" + fun code -> prefix + string code + + let suffix = "omega" + fun code -> suffix + string (code + 1) + ] + +let describe () = "updated" +""" + + File.WriteAllText(programPath, baselineProgram) + + let checker = createChecker () + let projectOptions = prepareProjectOptions checker programPath dllPath baselineProgram + + checker.InvalidateAll() + compileProject checker projectOptions true + + use session = checker.CreateHotReloadSession() + + match session.AddProject(createProjectSnapshot projectOptions) |> Async.RunImmediate with + | Error error -> failwithf "Failed to start session: %A" error + | Ok () -> () + + let describeToken = getMethodToken dllPath "Program" "describe" + + File.WriteAllText(programPath, updatedProgram) + checker.NotifyFileChanged(programPath, projectOptions) |> Async.RunImmediate + + match session.EmitDelta(createProjectSnapshot projectOptions) |> Async.RunImmediate with + | Error error -> failwithf "EmitDelta failed for in-process top-level closure edit: %A" error + | Ok delta -> Assert.Contains(describeToken, delta.UpdatedMethods) + + try + Directory.Delete(projectDir, true) + with _ -> () + [] let ``Type-provider erased usage edit updates user-authored method token`` () = let projectDir = Path.Combine(Path.GetTempPath(), "fcs-hotreload-typeprovider-erased", Guid.NewGuid().ToString("N"))