Add F# hot reload support to dotnet-watch#55128
Draft
NatElkins wants to merge 26 commits into
Draft
Conversation
Mirror the C#/VB SupportsHotReload project capability for F# projects targeting .NET 6.0 or newer, with an opt-out via SupportsHotReload=false. Add coverage in GivenThatWeWantToBuildALibraryWithFSharp.
Add FSharpHotReloadService and FSharpProjectInfo, which bridge dotnet-watch to FSharp.Compiler.Service hot reload APIs (StartHotReloadSession, EmitHotReloadDelta, EndHotReloadSession) via reflection. The service discovers F# projects in the project graph, tracks per-project compiler inputs, and emits managed code deltas for changed F# sources, with an optional FSharpWorkspace snapshot bridge for custom compiler service builds.
Route file changes through FSharpHotReloadService alongside the Roslyn update path in CompilationHandler. Managed code updates are now tracked as ManagedCodeUpdateEnvelope (project path + runtime update) so that F# deltas, which have no Roslyn ProjectId, flow through the same builder, previous-update replay, and discard-on-rebuild logic as C#/VB updates. F# edits that cannot be applied as deltas fall back to rebuild + restart of the affected project. The agent reports trace-only diagnostics when no loaded assembly matches an update's module id and DOTNET_WATCH_TRACE_FSHARP_HOTRELOAD is set.
Add unit tests for FSharpHotReloadService change classification and the reflection host F# option accessor handling, and integration scenarios covering in-place apply, computation expression edits, whitespace-only edits, dependency and XAML dependency edits, and rude-edit restart fallback for F# projects.
Pass the same aggregate runtime edit-and-continue capabilities that the Roslyn hot reload service receives into FSharpHotReloadService, and probe the loaded FCS surface for the optional capabilities parameter on StartHotReloadSession via reflection. The capabilities are forwarded as FSharpOption<seq<string>> when the parameter exists and gracefully omitted for older FCS builds without it.
…t them The F# hot reload session is prestarted before any agent connects, freezing an empty capability set into the session and pinning classification to baseline-only edits. Update the live session via the compiler service's UpdateHotReloadCapabilities once the real aggregate set is available. Restarting the session instead is not an option: a restart re-captures the baseline from sources that may already contain the pending edit, producing an empty diff against the running module. Older compiler services without the update API keep the session's original capabilities.
When the loaded FSharp.Compiler.Service exposes FSharpChecker.CreateHotReloadSession, the watch bridge holds one FSharpHotReloadSession per watch session instead of switching the process-wide checker session between projects: - the session is created at StartSessionAsync with the current aggregate runtime capabilities and refreshed in place via the session's UpdateCapabilities; - every discovered F# project is captured into the session with AddProject(snapshot, outputPath) (eagerly at session start, lazily on first edit otherwise), so edits to F# library projects loaded into a running process now match and emit per-project deltas; - edits emit through the session's EmitDelta(snapshot); pending updates are committed at delta hand-off (the watch applies them immediately, mirroring Roslyn's CommitUpdate point) and discarded when hot reload is blocked or the restart prompt is declined; - EndSession disposes the session object. When CreateHotReloadSession is absent (older compiler service builds) the existing single-active-project switching path is kept unchanged as the fallback.
Two fixes surfaced by running the F# watch e2e suite against a compiler service with the session-object API: - Changed files owned exclusively by F# projects the session tracks are no longer surfaced to the Roslyn workspace. The Roslyn EnC service has no F# support, so it reported rude edit ENC1009 with a redundant auto-rebuild for every F# edit; when the F# emit was a no-op the empty Roslyn result swallowed the user-facing decision message and dotnet-watch reported nothing. Legacy single-session mode keeps the Roslyn auto-rebuild as the fallback for edits the single-active-project bridge cannot target. - The module id of the output captured by AddProject is recorded as the runtime target id for the project. Forced design-time rebuilds (after a no-op or dependency-only emit) move the on-disk MVID without the process reloading, which made the next delta target a module the runtime never loaded. A blocked emit keeps the recorded id (the loaded module is unchanged); a restart drops it so re-adding the project recaptures both. Also adds DOTNET_WATCH_FSHARP_USE_SESSION_OBJECT=0 as a safety valve forcing the legacy single-active-project path when the session-object API is present.
Adds the FSharpAppWithLib test asset (an F# App referencing an F# Lib, both compiled with --enable:hotreloaddeltas so baseline builds are deterministic) and an end-to-end watch test that interleaves edits across the two projects: edit Lib's method body (in-place apply targeting the Lib module loaded into the running App process), then App's body, then Lib again. The legacy single-active-session bridge could not interleave projects without recapturing baselines from already-edited sources; the session object keeps each project's committed baseline and generation chain independent. Verified locally: the new test passes, and the full F# watch test classes (FSharpHotReloadTests, FSharpHotReloadServiceTests, FSharpReflectionHostTests) pass 19/19 against an SDK layout provisioned with the hot-reload-v2 FSharp.Compiler.Service.
Match the F# compiler change moving the experimental hot reload Edit-and- Continue flag from the public --enable:hotreloaddeltas option to the help-hidden --test:HotReloadDeltas incubation switch. Update the watch service's flag detection/injection and the F# test assets accordingly.
The per-edit dotnet build refreshed the bin output the running process has loaded. Windows locks that file against writes while the app runs, so the build failed on every edit and the change fell back to a full restart instead of applying in place. Build the Compile target only (the obj intermediate assembly fsc writes) and point the hot reload session's baseline and emit reads at that intermediate assembly via FSharpProjectInfo.IntermediateAssemblyPath. The running process never loads that file, so nothing is locked, and the bin output is left at generation 0 until the next real restart. macOS and Linux already tolerated overwriting a mapped file; this lines all three platforms up on the same path. Reported on dotnet/fsharp#19941.
ChangeFileInFSharpProjectWithLoop_FirstEditAppliesInPlace asserted only the applied/no-restart log messages, so a delta that "applies" without changing behavior would still pass. Assert the running loop now prints the edited string in place (mirrors ChangeFileInFSharpProjectWithLoop_AppliesOrRestarts). Addresses review feedback on #1.
…t-watch
The F# bridge collapsed the rude-edit reason to a record/DU ToString() dump and
logged it at Debug only, so an edit that forces a restart gave the user no reason.
Now that FSharpHotReloadError.UnsupportedEdit carries a structured rude-edit list
(Id + Severity + Message), read it via reflection (TryFormatRudeEdits) into a clean
"{Id}: {Message}" reason and report it as a warning instead of at Debug. The
extraction is defensive: any shape mismatch falls back to ToString(), so the bridge
stays correct against older FCS builds (where UnsupportedEdit still carries a string).
Pairs with the FCS-side structured diagnostics channel on dotnet/fsharp#19941.
Addresses review feedback on #1.
Bringing the branch up to date with dotnet/sdk main moved dotnet-watch.Tests to MSTest.Sdk, which broke the fork's F# test files: they used xUnit [Fact]/[Theory]/[InlineData], the ITestOutputHelper-constructor base pattern, and the xUnit Assert API, none of which resolve under MSTest. Migrate the three F# hot-reload test files (bridge unit tests, reflection-host test, and the integration tests) to MSTest: [TestClass]/[TestMethod]/[DataRow], the TestContext-injected DotNetWatchTestBase pattern (no constructor), and the MSTest Assert API (AreEqual/IsTrue/IsNull/IsInstanceOfType). Restores the test project to a compiling state.
TryFormatRudeEdits read the structured rude-edit list via GetProperty("Item"),
which can throw AmbiguousMatchException on an F# single-field union case and then
get swallowed, falling back to the raw multi-line record ToString(). Read the
payload through the unambiguous no-arg get_Item() accessor instead, and add a
guaranteed fallback that collapses the default ToString() to a single line, so the
surfaced warning is always readable even if the structured read fails.
…on formats
error.ToString() renders as 'UnsupportedEdit\n [ ... ]', so ParseErrorCase (splitting
only on space/paren/colon) returned 'UnsupportedEdit\n', which never equals
'UnsupportedEdit'. The rude-edit formatting block was therefore skipped and the raw
multi-line record dump was surfaced. Stop at whitespace too so the case name matches and
TryFormatRudeEdits renders the tidy '{Id}: {Message}' reason.
…ecks The F# hot reload bridge created its FSharpChecker with only keepAssemblyContents set, so per-edit semantic checks ran on the legacy build and re-checked the whole project every edit (and degraded steadily as the checker's caches grew). Enable useTransparentCompiler in CreateCheckerArguments. TransparentCompiler caches per-file typecheck results keyed by content hash and uses a parse-time dependency graph, so a per-edit re-check only re-typechecks the changed file's dependency cone. Measured on a 72-file F# web app (harness over the real CreateHotReloadSession/EmitDelta API): the per-edit EmitDelta check dropped ~2x (3.8s -> 2.0s on the first edit) and, more importantly, stopped growing across a session (legacy: 3.8s -> 9.3s over four edits; transparent: ~2s steady). The remaining per-edit cost is the external dotnet build -t:Compile refresh, addressed separately.
The dotnet-watch F# hot reload forced build shelled out 'dotnet build <proj> -t:Compile' without a target framework, so a multi-targeted project would compile every TFM (or the wrong one) instead of the single TFM the running app loaded, and the refreshed obj assembly would not match the baseline the delta chains from. Pass --framework with the resolved TargetFramework (guarded on non-empty), matching how the watcher passes --framework to build/run elsewhere.
…nabled When FSHARP_HOTRELOAD_INPROCESS_COMPILE is set, the FCS hot reload session refreshes the obj assembly and PDB itself during EmitDelta, so the per-edit 'dotnet build -t:Compile' would be a redundant MSBuild and fsc invocation on top of the in-process compile. Skip it under the flag; unset keeps the external build as the default.
An F# rude edit (a change the compiler service cannot apply as a delta) falls back to rebuild and restart. The fallback added only the changed project's own path to the restart set, but the watcher restarts a process only when the set resolves to a running root project. For a console app the changed project IS the running project, so the lookup matched. For a web app whose running root loads the changed F# project through the project graph, the exact-path lookup found no running project: watch logged 'Restart is needed to apply the changes.' and then did nothing, leaving the process serving stale code indefinitely. Map the restart-required project through the loaded project graph to its ancestors (including itself) and schedule every matching path, falling back to the raw path when the graph does not know the project. A trace-level log records the mapping decision. GetManagedCodeUpdatesAsync now receives the loaded project graph from the watcher for this purpose. New test asset FSharpWebAppWithLib (Web SDK F# root referencing an F# library) and test ChangeFileInReferencedFSharpProject_WebAppRudeEditRestartsRoot: a rude edit in the library now exits and relaunches the web root. The existing console rude-edit restart test still passes.
The merge of main brought the MSTest migration for GivenThatWeWantToBuildALibraryWithFSharp, which converted the class to TestClass and its existing tests to TestMethod. The five hot reload capability tests added on this branch still carried xunit Fact attributes, which no longer compile (CS0246: Fact not found) and broke every TestBuild, AoT, and FullFramework CI leg on the PR. Convert the five methods to TestMethod to match the rest of the file. Verified: Microsoft.NET.Build.Tests builds clean with the change.
…n API The six tests that assert in-place apply or no-restart semantics point the watch bridge at the FSharp.Compiler.Service that ships inside the SDK under test. Against a stock FCS the bridge falls back to restart, so ManagedCodeChangesApplied never appears and the tests wait until the Helix work item is killed, which failed one work item per platform in the first run that got past the earlier build break. Probe the assembly metadata for CreateHotReloadSession without loading the assembly and mark the tests inconclusive when the API is absent. The tests still run fully against a hot-reload-capable FCS, locally today and on CI once the compiler side ships in the SDK, and the tolerant and rude-edit tests keep running everywhere since the restart fallback satisfies them. Verified: dotnet-watch.Tests builds clean with the change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds F# hot reload support to dotnet-watch: an F# bridge alongside the existing C# hot reload path that drives
FSharp.Compiler.ServiceEdit and Continue sessions, applies metadata/IL/PDB deltas through the existing agent pipeline, and falls back to a rebuild and restart for rude edits (including restarting running ancestor projects when the edit lands in a referenced F# project).Highlights:
Status: draft. This depends on F# compiler work being upstreamed in dotnet/fsharp (the PR train around dotnet/fsharp#19941: #20017, #20018, #20019, #20024, #20025, with the delta emitter and session slices to follow). It stays draft until that lands in an FCS the SDK consumes; opening now for early visibility on the dotnet-watch integration shape.
Verified end to end against a Giraffe EndpointRouting sample with a locally built FCS: body edits hot-apply in roughly 0.2s and structural line-shifting edits in under 1s, with state preserved and no restarts; rude edits degrade to the normal rebuild and restart path.