Skip to content

Add F# hot reload support to dotnet-watch#55128

Draft
NatElkins wants to merge 26 commits into
dotnet:mainfrom
NatElkins:fsharp-hotreload-watch-v2
Draft

Add F# hot reload support to dotnet-watch#55128
NatElkins wants to merge 26 commits into
dotnet:mainfrom
NatElkins:fsharp-hotreload-watch-v2

Conversation

@NatElkins

Copy link
Copy Markdown

Adds F# hot reload support to dotnet-watch: an F# bridge alongside the existing C# hot reload path that drives FSharp.Compiler.Service Edit 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:

  • Per-project F# hot reload sessions with baseline capture at startup and delta emission per file change
  • Rude edits fail closed: the change is applied by rebuild and restart, never a stale or corrupt process
  • The forced compile pins to the running TargetFramework for multi-targeted projects
  • An opt-in flag lets the session skip the external per-edit build when the FCS in-process compile path is enabled, cutting edit latency substantially (this flag ships dark until the corresponding compiler work lands)

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.

NatElkins added 24 commits June 10, 2026 12:27
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant