Skip to content

Add F# hot reload support to dotnet-watch (prototype; superseded by the #55464 design) - #55128

Closed
NatElkins wants to merge 38 commits into
dotnet:mainfrom
NatElkins:fsharp-hotreload-watch-v2
Closed

Add F# hot reload support to dotnet-watch (prototype; superseded by the #55464 design)#55128
NatElkins wants to merge 38 commits into
dotnet:mainfrom
NatElkins:fsharp-hotreload-watch-v2

Conversation

@NatElkins

@NatElkins NatElkins commented Jul 3, 2026

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: refreshed, verified, and ready for review. This consumes the compiler PR train around dotnet/fsharp#19941, including the delta-emitter PR #20027 and session PR #20030. The reflection bridge remains compatible with older FCS builds, while in-place F# updates require the new session API to land before this integration can merge.

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.

Refresh status (2026-07-17)

  • Refreshed against dotnet/sdk main at 7225c8c; the current reviewed head is 9c89611.
  • Mixed C# and F# updates now commit or discard as one transaction. Project/configuration edits, rude edits, and the kill switch retain the stock rebuild-and-restart path, while multi-project F# batches retain every affected project.
  • F# deltas forward the exact runtime capabilities reported by FCS, with Baseline as the compatibility fallback for older compiler-service builds. Cancellation now reaches reflected FSharpAsync calls.
  • DotNetWatchBuild is applied consistently to baseline and per-edit builds, so the on-disk assembly and the session compile use the same hot reload lowering.
  • User-facing FSHRDL restart warnings link to the compiler diagnostic reference.
  • When the managed F# bridge is unavailable, referenced-library edits now report the changed non-running project so the existing graph closure rebuilds it and restarts every running ancestor.
  • Local verification passed with zero-warning builds, 19 service tests, 3 reflection tests, the stock-FCS referenced-library fallback scenario, and all 9 end-to-end F# watch scenarios. Replacement CI passed on this exact head.

Refresh status (2026-07-24)

  • Current head: 227e1dfce0, with dotnet/sdk main aa2ca0b516 merged.
  • The branch remains the companion consumer of the refreshed F# session stack and preserves the Windows-safe Compile-target path, mixed-language transaction handling, required-capability forwarding, and fallback restart closure.
  • Local validation on this exact head: dotnet-watch test project build passed with 0 warnings and 0 errors, FSharpHotReloadServiceTests passed 19/19, and FSharpReflectionHostTests passed 3/3.
  • Every review thread is resolved. Exact-head SDK build 1525266 passed every build leg; its red Helix aggregate contains only dnceng/NuGet 503 feed failures outside dotnet watch and the F# bridge, with a privileged rerun requested.\n- SDK maintainer feedback says the large change was not discussed in advance and is not expected to be accepted under current priorities. The PR is back in draft as a working prototype, with a follow-up question asking for the right future design-review venue.

Design update (2026-08-14)

The design issue #55464 now scopes v1 to active F# graphs (each running project and every transitive dependency is F#). Mixed graphs keep rebuild-and-restart. This prototype remains a draft reference and will be replaced by the contract, adapter, and registration PRs described there.

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.
@NatElkins NatElkins closed this Jul 5, 2026
@NatElkins NatElkins reopened this Jul 5, 2026
NatElkins added 10 commits July 17, 2026 12:29
Verified on the refreshed SDK stack:\n\n- Microsoft.DotNet.HotReload.Watch and dotnet-watch.Tests builds: 0 warnings, 0 errors\n- FSharpHotReloadServiceTests: 17 passed\n- FSharpReflectionHostTests: 3 passed\n- BuildProjects: 17 passed\n- F# SupportsHotReload and compiler-flag tests: 7 passed\n- FSharpHotReloadTests: 9 passed end to end
Rebuilt the watch, watch tests, SDK tasks, and SDK task tests with zero warnings and zero errors.\n\nPassed FSharpHotReloadServiceTests (17), FSharpReflectionHostTests (3), BuildProjects (17), hot reload capability and compiler flag tests (7), and FSharpHotReloadTests against the rebuilt redist (9).
@NatElkins
NatElkins marked this pull request as ready for review July 18, 2026 03:57
@NatElkins
NatElkins requested review from a team and tmat as code owners July 18, 2026 03:57
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
2 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@NatElkins

Copy link
Copy Markdown
Author

The exact-head SDK build 1525266 is red only on unrelated NuGet feed outages. Build Analysis classifies Publish_HostedApp_WithRidSpecifiedAsArgument_Works and Incremental_add_single_file as the known 503 issue dotnet/arcade#11723. The remaining FeaturesSupport Helix work item failed two class-library cases because the dnceng package feeds returned 503 retry messages; its console log shows the restores ultimately succeeded, and none of these tests exercise dotnet watch or the F# bridge. All actual SDK build legs passed.

@tmat could you rerun the failed Helix work when you get a chance? There is no SDK PR code change indicated by these failures.

@tmat tmat left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a large change and was not previously discussed.

I don't think we'll accept this change at this time due to other priorities.

@NatElkins

Copy link
Copy Markdown
Author

Understood. This is the SDK side of the F# hot reload work in dotnet/fsharp#19941, but you are right that a change this large should have been discussed with SDK maintainers first. I will move it back to draft so it does not look like a request to take it now. I would still like to preserve it as the current working prototype. If priorities change, what would be the right design review venue or issue to start with before asking for another review?

@NatElkins
NatElkins marked this pull request as draft July 24, 2026 18:01
@smoothdeveloper

Copy link
Copy Markdown

@tmat as F# user and member of the community, this is highly anticipated feature; let's hope the dotnet team can figure out how to bring it, even in preview only, in next major release.

Probably best is to assign some of the people who were involved with the work that took place for C#, to provide guidance and review this PR.

@tmat

tmat commented Jul 24, 2026

Copy link
Copy Markdown
Member

@NatElkins Thanks for understanding. I'd start with filing an issue where we can discuss the proposed high-level design.

There are a few important principles that the design needs to follow:

  1. The proposed solution needs to have minimal footprint in dotnet-watch. Most of the F# specific logic needs to be in F# components that we would reference via nuget package like we do with Roslyn. We need to define a contract between watch and F#.
  2. It also needs to account for a mixed project graphs where C# and F# projects are referencing each other. Unless it's ok to limit it to solutions that only contain F# projects. I suspect it's pretty common to have mixed solution though. This would likely require change of design in Roslyn.
  3. The solution needs to be viable for VS and VS Code as well. We do share Hot Reload infrastructure across dotnet-watch, VS and VS Code and would not want to diverge too much in watch.

@NatElkins

Copy link
Copy Markdown
Author

Thanks. I've opened the design issue so we can continue the discussion there. Based on what we decide there, I'll modify this PR and potentially break it up.

@NatElkins NatElkins changed the title Add F# hot reload support to dotnet-watch Add F# hot reload support to dotnet-watch (prototype; superseded by the #55464 design) Aug 16, 2026
@NatElkins NatElkins closed this Aug 16, 2026
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.

3 participants