Skip to content

dotnet-watch: F# hot reload integration (inspection draft)#1

Draft
NatElkins wants to merge 12 commits into
mainfrom
fsharp-hotreload-watch-v2
Draft

dotnet-watch: F# hot reload integration (inspection draft)#1
NatElkins wants to merge 12 commits into
mainfrom
fsharp-hotreload-watch-v2

Conversation

@NatElkins

@NatElkins NatElkins commented Jun 12, 2026

Copy link
Copy Markdown
Owner

dotnet-watch: F# hot reload integration (inspection draft)

The dotnet-watch half of F# hot reload, paired with the compiler work in
NatElkins/fsharp#3 (full implementation).
Not for upstream until the FCS API ships publicly — this draft exists for review and for
anyone who wants to run the end-to-end experience.

🚀 Try it

hot-reload-quickstart.md
— two clones, two builds, copy-paste steps, ending with dotnet watch run hot-patching a
running F# console app (including adding lambdas to live methods). This branch builds the
complete .NET CLI (artifacts/bin/redist/Debug/dotnet) that the guide uses.

What this branch contains

  • FSharpHotReloadService + FSharpProjectInfo (src/Dotnet.Watch/Watch/HotReload/FSharp/):
    F# project discovery from the MSBuild graph, source/dependency change mapping, forced-build
    orchestration, and a reflection bridge into FSharp.Compiler.Service — no compile-time FCS
    dependency, graceful restart-fallback on stock toolsets
  • Session-object mode: one FSharpHotReloadSession per watch run (the FCS-side
    DebuggingSession analogue), every F# project added at prestart, per-project deltas with
    solution-wide commit. Multi-project interleaved edits apply in place — e2e
    ChangeFilesInFSharpAppAndLib_InterleavedEditsApplyInPlace edits a referenced library,
    then the app, then the library again, all without restart. Legacy single-project path
    preserved as the fallback for older FCS builds
    (DOTNET_WATCH_FSHARP_USE_SESSION_OBJECT=0 kill switch)
  • CompilationHandler integration: merged Roslyn + F# update flow; session-owned F# files
    kept out of the Roslyn workspace (prevents spurious ENC1009 restarts); runtime EnC
    capability forwarding with in-place refresh once agents connect (the session is prestarted
    before the app launches)
  • SupportsHotReload project capability in Microsoft.NET.Sdk.FSharp.targets (C#/VB parity)
  • MVID pinning at AddProject time: forced design-time rebuilds move the on-disk MVID;
    deltas must keep targeting the module the runtime actually loaded
  • Trace diagnostics throughout via DOTNET_WATCH_TRACE_FSHARP_HOTRELOAD=1

Evidence

19/19 F# watch test classes (FSharpHotReloadTests, FSharpHotReloadServiceTests,
FSharpReflectionHostTests) including the multi-project e2e, no-op edit handling (no
restart on whitespace saves), and dependency-edit handling — run against the locally built
FCS from hot-reload-v2.

Design docs

The architecture (session entity, snapshot contract, capability negotiation, determinism)
is documented on the compiler branch — start at
hot-reload-architecture.md.

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.
@NatElkins

Copy link
Copy Markdown
Owner Author

Compiler-side counterpart is now under upstream review at dotnet/fsharp#19941. This branch gained: a one-variable master kill switch (DOTNET_WATCH_FSHARP_HOTRELOAD=0), the SupportsHotReload capability now advertised only under dotnet-watch builds (no VS blast radius), and session-object-only FCS support (the bridge no longer requires the retired legacy checker surface — e2e suite 8/0 against current FCS).

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