Skip to content

Support F# Hot Reload in CLI dotnet watch through a typed provider #55464

Description

@NatElkins

Problem

dotnet watch applies C# updates in place through Roslyn. F# projects always fall back to rebuild and restart. PR #55128, plus the matching dotnet/fsharp work (the #19941 train and #20030), shows in-place F# updates working end to end.

The prototype isn't the right final boundary though. It puts about 2,900 lines of F#-specific logic inside dotnet-watch: a project model, reflection-based FCS loading, session state, delta conversion, file classification. Most of that belongs behind the F# compiler service API.

This issue lays out the component split, the loading model, the v1 scope, and a delivery plan, following the three principles from the #55128 review feedback.

Contract

The provider contract is internal to dotnet-watch, implemented by a small SDK-side adapter over the typed FCS session API from #20030: FSharpChecker.CreateHotReloadSession and FSharpHotReloadSession (AddProject, EmitDelta, Commit, Discard, UpdateCapabilities, SetActiveStatements, Dispose). That API is the cross-repo boundary, and dotnet/fsharp owns it.

Nothing upstream works as a home for the contract. dotnet/fsharp can't reference the SDK without a dependency cycle. Roslyn's ExternalAccess.HotReload assembly isn't viable either: its API is internal, its product IVTs cover only Microsoft.DotNet.HotReload.Watch and Microsoft.DotNet.HotReload.Utils.Generator (a third grants test access), and it targets net10.0 while the official FCS targets netstandard2.0.

dotnet-watch is also moving to a new architecture: watch connects to a Roslyn LSP server and the two speak a small RPC hot reload protocol. That changes where the F# integration plugs in, but not the boundary. The natural F# shape there is a server that implements the same protocol on top of FSharpHotReloadSession. Watch-side integration work waits for that architecture to settle; the FCS session API stays the contract in every variant discussed so far.

Loading and versioning

dotnet-watch takes a compile-only reference to the pinned Microsoft.FSharp.Compiler package (PrivateAssets="all", ExcludeAssets="runtime;contentFiles"). FSharp.Compiler.Service and FSharp.Core don't ship in the watch tool layout. At startup, watch registers a resolver that loads both from the toolset directory sdk/<version>/FSharp/, in the default load context. This mirrors what watch already does for Roslyn: normal compile-time reference, layout exclusion, runtime resolution from Roslyn/bincore (dotnet-watch/Program.cs, RegisterAssemblyResolutionEvents).

The default load context matters. A separate load context gives the FCS types different identities and breaks a typed adapter.

The runtime assembly identities must match the compile-time FCS and FSharp.Core identities exactly. The session API is experimental, so no compatibility range is safe in either direction. When FscToolPath overrides the compiler, or the toolset FCS doesn't have the session API, watch keeps today's rebuild-and-restart behavior.

The prototype already resolved this directory through the DotnetFscCompilerPath MSBuild property, it just used reflection on top. The path derivation stays, the reflection goes away.

The prototype also aligned on-disk baseline builds with session compiles through a watch-set MSBuild property. Watch-specific build properties are the wrong mechanism, so that's dropped. The need is transitional anyway: once the in-process compile path lands (dotnet/fsharp#20031), the session produces the baseline and the deltas from the same compile, and no build property is involved. Any targets wiring for the compiler flag waits on the shipped compiler either way (stock fsc warns FS1063 on it today).

v1 scope

v1 applies in-place updates only when each running project and every transitive dependency is an F# project (.fsproj extension or the evaluated Language property; an unknown language counts as not F#). Call this the active F# graph.

Mixed active graphs keep today's behavior exactly: F# edits rebuild and restart, C# edits keep the unchanged Roslyn path. Unrelated non-F# projects elsewhere in the solution don't disable the feature, only the running projects and their dependencies matter. The cost is explicit: an F# app that references a C# library always restarts on edit in v1, even for body-only F# edits.

FSHRDL rude edit diagnostics go through the same watch console and browser channels as the ENC ones.

This keeps the new provider away from active C# graphs, which is the narrow scope the review feedback suggested.

Mixed graphs (v2)

Deferred, but recorded so v2 has a definition:

  • Metadata references are snapshots. A C# consumer compiles against the F# image captured when the reference was created, and an on-disk rebuild doesn't refresh it (verified with a repro on Roslyn 5.10: the error persists until the reference object itself is replaced). The reverse direction has the same problem.
  • A safe mixed policy needs a contract-change signal from each producing compiler. An empty metadata delta isn't a valid test: a body-only edit still produced a 404-byte metadata delta with 3 EncLog rows. F# can derive the signal from its symbol diffs. The watch-facing Roslyn API has no equivalent today.
  • Roslyn owns the single HotReloadMSBuildWorkspace/HotReloadService, so a unified cross-language graph is a Roslyn design change.

VS and VS Code

This issue only covers CLI dotnet watch, but the design shouldn't block the other hosts. The reusable core is the F# session in FCS: it has no dependency on watch, and it already models sequence point and active statement updates. The provider lifecycle mirrors the shared managed hot reload lifecycle (IManagedHotReloadLanguageService): start session, has changes, get updates, commit, discard, end session. The known gaps for VS and C# Dev Kit stay out of scope here: the brokered debugger contract, break states, debugger-side active statement tracking, richer update payloads, service discovery, and workspace integration.

Delivery plan

  1. fsharp: land [main] Update dependencies from dotnet/aspnetcore #20030 and the remaining compiler slices. No new facade is needed, the session API is the contract. This work carries over to every integration variant above. The contract-change signal moves to v2.
  2. sdk: wait for the new watch architecture, then build the typed integration in whatever shape it calls for (in-process adapter or protocol host), plus eligibility detection, F# project-reference reconstruction into graph-aware snapshots, the compiler flag wiring, and end-to-end tests. One transaction rule from the prototype must survive in any shape: refusing an F#-side restart must not call Roslyn DiscardUpdate() without a pending update. The prototype crashed watch exactly this way (InvalidOperationException: No pending update.).

PR #55128 is closed and preserved as the reference prototype.

Open questions

  1. Active F# graph: must each running project and every transitive dependency be F#, so unrelated non-F# projects don't disable the feature?
  2. Version policy: compile against the pinned Microsoft.FSharp.Compiler, require exact identity at runtime, and reject every custom FscToolPath?

Related: #55128, dotnet/fsharp#20030, dotnet/fsharp#19941, dotnet/roslyn#80556

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions